12 KiB
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:
- Allowing users to expand a banner to read the full email body before acting
- Providing inline re-classification chips to correct inaccurate classifier labels
- 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 re-classification | Two calls: POST /api/stage-signals/{id}/reclassify (body: {stage_signal:"neutral"}) then POST /api/stage-signals/{id}/dismiss. This persists the corrected label before dismissing, preserving training signal for Avocet. |
| 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)
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
}
Important — do NOT widen the stage_signal union. The five values above are exactly what the SQL query returns (all others are excluded by the NOT IN filter). SIGNAL_META in InterviewCard.vue and SIGNAL_META_PRE in InterviewsView.vue are both typed as Record<StageSignal['stage_signal'], ...>, which requires every union member to be a key. Widening the union to include neutral, unrelated, etc. would require adding entries to both maps or TypeScript will error. The reclassify endpoint accepts all nine classifier labels server-side, but client-side we only need the five actionable ones since neutral triggers dismiss (not a local label change).
GET /api/interviews — signal query additions
Add body, from_addr to the SELECT clause of the second query in list_interviews():
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:
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:
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:
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 withpreSelectedStagebased 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-wrapstyle block From:line shown iffrom_addris 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 | Two-call optimistic dismiss: fire POST reclassify (neutral) + POST dismiss in sequence, then remove from local array |
The chip matching the current stage_signal is highlighted (active state). Clicking a non-neutral chip:
- Optimistically updates
sig.stage_signalon the local signal object - Banner accent color, action label, and
[→ Move]preSelectedStage update reactively POST /api/stage-signals/{id}/reclassifyfires in background
Clicking Neutral:
- Optimistically removes the signal from the local
stage_signalsarray POST /api/stage-signals/{id}/reclassifyfires with{ stage_signal: "neutral" }to persist the corrected label (Avocet training hook)POST /api/stage-signals/{id}/dismissfires immediately after to suppress the banner going forward
Reactive re-labeling
When sig.stage_signal changes locally (after a chip click), the banner updates immediately:
- Accent color (
amber/green/red) fromSIGNAL_META[sig.stage_signal].color - Action label in
[→ Move]pre-selection fromSIGNAL_META[sig.stage_signal].stage - Active chip highlight moves to the new label
This works because sig is accessed through Pinia's reactive proxy chain — Vue 3 wraps nested objects on access, so sig.stage_signal = 'offer_received' triggers the proxy setter and causes the template to re-evaluate.
Note: This relies on sig being a live reactive proxy, not a raw copy. It would silently fail if job or stage_signals were passed through toRaw() or markRaw(). Additionally, if store.fetchAll() fires while a reclassify API call is in flight (e.g. triggered by email sync completing), the old sig reference becomes stale — the optimistic mutation has already updated the UI correctly, and fetchAll() will overwrite with server data. Since the reclassify endpoint persists immediately, the server value after fetchAll() will match the user's intent. No special handling needed.
Expand state
bodyExpanded — local ref per banner instance. Not persisted. Use bodyExpanded consistently (not sigBodyExpanded).
In InterviewCard.vue: one ref<boolean> per card instance (const bodyExpanded = ref(false)), since each card shows at most one visible signal at a time (the others hidden behind +N more).
In InterviewsView.vue pre-list: keyed by signal id using a ref<Record<number, boolean>> (NOT a Map). Vue 3 can track property access on plain objects held in a ref deeply, so bodyExpandedMap.value[sig.id] = true triggers re-render correctly. Using a Map would have the same copy-on-write trap as Set (documented in the previous spec). Implementation:
const bodyExpandedMap = ref<Record<number, boolean>>({})
function toggleBodyExpand(sigId: number) {
bodyExpandedMap.value = { ...bodyExpandedMap.value, [sigId]: !bodyExpandedMap.value[sigId] }
}
Or alternatively, mutate in place (Vue 3 tracks object property mutations on reactive refs):
function toggleBodyExpand(sigId: number) {
bodyExpandedMap.value[sigId] = !bodyExpandedMap.value[sigId]
}
The spread-copy pattern is safer and consistent with the sigExpandedIds Set pattern used in InterviewsView.vue. Use whichever the implementer verifies triggers re-render — the spread-copy is the guaranteed-safe choice.
SIGNAL_META Sync Contract
InterviewCard.vue has SIGNAL_META and InterviewsView.vue has SIGNAL_META_PRE. Both are Record<StageSignal['stage_signal'], ...> and must have exactly the same five keys. The reclassify feature does not add new chip targets that don't already exist in both maps — the five actionable labels are the same set. No changes to either map are needed. Implementation note: if a chip label is ever added or removed, it must be updated in both maps simultaneously.
Required Test Cases (tests/test_dev_api_interviews.py)
Existing test additions
test_interviews_includes_stage_signals: extend to assertbodyandfrom_addrare present in the returned signal objects (can beNoneif no body in fixture)
New reclassify endpoint tests
test_reclassify_signal_updates_label: POST valid label → 200{"ok": true}, DB row has newstage_signalvaluetest_reclassify_signal_invalid_label: POST unknown label → 400test_reclassify_signal_404_for_missing_id: POST to non-existent id → 404
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/− lessmulti-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 signalscount in Applied section header — unchanged