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>
18 KiB
type, status, repos
| type | status | repos | |||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| task-progress | active |
|
PROGRESS — Voice Treatment Entry
Spec: 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, andpartial_denturealready carrieschartRegion: 'crown'. - Stack rules stay frontend-only. The backend's dispatch check knows nothing about
stacking; porting
canStackLeafwould 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
persistDraftprunes 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
CatalogEntityKindvalues and seeded translations, rather than bare untranslated codes in the prompt onfa. - 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 --drywith agapNote, so the surveyor re-runs instead of replaying its cachedgaps_foundverdict. - 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-entryandbackup/pre-rebase-voiceare 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.
buildCatalogalready receivescategory,subcategory,chartRegionandstackGroupfromProsthesisCatalogServiceand 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
CatalogLabelServicein the actor's locale, so the model sees "پروتز" and "زیرکونیا مونولیتیک" as the spoken forms ofprosthesisandmonolithic_zirconiarather than being asked to translate. Catalog entities store a stablecodeand 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_quadrantwith 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
chartRegionof the assignment's codes.
partial_denture— aremovablecode that is nonetheless assigned per tooth — already ships withchartRegion: 'crown'incatalog-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.