ADR-0003 v1.1 scope + wave A dispatch cards (feel-alive: email watch, notify, dedupe, tailor, deadlines)
This commit is contained in:
parent
c69fb128bd
commit
5e539ba713
2 changed files with 82 additions and 0 deletions
27
docs/adr/0003-v11-scope.md
Normal file
27
docs/adr/0003-v11-scope.md
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# ADR-0003: v1.1 scope (feel-alive features + killer demo)
|
||||||
|
|
||||||
|
Status: accepted (2026-07-30)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
1. **Email reply tracking (read-only)**: IMAP poll (stdlib imaplib, env `IMAP_HOST/PORT/USER/PASS`, flag `EMAIL_WATCH_ENABLED` default false) every 15 min via scheduler. New messages matched to applications by contact domain/company; cheap-LLM classify into `interview_invite | rejection | question | noise`. Result stored as `suggestion` rows; user confirms card moves (state transitions stay user-gated per ADR-0001, no auto-move in v1.1).
|
||||||
|
2. **Notifications**: `NotificationChannel` interface; v1.1 implementations: `LogChannel` (default), `WebhookChannel` (generic POST to user URL, documented Hermes-webhook example). Triggers: daily digest (07:30), interview-invite suggestion. Payload: text + data JSON.
|
||||||
|
3. **Agency duplicate detection**: deterministic similarity (packages/matching with rapidfuzz: normalized employer name match OR token_set_ratio(title)>=85 AND token_set_ratio(description)>=80 -> same cluster). No LLM. `cluster_id` groups postings; API surfaces alternates ("same role via 3 agencies").
|
||||||
|
4. **CV tailoring per posting**: strong-class task `cv_tailor` -> tailored CV variant JSON (reordered skills, rephrased bullets toward posting keywords, unchanged facts — hallucination guard: only reorder/rephrase existing content, never invent). Stored as artifact(kind=cv) variant linked to application. ATS keyword report: deterministic keyword coverage (tokenizer intersection, no LLM).
|
||||||
|
5. **Deadline radar**: cheap extraction task `deadline_extract` during scoring; nullable `apply_by date` on job_posting (migration 004); /today adds `deadlines` strip (next 7 days).
|
||||||
|
|
||||||
|
## Rules kept
|
||||||
|
|
||||||
|
- Approval gate untouched; email watch is read-only.
|
||||||
|
- Zero-config still works: everything above degrades to mock/log/no-op.
|
||||||
|
- No paid-provider fallback for cheap classes.
|
||||||
|
|
||||||
|
## v1.1 delivery shape
|
||||||
|
|
||||||
|
Wave A (parallel, no shared files):
|
||||||
|
- WA1 apps/api: email watch + notifications + suggestion endpoints (owns main.py/schemas.py/migrations this wave)
|
||||||
|
- WA2 packages/matching (new) + packages/llm-gateway (mock additions only)
|
||||||
|
|
||||||
|
Wave B (after merge):
|
||||||
|
- WB1 apps/api: dedupe integration + cv-tailor + deadline endpoints (owns main.py etc.)
|
||||||
|
- WB2 apps/web: v1.1 UI (suggestions inbox strip, cluster alternates, tailor button + variant viewer, deadlines strip, notification settings stub)
|
||||||
55
docs/worker-tasks/v11-wave-a.md
Normal file
55
docs/worker-tasks/v11-wave-a.md
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
# 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`:
|
||||||
|
```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)
|
||||||
Loading…
Reference in a new issue