docs: fix signal banner spec — neutral two-call path, Map reactivity, type union notes
This commit is contained in:
parent
168d2b7a4a
commit
f4dd99a136
1 changed files with 48 additions and 8 deletions
|
|
@ -20,7 +20,7 @@ Improve the stage signal banners added in the interviews improvements feature by
|
|||
| 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 ✕) |
|
||||
| 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 |
|
||||
|
|
@ -43,6 +43,8 @@ export interface StageSignal {
|
|||
}
|
||||
```
|
||||
|
||||
**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()`:
|
||||
|
|
@ -167,7 +169,7 @@ class ReclassifyBody(BaseModel):
|
|||
| `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) |
|
||||
| `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:
|
||||
1. Optimistically updates `sig.stage_signal` on the local signal object
|
||||
|
|
@ -175,8 +177,9 @@ The chip matching the current `stage_signal` is highlighted (active state). Clic
|
|||
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)
|
||||
1. Optimistically removes the signal from the local `stage_signals` array
|
||||
2. `POST /api/stage-signals/{id}/reclassify` fires with `{ stage_signal: "neutral" }` to persist the corrected label (Avocet training hook)
|
||||
3. `POST /api/stage-signals/{id}/dismiss` fires immediately after to suppress the banner going forward
|
||||
|
||||
### Reactive re-labeling
|
||||
|
||||
|
|
@ -185,15 +188,52 @@ When `sig.stage_signal` changes locally (after a chip click), the banner updates
|
|||
- 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.
|
||||
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
|
||||
|
||||
`sigBodyExpanded: boolean` — local `ref` per banner instance (per `sig.id`). Not persisted.
|
||||
`bodyExpanded` — local `ref` per banner instance. Not persisted. Use `bodyExpanded` consistently (not `sigBodyExpanded`).
|
||||
|
||||
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 `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 `Map<number, boolean>` ref, since multiple jobs × multiple signals can be expanded simultaneously.
|
||||
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:
|
||||
|
||||
```typescript
|
||||
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):
|
||||
```typescript
|
||||
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 assert `body` and `from_addr` are present in the returned signal objects (can be `None` if no body in fixture)
|
||||
|
||||
### New reclassify endpoint tests
|
||||
- `test_reclassify_signal_updates_label`: POST valid label → 200 `{"ok": true}`, DB row has new `stage_signal` value
|
||||
- `test_reclassify_signal_invalid_label`: POST unknown label → 400
|
||||
- `test_reclassify_signal_404_for_missing_id`: POST to non-existent id → 404
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue