peregrine/docs/superpowers/specs/2026-03-19-signal-banner-reclassify-design.md

219 lines
8.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Signal Banner Redesign — Expandable Email + Re-classification
**Date:** 2026-03-19
**Status:** Approved — ready for implementation planning
---
## Goal
Improve the stage signal banners added in the interviews improvements feature by:
1. Allowing users to expand a banner to read the full email body before acting
2. Providing inline re-classification chips to correct inaccurate classifier labels
3. Removing the implicit one-click stage advancement risk — `[→ Move]` remains but always routes through MoveToSheet confirmation
---
## Decisions Made
| Decision | Choice |
|---|---|
| Email body loading | Eager — add `body` and `from_addr` to `GET /api/interviews` signal query |
| Neutral/excluded re-classification | Optimistic dismiss (same path as ✕) |
| Re-classification persistence | Update `job_contacts.stage_signal` in place; no separate correction record |
| Avocet training integration | Deferred — reclassify endpoint is the hook; export logic added later |
| Expand state persistence | None — local component state only, resets on page reload |
| `[→ Move]` button rename | No rename — pre-selection hint is still useful; MoveToSheet confirm is the safeguard |
---
## Data Model Changes
### `StageSignal` interface (add two fields)
```typescript
export interface StageSignal {
id: number
subject: string
received_at: string
stage_signal: 'interview_scheduled' | 'positive_response' | 'offer_received' | 'survey_received' | 'rejected'
body: string | null // NEW — email body text
from_addr: string | null // NEW — sender address
}
```
### `GET /api/interviews` — signal query additions
Add `body, from_addr` to the SELECT clause of the second query in `list_interviews()`:
```sql
SELECT id, job_id, subject, received_at, stage_signal, body, from_addr
FROM job_contacts
WHERE job_id IN (:job_ids)
AND suggestion_dismissed = 0
AND stage_signal NOT IN ('neutral', 'unrelated', 'digest', 'event_rescheduled')
AND stage_signal IS NOT NULL
ORDER BY received_at DESC
```
The Python grouping dict append also includes the new fields:
```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"],
})
```
---
## New Endpoint
### `POST /api/stage-signals/{id}/reclassify`
```
POST /api/stage-signals/{id}/reclassify
Body: { stage_signal: string }
→ 200 { ok: true }
→ 400 if stage_signal is not a valid label
→ 404 if signal not found
```
Updates `job_contacts.stage_signal` for the given row. Valid labels are the nine classifier labels: `interview_scheduled`, `offer_received`, `rejected`, `positive_response`, `survey_received`, `neutral`, `event_rescheduled`, `unrelated`, `digest`.
Implementation:
```python
VALID_SIGNAL_LABELS = {
'interview_scheduled', 'offer_received', 'rejected',
'positive_response', 'survey_received', 'neutral',
'event_rescheduled', 'unrelated', 'digest',
}
@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}
```
With Pydantic model:
```python
class ReclassifyBody(BaseModel):
stage_signal: str
```
---
## Banner UI Redesign
### Layout — collapsed (default)
```
┌──────────────────────────────────────────────────┐
│ [existing card / row content] │
│──────────────────────────────────────────────────│ ← colored top border
│ 📧 Interview scheduled "Interview confirmed…" │
│ [▸ Read] [→ Move] [✕] │
└──────────────────────────────────────────────────┘
```
- Subject line: signal type label + subject snippet (truncated to ~60 chars)
- `[▸ Read]` — expand toggle (text button, no border)
- `[→ Move]` — opens MoveToSheet with `preSelectedStage` based on current signal type (unchanged from previous implementation)
- `[✕]` — dismiss
### Layout — expanded
```
┌──────────────────────────────────────────────────┐
│ [existing card / row content] │
│──────────────────────────────────────────────────│
│ 📧 Interview scheduled "Interview confirmed…" │
│ [▾ Hide] [→ Move] [✕] │
│ From: recruiter@acme.com │
│ "Hi, we'd like to schedule an interview │
│ for Tuesday at 2pm…" │
│ Re-classify: [🟡 Interview ✓] [🟢 Offer] │
│ [✅ Positive] [📋 Survey] │
│ [✖ Rejected] [— Neutral] │
└──────────────────────────────────────────────────┘
```
- `[▾ Hide]` — collapses back to default
- Email body: full text, not truncated, in a `pre-wrap` style block
- `From:` line shown if `from_addr` is non-null
- Re-classify chips: one per actionable type + neutral (see chip spec below)
### Re-classify chips
| Label | Display | Action on click |
|---|---|---|
| `interview_scheduled` | 🟡 Interview | Set as active label |
| `positive_response` | ✅ Positive | Set as active label |
| `offer_received` | 🟢 Offer | Set as active label |
| `survey_received` | 📋 Survey | Set as active label |
| `rejected` | ✖ Rejected | Set as active label |
| `neutral` | — Neutral | Optimistic dismiss (remove from local array) |
The chip matching the current `stage_signal` is highlighted (active state). Clicking a non-neutral chip:
1. Optimistically updates `sig.stage_signal` on the local signal object
2. Banner accent color, action label, and `[→ Move]` preSelectedStage update reactively
3. `POST /api/stage-signals/{id}/reclassify` fires in background
Clicking **Neutral**:
1. Optimistically removes the signal from the local `stage_signals` array (same as dismiss)
2. `POST /api/stage-signals/{id}/dismiss` fires in background (reuses existing dismiss endpoint — no need to store a "neutral" label, since dismissed = no longer shown)
### Reactive re-labeling
When `sig.stage_signal` changes locally (after a chip click), the banner updates immediately:
- Accent color (`amber` / `green` / `red`) from `SIGNAL_META[sig.stage_signal].color`
- Action label in `[→ Move]` pre-selection from `SIGNAL_META[sig.stage_signal].stage`
- Active chip highlight moves to the new label
This works because `sig` is a reactive object from the Pinia store — property mutations trigger re-render.
### Expand state
`sigBodyExpanded: boolean` — local `ref` per banner instance (per `sig.id`). Not persisted.
In `InterviewCard.vue`: one `ref` per card instance (`bodyExpanded = ref(false)`), since each card shows at most one visible signal at a time (the others behind `+N more`).
In `InterviewsView.vue` pre-list: keyed by signal id using a `Map<number, boolean>` ref, since multiple jobs × multiple signals can be expanded simultaneously.
---
## Files
| File | Action |
|---|---|
| `dev-api.py` | Add `body, from_addr` to signal SELECT; add `POST /api/stage-signals/{id}/reclassify` |
| `tests/test_dev_api_interviews.py` | Add tests for reclassify endpoint; verify body/from_addr in interviews response |
| `web/src/stores/interviews.ts` | Add `body: string \| null`, `from_addr: string \| null` to `StageSignal` |
| `web/src/components/InterviewCard.vue` | Expand toggle; body/from display; re-classify chips; reactive local re-label |
| `web/src/views/InterviewsView.vue` | Same for pre-list banner rows (keyed expand map) |
---
## What Stays the Same
- Dismiss endpoint and optimistic removal — unchanged
- `[→ Move]` button routing through MoveToSheet with preSelectedStage — unchanged
- `+N more` / ` less` multi-signal expand — unchanged
- Signal banner placement (InterviewCard kanban + InterviewsView pre-list) — unchanged
- Signal → stage → color mapping (`SIGNAL_META` / `SIGNAL_META_PRE`) — unchanged (but now reactive to re-classification)
- `⚡ N signals` count in Applied section header — unchanged