diff --git a/docs/superpowers/plans/2026-03-19-signal-banner-reclassify.md b/docs/superpowers/plans/2026-03-19-signal-banner-reclassify.md new file mode 100644 index 0000000..97b6e0e --- /dev/null +++ b/docs/superpowers/plans/2026-03-19-signal-banner-reclassify.md @@ -0,0 +1,707 @@ +# Signal Banner Redesign — Expandable Email + Re-classification Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Expand signal banners to show full email body, add inline re-classification chips, and remove single-click stage advancement (always route through MoveToSheet). + +**Architecture:** Backend adds `body`/`from_addr` to the signal query and a new `POST /api/stage-signals/{id}/reclassify` endpoint. The `StageSignal` TypeScript interface gains two nullable fields. Both `InterviewCard.vue` (kanban) and `InterviewsView.vue` (pre-list) get an expand toggle, body display, and six re-classification chips. Optimistic local mutation drives reactive re-labeling; neutral triggers a two-call dismiss path to preserve Avocet training signal. + +**Tech Stack:** FastAPI (Python), SQLite, Vue 3, TypeScript, Pinia, `useApiFetch` composable + +**Spec:** `docs/superpowers/specs/2026-03-19-signal-banner-reclassify-design.md` + +--- + +## File Map + +| File | Action | +|---|---| +| `dev-api.py` | Add `body, from_addr` to signal SELECT; add `reclassify_signal` endpoint | +| `tests/test_dev_api_interviews.py` | Add `body`/`from_addr` columns to fixture; extend existing signal test; add 3 reclassify tests | +| `web/src/stores/interviews.ts` | Add `body: string \| null`, `from_addr: string \| null` to `StageSignal` | +| `web/src/components/InterviewCard.vue` | `bodyExpanded` ref; expand toggle button; body+from_addr display; 6 reclassify chips; `reclassifySignal()` | +| `web/src/views/InterviewsView.vue` | `bodyExpandedMap` ref; `toggleBodyExpand()`; same body display + chips for pre-list rows | + +--- + +## Task 1: Backend — body/from_addr fields + reclassify endpoint + +**Files:** +- Modify: `dev-api.py` (lines ~309–325 signal SELECT + append block; after line 388 for new endpoint) +- Modify: `tests/test_dev_api_interviews.py` (fixture schema + 4 test changes) + +### Step 1.1: Write the four failing tests + +Add to `tests/test_dev_api_interviews.py`: + +```python +# ── Body/from_addr in signal response ───────────────────────────────────── + +def test_interviews_signal_includes_body_and_from_addr(client): + resp = client.get("/api/interviews") + assert resp.status_code == 200 + jobs = {j["id"]: j for j in resp.json()} + sig = jobs[1]["stage_signals"][0] + # Fields must exist (may be None when DB column is NULL) + assert "body" in sig + assert "from_addr" in sig + + +# ── POST /api/stage-signals/{id}/reclassify ──────────────────────────────── + +def test_reclassify_signal_updates_label(client, tmp_db): + resp = client.post("/api/stage-signals/10/reclassify", + json={"stage_signal": "positive_response"}) + assert resp.status_code == 200 + assert resp.json() == {"ok": True} + con = sqlite3.connect(tmp_db) + row = con.execute( + "SELECT stage_signal FROM job_contacts WHERE id = 10" + ).fetchone() + con.close() + assert row[0] == "positive_response" + + +def test_reclassify_signal_invalid_label(client): + resp = client.post("/api/stage-signals/10/reclassify", + json={"stage_signal": "not_a_real_label"}) + assert resp.status_code == 400 + + +def test_reclassify_signal_404_for_missing_id(client): + resp = client.post("/api/stage-signals/9999/reclassify", + json={"stage_signal": "neutral"}) + assert resp.status_code == 404 +``` + +- [ ] Add the four test functions above to `tests/test_dev_api_interviews.py` + +### Step 1.2: Also extend `test_interviews_includes_stage_signals` to assert body/from_addr + +The existing test (line 64) asserts `id`, `stage_signal`, and `subject`. Add assertions for the two new fields after the existing `assert signals[0]["id"] == 10` line: + +```python + assert "body" in signals[0] + assert "from_addr" in signals[0] +``` + +- [ ] Add those two lines inside `test_interviews_includes_stage_signals`, after `assert signals[0]["id"] == 10` + +### Step 1.3: Update the fixture to include body and from_addr columns + +The `job_contacts` CREATE TABLE in the `tmp_db` fixture is missing `body TEXT` and `from_addr TEXT`. The fixture test-side schema must match the real DB. + +Replace the `job_contacts` CREATE TABLE block (currently `id, job_id, subject, received_at, stage_signal, suggestion_dismissed`) with: + +```sql +CREATE TABLE job_contacts ( + id INTEGER PRIMARY KEY, + job_id INTEGER, + subject TEXT, + received_at TEXT, + stage_signal TEXT, + suggestion_dismissed INTEGER DEFAULT 0, + body TEXT, + from_addr TEXT +); +``` + +- [ ] Update the `tmp_db` fixture's `job_contacts` schema to add `body TEXT` and `from_addr TEXT` + +### Step 1.4: Run tests to confirm they all fail as expected + +```bash +cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa +/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_dev_api_interviews.py -v +``` + +Expected: 5 new/modified tests FAIL (body/from_addr not in response; reclassify endpoint 404s); existing 8 tests still PASS. + +- [ ] Run and confirm + +### Step 1.5: Update `dev-api.py` — add body/from_addr to signal SELECT and append + +Find the signal SELECT query (line ~309). Replace: + +```python + sig_rows = db.execute( + f"SELECT id, job_id, subject, received_at, stage_signal " +``` + +With: + +```python + sig_rows = db.execute( + f"SELECT id, job_id, subject, received_at, stage_signal, body, from_addr " +``` + +Then extend the `signals_by_job` append dict (line ~319). Replace: + +```python + signals_by_job[sr["job_id"]].append({ + "id": sr["id"], + "subject": sr["subject"], + "received_at": sr["received_at"], + "stage_signal": sr["stage_signal"], + }) +``` + +With: + +```python + signals_by_job[sr["job_id"]].append({ + "id": sr["id"], + "subject": sr["subject"], + "received_at": sr["received_at"], + "stage_signal": sr["stage_signal"], + "body": sr["body"], + "from_addr": sr["from_addr"], + }) +``` + +- [ ] Apply both edits to `dev-api.py` + +### Step 1.6: Add the reclassify endpoint to `dev-api.py` + +After the `dismiss_signal` endpoint (around line 388), add: + +```python +# ── POST /api/stage-signals/{id}/reclassify ────────────────────────────── + +VALID_SIGNAL_LABELS = { + 'interview_scheduled', 'offer_received', 'rejected', + 'positive_response', 'survey_received', 'neutral', + 'event_rescheduled', 'unrelated', 'digest', +} + +class ReclassifyBody(BaseModel): + stage_signal: str + +@app.post("/api/stage-signals/{signal_id}/reclassify") +def reclassify_signal(signal_id: int, body: ReclassifyBody): + if body.stage_signal not in VALID_SIGNAL_LABELS: + raise HTTPException(400, f"Invalid label: {body.stage_signal}") + db = _get_db() + result = db.execute( + "UPDATE job_contacts SET stage_signal = ? WHERE id = ?", + (body.stage_signal, signal_id), + ) + rowcount = result.rowcount + db.commit() + db.close() + if rowcount == 0: + raise HTTPException(404, "Signal not found") + return {"ok": True} +``` + +Note: `BaseModel` is already imported via `from pydantic import BaseModel` at the top of the file — check before adding a duplicate import. + +- [ ] Add the endpoint and `VALID_SIGNAL_LABELS` / `ReclassifyBody` to `dev-api.py` after the dismiss endpoint + +### Step 1.7: Run the full test suite to verify all tests pass + +```bash +/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_dev_api_interviews.py -v +``` + +Expected: all tests PASS (13 total: 8 existing + 5 new/extended). + +- [ ] Run and confirm + +### Step 1.8: Commit + +```bash +cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa +git add dev-api.py tests/test_dev_api_interviews.py +git commit -m "feat(signals): add body/from_addr to signal query; add reclassify endpoint" +``` + +- [ ] Commit + +--- + +## Task 2: Store — add body/from_addr to StageSignal interface + +**Files:** +- Modify: `web/src/stores/interviews.ts` (lines 5–10, `StageSignal` interface) + +**Why this is its own task:** Both `InterviewCard.vue` and `InterviewsView.vue` import `StageSignal`. TypeScript will error on `sig.body` / `sig.from_addr` until the interface is updated. Committing the type change first keeps Tasks 3 and 4 independently compilable. + +### Step 2.1: Update `StageSignal` in `web/src/stores/interviews.ts` + +Replace: + +```typescript +export interface StageSignal { + id: number // job_contacts.id — used for POST /api/stage-signals/{id}/dismiss + subject: string + received_at: string // ISO timestamp + stage_signal: 'interview_scheduled' | 'positive_response' | 'offer_received' | 'survey_received' | 'rejected' +} +``` + +With: + +```typescript +export interface StageSignal { + id: number // job_contacts.id — used for POST /api/stage-signals/{id}/dismiss + subject: string + received_at: string // ISO timestamp + stage_signal: 'interview_scheduled' | 'positive_response' | 'offer_received' | 'survey_received' | 'rejected' + body: string | null // email body text; null if not available + from_addr: string | null // sender address; null if not available +} +``` + +- [ ] Edit `web/src/stores/interviews.ts` + +### Step 2.2: Verify TypeScript compiles cleanly + +```bash +cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/web +npx vue-tsc --noEmit +``` + +Expected: 0 errors (the new fields are nullable so no existing code should break). + +- [ ] Run and confirm + +### Step 2.3: Commit + +```bash +cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa +git add web/src/stores/interviews.ts +git commit -m "feat(signals): add body and from_addr to StageSignal interface" +``` + +- [ ] Commit + +--- + +## Task 3: InterviewCard.vue — expand toggle, body display, reclassify chips + +**Files:** +- Modify: `web/src/components/InterviewCard.vue` + +**Context:** This component is the kanban card. It shows one signal by default, and a `+N more` button for additional signals. The `bodyExpanded` ref is per-card (not per-signal) because at most one signal is visible in collapsed state. + +### Step 3.1: Add `bodyExpanded` ref and `reclassifySignal` function + +In the `