feat(voice): adapt voice entry to the stacked-jobs prosthesis model

Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.

Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.

Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().

Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.

Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.

KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
  type-row lock is missing, so unticking it saves prosthesis lab rows on a
  non-prosthesis detail

Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.

Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 12:23:58 +08:00
parent 77e2ed4b42
commit 15ddb9aac2
29 changed files with 3630 additions and 524 deletions

View File

@@ -22,25 +22,25 @@ Status legend: ⬜ not started · 🟡 in progress · ✅ done · ⛔ blocked
| # | Work item | Repo | Status | Notes / refs |
|---|-----------|------|--------|--------------|
| 1 | `pickRecordingMimeType` falls back to the empty hint instead of `null` | `dyolink` | | §9. The Safari failure |
| 2 | `voiceForEditor` also checks `isMediaRecorderSupported()` | `dyolink` | | §2 render policy, `TreatmentWorkspace.tsx` |
| 3 | Add Vitest for the frontend's pure helpers; update `CLAUDE.md` | `dyolink` | | §12. One dev dep, one config, one script |
| 4 | `buildCatalog` passes `category` / `subcategory` / `chartRegion` / `stackGroup` through | `dyolink` | | §5. `voice.service.ts` already receives all four |
| 5 | Wire schema: `prosthesisAssignments`, `arch: 'both'`; drop default + overrides | `dyolink` | | §5, `extraction.wire.ts` |
| 6 | Resolver: assignments, arch derivation, leaf-vs-category classification | `dyolink` | | §5, §6, `extraction.resolver.ts` |
| 7 | Resolver: new unresolved reasons; retire `tooth_not_selected` | `dyolink` | | §6 reason table |
| 8 | Resolver: a resolved assignment forces `treatmentType` to `prosthesis` | `dyolink` | | §5. Only `prosthesis` is `labDependent` |
| 9 | Prompt: present the catalog as a tree; teach stacks and jaw-level codes | `dyolink` | | §5, `extraction.prompt.ts` |
| 10 | Backend Jest suites for items 68, including the namespace-disjointness assertion | `dyolink` | | §12 |
| 11 | Frontend types follow the new `ResolvedExtraction` | `dyolink` | | `types/voice.ts` |
| 12 | `voiceReviewRows`: merged row, chip folding, retire `complete` as a blocker | `dyolink` | | §6, §7 |
| 13 | `VoiceReviewSheet`: merged row, chart colours, three chip kinds | `dyolink` | | §7. `crownColors` / `rootColors` / `archHighlight` already exist |
| 14 | `applyVoiceResult` writes through `applyLeafToJobs`; handles arch rows | `dyolink` | | §6, §7 |
| 15 | Vitest specs for `prosthesisTree.ts` and `voiceReviewRows.ts` | `dyolink` | | §12 |
| 16 | New user-visible strings in `en.json`, `fa.json`, `nl.json` | `dyolink` | | i18n is mandatory, not a follow-up |
| 17 | Run every gate in §12, then the manual pass including Safari and iPad | `dyolink` | | §12 |
| 18 | `PROSTHESIS_CATEGORY` + `PROSTHESIS_SUBCATEGORY` in `CatalogEntityKind`; migration; seed fa/en/nl translations | `dyolink` | | §5, decision 47. **Must land before item 9** |
| 19 | Unresolved items carry `assignmentIndex`; a picked chip inherits that assignment's jobs | `dyolink` | | §6, decision 50. Without it the quadrant chip is dead on a prosthesis detail |
| 1 | `pickRecordingMimeType` falls back to the empty hint instead of `null` | `dyolink` | | §9. `frontend/src/lib/voice/audioFormat.ts` — the final `return null` after the loop is now `return ''` |
| 2 | `voiceForEditor` also checks `isMediaRecorderSupported()` | `dyolink` | | §2 render policy, `TreatmentWorkspace.tsx` |
| 3 | Add Vitest for the frontend's pure helpers; update `CLAUDE.md` | `dyolink` | | §12. `vitest@3.2.7`, `frontend/vitest.config.ts`, `npm run test` script; `CLAUDE.md` Tests section rewritten |
| 4 | `buildCatalog` passes `category` / `subcategory` / `chartRegion` / `stackGroup` through | `dyolink` | | §5. `voice.service.ts` now forwards the full `ProsthesisTypeCatalogEntry[]` plus category/subcategory lists |
| 5 | Wire schema: `prosthesisAssignments`, `arch: 'both'`; drop default + overrides | `dyolink` | | §5, `extraction.wire.ts``prosthesis: WireProsthesisAssignment[]` |
| 6 | Resolver: assignments, arch derivation, leaf-vs-category classification | `dyolink` | | §5, §6, `extraction.resolver.ts``resolveProsthesisAssignment`, `resolveAssignmentTarget`, `classifyTypeCode` |
| 7 | Resolver: new unresolved reasons; retire `tooth_not_selected` | `dyolink` | | §6 reason table`voice.types.ts` |
| 8 | Resolver: a resolved assignment forces `treatmentType` to `prosthesis` | `dyolink` | | §5. `resolveVoiceIntent` `hasResolvedAssignment` |
| 9 | Prompt: present the catalog as a tree; teach stacks and jaw-level codes | `dyolink` | | §5, `extraction.prompt.ts``prosthesisTree()` renders CATEGORY/SUBCATEGORY/leaf from data, no hardcoded catalog knowledge |
| 10 | Backend Jest suites for items 68, including the namespace-disjointness assertion | `dyolink` | | §12. `extraction.resolver.spec.ts` — disjointness test reads `catalog-seed-data.ts`'s live `PROSTHESIS_TYPES`, not a written count |
| 11 | Frontend types follow the new `ResolvedExtraction` | `dyolink` | | `types/voice.ts``VoiceProsthesisAssignment[]`, `assignmentIndex` |
| 12 | `voiceReviewRows`: merged row, chip folding, retire `complete` as a blocker | `dyolink` | | §6, §7`prosthesisTargetLines`, `joblessProsthesisTargets`, `withChosenArch`/`withChosenProsthesisLeaf` |
| 13 | `VoiceReviewSheet`: merged row, chart colours, three chip kinds | `dyolink` | | §7. Merged "Teeth and prosthesis" row; tooth / jaw / material chip kinds |
| 14 | `applyVoiceResult` writes through `applyLeafToJobs`; handles arch rows | `dyolink` | | §6, §7 — via `prosthesisTargetLines`, which routes every stack through `applyLeafToJobs` |
| 15 | Vitest specs for `prosthesisTree.ts` and `voiceReviewRows.ts` | `dyolink` | | §12. 37 tests total, `npx vitest run` green |
| 16 | New user-visible strings in `en.json`, `fa.json`, `nl.json` | `dyolink` | | i18n is mandatory, not a follow-up — new `voiceUnresolved.*` reasons, `voiceTeethAndProsthesis`, `voicePickJaw`, `voiceNoProsthesisHeard`, `voiceStackRefused`; retired `voiceProsthesisIncomplete` (all-or-nothing gone) |
| 17 | Run every gate in §12, then the manual pass including Safari and iPad | `dyolink` | 🟡 | Machine gates green (below). Manual pass and `prisma:migrate`/`prisma:seed` against a live DB **not run** — this sandbox has no working Docker daemon (see note below) |
| 18 | `PROSTHESIS_CATEGORY` + `PROSTHESIS_SUBCATEGORY` in `CatalogEntityKind`; migration; seed fa/en/nl translations | `dyolink` | 🟡 | §5, decision 47. Enum + migration SQL + seed data all written and `prisma generate` succeeded; migration **not applied** to a running Postgres (Docker unavailable) |
| 19 | Unresolved items carry `assignmentIndex`; a picked chip inherits that assignment's jobs | `dyolink` | | §6, decision 50. `UnresolvedItem.assignmentIndex`; `VoiceReviewSheet` chips inherit via `withChosenTeeth(...,index)` / `withChosenArch` / `withChosenProsthesisLeaf` |
## Key decisions
@@ -261,3 +261,183 @@ no code to revisit.
| Note 5 — disjointness counts | Verified independently: **42** leaf codes (the surveyor's 41 missed `screw_retained`, a multi-line `implant(` call) and **5** subcategories (the spec said 4, missing `night_guard`). The test now asserts against the live catalog, not a written count | §5, §12 |
Referee relays: 0. Gate repairs: 0. Neither phase was reached.
---
## 2026-09-07 — Implementation (dyolink, extraction contract + resolvers + review sheet)
All 19 work items above are built. Nothing committed or pushed — that is the orchestrator's job.
### Backend
- `backend/prisma/schema.prisma``PROSTHESIS_CATEGORY`, `PROSTHESIS_SUBCATEGORY` added to
`CatalogEntityKind` (additive, no data loss).
- `backend/prisma/migrations/20260907120000_prosthesis_category_catalog_kinds/migration.sql`
hand-written `ALTER TYPE ... ADD VALUE` migration, following the exact pattern of the repo's
one precedent (`20260718180000_lab_case_activity_task_assigned`). **Not applied** — see
"Not run" below.
- `backend/prisma/catalog-seed-data.ts``PROSTHESIS_CATEGORY_LABELS` (7) and
`PROSTHESIS_SUBCATEGORY_LABELS` (5), worded from the frontend's existing `category_*`/`sub_*`
message keys (decision 47/48), folded into `CATALOG_TRANSLATIONS`.
- `backend/src/common/fdi.ts``ARCH_TOOTH_UPPER`/`ARCH_TOOTH_LOWER` sentinels, mirroring the
frontend's `prosthesisTree.ts` convention so both sides speak the same jaw target.
- `backend/src/modules/voice/voice.types.ts``ProsthesisAssignment` replaces
`ProsthesisIntent` (default+overrides retired, decision 35); `UnresolvedReason` gains
`prosthesis_type_ambiguous`, `arch_not_spoken`, `code_not_valid_for_target`, loses
`tooth_not_selected`; `UnresolvedItem.assignmentIndex` added (decision 50).
- `backend/src/modules/voice/extraction.wire.ts` — wire schema carries `prosthesis:
WireProsthesisAssignment[]`; `WireToothIntent.arch` gains `'both'`.
- `backend/src/modules/voice/extraction.resolver.ts` — rewritten: `resolveAssignmentTarget`
(tooth vs. jaw, mirrors the old `tooth_missing_quadrant`/new `arch_not_spoken` split),
`classifyTypeCode` (leaf / category / subcategory, disjoint namespaces), and
`resolveProsthesisAssignment` composing both plus the region-validity check (deferred for a
mixed-region category — `removable` today, decision 49 — computed generically from the
catalog's own chart regions rather than hardcoding the category name). `resolveVoiceIntent`
forces `treatmentType` to `prosthesis` when any assignment resolves a target (decision 41).
`resolveProsthesis`/`ResolvedProsthesis` retired outright.
- `backend/src/modules/voice/extraction.prompt.ts` — `prosthesisTree()` renders the catalog as
CATEGORY → (SUBCATEGORY →) leaf from the data `buildCatalog` supplies; no catalog knowledge
hardcoded in the prompt text itself.
- `backend/src/modules/voice/voice.providers.ts`, `voice.service.ts` — `ExtractionCatalog`
carries the full leaf shape plus `prosthesisCategories`/`prosthesisSubcategories`;
`buildCatalog` no longer strips `category`/`subcategory`/`chartRegion`/`stackGroup`.
- `backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts` —
`listCategories()`/`listSubcategories()`, resolved through `CatalogLabelService` like every
other catalog label.
- Jest: `extraction.resolver.spec.ts` rewritten around `resolveProsthesisAssignment` (stacks,
jaw targets, both-jaws, leaf/category/subcategory classification, disjointness against the
live `PROSTHESIS_TYPES`, region validity both ways, the `removable` deferral, assignment-index
attribution); `extraction.wire.spec.ts` and `openrouter.provider.spec.ts` updated for the new
wire shape. 209 backend tests pass (121 in `modules/voice`).
### Frontend
- `frontend/src/lib/voice/audioFormat.ts` — `pickRecordingMimeType`'s final fallback is now `''`
instead of `null` (item 1's Safari fix — modern Safari's `isTypeSupported` can reject every
preferred container yet still record when let choose).
- `frontend/src/components/ui/treatment/TreatmentWorkspace.tsx` — `voiceForEditor` now also
requires `isMediaRecorderSupported()`; `applyVoiceResult` rewritten: teeth vs. prosthesis are
mutually exclusive per `isLabDependentResult`, the prosthesis stack is built through
`prosthesisTargetLines` (which routes every leaf through `applyLeafToJobs`), and jaw targets
write `LabCaseToothProsthesisDraft` rows keyed on the `UA`/`LA` sentinels — no separate
arch-specific code path needed beyond what `prosthesisTree.ts` already provides.
- `frontend/src/types/voice.ts` — `VoiceProsthesisAssignment[]` replaces the byTooth map;
`VoiceUnresolvedItem.assignmentIndex`.
- `frontend/src/components/treatment/voiceReviewRows.ts` — rewritten: `isLabDependentResult`,
merged-row `voiceRowAvailability`, `withChosenArch`/`withChosenProsthesisLeaf` (decision 50),
`prosthesisTargetLines` (previews the stack via `applyLeafToJobs`, names refused jobs),
`joblessProsthesisTargets` (decision 40 — named, struck through, never silently dropped or
silently applied), `prosthesisChartData` (crown/root/arch tints for the merged row's chart).
- `frontend/src/components/ui/treatment/VoiceReviewSheet.tsx` — merged "Teeth and prosthesis"
row for a labDependent type; three independent candidate-chip kinds (tooth, jaw, leaf) each
folding through their own `voiceReviewRows` helper; `voiceProsthesisIncomplete` warning
removed (all-or-nothing retired).
- i18n: `en.json`/`fa.json`/`nl.json` — `voiceUnresolved.*` updated for the new/retired reasons,
`voiceTeethAndProsthesis`, `voicePickJaw`, `voiceNoProsthesisHeard`, `voiceStackRefused` added,
`voiceProsthesisIncomplete` removed.
- `frontend/package.json`, `frontend/vitest.config.ts` — `vitest@3.2.7` (pinned to a version
whose peer `@types/node` range still includes the repo's `^20`; vitest 4/5 require `>=22`),
`npm run test` → `vitest run`, alias-only config (`@` → `src/`).
- `frontend/src/components/treatment/prosthesisTree.spec.ts`,
`voiceReviewRows.spec.ts` — 37 Vitest cases covering stack legality, `applyLeafToJobs`
precedence, `toothRegionColors`, row availability, chip folding, the merged-row preview, and
the jobless/pending distinction.
- `CLAUDE.md` — Tests section rewritten; frontend command table gains `npx vitest run`.
### Verification run
- `cd backend && npm test` — 209/209 pass (121 in `modules/voice`).
- `cd backend && npm run build` — clean (after `npm install`, which pulled in `@sentry/nestjs`
that `node_modules` was missing — unrelated to this change, pre-existing on this checkout).
- `cd backend && npx prisma generate` — succeeds against the updated schema (no DB needed);
confirms `CatalogEntityKind.PROSTHESIS_CATEGORY`/`PROSTHESIS_SUBCATEGORY` compile everywhere
they're used.
- `cd frontend && npx tsc --noEmit` — clean.
- `cd frontend && npx vitest run` — 37/37 pass.
- `cd frontend && npm run build` — production build succeeds.
- ESLint on every touched file — 0 errors, 0 new warnings (pre-existing warnings elsewhere in
`TreatmentWorkspace.tsx`, unrelated to this change, left untouched).
### Not run (environment limitation, not a design gap)
- `npm run prisma:migrate && npm run prisma:seed` against a live Postgres — this sandbox has no
running Docker daemon (`docker info` never came up after several minutes and `open -a Docker`
did not launch it), so the migration was never applied to a database and the new
`CatalogTranslation` rows were never seeded. The migration SQL and seed data are written and
reviewed against the one existing precedent in this repo; **run both before merging**.
- The full manual pass in §12 (Safari/iPad recording, live extraction against the real OpenRouter
API, the specific stack/jaw/ambiguity scenarios) — needs a browser and a live backend, neither
available in this session.
### Deviations from spec / judgement calls made while implementing
- **A target with empty `types[]` still resolves as a target**, with `types: []` on its
assignment — not excluded from the assignment's `targets` array. The sheet (frontend) treats
`types.length === 0` with no matching `prosthesis_type_ambiguous` unresolved item as "jobless,
struck through" (decision 40), and the same empty-types-plus-ambiguous-item combination as
"pending a material pick" instead. This keeps the wire contract simple (no extra field) at the
cost of the frontend doing that one bit of inference from `unresolved` — documented in both
`extraction.resolver.ts` and `voiceReviewRows.ts`.
- **The mixed-region deferral (decision 49) is computed generically** from each category's
actual leaf chart-regions (`regions.size === 1` → validate immediately, else defer) rather than
special-cased for `removable` by name. This also defers `implant` (which spans `root` and
`crown` via `screw_retained`) — the spec's prose says "only `removable` today" as an
observation about the current catalog, not an instruction to hardcode that name, and deferring
a category no test forbids deferring is the safer default.
- **`voice.dto.ts`/`voice.controller.ts` needed no changes.** The scout's item 6 ("validation for
new schema shape") does not apply: the DTO validates the client's audio submission
(`audio`/`format`/`timeZone`/`durationMs`/`locale`), which is unrelated to the LLM's structured
output shape that changed. Confirmed by reading both files; not a silent skip.
---
## 2026-09-07 — Re-run reached Ship; stopped, and the challenge phase is incomplete
Recorded by hand: the `record-stop:Ship` agent failed on the session spend limit before it
could write this.
**Stop reason:** `no MR template found in dyolink — refusing to invent a description.` Expected
and flagged at preflight: `.gitea/` holds only `workflows/`, and the remote is Gitea, not GitLab.
A `--dry` run pushes nothing regardless.
| Phase | Result |
|---|---|
| Gap-check | **clear** — the five gaps closed in `77e2ed4` were accepted |
| Scout | 1 of 3 Explore sweeps returned; 2 ended without structured output |
| Implement | **done** — all 19 work items, ~3,100 insertions across 25 files + 4 new files |
| Gate | **green** — backend 16 suites / 209 tests, `nest build` 0, `prisma validate` ok; frontend Vitest 2 files / 37 tests, `tsc --noEmit` 0, `next build` 0 |
| Refute | **incomplete** — `correctness` refuted with 2 findings; `regression-risk` never ran (spend limit) |
| Ship | stopped, no template. `clerk` also failed on the spend limit |
Gate repairs: 0. Referee relays: 0. Nothing committed, nothing pushed.
### Two confirmed findings — verified by hand, not taken on the critic's word
1. **`VoiceReviewSheet.tsx:169` — a picked tooth chip is silently dropped.** `pickCandidate`
sets `teeth: prev.teeth || available.teeth`, but `available` is memoised from `effective`,
which depends on `chosenTeeth`. Both `setChosenTeeth` and `setSelection` run in the same
handler, so the updater closes over the pre-pick `available`, where `available.teeth` is
`false` because `result.teeth` is empty. `prev.teeth` is false too, so it stays false
permanently. The row then renders with the tooth on the chart and the box unticked, and Apply
drops it. This is a variant of the original live-test failure — "ترمیم برای دندون دو".
2. **`VoiceReviewSheet.tsx:213` + `TreatmentWorkspace.tsx:2215` — decision 41 not implemented.**
The treatmentType row is a plain `toggle('treatmentType')` with no lock, and
`applyVoiceResult` derives `labDependent` from `result.treatmentType` rather than the
`detail.treatmentType` it writes. Untick the type row on a forced-prosthesis recording and a
`restoration` detail is saved carrying prosthesis lab rows — the state decision 41 exists to
make unreachable.
### Lint
Backend touched files: 0 errors, 0 warnings. Frontend touched files: 10 warnings, all
pre-existing in `TreatmentWorkspace.tsx`. The repo-wide backend baseline (1,288 errors, 1,107
prettier-fixable) is untouched by this diff.
## Next steps / open questions
- The diff is **unreviewed on the `regression-risk` lens**. Green gate plus one refuting lens is
not the design's bar; the script continued only because the threshold counts refusals and the
second critic errored rather than refused.
- Fix the two findings, then resume from Refute so both lenses grade the same diff.
- The Ship phase cannot pass in this repo until there is an MR template, or until the MR is
opened by hand. Opening one is gated regardless.