From 224663a4812c651a322d9273d5fccff1c2df5796 Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Mon, 7 Sep 2026 04:23:16 +0800 Subject: [PATCH] docs: revise the voice spec for the new prosthesis model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The treatment form was overhauled after voice v1 merged (a3c14a1, 7f92e73, 72f885d, 5d3597f): one prosthesis type per tooth became stacked jobs, jaw-level appliances and a category tree. Voice still compiled against it but could no longer express it, and in two places wrote data the form itself refuses. Rewrites the extraction contract (§5), the resolver rules and the unresolved-reason table (§6), the review sheet (§7) and verification (§12), and records decisions 34-46. Adds the repos: block and a progress ledger so the task resolves from the branch. Co-Authored-By: Claude Opus 5 (1M context) --- docs/specs/voice-treatment-entry/progress.md | 71 ++++ docs/specs/voice-treatment-entry/spec.md | 387 +++++++++++++++---- 2 files changed, 387 insertions(+), 71 deletions(-) create mode 100644 docs/specs/voice-treatment-entry/progress.md diff --git a/docs/specs/voice-treatment-entry/progress.md b/docs/specs/voice-treatment-entry/progress.md new file mode 100644 index 0000000..5848aee --- /dev/null +++ b/docs/specs/voice-treatment-entry/progress.md @@ -0,0 +1,71 @@ +--- +type: task-progress +status: active +repos: + - repo: dyolink + path: ~/PersonalProjects/dyolink + branch: feat/treatment/add-voice-input-for-new-treatment-form + base: origin/master + role: extraction contract + resolvers + review sheet + spec_slug: voice-treatment-entry + merge_after: [] +--- + +# PROGRESS — Voice Treatment Entry + +Spec: [spec.md](./spec.md) + +Adapting the merged voice feature to the overhauled prosthesis model (spec §5–§7, +decisions 34–46). v1 shipped on `master`; this branch revises it. + +Status legend: ⬜ not started · 🟡 in progress · ✅ done · ⛔ blocked + +| # | Work item | Repo | Status | Notes / refs | +|---|-----------|------|--------|--------------| +| 1 | `pickRecordingMimeType` falls back to the empty hint instead of `null` | `dyolink` | ⬜ | §9. The Safari failure | +| 2 | `voiceForEditor` also checks `isMediaRecorderSupported()` | `dyolink` | ⬜ | §2 render policy, `TreatmentWorkspace.tsx` | +| 3 | Add Vitest for the frontend's pure helpers; update `CLAUDE.md` | `dyolink` | ⬜ | §12. One dev dep, one config, one script | +| 4 | `buildCatalog` passes `category` / `subcategory` / `chartRegion` / `stackGroup` through | `dyolink` | ⬜ | §5. `voice.service.ts` already receives all four | +| 5 | Wire schema: `prosthesisAssignments`, `arch: 'both'`; drop default + overrides | `dyolink` | ⬜ | §5, `extraction.wire.ts` | +| 6 | Resolver: assignments, arch derivation, leaf-vs-category classification | `dyolink` | ⬜ | §5, §6, `extraction.resolver.ts` | +| 7 | Resolver: new unresolved reasons; retire `tooth_not_selected` | `dyolink` | ⬜ | §6 reason table | +| 8 | Resolver: a resolved assignment forces `treatmentType` to `prosthesis` | `dyolink` | ⬜ | §5. Only `prosthesis` is `labDependent` | +| 9 | Prompt: present the catalog as a tree; teach stacks and jaw-level codes | `dyolink` | ⬜ | §5, `extraction.prompt.ts` | +| 10 | Backend Jest suites for items 6–8, including the namespace-disjointness assertion | `dyolink` | ⬜ | §12 | +| 11 | Frontend types follow the new `ResolvedExtraction` | `dyolink` | ⬜ | `types/voice.ts` | +| 12 | `voiceReviewRows`: merged row, chip folding, retire `complete` as a blocker | `dyolink` | ⬜ | §6, §7 | +| 13 | `VoiceReviewSheet`: merged row, chart colours, three chip kinds | `dyolink` | ⬜ | §7. `crownColors` / `rootColors` / `archHighlight` already exist | +| 14 | `applyVoiceResult` writes through `applyLeafToJobs`; handles arch rows | `dyolink` | ⬜ | §6, §7 | +| 15 | Vitest specs for `prosthesisTree.ts` and `voiceReviewRows.ts` | `dyolink` | ⬜ | §12 | +| 16 | New user-visible strings in `en.json`, `fa.json`, `nl.json` | `dyolink` | ⬜ | i18n is mandatory, not a follow-up | +| 17 | Run every gate in §12, then the manual pass including Safari and iPad | `dyolink` | ⬜ | §12 | + +## 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. + +## Deviations from spec + +(none yet) + +## Next steps / open questions + +- 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. diff --git a/docs/specs/voice-treatment-entry/spec.md b/docs/specs/voice-treatment-entry/spec.md index 3bb3f3c..a798047 100644 --- a/docs/specs/voice-treatment-entry/spec.md +++ b/docs/specs/voice-treatment-entry/spec.md @@ -1,14 +1,33 @@ +--- +type: task +status: active +created: 2026-08-20 +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: [] +aliases: [voice treatment entry, voice input, dictation, mic] +--- + # Voice treatment entry -**Status:** Implemented on `feat/voice-treatment-entry`, with one specified piece missing — -the transcript-salvage dialog (§9). First live test on 2026-08-21 sent the tooth path back -for revision — a spoken number is now read as its FDI code (§6). +**Status:** v1 is merged to `master` (`c07f550`…`dc10d8d`). The treatment form was then +overhauled on top of it (`a3c14a1`, `7f92e73`, `72f885d`, `5d3597f`): one prosthesis type +per tooth became **stacked jobs**, **jaw-level appliances** and a **category tree**. Voice +still compiles against that model but can no longer express it, and in two places now +writes data the form itself refuses. This revision adapts the extraction contract (§5), +the resolvers (§6) and the review sheet (§7), and carries two recording defects found on +Safari (§2, §9). Still blocked on the ASR spike (§11 item 1) before it is trustworthy in front of patients **Area:** Treatment workspace (CLINIC orgs) -**Created:** 2026-08-20 +**Created:** 2026-08-20 · **Revised:** 2026-09-07 Fill a `TreatmentDetail` — including its lab dispatch — by speaking, instead of by -tapping through the type dropdown, the FDI chart, the prosthesis wizard and the lab +tapping through the type dropdown, the FDI chart, the prosthesis job popover and the lab picker. --- @@ -30,7 +49,7 @@ One recording produces **exactly one** `TreatmentDetail`, and may fill every fie | `teeth` | FDI codes, via tooth-intent resolver | | `toothSelectionGroups` | connected (bridge) / single spans | | `comment` | cleaned dictated notes | -| lab: `prosthesisTypeCode` per tooth | default type + per-tooth overrides | +| lab: `LabCaseToothProsthesis` rows | one assignment list — a target is a tooth **or a jaw**, and carries a **stack** of job codes | | lab: `destinationOrganizationId` | matched against the clinic's linked labs | | lab: `dueDate` | via due-date intent resolver | @@ -180,6 +199,12 @@ Three different reasons for "no", rendered differently: "This feature isn't yours" and "not right now" are different statements. Absence avoids a permanently dead control; disabling avoids the button resizing as the day strip moves. +**The technical row was specified and never implemented.** `voiceForEditor` gated the mic on +the server's availability response alone — `enabled && locales.includes(locale)` — and never +called `isMediaRecorderSupported()`. So the control rendered on a browser that cannot record +and failed on tap, which is how the Safari report arrived. It now tests both. That is what +"segment absent" meant all along. + `TAB_TREATMENT_EDIT` is resolved via `common/membership-permissions.ts`, never by reading `membership.permissions` directly. @@ -380,18 +405,22 @@ type VoiceIntent = { teeth: ToothIntent[]; connectedSpans: { from: ToothIntent; to: ToothIntent }[]; comment: string | null; - prosthesis: { - defaultType: string | null; // catalog code - overrides: { tooth: ToothIntent; type: string }[]; - } | null; + prosthesis: ProsthesisAssignment[]; // empty array, never null labId: string | null; // must be one of the supplied linked-lab ids labMatchExact: boolean; due: DueIntent | null; }; +/** One spoken instruction: these targets get these jobs. */ +type ProsthesisAssignment = { + targets: ToothIntent[]; // each one a tooth, or a jaw — see below + types: string[]; // leaf codes, or one category / subcategory code + spoken: string; // the transcript span, echoed back to the clinician +}; + type ToothIntent = | { kind: 'explicit'; fdi: string; spoken: string } - | { kind: 'positional'; arch: 'upper' | 'lower'; + | { kind: 'positional'; arch: 'upper' | 'lower' | 'both'; side: 'patient_right' | 'patient_left'; position: number; spoken: string }; type DueIntent = @@ -401,12 +430,71 @@ type DueIntent = | { kind: 'gregorian'; y: number; m: number; d: number }; ``` +The wire form stays deliberately flat (`extraction.wire.ts`): strict `json_schema` mode has +poor support for discriminated unions, so every variant field is present and nullable and +`toVoiceIntent` narrows it. + +### One assignment list, no default + +`prosthesisDefaultType` and `prosthesisOverrides` are gone. A default that fills every tooth +and is then overwritten per tooth has a precedence rule, and a precedence rule has a wrong +side — the first live prosthesis test previewed PFZ on tooth 13 and filled PFM. A single list +where every entry names its own targets has no precedence to get wrong. + +Targets are `ToothIntent`s; the examples below write them as bare FDI codes for brevity. + +- `{ targets: [12, 13], types: ['pfm_crown'] }` — two teeth, one job. Everything the default + expressed, without the default. +- `{ targets: [12], types: ['zirconia_abutment', 'monolithic_zirconia'] }` — one tooth, a + **stack**. This is what the old shape could not say at all. + +### A target is a tooth or a jaw, and the code decides which + +Nothing on the wire declares "this is a jaw". 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. +`arch` gains `'both'`, which the old enum could not express. + +Deriving it costs no second source of truth. The catalog already answers the question, and +`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. An arch appliance +aimed at tooth 12, or a crown aimed at the upper jaw, resolves to +`code_not_valid_for_target` (§6). + +### `types[]` may hold a leaf or a category + +"روکش" 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). + +This needs no extra field because the namespaces are disjoint: 42 leaf codes against 7 +category names and 4 subcategory names, no collisions. The resolver classifies by lookup. A +test asserts the disjointness, because a future catalog entry named `crown` would quietly turn +a leaf into an ambiguity. + +### Prosthesis work implies the treatment type + +`prosthesis` is the only `labDependent` treatment type. Any resolved assignment therefore +forces `treatmentType` to `prosthesis`, and the sheet locks that row while the prosthesis row +is ticked. Without it, a recording that names an appliance but no treatment type seeds the +type from the appointment purpose, `prosthesisAssignActive` stays false, the plain chart +renders, and the lab rows are orphaned behind a chart that cannot show them. + +### Closed lists + Every code-valued field is constrained to a **closed list supplied in the prompt**: - 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. +- **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. - Lab candidates are the clinic's linked labs only (`OrganizationLink`), passed as `{ id, name }`. The model may return one of those ids or `null`, nothing else. @@ -416,9 +504,11 @@ Every unresolved or rejected item is reported, never silently dropped. ## 6. Resolvers -Both live in `backend/src/`, pure and Jest-covered. The frontend has **no test runner** -(no jest/vitest, zero spec files) — putting them there would forfeit the testability -that justified this whole design. +Both live in `backend/src/`, pure and Jest-covered. That placement was originally forced — +the frontend had no test runner at all. This revision adds **Vitest** for the frontend's pure +helpers (§12), so the split is now a judgement rather than a constraint: intent resolution +stays on the backend because it is the trust boundary, and the stack rules stay on the +frontend because that is where the form's own rules already live. ### `resolveToothIntent()` @@ -466,16 +556,51 @@ that justified this whole design. - Connected spans validate through the shipped helpers — `areArchNeighbors`, `sameArch`, `teethBetweenInclusive`. **Never a 1-tooth connected group.** Anything invalid degrades - to singles and is flagged on the review sheet. -- Prosthesis: expand `defaultType` across all teeth, then apply per-tooth overrides. -- **All-or-nothing.** `assertCompleteToothProsthesisMap` requires every tooth on a - `prosthesis` detail to carry a `prosthesisTypeCode` or the send throws - `TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE`. So if even one tooth ends untyped, the - prosthesis row is marked incomplete and stays unticked — the unshippable state surfaces - at review, where it is cheap, not at dispatch minutes later on another screen. + to singles and is flagged on the review sheet. Spans stay their own array rather than + folding into assignments: linking is offered on the plain chart too (the non-prosthesis + `onToggleLink` branch in `TreatmentWorkspace.tsx`), so it is not prosthesis-only. +- **An assignment target is a selection.** On a `prosthesis` detail a tooth exists only by + carrying a job: `applyCodes` in `ProsthesisAssignChart` returns early on an empty code + list, and `pruneDetailTeethToJobs` strips the rest before every save. So + `tooth_not_selected` is unreachable for prosthesis work and is retired. +- **A tooth with no job is named and left out.** "۱۲ و ۱۳، روکش برای ۱۲" adds only 12; 13 + appears struck through in the sheet reading *no prosthesis heard*. Keeping it would + manufacture a state the manual chart cannot produce and `persistDraft` would delete on the + way out. +- **Stack legality is not a resolver concern.** `canStackLeaf`, `categoryDisabledForJobs` and + the screw-retained exclusion live in `frontend/src/components/treatment/prosthesisTree.ts`, + and the backend's own dispatch check knows nothing about stacking. Porting them would make + the voice path stricter than the manual path and create two copies of a rule that must never + disagree. The sheet and the apply path both route through `applyLeafToJobs` instead (§7). + The backend checks only that a code exists and that its region suits its target. +- **All-or-nothing is retired.** `assertCompleteToothProsthesisMap` still requires every + *remaining* tooth on a prosthesis detail to carry a code, but the new form guarantees that by + pruning rather than by refusing, and it now also accepts a detail with no teeth and an arch + job. So an incomplete map no longer blocks a tick; the field survives only to name the + targets that will be dropped. - **Apply order is teeth → groups → prosthesis**, so `pruneToothProsthesisForGroups` behaves. +### Unresolved reasons + +Every one is rendered with what was heard, so the clinician sees what the system did not +understand. Three of them carry candidates and become chips (§7). + +| Reason | Meaning | Carries | +|---|---|---| +| `not_permanent_tooth` | deciduous, or outside the permanent set | — | +| `position_out_of_range` | a position outside 1–8 | — | +| `tooth_missing_quadrant` | a lone digit — four teeth match, fewer when an arch or side was also heard | `candidates`: FDI codes | +| `prosthesis_type_ambiguous` | a category or subcategory was heard, not a material | `candidates`: leaf codes | +| `arch_not_spoken` | a jaw-level appliance with no jaw | `candidates`: `upper`, `lower` | +| `code_not_valid_for_target` | an arch code aimed at a tooth, or a tooth code aimed at a jaw | — | +| `unknown_catalog_code` | a code the supplied catalog does not contain | — | +| `span_not_same_arch` | a connected span crossing arches | — | +| `malformed` | neither an FDI code nor a usable description | — | +| `invalid_date` | a date the resolver cannot build | — | + +`tooth_not_selected` is removed — assignments now define their own teeth. + --- ## 7. Review sheet @@ -492,29 +617,72 @@ that justified this whole design. numbers. - Each row has a checkbox. Ticked rows apply; nothing else is touched. Confirm is also what creates the new detail — see §2. -- Rows default to ticked **except**: - - the lab row when `labMatchExact` is false — shipping to a lab always requires a - deliberate tick; - - any row carrying an unresolved item or an incomplete prosthesis map. -- Unresolved items are shown with what was heard ("دندان شیری — بازشناسی نشد"), so the - clinician can see what the system did not understand. -- **The sheet is a contract: confirm fills exactly what it previewed — no more.** Any - per-detail convenience that would top the case up afterwards has to be suppressed for a - voice-created case, because a default that quietly adds a prosthesis type to a tooth the - sheet never mentioned turns the confirmation step into a lie about what it was going to - do — which is the whole reason the step exists. +- Rows default to ticked **except** the lab row when `labMatchExact` is false — shipping to a + lab always requires a deliberate tick. - > This branch carried an exemption for one such default, the dispatch panel's - > remembered-prosthesis auto-fill. `origin/master` deleted that feature outright - > (`f52ad6b`), so the exemption went with it in the rebase and nothing enforces this rule - > in code today. It is a constraint on whatever gets added next, not a description of - > something that exists. -- An item that carries `candidates` renders them as **tappable chips** — the one place the - sheet is interactive. Picking one folds the tooth into the result (`withChosenTeeth`) and - ticks the teeth row, so an under-specified tooth is one tap from resolved instead of a - dead end. Everything the sheet renders comes from that folded result, not the raw one. -- RTL-safe: logical `text-start` / `text-end` only, never `text-left`/`text-right`. - Dates via `lib/i18n/format.ts`. +### Teeth and prosthesis are one row + +For a lab-dependent type they are not independent, and two checkboxes let the clinician save a +state the form immediately undoes. On `master` today, ticking **teeth** and unticking +**prosthesis** on a prosthesis detail saves an **empty detail**: `persistDraft` prunes +lab-dependent details to their jobs and `applyVoiceResult` persists straight after applying. + +So a lab-dependent type gets one **Teeth and prosthesis** row that applies together. Every +other type keeps a plain teeth row and has no prosthesis row at all. The merged row is: + +- the existing read-only mini `FdiToothChart`, now fed `crownColors` / `rootColors` from + `toothRegionColors` and `archHighlight` from the arch jobs. The chart already accepts all + three, so showing stacks and jaw work costs no new component; +- a line underneath naming each target and what lands on it — + `۱۲: ایمپلنت + روکش زیرکونیا · ۱۳: روکش پی‌اف‌ام · فک بالا: نایت گارد`. + +The chart catches a misheard tooth number at a glance; the list confirms the material. Either +one alone is the weaker check. + +### The sheet previews the stack that will actually land + +The merged row builds its preview through `applyLeafToJobs` — the same function the manual +chart writes through — and `applyVoiceResult` applies through it too. A job the stack rules +refuse, such as an implant plus a post & core on one tooth, is shown struck through and named. +Not silently dropped, and not silently applied. The rules stay in one file (§6). + +### Three chip kinds, one pattern + +An item carrying `candidates` renders them as **tappable chips** — the one place the sheet is +interactive. Picking one folds the choice into the result, so an under-specified item is one +tap from resolved instead of a dead end. Everything the sheet renders comes from that folded +result, not the raw one. + +| Heard | Missing | Chips | +|---|---|---| +| "دندون دو" | the quadrant | the four FDI candidates, narrowed by any arch or side also heard | +| "روکش" | the material | the leaves of that category or subcategory | +| "نایت گارد" | the jaw | Upper / Lower, multi-pick — picking both is how a both-jaw appliance is expressed, since the manual chart has no *both* control either | + +Nothing is guessed on the clinician's behalf. A per-category default material would have the +same shape as the auto-fill this design already rejects. + +### The sheet is a contract + +**Confirm fills exactly what the sheet previewed — no more.** Any per-detail convenience that +would top the case up afterwards has to be suppressed for a voice-created case, because a +default that quietly adds a prosthesis type to a tooth the sheet never mentioned turns the +confirmation step into a lie about what it was going to do — which is the whole reason the +step exists. + +> The v1 branch carried an exemption for one such default, the dispatch panel's +> remembered-prosthesis auto-fill. `master` deleted that feature outright (`f52ad6b`), so the +> exemption went with it and nothing enforces this rule in code today. It is a constraint on +> whatever gets added next, not a description of something that exists. + +This is also why a missing material becomes chips rather than a per-category default, and why +a jobless tooth is named rather than quietly filled. + +### RTL + +Logical `text-start` / `text-end` only, never `text-left` / `text-right`. Dates via +`lib/i18n/format.ts`. The merged row's per-target line reads right to left in `fa`, so the +separator is a bare `·` with no direction of its own. --- @@ -581,6 +749,15 @@ that throws after permission was already granted. All three are "this browser ca and now report `VOICE_UNSUPPORTED_FORMAT`; blaming the microphone sends the clinician hunting in site settings for a permission nothing ever asked for. +**The container list is a preference, not a requirement.** `pickRecordingMimeType` returned +`null` when `MediaRecorder.isTypeSupported` rejected all six candidates — five of which are +WebM or OGG, which Safari cannot record. So Safari was refused outright, even though it +records `audio/mp4`, `mimeTypeToFormat` already maps that to `m4a`, and the backend accepts +`m4a`. It now falls back to the empty hint, which is the *let the browser choose* path the +function already had for Safari versions that shipped no `isTypeSupported`; `onstop` derives +the real container from `recorder.mimeType`, as it already did. `VOICE_UNSUPPORTED_FORMAT` is +left for a browser that genuinely cannot record. + **Transcript salvage — specified, NOT built.** The backend half exists: `VOICE_EXTRACT_FAILED` carries `details.transcript` and `HttpExceptionFilter` forwards it. The client half was never written — `onError` only resolves a message through `getUserFacingError`, which never @@ -715,39 +892,89 @@ enabling this for real clinics. `Plan.features` gate, lower `maxMs` or the throttle, or add an org-level monthly minute budget. +15. **The voice body limit is raised before any guard runs.** `isVoiceExtractPath` in + `common/body-parsers.ts` selects the large JSON limit by path, and Express body parsers + run ahead of `JwtAuthGuard`. So an unauthenticated request to that path may upload the + full clip-sized body before anything rejects it. The throttle does not help: it is a guard + too. Bounded by the DTO cap and by the reverse proxy's own limit, but it is a + pre-authentication allocation and nobody has decided whether that is acceptable. Not + changed in this revision. + +16. **Transcript salvage is still specified and not built.** `VOICE_EXTRACT_FAILED` carries + `details.transcript`, `HttpExceptionFilter` forwards it, and nothing on the client reads + it — `getUserFacingError` resolves a message only. The field also does not match + `ApiError['details']`, which is an array. Either build the dialog in §9 or stop returning + the transcript. Carried forward unchanged; it is orthogonal to the prosthesis model. + --- ## 12. Verification -- `cd backend && npm test` — new suites for `resolveToothIntent` (quadrant mapping in all - four quadrants, out-of-range rejection, deciduous → unresolved), `resolveDueDate` - (per-locale week start, "this" vs "next" weekday, Jalali leap year, month-end), the - Jalali port, prosthesis expansion + completeness, and connected-span validation. +### Machine gates + +- `cd backend && npm test` — the existing suites, plus new coverage for the revised contract: + - an assignment resolves its targets, and a target that names an arch with no position + resolves to the `UA` / `LA` sentinels; + - a leaf code, a category code and a subcategory code are each classified correctly, and + the three namespaces are asserted disjoint; + - an arch code aimed at a tooth, and a tooth code aimed at a jaw, both resolve to + `code_not_valid_for_target`; + - a resolved assignment forces `treatmentType` to `prosthesis`; + - a target with no types is reported and excluded, and does not fail the whole assignment. + The pre-existing suites stay: `resolveToothIntent` in all four quadrants, out-of-range + rejection, deciduous → unresolved, `resolveDueDate` per-locale week start, "this" vs "next", + Jalali leap year and month-end, and connected-span validation. - `cd backend && npm run build` — cross-cutting backend gate. -- `cd frontend && npx tsc --noEmit` — frontend gate. -- Manual: fa locale, editable day, prosthesis detail with a bridge, dispatch to a linked - lab. Then specifically: - - **past day** → both segments disabled, control still split (not absent); - - **locale with no profile** → control renders unsplit, identical to today; - - **fa vs en** → mic sits at the logical end in both, on the same side as the chip's - trash; - - **cancel mid-recording** → chip strip unchanged, no orphan detail; - - **confirm** → always appends a new chip, whatever the active detail contains; - - **Add half** → behaves exactly as it did before this change; - - **tap 🎤 during the Lab wizard step** → confirm returns to the treatment step; - - no layout shift in the header row on record start, stop, or the 2:00 auto-stop; - - **hold past 2:00** → auto-stops and proceeds to processing, not an error; - - **cancel during processing** → the vendor request is actually aborted; - - **review sheet on mobile** → full-screen overlay; closing it leaves the draft intact; - - **confirm with a lab, a due date or a prosthesis map, then reload** → all three are still - there. They live on the lab case, which the autosave effect does not watch, so this is - the check that catches a lab draft left unsaved in component state; - - **record straight after opening a visit**, while the blank chip is still untyped, and - confirm with a lab ticked → no error toast: confirm detects the preview treatment and - skips the lab-case save rather than posting an id the server has never seen; - - **dictate two different prosthesis types** ("۱۲ روکش PFM، ۱۳ روکش PFZ") → the form shows - both, and the bulk «اعمال برای همه دندان‌ها» select stays on its placeholder. Nothing may - rewrite a per-tooth type the sheet already showed. +- `cd frontend && npx vitest run` — **new**. One dev dependency, one config, one script, + covering the pure helpers only: `prosthesisTree.ts` (stack legality, `applyLeafToJobs` + precedence, `toothRegionColors`) and `voiceReviewRows.ts` (row availability, the merged + row, folding chips into the result). No React, no DOM. `CLAUDE.md` is updated in the same + commit — "there are no frontend tests" stops being true. +- `cd frontend && npx tsc --noEmit` — frontend type gate. +- `cd frontend && npm run build` — production build. +- ESLint on every touched file, no new warnings. + +### Manual + +fa locale, editable day, prosthesis detail with a bridge, dispatch to a linked lab. Then: + +**Carried forward, still required** + +- **past day** → both segments disabled, control still split (not absent); +- **locale with no profile** → control renders unsplit, identical to today; +- **fa vs en** → mic sits at the logical end in both, on the same side as the chip's trash; +- **cancel mid-recording** → chip strip unchanged, no orphan detail; +- **confirm** → always appends a new chip, whatever the active detail contains; +- **Add half** → behaves exactly as it did before this change; +- no layout shift in the header row on record start, stop, or the 2:00 auto-stop; +- **hold past 2:00** → auto-stops and proceeds to processing, not an error; +- **cancel during processing** → the vendor request is actually aborted; +- **review sheet on mobile** → full-screen overlay; closing it leaves the draft intact; +- **confirm with a lab, a due date or a prosthesis map, then reload** → all three are still + there. + +**New to this revision** + +- **Safari on macOS, and Safari on iPad** → the mic records, and the clip reaches the server + as `m4a`. This is the report that started the revision; +- **a browser with no `MediaRecorder`** → the Add button renders unsplit, and no mic appears; +- **stack** — "دندون ۱۲ ایمپلنت با روکش زیرکونیا" → the sheet shows both jobs on 12, the chart + colours crown and root differently, and the chart after apply shows the same stack; +- **jaw appliance** — "نایت گارد فک بالا" → the sheet shows an upper-jaw row, the treatment + type row reads *prosthesis* and is locked, and apply produces a `UA` row with no teeth; +- **jaw not spoken** — "نایت گارد" → Upper / Lower chips; tapping both produces `UA` and `LA`; +- **material not spoken** — "دندون ۱۲ روکش" → chips for the nine crown leaves; nothing is + applied until one is picked; +- **tooth with no job** — "۱۲ و ۱۳، روکش پی‌اف‌ام برای ۱۲" → 13 is struck through in the sheet, + apply adds only 12, and 13 is **not** silently saved and then deleted; +- **illegal stack** — "دندون ۱۲ ایمپلنت و پست و کور" → the refused job is struck through and + named; apply writes only the legal one, and the manual chart agrees; +- **contradiction** — "دندون ۱۲ نایت گارد" → reported as `code_not_valid_for_target`, nothing + applied for that assignment; +- **two different materials** — "۱۲ روکش پی‌اف‌ام، ۱۳ روکش پی‌اف‌زد" → the form shows both. + Nothing may rewrite a per-tooth type the sheet already showed; +- **non-lab-dependent type** — "ترمیم برای دندون ۱۴" → a plain teeth row, no prosthesis row, + and the teeth survive the save. --- @@ -800,3 +1027,21 @@ UI placement settled in a second grilling session on 2026-08-20. | 20 | Side | Logical end (`border-s`), exactly like the chip's trash | | 21 | Creation | On confirm, never on tap | | 22 | Creation rule | Confirm **always** appends a new detail — no blank-reuse guard | + +Adapting to the overhauled treatment form, in a grilling session on 2026-09-07. + +| # | Question | Decision | +|---|---|---| +| 34 | Scope | Full parity — voice reaches stacked jobs, jaw-level appliances and the category tree | +| 35 | Intent shape | One `prosthesis` assignment list. `prosthesisDefaultType` + `prosthesisOverrides` are removed, and with them the precedence rule that mis-filled tooth 13 (§5) | +| 36 | Arch targets | Derived, not declared — an arch with no position, confirmed against the code's `chartRegion`. `arch` gains `'both'` (§5) | +| 37 | Partial codes | `types[]` may hold a category or subcategory code; the namespaces are disjoint and a test asserts it (§5) | +| 38 | Stack legality | Frontend only, in `prosthesisTree.ts`. Both the sheet and the apply path route through `applyLeafToJobs`; the backend never learns the rules (§6) | +| 39 | Sheet rows | Teeth and prosthesis merge into one row for lab-dependent types — they are not independent, and two ticks could save an empty detail (§7) | +| 40 | Tooth with no job | Named and left out. The manual chart cannot produce a jobless tooth, so voice must not either (§6) | +| 41 | Type coupling | A resolved assignment forces `treatmentType` to `prosthesis` and locks that row (§5) | +| 42 | Missing material | Chips for the category's leaves. Never a per-category default — that is the auto-fill shape this design rejects (§7) | +| 43 | Missing jaw | Upper / Lower chips, multi-pick. Picking both is how a both-jaw appliance is expressed (§7) | +| 44 | Frontend tests | Vitest added for the pure helpers. The split between backend and frontend resolvers becomes a judgement rather than a constraint (§6, §12) | +| 45 | Recording defects | Carried in this branch: the container fallback that refused Safari, and the render gate that never checked `isMediaRecorderSupported()` (§2, §9) | +| 46 | Delivery | One merge request |