Five migrations were pending, not one: this dev database had not been migrated since the prosthesis overhaul landed on master. Used migrate deploy rather than migrate dev so no drift prompt could offer a reset, and counted the DELETE first — 0 rows matched against 2 total. Seeded translations verified: 7 categories and 5 subcategories in fa, en and nl, with crown resolving to روکشها. Both login accounts survived. Also records that .gitea/workflows run no test, lint or typecheck step at all, which is why two spec-file type errors reached the branch unnoticed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
533 lines
36 KiB
Markdown
533 lines
36 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` | 🟡 | Machine gates green (below). Manual pass and `prisma:migrate`/`prisma:seed` against a live DB **not run** — this sandbox has no working Docker daemon (see note below) |
|
||
| 18 | `PROSTHESIS_CATEGORY` + `PROSTHESIS_SUBCATEGORY` in `CatalogEntityKind`; migration; seed fa/en/nl translations | `dyolink` | 🟡 | §5, decision 47. Enum + migration SQL + seed data all written and `prisma generate` succeeded; migration **not applied** to a running Postgres (Docker unavailable) |
|
||
| 19 | Unresolved items carry `assignmentIndex`; a picked chip inherits that assignment's jobs | `dyolink` | ✅ | §6, decision 50. `UnresolvedItem.assignmentIndex`; `VoiceReviewSheet` chips inherit via `withChosenTeeth(...,index)` / `withChosenArch` / `withChosenProsthesisLeaf` |
|
||
|
||
## Key decisions
|
||
|
||
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.
|
||
|
||
## 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.
|