jobhunt-platform/docs/worker-tasks/v11-wave-a.md
hermes 5e539ba713
Some checks failed
CI / api-tests (push) Failing after 31s
CI / package-tests (push) Failing after 25s
CI / web-tests (push) Failing after 35s
ADR-0003 v1.1 scope + wave A dispatch cards (feel-alive: email watch, notify, dedupe, tailor, deadlines)
2026-07-30 20:36:02 +00:00

4.6 KiB

v1.1 worker dispatch cards

Global v1 rules still binding (see v1-tasks.md header): own paths only, uv, no ORM, no em dashes, mock-first, approval gate untouched, DinD test pattern docker compose run --rm api-test, COPY dir ./dir not COPY dir dest, commit early and often on your own branch, never discard files you did not create (no git clean/reset --hard/checkout --).

WA1: apps/api — email watch + notifications + suggestions

Paths: apps/api/** only (this wave you OWN apps/api; WA2 never touches it).

Migration 003_email_notify.sql:

CREATE TABLE IF NOT EXISTS email_suggestion (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  application_id uuid REFERENCES application(id) ON DELETE SET NULL,
  mailbox_from text NOT NULL,
  subject text NOT NULL,
  snippet text NOT NULL,
  classification text NOT NULL CHECK (classification IN ('interview_invite','rejection','question','noise')),
  state_proposal text,             -- e.g. 'interviewing', null = no move suggested
  status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','accepted','dismissed')),
  received_at timestamptz NOT NULL,
  created_at timestamptz DEFAULT now()
);
CREATE TABLE IF NOT EXISTS notification_log (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  channel text NOT NULL,
  kind text NOT NULL,              -- 'daily_digest' | 'email_suggestion'
  payload jsonb NOT NULL,
  delivered boolean NOT NULL,
  error text,
  created_at timestamptz DEFAULT now()
);

Deliverables:

  • app/imap_watch.py: stdlib imaplib client (SSL, env IMAP_HOST/PORT/USER/PASS; disabled unless EMAIL_WATCH_ENABLED=true). Fetch UNSEEN since last poll; match sender domain + subject/body keywords to open applications (status in sent/interviewing) via repo query (company name in subject/body, or sender domain in posting URL raw); cheap-class llm task email_classify -> {classification, state_proposal?, reason}; insert email_suggestion rows, skip noise↔noise spam dedupe (same from+subject+day -> skip). Tests use a FakeImap (no network).
  • app/notify.py: NotificationChannel protocol; LogChannel (writes notification_log delivered=true); WebhookChannel (env NOTIFY_WEBHOOK_URL, httpx POST {kind,text,data}, 2xx=delivered else error row). send_notification(kind,text,data) used by scheduler + email watch.
  • Scheduler additions: imap poll job every 15 min (only when enabled); daily digest 07:30 -> /today payload text.
  • Endpoints: GET /suggestions (pending), POST /suggestions/{id}/accept (applies state_proposal via normal guarded transition path; apply last_activity), POST /suggestions/{id}/dismiss, GET /notifications/log (last 50).
  • Mock additions NOT your job (WA2 adds email_classify mock to llm-gateway); your code calls gateway task email_classify defensively (mock mode must work; ship a fallback inline mock dict in app/llm.py like existing tasks so tests pass even before WA2 lands).
  • Tests (+ target 25): imap matching logic, classifier->row, noise dedupe, accept applies transition through guard, webhook success/failure rows, digest payload shape.
  • Run docker compose run --rm api-test, keep suite green (previous 90 + yours).

WA2: packages/matching + llm-gateway mocks

Paths: packages/matching/** (new), packages/llm-gateway/src/llm_gateway/mock.py + its test file ONLY.

  • packages/matching:
    • similarity.py: normalize (lowercase, strip agency suffixes like AB/Consulting... keep conservative), title_score(a,b) rapidfuzz token_set_ratio, employer_match(a,b) normalized equality, desc_score(a,b) token_set_ratio on first 2000 chars.
    • dedupe.py: cluster(postings: list[dict]) -> map[cluster_id, list[id]] with rule: same employer OR (title>=85 AND desc>=80). Deterministic, sorted cluster ids c1..cN by max score desc.
    • keywords.py: extract_keywords(text, top_n=30) (freq, drop swedish+english stopwords, keep tech multiwords like "fast api"->fastapi ok simple), coverage(cv_text, posting_text) -> {matched, missing, ratio}.
    • pyproject (uv/hatchling), README, pytest suite (>=20 tests incl. agency repost fixture pairs: invent 3 realistic triples, one of them being legit-different jobs at same agency that must NOT cluster).
  • llm-gateway mock additions: deterministic outputs for email_classify (interview_invite w/ state_proposal interviewing), cv_tailor (reordered sections + change_log list), deadline_extract ({apply_by: null or ISO date}); register in task->class map (email_classify+deadline_extract = CHEAP, cv_tailor = STRONG); extend tests (+6).
  • uv venv per package, pytest green, branch feat/WA2-matching, push.

(Wave B cards get dispatched after Wave A merges — see ADR-0003)