applyVoiceResult decided what to write and wrote it, in one callback inside a
3200-line client component. Nothing could reach it — not exported, and rendering
its component means mocking next-intl, the i18n router and six axios modules.
Three of the five defects found in live use sat in that callback, while 209
green tests covered the helpers around it.
buildVoiceApplyPlan(result, selection, ctx) -> { detail, labCaseDraft } now
holds the decision and writes nothing. The callback keeps only what a component
must do: setDetails, the ref writes the in-flight save reads, and the order that
lets lab rows carry a real treatmentDetailId. newDetail and newLabCaseDraft move
to treatmentDetailRules.ts so the pure module can build a draft without
importing a component.
21 tests. Verified they bite by reverting each bug in place: reading
labDependent from result.treatmentType fails 1, merging the plain teeth list
onto a prosthesis detail fails 2.
One group asserts an invariant that belongs to the backend — every
toothProsthesis row naming a real tooth must be in detail.teeth, which is
TREATMENT_TOOTH_NOT_ON_DETAIL at treatments.service.ts:806. It spans two
processes, so neither side could state it alone before.
Not covered, still manual: the labCaseDraftsRef timing needs a real render.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
594 lines
39 KiB
Markdown
594 lines
39 KiB
Markdown
---
|
||
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. `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 6–8, 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` | 🟡 | Every machine gate green, migration and seed now applied and verified (2026-09-07 entry below). **Manual pass still not run** — Safari, iPad, and the stack / jaw / chip flows in `fa` |
|
||
| 18 | `PROSTHESIS_CATEGORY` + `PROSTHESIS_SUBCATEGORY` in `CatalogEntityKind`; migration; seed fa/en/nl translations | `dyolink` | ✅ | §5, decision 47. Applied via `migrate deploy` and seeded: enum carries both kinds, 7 categories + 5 subcategories per locale, `crown` → `روکشها` |
|
||
| 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
|
||
|
||
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.
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
---
|
||
|
||
## 2026-09-07 — Second refute round, both lenses, on the repaired diff
|
||
|
||
Ran both critics directly rather than resuming the workflow: a resume would have replayed the
|
||
`correctness` verdict from cache against a diff that had changed, and re-run the Ship phase that
|
||
cannot pass in this repo anyway.
|
||
|
||
**`correctness` refuted with 4 findings — all confirmed by hand, all fixed in `f1a4594`.**
|
||
|
||
Three were the same root cause: the region check never actually ran. Each wrote a job the manual
|
||
chart cannot produce, and each passed all 209 tests.
|
||
|
||
| Finding | Effect |
|
||
|---|---|
|
||
| The deferred region check had nothing to complete it | "پروتز متحرک برای دندون ۱۲" wrote a complete denture onto tooth 12 |
|
||
| `some` over a stack's regions | `['pfm_crown','night_guard_soft']` on tooth 12 wrote a night guard onto that tooth |
|
||
| `regions.size === 1` also deferred `implant` (root + crown, both tooth regions) | an implant aimed at a jaw resolved as a `UA` target |
|
||
| The `e271858` lock ticked the row and the payload but not the count | the sheet read "Apply 1 field" while two landed |
|
||
|
||
Fix: targets resolve **before** types, so the target kind narrows a category's candidates and an
|
||
impossible leaf is never offered. The deferral concept is gone. Region validation is now
|
||
`every`, and a category with no usable leaf drops the assignment's targets rather than leaving
|
||
them jobless. Four tests added.
|
||
|
||
**`regression-risk` refuted with 2 findings.**
|
||
|
||
1. Already fixed in `f1a4594` — the spec file's `filter(Boolean)` left two `tsc` errors. It added
|
||
a fact worth keeping: **`.gitea/workflows/*` run no test, lint or typecheck step at all.**
|
||
`nest build` excludes `**/*spec.ts` and ts-jest runs transpile-only under
|
||
`isolatedModules`, so nothing in this repo would ever have caught it. Repo-wide gap, not this
|
||
branch's.
|
||
2. **Open — needs a decision, not a fix.** See below.
|
||
|
||
### Open question: teeth are unreachable when the merged row is unticked
|
||
|
||
On a forced-prosthesis result `voiceRowAvailability` sets `teeth: false`, so no teeth row renders
|
||
and `selection.teeth` can never become true. Untick the merged prosthesis row (which releases the
|
||
decision-41 lock) and then untick the type row, and both write paths in `applyVoiceResult` are
|
||
skipped — the detail is appended with `teeth: []`. `master` kept the teeth here, because the rows
|
||
were separate.
|
||
|
||
This is decision 39 behaving exactly as approved: teeth and prosthesis apply together. But it
|
||
means a clinician who wants only the teeth from a prosthesis dictation, intending to pick
|
||
materials by hand, has no way to get them. Not a spec violation — an unstated consequence.
|
||
Deliberately not changed unilaterally.
|
||
|
||
---
|
||
|
||
## 2026-09-07 — Migration and seed proven against dev Postgres
|
||
|
||
The last unverified gate. The inspector had run `prisma validate`, which only parses the schema;
|
||
the build compiled solely because the generated client in `node_modules` already carried the new
|
||
enum values.
|
||
|
||
Found five migrations pending, not one — this dev database had not been migrated since the
|
||
prosthesis overhaul landed on `master` on 1 September (`a3c14a1`, `72f885d`). Prisma applies in
|
||
order, so proving one required applying all five.
|
||
|
||
Used **`prisma migrate deploy`, not `migrate dev`**: `deploy` applies pending migrations and never
|
||
offers to reset, so there is no path where a drift prompt drops dev data. §12 should say `deploy`
|
||
for this reason.
|
||
|
||
Counted the `DELETE` in `…_prosthesis_tree_multi_type` before applying rather than trusting its
|
||
own "no-op for current data" comment: **0 rows matched**, against 2 rows total in
|
||
`lab_case_tooth_prosthesis`. Both login accounts survived (`users` = 2).
|
||
|
||
Verified after seeding:
|
||
|
||
| Check | Result |
|
||
|---|---|
|
||
| `enum_range(CatalogEntityKind)` | now 5 values, including `PROSTHESIS_CATEGORY` and `PROSTHESIS_SUBCATEGORY` |
|
||
| Seeded `PROSTHESIS_CATEGORY` rows | 7 per locale × fa/en/nl |
|
||
| Seeded `PROSTHESIS_SUBCATEGORY` rows | 5 per locale × fa/en/nl |
|
||
| `crown` in `fa` | `روکشها` — the label that makes "روکش" resolvable to a category |
|
||
| Code path | `prosthesis-catalog.service.ts:108,119` resolves both kinds via `CatalogLabelService` |
|
||
|
||
**Repo-wide gap worth acting on separately:** `.gitea/workflows/*` run no test, lint or typecheck
|
||
step — only build and deploy. Combined with `nest build` excluding `**/*spec.ts` and ts-jest
|
||
running transpile-only under `isolatedModules`, nothing in this repo would ever catch a type error
|
||
in a spec file. That is how two of them reached this branch unnoticed.
|
||
|
||
## 2026-09-11 — note triggers and where a note ends (dyolink)
|
||
|
||
Two reports from live dictation in `fa`, both prompt-side, no resolver change.
|
||
|
||
1. «یادداشت» / «یادداشتها» produced no trigger, so the note was discarded. The locale notes
|
||
listed only verbs that ask («بنویس», «یادداشت کن»); naming the field itself was not in the
|
||
vocabulary. Added the field-name shape for all three locales — fa «یادداشت/یادداشتها/توضیحات/
|
||
کامنت», en "note/notes/comments/remarks", nl "notitie/notities/opmerkingen".
|
||
2. The note swallowed the next instruction. For «توی توضیحات بنویس سلام چطوری و بفرست برای
|
||
لابراتوآر سانشاین» the model sometimes put the whole tail in `comment`, sometimes only
|
||
«سلام چطوری». Added a no-overlap invariant: a clause reported as a lab, tooth, prosthesis or
|
||
deadline is an instruction and must not also appear in `comment`, plus that exact sentence as
|
||
a worked example.
|
||
|
||
Both rules are stated twice on purpose — in `extraction.prompt.ts` and in the `comment` /
|
||
`commentTrigger` descriptions in `extraction.wire.ts`, since the schema description is what the
|
||
model reads while filling the field.
|
||
|
||
No code enforcement for (2). Stripping an extracted clause out of free-speech note text by string
|
||
matching is guesswork, and the review sheet shows the note before it is saved.
|
||
|
||
Files: `backend/src/modules/voice/extraction.prompt.ts`,
|
||
`backend/src/modules/voice/extraction.wire.ts`, `docs/specs/voice-treatment-entry/spec.md`.
|
||
Verified: `npx eslint src/modules/voice/**/*.ts` clean, `npm test -- extraction` 74/74.
|
||
No unit test — both are prompt text, which the extraction specs deliberately do not assert on.
|
||
|
||
## 2026-09-11 — the apply decision is now testable (dyolink)
|
||
|
||
Pulled the decision out of `applyVoiceResult` into
|
||
`frontend/src/components/treatment/voiceApply.ts` — `buildVoiceApplyPlan(result, selection, ctx)`
|
||
returns `{ detail, labCaseDraft }` and writes nothing. The callback shrank from ~115 lines to the
|
||
commit it always should have been: two `setState`s, two ref writes, and the save order.
|
||
|
||
`newDetail` / `newLabCaseDraft` moved from `TreatmentWorkspace.tsx` to `treatmentDetailRules.ts`,
|
||
so the pure module can build a draft without importing a component.
|
||
|
||
21 new tests in `voiceApply.spec.ts`. Proved they bite by reverting each bug in place:
|
||
|
||
| Reintroduced bug | Failing tests |
|
||
|---|---|
|
||
| `labDependent` read from `result.treatmentType` (decision 41) | 1 |
|
||
| plain `teeth` merged onto a prosthesis detail (decision 40) | 2 |
|
||
|
||
One group of tests asserts an invariant that belongs to the *backend*: every `toothProsthesis`
|
||
row naming a real tooth must appear in `detail.teeth`. That is
|
||
`TREATMENT_TOOTH_NOT_ON_DETAIL` (`treatments.service.ts:806`) — the error the clinic saw — now
|
||
checked on the client side of the wire, across the four shapes that produced it: two separated
|
||
teeth, one tooth, a two-code stack, and a jaw plus a tooth.
|
||
|
||
Still not covered, and still manual (§12): the `labCaseDraftsRef` timing, which needs a real
|
||
render, and the persist ordering.
|
||
|
||
Gate: `tsc --noEmit` clean, `vitest run` 73/73 (was 52), `next build` compiled, eslint back to its
|
||
pre-change 10 warnings (the 11th was an import I had orphaned).
|
||
|
||
Files: `frontend/src/components/treatment/voiceApply.ts` (new),
|
||
`frontend/src/components/treatment/voiceApply.spec.ts` (new),
|
||
`frontend/src/components/treatment/treatmentDetailRules.ts`,
|
||
`frontend/src/components/ui/treatment/TreatmentWorkspace.tsx`,
|
||
`docs/specs/voice-treatment-entry/spec.md`, `CLAUDE.md`.
|
||
|
||
## Remaining before this is shippable
|
||
|
||
- The **manual pass in §12** — none of it has been run. Safari and iPad especially, since that is
|
||
the report that started the revision, and the stack / jaw / chip flows in `fa`.
|
||
- The Ship phase cannot pass in this repo: no MR template exists, and opening a merge request is
|
||
gated regardless.
|