--- type: task-progress status: active repos: - repo: dyolink path: ~/PersonalProjects/dyolink branch: feat/treatment/add-voice-input-for-new-treatment-form base: origin/master role: extraction contract + resolvers + review sheet spec_slug: voice-treatment-entry merge_after: [] --- # PROGRESS — Voice Treatment Entry Spec: [spec.md](./spec.md) Adapting the merged voice feature to the overhauled prosthesis model (spec §5–§7, decisions 34–46). v1 shipped on `master`; this branch revises it. 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 6–8, 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 | ## Key decisions Full table in the spec's §13, rows 34–46. The load-bearing ones: - **One assignment list, no default type.** A default plus per-tooth overrides has a precedence rule, and that rule already mis-filled tooth 13 once. - **An arch target is derived from the code's `chartRegion`, not declared on the wire.** The catalog already answers it, and `partial_denture` already carries `chartRegion: 'crown'`. - **Stack rules stay frontend-only.** The backend's dispatch check knows nothing about stacking; porting `canStackLeaf` would make voice stricter than the manual path and create a second copy of a rule that must never disagree. - **Teeth and prosthesis merge into one sheet row for lab-dependent types.** Two ticks can save an empty detail today, because `persistDraft` prunes lab-dependent details to their jobs. - **Nothing is guessed.** A missing material, a missing jaw and a missing quadrant all become chips. A jobless tooth is named and left out. - **Categories become real catalog entities** (decision 47). Two new `CatalogEntityKind` values and seeded translations, rather than bare untranslated codes in the prompt on `fa`. - **A chip must inherit its assignment's jobs** (decision 50), or it resolves to a jobless tooth and the tap does nothing. ## Deviations from spec (none yet) ## Next steps / open questions - Re-run `/orchestrate --dry` with a `gapNote`, so the surveyor re-runs instead of replaying its cached `gaps_found` verdict. - Amin reviews and approves this spec revision. That approval is the only human gate before `/orchestrate --dry`. - Open items 15 and 16 in §11 are carried forward unchanged and are **not** in this branch: the pre-authentication body limit on the voice path, and transcript salvage still being specified but not built. - `feat/voice-treatment-entry` and `backup/pre-rebase-voice` are stale local branches from the v1 work. Safe to delete once this lands. --- ## 2026-09-07 — Gap-check halted (nothing built, nothing pushed) - **Phase:** Gap-check - **Reason:** the spec has 1 gap that blocks implementation — nothing was built. - **Repo:** dyolink @ `feat/treatment/add-voice-input-for-new-treatment-form` ### Blocking gap 1 — `missing_constraint` (dyolink) **Summary:** The prompt must present prosthesis categories and subcategories so the model can return `crown` for "روکش", but no localized label for a category or subcategory exists anywhere the backend can read. `CatalogEntityKind` has only TREATMENT_TYPE / PROSTHESIS_TYPE / LAB_WORKFLOW_STEP, and the only category labels in the repo are frontend message keys (`category_crown`, `sub_night_guard`) plus a hardcoded single-locale map in a dev export script. **Spec quotes:** > **The prosthesis list is no longer flat.** `buildCatalog` already receives `category`, `subcategory`, `chartRegion` and `stackGroup` from `ProsthesisCatalogService` and throws all four away. It now passes them through, so the prompt presents the catalog as the tree it is and marks which codes are jaw-level. Still no catalog knowledge hardcoded in the prompt — the shape comes from the data. > Treatment types and prosthesis types come from `CatalogLabelService` in the actor's locale, so the model sees "پروتز" and "زیرکونیا مونولیتیک" as the spoken forms of `prosthesis` and `monolithic_zirconia` rather than being asked to translate. Catalog entities store a stable `code` and no label — never hardcode a label. > "روکش" is *crown* — a category with nine leaves, not a material. The model returns the category code it actually heard rather than guessing `pfm_crown`, and the review sheet offers the leaves as chips (§7). **Why blocking:** Work item 9 ("Prompt: present the catalog as a tree") cannot be built. `ProsthesisCatalogService.list()` returns `category` and `subcategory` as bare codes with no label (prosthesis-catalog.service.ts:82-95), and `CatalogLabelService` cannot resolve them because `CatalogEntityKind` has no category kind (schema.prisma:331-335). The three ways out have different costs and one breaks an explicit repo rule, so a builder cannot choose: (a) add PROSTHESIS_CATEGORY / PROSTHESIS_SUBCATEGORY to the enum plus a migration and seeded translations — a schema change the spec never mentions; (b) hardcode a fa/en/nl label map in the backend, which the spec and CLAUDE.md both forbid; (c) send bare codes only, which makes the whole category feature unmeasured on the weakest locale. **Question:** Where do the localized labels for the 7 prosthesis categories and 5 subcategories come from for the extraction prompt: a new `CatalogEntityKind` (PROSTHESIS_CATEGORY / PROSTHESIS_SUBCATEGORY) with a migration and seeded `CatalogTranslation` rows, or bare codes with no labels in the prompt? **Suggested spec change:** Add to §5: "Category and subcategory labels have no backend source today — `CatalogEntityKind` covers only TREATMENT_TYPE, PROSTHESIS_TYPE and LAB_WORKFLOW_STEP, and `category_*` / `sub_*` exist only in `frontend/messages/*.json`. Add `PROSTHESIS_CATEGORY` and `PROSTHESIS_SUBCATEGORY` to `CatalogEntityKind` (one migration), seed their fa/en/nl translations from the existing message keys, and resolve them through `CatalogLabelService` like every other catalog label." **Evidence:** backend/prisma/schema.prisma:331; backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts:82; frontend/messages/en.json:1124; backend/prisma/export-prosthesis-catalog.ts:16 ### Non-blocking notes **Note 1 — `ambiguity` (dyolink).** An unresolved item carries only `spoken`, `reason` and `candidates`, with no link back to the assignment it came from. On a prosthesis detail a `tooth_missing_quadrant` chip therefore resolves to a tooth with no job, which the "tooth with no job is named and left out" rule then discards — so tapping the chip does nothing. > **A single digit alone is never resolved.** "دندون دو" names four teeth. It is reported as `tooth_missing_quadrant` **with the candidate codes attached** > **A tooth with no job is named and left out.** > Picking one folds the choice into the result, so an under-specified item is one tap from resolved instead of a dead end. *Why not blocking:* a builder can add an assignment reference to the unresolved item. But §6's reason table reads as a complete contract and its "Carries" column names only `candidates`, so a builder following it literally ships a dead chip on exactly the prosthesis flow §12 asks them to test. *Question:* Should `VoiceUnresolvedItem` gain an assignment reference (for example `assignmentIndex`) so a chosen quadrant chip inherits that assignment's job stack? *Suggested spec change:* In §6's reason table, add: "`tooth_missing_quadrant` and `arch_not_spoken` also carry the index of the assignment they came from, so a picked chip inherits that assignment's `types[]` rather than becoming a jobless tooth." *Evidence:* frontend/src/types/voice.ts:15-25; frontend/src/components/treatment/voiceReviewRows.ts:66 **Note 2 — `ambiguity` (dyolink).** "The resolver confirms [jaw vs tooth] against the `chartRegion` of the assignment's codes" is undefined when `types[]` holds a category whose leaves have mixed regions. `removable` is exactly that: `complete_denture` and `overdenture` are `chartRegion: 'arch'`, `partial_denture` is `'crown'`. > A target that names an arch and no position is a jaw, and the resolver confirms that against the `chartRegion` of the assignment's codes. > `partial_denture` — a `removable` code that is nonetheless assigned per tooth — already ships with `chartRegion: 'crown'` in `catalog-seed-data.ts`, so the exception needs no special case either. > A code whose region contradicts its target is reported, never coerced. *Why not blocking:* deferring the region check until the ambiguity chip is picked is a reasonable default. Worth stating so the builder does not emit `code_not_valid_for_target` for a category that has not been narrowed yet. *Question:* When `types[]` holds a category whose leaves span both `crown` and `arch` regions (only `removable` today), is the region check deferred until the clinician picks a leaf chip? *Suggested spec change:* In §5 under "A target is a tooth or a jaw": "When `types[]` holds a category whose leaves have more than one `chartRegion`, the region check is deferred — the item resolves to `prosthesis_type_ambiguous` and the region is confirmed against the leaf the clinician picks." *Evidence:* backend/prisma/catalog-seed-data.ts:266-288 **Note 3 — `brownfield` (dyolink).** §3 names the endpoint `POST /treatments/voice-extract`; the shipped route is `POST /voice/extract` (`@Controller('voice')` + `@Post('extract')`), and `common/body-parsers.ts` hardcodes `/api/voice/extract` to select the 10 MB body limit. > `POST /treatments/voice-extract` *Why not blocking:* no work item asks to move the route. But if a builder aligned the route with §3, `isVoiceExtractPath` would stop matching and every real recording would 413 against the 100 kb default, which reads as a broken microphone rather than a route change. *Question:* Should §3 be corrected to the shipped path `POST /voice/extract`, since no work item moves the route? *Suggested spec change:* Change §3's heading to `POST /voice/extract` and note that `VOICE_EXTRACT_PATH` in `common/body-parsers.ts` must move with it if it ever changes. *Evidence:* backend/src/modules/voice/voice.controller.ts:23,36; backend/src/common/body-parsers.ts:10 **Note 4 — `contradiction` (dyolink).** §11 item 13 says v1 ships with "no availability endpoint", but §4 requires the frontend to learn enabled locales from the API, and `GET /voice/availability` exists and is what `voiceForEditor` reads today. > ~~**Availability API**~~ — **resolved for v1:** voice ships **open to everyone** with a configured locale profile. No plan check, no availability endpoint. > The frontend must learn which locales are enabled **from the API**, not from a `NEXT_PUBLIC_*` var *Why not blocking:* work item 2 ("`voiceForEditor` also checks `isMediaRecorderSupported()`") presupposes `voiceAvailability` stays, so the working reading is obvious. The stale line just needs correcting so nobody deletes a live endpoint. *Question:* Confirm the availability endpoint stays in v1 and only the plan check is deferred? *Suggested spec change:* Reword item 13 to "no plan check; the availability endpoint ships and reports configured locales and `maxRecordingMs` only." *Evidence:* backend/src/modules/voice/voice.controller.ts:28; frontend/src/components/ui/treatment/TreatmentWorkspace.tsx:625 **Note 5 — `brownfield` (dyolink).** §5's disjointness counts are slightly off: the seed defines 41 distinct prosthesis codes (not 42) and 5 subcategory names — veneer, inlay, onlay, overlay, night_guard (not 4). The disjointness claim itself holds: no leaf code equals any category or subcategory name. > This needs no extra field because the namespaces are disjoint: 42 leaf codes against 7 category names and 4 subcategory names, no collisions. *Why not blocking:* the test asserts disjointness, not counts, and disjointness is true against the current seed. Flagged so the builder writes the test against the live catalog rather than a hardcoded 42/7/4. *Question:* May the disjointness test assert against the live catalog instead of the counts written in §5? *Suggested spec change:* Drop the exact counts and keep only "the leaf, category and subcategory namespaces are disjoint; a test asserts it against the live catalog". *Evidence:* backend/prisma/catalog-seed-data.ts:121-400 ### Files read during the gap-check `docs/specs/voice-treatment-entry/spec.md`, `docs/specs/voice-treatment-entry/progress.md`, `backend/src/modules/voice/extraction.prompt.ts`, `backend/src/modules/voice/extraction.wire.ts`, `backend/src/modules/voice/voice.providers.ts`, `backend/src/modules/voice/voice.service.ts:243`, `backend/src/modules/voice/voice.controller.ts:23`, `backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts:72`, `backend/prisma/catalog-seed-data.ts`, `backend/prisma/schema.prisma:331`, `backend/prisma/export-prosthesis-catalog.ts:16`, `backend/src/common/fdi.ts`, `backend/src/common/body-parsers.ts:10`, `backend/src/modules/treatments/lab-case-send.validation.ts:116`, `frontend/src/components/treatment/prosthesisTree.ts`, `frontend/src/components/treatment/voiceReviewRows.ts`, `frontend/src/types/voice.ts`, `frontend/src/lib/voice/audioFormat.ts`, `frontend/src/lib/voice/useVoiceCapture.ts:157`, `frontend/src/components/ui/treatment/TreatmentWorkspace.tsx:625` and `:2184`, `frontend/src/components/ui/treatment/FdiToothChart.tsx:218`, `frontend/src/components/ui/treatment/ProsthesisJobPopover.tsx:200`, `frontend/messages/en.json:1124`, `frontend/package.json` (all paths relative to `/Users/aminmsvi/PersonalProjects/dyolink`) ### How to resume Close the gaps in the spec, then re-run. Pass `gapNote` describing what you changed, or the analyst replays this same verdict from cache. --- ## 2026-09-07 — Gaps closed, ready to re-run All five findings are now answered in the spec. Nothing was built in the halted run, so there is no code to revisit. | Finding | Resolution | Spec | |---|---|---| | **Blocking** — no backend source for category labels | Add `PROSTHESIS_CATEGORY` + `PROSTHESIS_SUBCATEGORY` to `CatalogEntityKind`, one migration, seed fa/en/nl from the existing `prosthesis.category_*` / `sub_*` keys, resolve via `CatalogLabelService`. Frontend keeps its own keys — migrating it is out of scope | §5, decisions 47–48 | | Note 1 — chip with no assignment link | Unresolved items raised inside an assignment carry `assignmentIndex`; a picked chip inherits that assignment's `types[]` | §6, decision 50 | | Note 2 — mixed-region category | `removable` defers its region check to the picked leaf; never `code_not_valid_for_target` while still a category | §5, decision 49 | | Note 3 — wrong endpoint path in §3 | Corrected to `POST /voice/extract`, with the `body-parsers.ts` coupling called out. No work item moves the route | §3 | | Note 4 — availability endpoint contradiction | §11 item 13 corrected: the endpoint exists, only the plan check is deferred | §11 | | 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.