Files
dyolink/docs/specs/voice-treatment-entry/progress.md
Amin Mousavi 77e2ed4b42 docs: close the gaps the orchestrate surveyor found
The Gap-check phase halted the run on one blocking gap and reported four
notes. Nothing was built.

Blocking: the prompt cannot offer a prosthesis category the model can name,
because no category label exists anywhere the backend can read —
CatalogEntityKind covers only TREATMENT_TYPE, PROSTHESIS_TYPE and
LAB_WORKFLOW_STEP. Adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY as
catalog entities with seeded fa/en/nl translations, rather than sending bare
codes that would read untranslated on the locale this feature exists for.

Also closed: a chip that resolved to a jobless tooth and was then discarded
by the jobless-tooth rule, so the tap did nothing; the undefined region check
for a category whose leaves span crown and arch; the wrong endpoint path in
§3; and the availability-endpoint contradiction in §11.

Corrects two counts of my own: 5 subcategories, not 4 (night_guard was
missed), and the disjointness test now asserts against the live catalog
rather than a number written in prose.

Decisions 47-50. Work items 18-19 added to the ledger.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 04:54:02 +08:00

18 KiB
Raw Blame History

type, status, repos
type status repos
task-progress active
repo path branch base role spec_slug merge_after
dyolink ~/PersonalProjects/dyolink feat/treatment/add-voice-input-for-new-treatment-form origin/master extraction contract + resolvers + review sheet voice-treatment-entry

PROGRESS — Voice Treatment Entry

Spec: spec.md

Adapting the merged voice feature to the overhauled prosthesis model (spec §5§7, decisions 3446). 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 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

Key decisions

Full table in the spec's §13, rows 3446. 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

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 APIresolved 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 4748
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.