Compare commits

...

14 Commits

Author SHA1 Message Date
d8c9fa8995 feat(voice): write a note only when the clinician asks for one
The comment instruction told the model to sweep up leftovers: "clinical
notes, in the language spoken. Omit the parts already captured as treatment
type, teeth, prosthesis work or deadline." So filler, small talk and an
unrequested diagnosis all became a persisted clinical note — and comment is
the one durable trace a recording leaves (§10).

The model now reports the exact words that asked for the note in a new
commentTrigger field, and comment holds only what was dictated after them.
resolveVoiceIntent keeps comment only when a trigger is present, so the rule
is enforced by code instead of trusted to the prompt: a model that decides
leftover speech was a note loses that note.

Per-locale trigger vocabulary goes in the prompt's locale notes, beside the
tooth vocabulary — «بنویس که», "write this in the notes", "noteer". The
resolver only checks that a trigger was reported, so it needs no per-locale
knowledge and stays locale-neutral, as §6 requires.

Also adds HARD RULE 7: never decide something is a note; speech that fits no
field is simply not reported.

Four resolver tests. The two that drop an unasked note fail without the
guard; the trigger-only and trigger-with-note cases pass either way and exist
to prove the guard does not swallow a real note.

Frontend untouched — it only ever saw the resolved comment, never the
trigger.

Spec: §5 gains the rule and the field, decision 54 added.

Gates: backend 220 tests, nest build, ESLint clean on the voice module;
frontend tsc --noEmit clean, 52 Vitest tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 17:47:13 +08:00
10408058c4 feat(voice): keep the transcript on the server
The review sheet rendered the full dictation at the top of the modal,
unconditionally. A raw transcript can carry the patient's spoken name — the
spec said so itself in §9, while §10 said telemetry must never contain it.

The transcript no longer reaches the browser by any route:

- removed from the success response (VoiceExtractionResponse is now plain
  ResolvedExtraction, which never had the field)
- removed from the error body. VOICE_EXTRACT_FAILED carried
  details.transcript for a salvage dialog that was never built, and
  ApiError['details'] is an array, so the shape never even matched — it was
  serialized onto the wire and dropped
- removed from the sheet, and from VoiceExtractionResult. tsc proves that
  <p> was the only reader in the whole frontend

It is logged instead: one info line per recording, written immediately after
the emptiness check so a failed extraction still records it, and deliberately
outside logTelemetry so that method's patient-free guarantee stays literally
true.

Accepted consequence, recorded in §10: patient words now persist in
production server logs at default level, so whatever retention and access
control applies to those logs applies to dictation. The repo's other
sensitive-text path (openrouter.provider.ts) uses debug level with
truncation; moving this line to debug is a one-word change.

Transcript salvage is dropped rather than deferred, which settles §11 open
item 16 by taking its second option. When extraction fails the clinician
re-dictates; an operator can read the words in the log, the person who spoke
them cannot.

Spec: §7, §9 and §10 rewritten, item 16 resolved, decisions 51-53 added, and
decisions 10 and 25 marked superseded so the log stops contradicting itself.

No automated coverage for the response shape or the log line: there is no
voice.service.spec.ts — the service is I/O orchestration and has never been
unit tested. Removing the type field is what proves no reader survives.

Gates: backend 216 tests, nest build, ESLint clean on the voice module;
frontend tsc --noEmit clean, 52 Vitest tests, next build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 17:39:27 +08:00
46c8a25139 fix(treatment): never keep a bridge across a tooth that left it
A bridge is a contiguous span, but every operation that filtered teeth out of
a group kept the `connected` kind as long as two teeth remained. Reduce a
12-13-14 span to 12 and 14 and you get a "bridge" with no pontic — and worse,
both teeth keep one selectionGroupId, so the lab receives them as a single
unit and task generation builds work that cannot be made.

linkedEdgesFromGroups already guarded the drawn marks with areArchNeighbors,
so the chart looked right while the data was wrong.

Adds splitDisconnectedRuns, which breaks what remains into contiguous runs: a
run of two or more stays connected, a run of one becomes a single. The first
run keeps the original groupId so lab rows pointing at it stay valid, and
pruneToothProsthesisForGroups re-maps the rest.

Applied at all three sites that filter a group's teeth, which each carried
their own copy of the length check:
- groupsFromFlatTeeth, when teeth are no longer selected
- applyShiftRange, when a shift-range takes teeth from an existing group
- removeTeethFromGroups, the path pruneDetailTeethToJobs uses

toggleToothInGroups already split into runs by hand for the single-removal
case; this is the same rule, shared.

Tests: a new toothSelectionGroups.spec.ts (13) plus two for
removeTeethFromGroups. The middle-tooth cases fail without the fix; the
end-tooth and contiguous cases pass either way and exist to prove it does not
over-reach. CLAUDE.md updated for the third Vitest file.

Not fixed: a pre-existing unused `leftIdx` warning in unlinkAdjacentTeeth,
unrelated to this change.

Gates: 52 Vitest tests, tsc --noEmit clean, next build clean, ESLint
unchanged at 1 pre-existing warning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 17:26:23 +08:00
dc23c3dd1c fix(voice): prime the lab-drafts ref before the save that reads it
"برای دندون ۱۲ و ۱۴ یه فول متال کران" previewed correctly, then Apply failed
with TREATMENT_TOOTH_NOT_ON_DETAIL. The form still showed both teeth and both
crowns — that is React state. The database got a detail with no teeth.

applyVoiceResult called setLabCaseDrafts but never wrote
labCaseDraftsRef.current, which is only refreshed in the render body.
persistDraft runs in the same tick and starts by pruning each lab-dependent
detail down to the teeth its jobs cover, reading that stale ref. It found no
draft for the brand-new detail, so jobs were empty, so the detail was saved
with teeth: []. persistLabCases then posted rows for 12 and 14 against it,
and the server — which re-reads the detail's teeth from the database, because
the lab-case endpoint carries no teeth field — correctly refused.

detailsRef was already written by hand two lines above for exactly this
reason (3911477). This is the other half of the same mistake.

Two sibling handlers had the same gap and are fixed with it: adopting an
orphan lab case, and creating a new lab draft. Both call setLabCaseDrafts and
then persistDraft in the same tick. handleLabCasesChange was the only site
that already primed the ref, and it is the pattern the others now follow.

Adds a comment at the ref declaration, because the coupling between this ref
and persistDraft's prune is invisible at the call sites and has now cost two
bugs.

No automated test: this is state-and-ref ordering inside a React component,
and the frontend's Vitest scope is pure helpers only — no React, no DOM.
Mirroring the server's tooth rule on the client to make it testable would
duplicate a rule across layers, which decision 38 rules out.

Gates: tsc --noEmit clean, 37 Vitest tests, next build clean, ESLint warnings
unchanged at 10 (all pre-existing, count verified against HEAD).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 17:18:02 +08:00
76d929de3a fix(voice): stop reading a dictated jaw as a broken tooth
"یه کامپلیت دنچر برای فک بالا میخوام" resolved the upper arch and the
complete denture correctly, but the review sheet also showed
position_out_of_range against «فک بالا» and asked which tooth was meant. No
tooth was said.

The model names the jaw in the top-level `teeth` array as well as in the
prosthesis target it belongs to. resolveVoiceIntent passed that array
straight to resolveToothIntents, extraction.wire.ts turns `position: null`
into NaN, and unresolvedReason tests positionBad first — so it reported a
range fault for a value that was never a number, before ever reaching the
quadrant branch.

resolveVoiceIntent now drops jaw-shaped entries — an arch with no usable
position — before the tooth resolver sees them. The jaw already reaches the
form through its assignment, so the duplicate carries no information worth
reporting. An entry that DOES give a position survives: arch plus position
without a side is a real tooth described without its quadrant, and must keep
offering its candidate chips.

Two invitations removed as well, both ours: the `teeth` property in
VOICE_INTENT_JSON_SCHEMA had no description at all, and no prompt rule said a
jaw must stay out of it, while TOOTH_SCHEMA — shared with
prosthesis[].targets — describes jaws as acceptable. Adds the description and
HARD RULE 6.

Four tests. The two jaw cases fail without the filter; the out-of-range and
missing-quadrant cases pass either way and exist to prove the filter does not
over-reach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 16:56:54 +08:00
fbe423bc95 docs: mark the catalog-kinds work item done now that it is applied
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 14:14:05 +08:00
dd8e6b17e5 docs: prove the migration and seed against dev Postgres
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>
2026-09-07 14:13:38 +08:00
c1d3a241a5 docs: record that a prosthesis dictation reaches teeth only through the merged row
Decision 39 couples teeth and jobs on a lab-dependent type, so unticking the
merged row and then the type row applies a detail with no teeth at all. master
kept them, because the rows were separate. Approved as an accepted consequence
rather than patched, since applying teeth the clinician just unticked would
break the sheet-is-a-contract rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 13:20:00 +08:00
ebfc8af87c docs: record the second refute round and the one open consequence
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:43:26 +08:00
f1a4594a0a fix(voice): validate a code's region against its target properly
Second correctness pass on e271858 found three ways an arch-only appliance
could be written as a per-tooth job, plus a gap in the previous repair.

1. The deferred region check was never completed. A mixed-region category
   resolved with no region recorded, and nothing re-validated the leaf the
   clinician then picked — "پروتز متحرک برای دندون ۱۲" wrote a complete
   denture onto tooth 12, which the manual chart cannot produce and which
   task generation would expand as denture steps. Targets now resolve BEFORE
   types, so the target kind narrows a category's candidates and an
   impossible leaf is never offered. The deferral disappears with it.

2. `some` over the stack's regions let one legal code admit every other.
   `types: ['pfm_crown','night_guard_soft']` on tooth 12 passed, because
   `crown` suited the tooth, and wrote a night guard onto that tooth. Now
   every named code must suit the target.

3. `regions.size === 1` also deferred `implant`, whose leaves span root and
   crown — both tooth regions, so not a tooth/arch category at all. An
   implant aimed at a jaw resolved as a UA target. Narrowing by target kind
   rejects it instead.

4. The e271858 type-row lock ticked the row and the payload but not the
   count, so the sheet read "Apply 1 field" while two landed. One
   `effectiveSelection` now drives the count and the payload.

Also narrows a `filter(Boolean)` in the disjointness test that left two
implicit-any errors under the full tsconfig (nest build excludes specs, so
the repo gate never saw them). Pre-existing at 15ddb9a.

Adds four resolver tests: candidate narrowing per target kind, a category
with no usable leaf, a stack where only one code suits, and a legal stack.
All three defects passed the previous 209 tests.

Gates: backend 212 tests, nest build, ESLint clean on the voice module;
frontend 37 Vitest tests, tsc --noEmit, ESLint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:42:06 +08:00
e271858a33 fix(voice): repair the two defects the correctness critic found
Both found by the /orchestrate correctness critic against 15ddb9a, and both
verified by hand before fixing.

1. A picked tooth chip was dropped on Apply. pickCandidate ticked the teeth
   row from `available`, which is memoised from `effective` — the value the
   same handler is in the middle of changing. On "ترمیم برای دندون دو" the
   backend returns no teeth, so `available.teeth` was false at pick time and
   `prev.teeth` was false too; `false || false` stuck permanently. A variant
   of the original live-test failure. Each branch now ticks the row it feeds
   and returns, so nothing reads the stale memo.

2. Decision 41's type-row lock was missing. The treatmentType row was a plain
   toggle, and applyVoiceResult derived `labDependent` from
   `result.treatmentType` rather than the `detail.treatmentType` it writes —
   so unticking the type row saved prosthesis lab rows on whatever type the
   appointment purpose had seeded. Row gains a `locked` state, the type row
   locks while the prosthesis row is ticked, Apply sends the forced tick, and
   `labDependent` now comes from the detail being written.

Gates: backend 16 suites / 209 tests, frontend 37 Vitest tests,
tsc --noEmit clean, next build clean, ESLint 0 new warnings
(VoiceReviewSheet.tsx 0 issues; the 10 in TreatmentWorkspace.tsx are
pre-existing and unchanged in count).

Not covered by a test: both fixes live in component state logic, which the
chosen Vitest scope — pure helpers, no React, no DOM — cannot reach. §12's
manual checks cover them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:27:43 +08:00
15ddb9aac2 feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.

Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.

Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().

Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.

Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.

KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
  type-row lock is missing, so unticking it saves prosthesis lab rows on a
  non-prosthesis detail

Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.

Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
77e2ed4b42 docs: close the gaps the orchestrate surveyor found
The Gap-check phase halted the run on one blocking gap and reported four
notes. Nothing was built.

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 04:54:02 +08:00
224663a481 docs: revise the voice spec for the new prosthesis model
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) <noreply@anthropic.com>
2026-09-07 04:23:16 +08:00
33 changed files with 4989 additions and 644 deletions

View File

@@ -39,6 +39,7 @@ There is **no root `package.json`**. Every npm command runs inside `backend/` or
| `npx tsc --noEmit` | **Verification gate for any type or cross-cutting frontend change** |
| `npm run build` | Production build (`output: 'standalone'`) |
| `npm run lint` | ESLint via Next |
| `npx vitest run` | Vitest — pure helpers only (`prosthesisTree.ts`, `voiceReviewRows.ts`, `toothSelectionGroups.ts`) |
`NEXT_PUBLIC_*` values are baked in at build time — restart `npm run dev` after changing `.env.local`.
@@ -102,7 +103,7 @@ Clinics may only dispatch to labs they are linked to: `OrganizationLink` (A↔B,
### Tests
Jest covers pure logic only — permission normalization, phone/timezone helpers, task generation, lab-send validation (7 suites in `backend/src/**`). There are no frontend tests; `npx tsc --noEmit` is the frontend gate.
Jest covers pure logic only — permission normalization, phone/timezone helpers, task generation, lab-send validation, voice extraction contract (`backend/src/**`). Frontend has Vitest for its own pure helpers only — no React, no DOM: `prosthesisTree.ts`, `voiceReviewRows.ts` and `toothSelectionGroups.ts` (`frontend/src/components/treatment/*.spec.ts`), run via `npx vitest run`. `npx tsc --noEmit` remains the frontend's cross-cutting gate.
## Deployment

View File

@@ -71,7 +71,11 @@ export const LAB_WORKFLOW_STEPS = [
] as const;
export type ProsthesisChartRegion = 'crown' | 'root' | 'arch';
export type ProsthesisStackGroup = 'restoration' | 'implant' | 'post_core' | 'arch';
export type ProsthesisStackGroup =
| 'restoration'
| 'implant'
| 'post_core'
| 'arch';
export type ProsthesisTypeSeed = {
code: string;
@@ -87,7 +91,13 @@ export type ProsthesisTypeSeed = {
};
const SCAN_DESIGN_MODEL = ['intraoral_scan', 'design', 'model_create'] as const;
const INDIRECT_FULL = [...SCAN_DESIGN_MODEL, 'milling_wet', 'sinter', 'stain', 'glaze'] as const;
const INDIRECT_FULL = [
...SCAN_DESIGN_MODEL,
'milling_wet',
'sinter',
'stain',
'glaze',
] as const;
const INDIRECT_LAYERED = [
...SCAN_DESIGN_MODEL,
'milling_wet',
@@ -115,9 +125,24 @@ const KEEP_LAYERED = [
'glaze',
'polish_prep',
] as const;
const DENTURE = ['intraoral_scan', 'design', 'printer_resin', 'polish_prep'] as const;
const APPLIANCE = ['intraoral_scan', 'design', 'printer_resin', 'polish_prep'] as const;
const POST_CORE = ['intraoral_scan', 'design', 'milling_wet', 'polish_prep'] as const;
const DENTURE = [
'intraoral_scan',
'design',
'printer_resin',
'polish_prep',
] as const;
const APPLIANCE = [
'intraoral_scan',
'design',
'printer_resin',
'polish_prep',
] as const;
const POST_CORE = [
'intraoral_scan',
'design',
'milling_wet',
'polish_prep',
] as const;
function crown(
code: string,
@@ -165,7 +190,8 @@ function indirect(
subcategory: indication,
chartRegion: 'crown',
stackGroup: 'restoration',
manufacturingSteps: technique === 'layered' ? INDIRECT_LAYERED : INDIRECT_FULL,
manufacturingSteps:
technique === 'layered' ? INDIRECT_LAYERED : INDIRECT_FULL,
};
}
@@ -200,10 +226,24 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
'stain',
'glaze',
]),
crown('full_metal_crown', 5, [...SCAN_DESIGN_MODEL, 'milling_wet', 'polish_prep']),
crown('temporary_resin_crown', 6, ['intraoral_scan', 'design', 'milling_wet', 'polish_prep']),
crown('full_metal_crown', 5, [
...SCAN_DESIGN_MODEL,
'milling_wet',
'polish_prep',
]),
crown('temporary_resin_crown', 6, [
'intraoral_scan',
'design',
'milling_wet',
'polish_prep',
]),
crown('pmma', 7, ['intraoral_scan', 'design', 'milling_dry', 'polish_prep']),
crown('peek_crown', 8, ['intraoral_scan', 'design', 'milling_dry', 'polish_prep']),
crown('peek_crown', 8, [
'intraoral_scan',
'design',
'milling_dry',
'polish_prep',
]),
crown('press_ceramic', 9, [
'intraoral_scan',
'design',
@@ -243,7 +283,11 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
'model_create',
'polish_prep',
]),
implant('ti_base_abutment', 41, ['intraoral_scan', 'model_create', 'polish_prep']),
implant('ti_base_abutment', 41, [
'intraoral_scan',
'model_create',
'polish_prep',
]),
implant('multi_unit_abutment', 42, [
'intraoral_scan',
'choosing_abutment',
@@ -255,7 +299,11 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
'milling_wet',
'polish_prep',
]),
implant('zirconia_abutment', 44, [...SCAN_DESIGN_MODEL, 'milling_dry', 'sinter']),
implant('zirconia_abutment', 44, [
...SCAN_DESIGN_MODEL,
'milling_dry',
'sinter',
]),
implant(
'screw_retained',
45,
@@ -450,7 +498,11 @@ export {
const TREATMENT_LABELS: Record<string, Record<string, string>> = {
restoration: { en: 'Restoration', fa: 'ترمیم', nl: 'Restauratie' },
specialized_restoration: { en: 'Specialized Restoration', fa: 'ترمیم تخصصی', nl: 'Gespecialiseerde Restauratie' },
specialized_restoration: {
en: 'Specialized Restoration',
fa: 'ترمیم تخصصی',
nl: 'Gespecialiseerde Restauratie',
},
radiography: { en: 'Radiography', fa: 'رادیوگرافی', nl: 'Röntgen' },
endo: { en: 'Endo', fa: 'اندو', nl: 'Endo' },
surgery: { en: 'Surgery', fa: 'جراحی', nl: 'Chirurgie' },
@@ -460,8 +512,16 @@ const TREATMENT_LABELS: Record<string, Record<string, string>> = {
perio: { en: 'Perio', fa: 'پریو', nl: 'Paro' },
pediatrics: { en: 'Pediatrics', fa: 'اطفال', nl: 'Kinderen' },
extraction: { en: 'Extraction', fa: 'کشیدن', nl: 'Extractie' },
clinic_visit: { en: 'Clinic Visit', fa: 'ویزیت درمانگاه', nl: 'Kliniekbezoek' },
continue_treatment: { en: 'Continue Treatment', fa: 'ادامه درمان', nl: 'Behandeling Voortzetten' },
clinic_visit: {
en: 'Clinic Visit',
fa: 'ویزیت درمانگاه',
nl: 'Kliniekbezoek',
},
continue_treatment: {
en: 'Continue Treatment',
fa: 'ادامه درمان',
nl: 'Behandeling Voortzetten',
},
consultation: { en: 'Consultation', fa: 'مشاوره', nl: 'Consult' },
filling: { en: 'Filling', fa: 'پر کردن', nl: 'Vulling' },
visit: { en: 'Visit', fa: 'ویزیت', nl: 'Bezoek' },
@@ -471,50 +531,182 @@ const TREATMENT_LABELS: Record<string, Record<string, string>> = {
export const PROSTHESIS_LABELS: Record<string, Record<string, string>> = {
pfm_crown: { en: 'PFM Crown', fa: 'روکش PFM', nl: 'PFM Kroon' },
pfz_crown: { en: 'PFZ Crown', fa: 'روکش PFZ', nl: 'PFZ Kroon' },
monolithic_zirconia: { en: 'Monolithic Zirconia', fa: 'زیرکونیا مونولیتیک', nl: 'Monolithisch Zirconia' },
glass_ceramic_crown: { en: 'Glass Ceramic Crown', fa: 'روکش سرامیک شیشه‌ای', nl: 'Glaskeramische Kroon' },
full_metal_crown: { en: 'Full Metal Crown', fa: 'روکش تمام فلز', nl: 'Volledige Metalen Kroon' },
temporary_resin_crown: { en: 'Temporary Resin Crown', fa: 'روکش موقت رزینی', nl: 'Tijdelijke Harskroon' },
monolithic_zirconia: {
en: 'Monolithic Zirconia',
fa: 'زیرکونیا مونولیتیک',
nl: 'Monolithisch Zirconia',
},
glass_ceramic_crown: {
en: 'Glass Ceramic Crown',
fa: 'روکش سرامیک شیشه‌ای',
nl: 'Glaskeramische Kroon',
},
full_metal_crown: {
en: 'Full Metal Crown',
fa: 'روکش تمام فلز',
nl: 'Volledige Metalen Kroon',
},
temporary_resin_crown: {
en: 'Temporary Resin Crown',
fa: 'روکش موقت رزینی',
nl: 'Tijdelijke Harskroon',
},
pmma: { en: 'PMMA', fa: 'PMMA', nl: 'PMMA' },
peek_crown: { en: 'PEEK Crown', fa: 'روکش PEEK', nl: 'PEEK Kroon' },
press_ceramic: { en: 'Pressed Ceramic / IPS e.max', fa: 'سرامیک پرس / IPS e.max', nl: 'Perskeramiek / IPS e.max' },
veneer_full_contour: { en: 'Veneer · Full contour', fa: 'ونیر · تمام‌کانتور', nl: 'Veneer · Full contour' },
veneer_layered: { en: 'Veneer · Layered ceramic', fa: 'ونیر · سرامیک لایه‌ای', nl: 'Veneer · Gelaagd keramiek' },
inlay_full_contour: { en: 'Inlay · Full contour', fa: 'اینلی · تمام‌کانتور', nl: 'Inlay · Full contour' },
inlay_layered: { en: 'Inlay · Layered ceramic', fa: 'اینلی · سرامیک لایه‌ای', nl: 'Inlay · Gelaagd keramiek' },
onlay_full_contour: { en: 'Onlay · Full contour', fa: 'آنلی · تمام‌کانتور', nl: 'Onlay · Full contour' },
onlay_layered: { en: 'Onlay · Layered ceramic', fa: 'آنلی · سرامیک لایه‌ای', nl: 'Onlay · Gelaagd keramiek' },
overlay_full_contour: { en: 'Overlay · Full contour', fa: 'اورلی · تمام‌کانتور', nl: 'Overlay · Full contour' },
overlay_layered: { en: 'Overlay · Layered ceramic', fa: 'اورلی · سرامیک لایه‌ای', nl: 'Overlay · Gelaagd keramiek' },
cast_post_core: { en: 'Cast Post & Core', fa: 'پست و کور ریختگی', nl: 'Gegoten Stiftopbouw' },
fiber_post_core: { en: 'Fiber Post & Core', fa: 'پست و کور فایبر', nl: 'Fiber Stiftopbouw' },
customized_abutment: { en: 'Custom Abutment (Titanium)', fa: 'اباتمنت سفارشی (تیتانیوم)', nl: 'Aangepast abutment (titanium)' },
prefabricated_abutment: { en: 'Prefabricated Abutment', fa: 'اباتمنت آماده', nl: 'Prefab Abutment' },
ti_base_abutment: { en: 'Ti Base Abutment', fa: 'اباتمنت پایه تیتانیوم', nl: 'Ti Basis Abutment' },
multi_unit_abutment: { en: 'Multi Unit Abutment', fa: 'اباتمنت مولتی یونیت', nl: 'Multi Unit Abutment' },
zirconia_abutment: { en: 'Custom Abutment (Zirconia)', fa: 'اباتمنت سفارشی (زیرکونیا)', nl: 'Aangepast abutment (zirconia)' },
press_ceramic: {
en: 'Pressed Ceramic / IPS e.max',
fa: 'سرامیک پرس / IPS e.max',
nl: 'Perskeramiek / IPS e.max',
},
veneer_full_contour: {
en: 'Veneer · Full contour',
fa: 'ونیر · تمام‌کانتور',
nl: 'Veneer · Full contour',
},
veneer_layered: {
en: 'Veneer · Layered ceramic',
fa: 'ونیر · سرامیک لایه‌ای',
nl: 'Veneer · Gelaagd keramiek',
},
inlay_full_contour: {
en: 'Inlay · Full contour',
fa: 'اینلی · تمام‌کانتور',
nl: 'Inlay · Full contour',
},
inlay_layered: {
en: 'Inlay · Layered ceramic',
fa: 'اینلی · سرامیک لایه‌ای',
nl: 'Inlay · Gelaagd keramiek',
},
onlay_full_contour: {
en: 'Onlay · Full contour',
fa: 'آنلی · تمام‌کانتور',
nl: 'Onlay · Full contour',
},
onlay_layered: {
en: 'Onlay · Layered ceramic',
fa: 'آنلی · سرامیک لایه‌ای',
nl: 'Onlay · Gelaagd keramiek',
},
overlay_full_contour: {
en: 'Overlay · Full contour',
fa: 'اورلی · تمام‌کانتور',
nl: 'Overlay · Full contour',
},
overlay_layered: {
en: 'Overlay · Layered ceramic',
fa: 'اورلی · سرامیک لایه‌ای',
nl: 'Overlay · Gelaagd keramiek',
},
cast_post_core: {
en: 'Cast Post & Core',
fa: 'پست و کور ریختگی',
nl: 'Gegoten Stiftopbouw',
},
fiber_post_core: {
en: 'Fiber Post & Core',
fa: 'پست و کور فایبر',
nl: 'Fiber Stiftopbouw',
},
customized_abutment: {
en: 'Custom Abutment (Titanium)',
fa: 'اباتمنت سفارشی (تیتانیوم)',
nl: 'Aangepast abutment (titanium)',
},
prefabricated_abutment: {
en: 'Prefabricated Abutment',
fa: 'اباتمنت آماده',
nl: 'Prefab Abutment',
},
ti_base_abutment: {
en: 'Ti Base Abutment',
fa: 'اباتمنت پایه تیتانیوم',
nl: 'Ti Basis Abutment',
},
multi_unit_abutment: {
en: 'Multi Unit Abutment',
fa: 'اباتمنت مولتی یونیت',
nl: 'Multi Unit Abutment',
},
zirconia_abutment: {
en: 'Custom Abutment (Zirconia)',
fa: 'اباتمنت سفارشی (زیرکونیا)',
nl: 'Aangepast abutment (zirconia)',
},
screw_retained: { en: 'Screw Retained', fa: 'پیچی', nl: 'Schroefgehouden' },
complete_denture: { en: 'Complete Denture', fa: 'دنچر کامل', nl: 'Volledige prothese' },
partial_denture: { en: 'Partial Denture', fa: 'دنچر پارسیل', nl: 'Partiële prothese' },
complete_denture: {
en: 'Complete Denture',
fa: 'دنچر کامل',
nl: 'Volledige prothese',
},
partial_denture: {
en: 'Partial Denture',
fa: 'دنچر پارسیل',
nl: 'Partiële prothese',
},
overdenture: { en: 'Overdenture', fa: 'اوردنچر', nl: 'Overkappingsprothese' },
night_guard_soft: { en: 'Night Guard · Soft', fa: 'نایت گارد · نرم', nl: 'Nachtbeugel · Zacht' },
night_guard_hard: { en: 'Night Guard · Hard', fa: 'نایت گارد · سخت', nl: 'Nachtbeugel · Hard' },
night_guard_dual: { en: 'Night Guard · Dual laminate', fa: 'نایت گارد · دو لایه', nl: 'Nachtbeugel · Dual laminate' },
night_guard_soft: {
en: 'Night Guard · Soft',
fa: 'نایت گارد · نرم',
nl: 'Nachtbeugel · Zacht',
},
night_guard_hard: {
en: 'Night Guard · Hard',
fa: 'نایت گارد · سخت',
nl: 'Nachtbeugel · Hard',
},
night_guard_dual: {
en: 'Night Guard · Dual laminate',
fa: 'نایت گارد · دو لایه',
nl: 'Nachtbeugel · Dual laminate',
},
bleaching_tray: { en: 'Bleaching Tray', fa: 'تری بلیچینگ', nl: 'Bleeklepel' },
clear_aligner: { en: 'Clear Aligner', fa: 'الاینر شفاف', nl: 'Clear aligner' },
soft_structure: { en: 'Soft Structure', fa: 'ساختار نرم', nl: 'Zachte Structuur' },
surgical_guide: { en: 'Surgical Guide', fa: 'گاید جراحی', nl: 'Chirurgische mal' },
clear_aligner: {
en: 'Clear Aligner',
fa: 'الاینر شفاف',
nl: 'Clear aligner',
},
soft_structure: {
en: 'Soft Structure',
fa: 'ساختار نرم',
nl: 'Zachte Structuur',
},
surgical_guide: {
en: 'Surgical Guide',
fa: 'گاید جراحی',
nl: 'Chirurgische mal',
},
smile_design: { en: 'Smile Design', fa: 'طراحی لبخند', nl: 'Smile Design' },
mockup: { en: 'Mockup', fa: 'ماکاپ', nl: 'Mockup' },
veneer_zirconia: { en: 'Veneer Zirconia', fa: 'ونیر زیرکونیا', nl: 'Veneer Zirconia' },
veneer_ips_press: { en: 'Veneer IPS Press', fa: 'ونیر IPS پرس', nl: 'Veneer IPS Press' },
veneer_ips_cad: { en: 'Veneer IPS CAD', fa: 'ونیر IPS CAD', nl: 'Veneer IPS CAD' },
zirconia_overlay: { en: 'Zirconia Overlay', fa: 'اورلی زیرکونیا', nl: 'Zirconia Overlay' },
veneer_zirconia: {
en: 'Veneer Zirconia',
fa: 'ونیر زیرکونیا',
nl: 'Veneer Zirconia',
},
veneer_ips_press: {
en: 'Veneer IPS Press',
fa: 'ونیر IPS پرس',
nl: 'Veneer IPS Press',
},
veneer_ips_cad: {
en: 'Veneer IPS CAD',
fa: 'ونیر IPS CAD',
nl: 'Veneer IPS CAD',
},
zirconia_overlay: {
en: 'Zirconia Overlay',
fa: 'اورلی زیرکونیا',
nl: 'Zirconia Overlay',
},
ips_overlay: { en: 'IPS Overlay', fa: 'اورلی IPS', nl: 'IPS Overlay' },
};
export const WORKFLOW_STEP_LABELS: Record<string, Record<string, string>> = {
intraoral_scan: { en: 'Intraoral Scan', fa: 'اسکن داخل دهان', nl: 'Intraorale Scan' },
intraoral_scan: {
en: 'Intraoral Scan',
fa: 'اسکن داخل دهان',
nl: 'Intraorale Scan',
},
choosing_abutment: {
en: 'Choosing Abutment',
fa: 'انتخاب اباتمنت',
@@ -530,7 +722,11 @@ export const WORKFLOW_STEP_LABELS: Record<string, Record<string, string>> = {
build_up: { en: 'Build Up', fa: 'بیلدآپ', nl: 'Opbouw' },
stain: { en: 'Stain', fa: 'رنگ‌آمیزی', nl: 'Kleuren' },
glaze: { en: 'Glaze', fa: 'گلیز', nl: 'Glazuur' },
polish_prep: { en: 'Polish/Prep', fa: 'پولیش/آماده‌سازی', nl: 'Polijsten/Voorbereiding' },
polish_prep: {
en: 'Polish/Prep',
fa: 'پولیش/آماده‌سازی',
nl: 'Polijsten/Voorbereiding',
},
packing: { en: 'Packing', fa: 'بسته‌بندی', nl: 'Verpakken' },
shipping: { en: 'Shipping', fa: 'ارسال', nl: 'Verzending' },
pressing: { en: 'Pressing', fa: 'پرس', nl: 'Persen' },
@@ -549,8 +745,58 @@ function labelsToTranslations(
return out;
}
/**
* The 7 prosthesis categories, worded from `frontend/messages/*.json`'s `category_*` keys
* (decision 47) so the extraction prompt and the manual picker read identically on day one.
* The frontend keeps its own message keys — migrating it to read these is out of scope
* (decision 48) — this seed is written *from* those keys precisely so the two agree at the
* point they diverge.
*/
export const PROSTHESIS_CATEGORY_LABELS: Record<
string,
Record<string, string>
> = {
crown: { en: 'Crowns', fa: 'روکش‌ها', nl: 'Kronen' },
indirect: {
en: 'Veneer/Inlay/Onlay/Overlay',
fa: 'ونیر/اینلی/آنلی/اورلی',
nl: 'Veneer/Inlay/Onlay/Overlay',
},
implant: { en: 'Implants', fa: 'ایمپلنت', nl: 'Implantaten' },
post_core: { en: 'Post & core', fa: 'پست و کور', nl: 'Stiftopbouw' },
removable: { en: 'Removable', fa: 'متحرک', nl: 'Uitneembaar' },
appliance: { en: 'Appliances', fa: 'اپلاینس‌ها', nl: 'Apparatuur' },
digital: { en: 'Digital', fa: 'دیجیتال', nl: 'Digitaal' },
};
/**
* Only the 5 real subcategories — the `sub_*` message key set is wider (it also carries
* technique names and two leaf codes); those are not subcategory catalog entities.
*/
export const PROSTHESIS_SUBCATEGORY_LABELS: Record<
string,
Record<string, string>
> = {
veneer: { en: 'Veneer', fa: 'ونیر', nl: 'Veneer' },
inlay: { en: 'Inlay', fa: 'اینلی', nl: 'Inlay' },
onlay: { en: 'Onlay', fa: 'آنلی', nl: 'Onlay' },
overlay: { en: 'Overlay', fa: 'اورلی', nl: 'Overlay' },
night_guard: { en: 'Night guard', fa: 'نایت گارد', nl: 'Nachtbeugel' },
};
export const CATALOG_TRANSLATIONS: CatalogTranslationSeed[] = [
...labelsToTranslations(CatalogEntityKind.TREATMENT_TYPE, TREATMENT_LABELS),
...labelsToTranslations(CatalogEntityKind.PROSTHESIS_TYPE, PROSTHESIS_LABELS),
...labelsToTranslations(CatalogEntityKind.LAB_WORKFLOW_STEP, WORKFLOW_STEP_LABELS),
...labelsToTranslations(
CatalogEntityKind.LAB_WORKFLOW_STEP,
WORKFLOW_STEP_LABELS,
),
...labelsToTranslations(
CatalogEntityKind.PROSTHESIS_CATEGORY,
PROSTHESIS_CATEGORY_LABELS,
),
...labelsToTranslations(
CatalogEntityKind.PROSTHESIS_SUBCATEGORY,
PROSTHESIS_SUBCATEGORY_LABELS,
),
];

View File

@@ -0,0 +1,6 @@
-- AlterEnum
-- Category and subcategory labels have no backend source today (spec §5, decision 47):
-- CatalogEntityKind covered only TREATMENT_TYPE / PROSTHESIS_TYPE / LAB_WORKFLOW_STEP, so
-- CatalogLabelService could not resolve a category at all. Additive only, no data loss.
ALTER TYPE "CatalogEntityKind" ADD VALUE 'PROSTHESIS_CATEGORY';
ALTER TYPE "CatalogEntityKind" ADD VALUE 'PROSTHESIS_SUBCATEGORY';

View File

@@ -332,6 +332,8 @@ enum CatalogEntityKind {
TREATMENT_TYPE
PROSTHESIS_TYPE
LAB_WORKFLOW_STEP
PROSTHESIS_CATEGORY
PROSTHESIS_SUBCATEGORY
}
model CatalogTranslation {

View File

@@ -62,6 +62,14 @@ export const FDI_TOOTH_IDS: ReadonlySet<string> = new Set<string>([
...FDI_LOWER_ARCH_ORDER,
]);
/**
* Not FDI codes — jaw-level prosthesis targets on `LabCaseToothProsthesis.tooth`. Mirrors
* `frontend/src/components/treatment/prosthesisTree.ts`'s `ARCH_TOOTH_UPPER`/`ARCH_TOOTH_LOWER`
* so the voice contract speaks the same sentinel the manual chart already writes.
*/
export const ARCH_TOOTH_UPPER = 'UA';
export const ARCH_TOOTH_LOWER = 'LA';
export function isFdiTooth(value: unknown): value is string {
return typeof value === 'string' && FDI_TOOTH_IDS.has(value);
}

View File

@@ -69,7 +69,9 @@ export class ProsthesisCatalogService implements OnModuleInit {
this.loaded = true;
}
async list(localeInput?: string | null): Promise<ProsthesisTypeCatalogEntry[]> {
async list(
localeInput?: string | null,
): Promise<ProsthesisTypeCatalogEntry[]> {
await this.refresh();
const locale = normalizeCatalogLocale(localeInput);
const codes = [...this.byCode.keys()];
@@ -92,17 +94,64 @@ export class ProsthesisCatalogService implements OnModuleInit {
stackGroup: row.stackGroup,
};
})
.sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code));
.sort(
(a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code),
);
}
/** The 7 distinct category codes present in the active catalog, with labels in `locale`. */
async listCategories(
localeInput?: string | null,
): Promise<{ code: string; label: string }[]> {
return this.listDistinct(
(row) => row.category,
CatalogEntityKind.PROSTHESIS_CATEGORY,
localeInput,
);
}
/** The 5 distinct subcategory codes (veneer, inlay, onlay, overlay, night_guard, …). */
async listSubcategories(
localeInput?: string | null,
): Promise<{ code: string; label: string }[]> {
return this.listDistinct(
(row) => row.subcategory,
CatalogEntityKind.PROSTHESIS_SUBCATEGORY,
localeInput,
);
}
private async listDistinct(
pick: (row: CatalogRow) => string,
entityKind: CatalogEntityKind,
localeInput?: string | null,
): Promise<{ code: string; label: string }[]> {
await this.refresh();
const locale = normalizeCatalogLocale(localeInput);
const codes = [
...new Set([...this.byCode.values()].map(pick).filter(Boolean)),
].sort();
const labels = await this.catalogLabels.resolveLabels(
entityKind,
codes,
locale,
);
return codes.map((code) => ({ code, label: labels.get(code) ?? code }));
}
assertKnownProsthesisType(code: string): void {
this.ensureLoaded();
if (!this.byCode.has(code)) {
throw new AppException(ErrorCode.CATALOG_UNKNOWN_PROSTHESIS_TYPE, HttpStatus.BAD_REQUEST);
throw new AppException(
ErrorCode.CATALOG_UNKNOWN_PROSTHESIS_TYPE,
HttpStatus.BAD_REQUEST,
);
}
}
async getStepCodesForProsthesisType(prosthesisTypeCode: string): Promise<string[]> {
async getStepCodesForProsthesisType(
prosthesisTypeCode: string,
): Promise<string[]> {
const type = await this.prisma.prosthesisType.findUnique({
where: { code: prosthesisTypeCode },
select: {
@@ -120,7 +169,10 @@ export class ProsthesisCatalogService implements OnModuleInit {
return type.steps.map((s) => s.labWorkflowStep.code);
}
async resolveStepLabels(stepCodes: string[], locale: CatalogLocale): Promise<Map<string, string>> {
async resolveStepLabels(
stepCodes: string[],
locale: CatalogLocale,
): Promise<Map<string, string>> {
return this.catalogLabels.resolveLabels(
CatalogEntityKind.LAB_WORKFLOW_STEP,
stepCodes,

View File

@@ -8,14 +8,24 @@ const LOCALE_NOTES: Record<string, string> = {
'("دندون شماره ۲۶"). Digits may arrive in Persian or Latin script — either way, copy',
'the number into "fdi" as two Latin digits. The descriptive form is quadrant-relative:',
'"شش بالا راست" = upper right six -> arch "upper", side "patient_right", position 6.',
'A jaw is spoken as "فک بالا" (upper jaw) or "فک پایین" (lower jaw), sometimes just',
'"بالا"/"پایین" in context, or "هر دو فک" (both jaws).',
'A note is asked for with "بنویس", "یادداشت کن", "این را یادداشت کن", "بنویس که",',
'"در توضیحات بنویس". Anything else the clinician says is not a note.',
].join(' '),
nl: [
'The clinician is speaking Dutch, where FDI is standard. "zesentwintig" and "26" are',
'tooth 26. The descriptive form is "rechtsboven zes" = upper right six.',
'tooth 26. The descriptive form is "rechtsboven zes" = upper right six. A jaw is',
'"bovenkaak" (upper) or "onderkaak" (lower), or "beide kaken" (both).',
'A note is asked for with "schrijf op", "noteer", "zet in de notities". Anything else',
'the clinician says is not a note.',
].join(' '),
en: [
'The clinician is speaking English and uses FDI. "twenty-six", "two six" and "26" are',
'all tooth 26. The descriptive form is "upper right six".',
'all tooth 26. The descriptive form is "upper right six". A jaw is "upper jaw"/"lower',
'jaw", or "both jaws".',
'A note is asked for with "write this in the notes", "note that", "add a note",',
'"put in the comments". Anything else the clinician says is not a note.',
].join(' '),
};
@@ -24,6 +34,63 @@ function codeList(entries: { code: string; label: string }[]): string {
return entries.map((e) => `- ${e.code} = ${e.label}`).join('\n');
}
/** "per-tooth" | "jaw" | "per-tooth or jaw" — derived from the leaves' own chartRegion, never hardcoded. */
function regionNote(regions: ReadonlySet<string>): string {
const isJaw = regions.has('arch');
const isTooth = regions.has('crown') || regions.has('root');
if (isJaw && isTooth)
return 'per-tooth or jaw, depending on the specific code';
return isJaw ? 'jaw' : 'per-tooth';
}
/**
* Renders the prosthesis catalog as the tree it is: category -> (subcategory ->) leaves.
* Nothing here is hardcoded — every code, label and region annotation comes from the data
* `buildCatalog` fetched, so a catalog change needs no prompt change.
*/
function prosthesisTree(catalog: ExtractionCatalog): string {
const byCategory = new Map<string, ExtractionCatalog['prosthesisTypes']>();
for (const leaf of catalog.prosthesisTypes) {
const list = byCategory.get(leaf.category) ?? [];
list.push(leaf);
byCategory.set(leaf.category, list);
}
const lines: string[] = [];
for (const category of catalog.prosthesisCategories) {
const leaves = byCategory.get(category.code) ?? [];
const regions = new Set(leaves.map((l) => l.chartRegion));
lines.push(
`CATEGORY ${category.code} = ${category.label} (${regionNote(regions)})`,
);
const bySubcategory = new Map<string, typeof leaves>();
const bare: typeof leaves = [];
for (const leaf of leaves) {
if (leaf.subcategory) {
const list = bySubcategory.get(leaf.subcategory) ?? [];
list.push(leaf);
bySubcategory.set(leaf.subcategory, list);
} else {
bare.push(leaf);
}
}
for (const leaf of bare) {
lines.push(` - ${leaf.code} = ${leaf.label}`);
}
for (const sub of catalog.prosthesisSubcategories) {
const subLeaves = bySubcategory.get(sub.code);
if (!subLeaves || subLeaves.length === 0) continue;
lines.push(` SUBCATEGORY ${sub.code} = ${sub.label}`);
for (const leaf of subLeaves) {
lines.push(` - ${leaf.code} = ${leaf.label}`);
}
}
}
return lines.join('\n');
}
export function buildExtractionPrompt(
transcript: string,
catalog: ExtractionCatalog,
@@ -36,17 +103,22 @@ export function buildExtractionPrompt(
'You are a parser, not an assistant: report only what was said.',
'',
'HARD RULES',
'1. Never invent a code. treatmentType, prosthesisDefaultType and prosthesisOverrides[].type',
' must be codes from the lists below. labId must be an id from the lab list. If what you',
' heard is not in a list, use null.',
'1. Never invent a code. treatmentType and prosthesis[].types[] must be codes from the',
' lists below. labId must be an id from the lab list. If what you heard is not in a',
' list, use null (or omit it from prosthesis[].types[]).',
'2. "side" is always the PATIENT\'s side. The patient\'s upper right is quadrant 1. Never',
" flip to the viewer's point of view.",
'3. Never do calendar arithmetic. Report the deadline as it was said, using due.kind.',
' If no deadline was mentioned, use due.kind = "none".',
'4. Copy the exact spoken words for each tooth into "spoken", so the clinician can see',
' what was heard.',
'4. Copy the exact spoken words for each tooth, jaw and prosthesis instruction into',
' "spoken", so the clinician can see what was heard.',
'5. If you are unsure about a value, use null. A missing field is recoverable; a wrong',
' one is not.',
'6. A whole jaw is not a tooth. Never put a jaw in the "teeth" list. A jaw appears only',
' as a prosthesis[].targets entry with "arch" set and "position" null.',
'7. Never decide something is a note. Fill "comment" only when the clinician asked for',
' one, and report the words they used in "commentTrigger". Speech you cannot place in',
' a field is simply not reported — it is never a note.',
'',
'TOOTH NUMBERS',
'A number the clinician says for a tooth IS that tooth\'s FDI code. Put it in "fdi" as',
@@ -71,8 +143,24 @@ export function buildExtractionPrompt(
'TREATMENT TYPE CODES',
codeList(catalog.treatmentTypes),
'',
'PROSTHESIS TYPE CODES',
codeList(catalog.prosthesisTypes),
'PROSTHESIS WORK — prosthesis[]',
'One entry per spoken instruction: "these targets get these jobs". Each entry is',
'{ targets, types, spoken }:',
'- targets: the teeth OR jaw(s) this instruction is for. A jaw target is a tooth object',
' with "arch" set ("upper", "lower", or "both" for both jaws) and "position" left null —',
' never invent a tooth number for a jaw-level appliance. A tooth target is the normal',
' fdi / arch+side+position shape above.',
'- types: one or more codes from the tree below, applied to every target in this entry.',
' More than one code means a STACK on the same tooth — e.g. an abutment plus a crown on',
' one implant site: types: ["zirconia_abutment", "monolithic_zirconia"].',
' If only the general term was said ("روکش", "veneer") and not a specific material, use',
' the CATEGORY or SUBCATEGORY code instead of guessing a leaf.',
'- A tooth named with no job at all still belongs in the top-level "teeth" list, not here.',
'- If several teeth share one job, list them all as targets in one entry rather than',
' repeating the entry — "12 and 13, PFM crown on both" -> one entry, two targets.',
'',
'PROSTHESIS TYPE CODES (tree — CATEGORY and SUBCATEGORY are marked; the rest are leaves)',
prosthesisTree(catalog),
'',
'LABS THIS CLINIC CAN SEND TO',
catalog.labs.length > 0
@@ -81,8 +169,13 @@ export function buildExtractionPrompt(
'',
'OTHER FIELDS',
'- connectedSpans: only for bridges or splinted units. Endpoints inclusive.',
'- comment: clinical notes, in the language spoken. Omit the parts already captured as',
' treatment type, teeth or deadline.',
'- comment + commentTrigger: a note is written ONLY when the clinician asked for one.',
' "commentTrigger" is the exact words that asked, copied from the transcript. "comment"',
' is what they then dictated, WITHOUT those words: for "بنویس که بیمار حساسیت به سرما',
' دارد", commentTrigger is "بنویس که" and comment is "بیمار حساسیت به سرما دارد".',
' If nobody asked, BOTH are null. Never sweep up leftover speech, filler, small talk or',
' a diagnosis nobody asked you to record. A comment without a trigger is discarded, so',
' guessing costs the clinician the note.',
'- labMatchExact: true only when the spoken name matched a lab name exactly.',
].join('\n');

View File

@@ -1,10 +1,22 @@
import { ARCH_TOOTH_LOWER, ARCH_TOOTH_UPPER } from '../../common/fdi';
import {
PROSTHESIS_TYPES,
PROSTHESIS_CATEGORY_LABELS,
PROSTHESIS_SUBCATEGORY_LABELS,
} from '../../../prisma/catalog-seed-data';
import {
resolveConnectedSpans,
resolveProsthesis,
resolveProsthesisAssignment,
resolveVoiceIntent,
type ProsthesisLeaf,
type ResolveContext,
} from './extraction.resolver';
import type { ToothIntent, VoiceIntent } from './voice.types';
import type {
ProsthesisAssignment,
ToothIntent,
UnresolvedItem,
VoiceIntent,
} from './voice.types';
const tooth = (fdi: string, spoken = fdi): ToothIntent => ({
kind: 'explicit',
@@ -12,12 +24,75 @@ const tooth = (fdi: string, spoken = fdi): ToothIntent => ({
spoken,
});
const positional = (
overrides: Partial<Extract<ToothIntent, { kind: 'positional' }>> = {},
): ToothIntent => ({
kind: 'positional',
arch: undefined as never,
side: undefined as never,
position: Number.NaN,
spoken: '',
...overrides,
});
const PROSTHESIS_LEAVES: ProsthesisLeaf[] = [
{
code: 'pfm_crown',
category: 'crown',
subcategory: '',
chartRegion: 'crown',
},
{
code: 'monolithic_zirconia',
category: 'crown',
subcategory: '',
chartRegion: 'crown',
},
{
code: 'zirconia_abutment',
category: 'implant',
subcategory: '',
chartRegion: 'root',
},
{
code: 'screw_retained',
category: 'implant',
subcategory: '',
chartRegion: 'crown',
},
{
code: 'night_guard_soft',
category: 'appliance',
subcategory: 'night_guard',
chartRegion: 'arch',
},
{
code: 'complete_denture',
category: 'removable',
subcategory: '',
chartRegion: 'arch',
},
{
code: 'partial_denture',
category: 'removable',
subcategory: '',
chartRegion: 'crown',
},
];
const CTX: ResolveContext = {
todayIso: '2025-10-11',
weekStartJs: 6, // Saturday — the fa week
treatmentTypeCodes: new Set(['restoration', 'prosthesis', 'extraction']),
prosthesisTypeCodes: new Set(['monolithic_zirconia', 'pfm_crown']),
prosthesisLeaves: PROSTHESIS_LEAVES,
prosthesisCategoryCodes: new Set([
'crown',
'implant',
'removable',
'appliance',
]),
prosthesisSubcategoryCodes: new Set(['night_guard']),
linkedLabIds: new Set(['lab-sina', 'lab-mehr']),
};
@@ -111,122 +186,317 @@ describe('resolveConnectedSpans', () => {
});
});
describe('resolveProsthesis', () => {
const allowed = CTX.prosthesisTypeCodes;
it('expands the default across every tooth', () => {
const result = resolveProsthesis(
{ defaultType: 'monolithic_zirconia', overrides: [] },
['14', '15'],
allowed,
describe('resolveProsthesisAssignment', () => {
function resolve(assignment: ProsthesisAssignment, index = 0) {
const unresolved: UnresolvedItem[] = [];
const resolved = resolveProsthesisAssignment(
assignment,
index,
CTX,
unresolved,
);
expect(result.prosthesis?.byTooth).toEqual({
'14': 'monolithic_zirconia',
'15': 'monolithic_zirconia',
return { resolved, unresolved };
}
it('resolves explicit tooth targets to FDI codes', () => {
const { resolved } = resolve({
targets: [tooth('12'), tooth('13')],
types: ['pfm_crown'],
spoken: 'روکش پی‌اف‌ام برای ۱۲ و ۱۳',
});
expect(result.prosthesis?.complete).toBe(true);
expect(resolved.targets.sort()).toEqual(['12', '13']);
expect(resolved.types).toEqual(['pfm_crown']);
});
it('applies per-tooth overrides on top of the default', () => {
const result = resolveProsthesis(
{
defaultType: 'monolithic_zirconia',
overrides: [{ tooth: tooth('26'), type: 'pfm_crown' }],
},
['14', '26'],
allowed,
);
expect(result.prosthesis?.byTooth).toEqual({
'14': 'monolithic_zirconia',
'26': 'pfm_crown',
it('resolves a stack — more than one type on the same target', () => {
const { resolved } = resolve({
targets: [tooth('12')],
types: ['zirconia_abutment', 'monolithic_zirconia'],
spoken: 'ایمپلنت با روکش زیرکونیا روی ۱۲',
});
expect(result.prosthesis?.complete).toBe(true);
});
it('marks the map incomplete when a tooth ends up untyped', () => {
// Unshippable: assertCompleteToothProsthesisMap would reject this at dispatch.
const result = resolveProsthesis(
{
defaultType: null,
overrides: [{ tooth: tooth('14'), type: 'pfm_crown' }],
},
['14', '15'],
allowed,
);
expect(result.prosthesis?.complete).toBe(false);
expect(result.prosthesis?.missingTeeth).toEqual(['15']);
});
it('rejects a catalog code the clinic does not have', () => {
const result = resolveProsthesis(
{ defaultType: 'gold_foil', overrides: [] },
['14'],
allowed,
);
// Nothing usable was said, so there is no prosthesis to show — not an empty one.
expect(result.prosthesis).toBeNull();
expect(result.unresolved).toEqual([
{ spoken: 'gold_foil', reason: 'unknown_catalog_code' },
expect(resolved.targets).toEqual(['12']);
expect(resolved.types.sort()).toEqual([
'monolithic_zirconia',
'zirconia_abutment',
]);
});
it('ignores an override for a tooth that is not selected', () => {
const result = resolveProsthesis(
{
defaultType: 'monolithic_zirconia',
overrides: [{ tooth: tooth('37', 'سی و هفت'), type: 'pfm_crown' }],
},
['14'],
allowed,
);
expect(result.prosthesis?.byTooth).toEqual({ '14': 'monolithic_zirconia' });
expect(result.unresolved).toEqual([
{ spoken: 'سی و هفت', reason: 'tooth_not_selected' },
]);
it('resolves an arch target with no position to the jaw sentinel', () => {
const { resolved } = resolve({
targets: [positional({ arch: 'upper' })],
types: ['night_guard_soft'],
spoken: 'نایت گارد فک بالا',
});
expect(resolved.targets).toEqual([ARCH_TOOTH_UPPER]);
});
it('reports no prosthesis at all when the object carries nothing usable', () => {
// An empty-but-present map would paint a plain restoration with a fabricated
// "incomplete, cannot ship" warning.
for (const empty of [{ defaultType: null, overrides: [] }, {} as never]) {
expect(
resolveProsthesis(empty, ['14', '15'], allowed).prosthesis,
).toBeNull();
it('resolves "both jaws" to both sentinels', () => {
const { resolved } = resolve({
targets: [positional({ arch: 'both' })],
types: ['night_guard_soft'],
spoken: 'نایت گارد هر دو فک',
});
expect(resolved.targets.sort()).toEqual(
[ARCH_TOOTH_LOWER, ARCH_TOOTH_UPPER].sort(),
);
});
it('classifies a leaf, a category and a subcategory code independently', () => {
const leaf = resolve({
targets: [tooth('12')],
types: ['pfm_crown'],
spoken: '',
});
expect(leaf.resolved.types).toEqual(['pfm_crown']);
expect(leaf.unresolved).toEqual([]);
const category = resolve({
targets: [tooth('12')],
types: ['crown'],
spoken: 'روکش',
});
expect(category.resolved.types).toEqual([]);
expect(category.unresolved).toEqual([
{
spoken: 'روکش',
reason: 'prosthesis_type_ambiguous',
candidates: ['monolithic_zirconia', 'pfm_crown'],
assignmentIndex: 0,
},
]);
const subcategory = resolve({
targets: [positional({ arch: 'upper' })],
types: ['night_guard'],
spoken: 'نایت گارد',
});
expect(subcategory.resolved.types).toEqual([]);
expect(subcategory.unresolved[0]).toMatchObject({
reason: 'prosthesis_type_ambiguous',
candidates: ['night_guard_soft'],
});
});
it('asserts the leaf, category and subcategory namespaces are disjoint on the live catalog', () => {
const leafCodes = new Set(PROSTHESIS_TYPES.map((t) => t.code));
const categoryCodes = new Set(PROSTHESIS_TYPES.map((t) => t.category));
const subcategoryCodes = new Set(
PROSTHESIS_TYPES.map((t) => t.subcategory).filter(
(code): code is string => Boolean(code),
),
);
for (const code of categoryCodes) expect(leafCodes.has(code)).toBe(false);
for (const code of subcategoryCodes) {
expect(leafCodes.has(code)).toBe(false);
expect(categoryCodes.has(code)).toBe(false);
}
});
it('reports no prosthesis when there are no teeth to type', () => {
const result = resolveProsthesis(
{ defaultType: 'monolithic_zirconia', overrides: [] },
[],
allowed,
);
expect(result.prosthesis).toBeNull();
});
it('distinguishes a tooth it could not understand from one that is not selected', () => {
// Different corrective actions: add the tooth, versus repeat yourself.
const result = resolveProsthesis(
it('reports a code not in the supplied catalog', () => {
const { resolved, unresolved } = resolve({
targets: [tooth('12')],
types: ['gold_foil'],
spoken: '',
});
expect(resolved.types).toEqual([]);
expect(unresolved).toEqual([
{
defaultType: 'monolithic_zirconia',
overrides: [
{
tooth: { kind: 'explicit', fdi: '99', spoken: 'نود و نه' },
type: 'pfm_crown',
},
],
spoken: 'gold_foil',
reason: 'unknown_catalog_code',
assignmentIndex: 0,
},
['14'],
allowed,
);
expect(result.unresolved).toEqual([
{ spoken: 'نود و نه', reason: 'malformed' },
]);
});
it('returns null when no prosthesis was spoken', () => {
expect(resolveProsthesis(null, ['14'], allowed).prosthesis).toBeNull();
it('rejects an arch code aimed at a tooth', () => {
const { resolved, unresolved } = resolve({
targets: [tooth('12')],
types: ['night_guard_soft'],
spoken: 'دندون ۱۲ نایت گارد',
});
expect(resolved.targets).toEqual([]);
expect(unresolved).toEqual([
{ spoken: '12', reason: 'code_not_valid_for_target', assignmentIndex: 0 },
]);
});
it('rejects a tooth code aimed at a jaw', () => {
const { resolved, unresolved } = resolve({
targets: [positional({ arch: 'upper', spoken: 'فک بالا' })],
types: ['pfm_crown'],
spoken: 'فک بالا',
});
expect(resolved.targets).toEqual([]);
expect(unresolved).toEqual([
{
spoken: 'فک بالا',
reason: 'code_not_valid_for_target',
assignmentIndex: 0,
},
]);
});
it('narrows a mixed-region category to the leaves the target can carry', () => {
// complete_denture is 'arch', partial_denture is 'crown'. Deferring the check until a
// leaf was picked left nothing to complete it, and wrote a complete denture onto one
// tooth. The target kind narrows the candidates instead, so an impossible leaf is never
// offered.
const toothTarget = resolve({
targets: [tooth('12')],
types: ['removable'],
spoken: 'دنچر برای ۱۲',
});
expect(toothTarget.resolved.targets).toEqual(['12']);
expect(toothTarget.unresolved).toContainEqual(
expect.objectContaining({
reason: 'prosthesis_type_ambiguous',
candidates: ['partial_denture'],
}),
);
expect(toothTarget.unresolved).not.toContainEqual(
expect.objectContaining({ reason: 'code_not_valid_for_target' }),
);
const jawTarget = resolve({
targets: [positional({ arch: 'upper' })],
types: ['removable'],
spoken: 'دنچر فک بالا',
});
expect(jawTarget.resolved.targets).toEqual([ARCH_TOOTH_UPPER]);
expect(jawTarget.unresolved).toContainEqual(
expect.objectContaining({
reason: 'prosthesis_type_ambiguous',
candidates: ['complete_denture'],
}),
);
expect(jawTarget.unresolved).not.toContainEqual(
expect.objectContaining({ reason: 'code_not_valid_for_target' }),
);
});
it('rejects a category with no leaf the target can carry', () => {
// `implant` spans root + crown, both tooth regions, so it is not a mixed tooth/arch
// category and must not defer. Aimed at a jaw it is a contradiction.
const { resolved, unresolved } = resolve({
targets: [positional({ arch: 'upper', spoken: 'فک بالا' })],
types: ['implant'],
spoken: 'ایمپلنت بالا',
});
expect(resolved.targets).toEqual([]);
expect(resolved.types).toEqual([]);
expect(unresolved).toContainEqual(
expect.objectContaining({ reason: 'code_not_valid_for_target' }),
);
expect(unresolved).not.toContainEqual(
expect.objectContaining({ reason: 'prosthesis_type_ambiguous' }),
);
});
it('rejects a stack where only one code suits the target', () => {
// One legal crown must not admit an arch-only appliance onto the same tooth. Validating
// with `some` over the stack's regions did exactly that.
const { resolved, unresolved } = resolve({
targets: [tooth('12')],
types: ['pfm_crown', 'night_guard_soft'],
spoken: 'دندون ۱۲ روکش و نایت گارد',
});
expect(resolved.targets).toEqual([]);
expect(unresolved).toContainEqual(
expect.objectContaining({
spoken: '12',
reason: 'code_not_valid_for_target',
}),
);
});
it('accepts a stack whose every code suits the target', () => {
const { resolved, unresolved } = resolve({
targets: [tooth('12')],
types: ['zirconia_abutment', 'monolithic_zirconia'],
spoken: 'دندون ۱۲ ایمپلنت با روکش زیرکونیا',
});
expect(resolved.targets).toEqual(['12']);
expect(resolved.types).toEqual([
'zirconia_abutment',
'monolithic_zirconia',
]);
expect(unresolved).toEqual([]);
});
it('a target with no types resolves with an empty types[], and does not fail the assignment', () => {
const { resolved } = resolve({
targets: [tooth('13'), tooth('14')],
types: [],
spoken: '۱۳ و ۱۴',
});
// Nothing to validate a region against yet, so a bare target still resolves as valid
// geometry — the frontend renders it struck through ("no prosthesis heard") because
// `types` is empty and no `prosthesis_type_ambiguous` item references this assignment.
expect(resolved.targets.sort()).toEqual(['13', '14']);
expect(resolved.types).toEqual([]);
});
it('one target failing validity does not exclude the rest of the assignment', () => {
const { resolved, unresolved } = resolve({
targets: [tooth('12'), positional({ arch: 'upper' })],
types: ['pfm_crown'],
spoken: '',
});
expect(resolved.targets).toEqual(['12']);
expect(unresolved).toContainEqual(
expect.objectContaining({ reason: 'code_not_valid_for_target' }),
);
});
it('carries an assignment target that could not be resolved, with the arch candidates', () => {
const { unresolved } = resolve({
targets: [positional({ spoken: 'نایت گارد' })],
types: ['night_guard_soft'],
spoken: 'نایت گارد',
});
expect(unresolved).toContainEqual({
spoken: 'نایت گارد',
reason: 'arch_not_spoken',
candidates: ['upper', 'lower'],
assignmentIndex: 0,
});
});
it('carries the assignment index on a missing-quadrant target', () => {
const { unresolved } = resolve({
targets: [positional({ position: 2, spoken: 'دندون دو' })],
types: ['pfm_crown'],
spoken: 'دندون دو روکش',
});
expect(unresolved).toContainEqual(
expect.objectContaining({
reason: 'tooth_missing_quadrant',
assignmentIndex: 0,
}),
);
});
});
describe('every prosthesis category and subcategory has a non-empty label in fa/en/nl', () => {
const locales = ['fa', 'en', 'nl'] as const;
it.each(Object.entries(PROSTHESIS_CATEGORY_LABELS))(
'category %s',
(_code, labels) => {
for (const locale of locales) {
expect(labels[locale]?.trim()).toBeTruthy();
}
},
);
it.each(Object.entries(PROSTHESIS_SUBCATEGORY_LABELS))(
'subcategory %s',
(_code, labels) => {
for (const locale of locales) {
expect(labels[locale]?.trim()).toBeTruthy();
}
},
);
});
describe('resolveVoiceIntent', () => {
@@ -235,7 +505,8 @@ describe('resolveVoiceIntent', () => {
teeth: [tooth('14'), tooth('15')],
connectedSpans: [],
comment: ' حساسیت به سرما ',
prosthesis: null,
commentTrigger: 'بنویس که',
prosthesis: [],
labId: null,
labMatchExact: false,
due: null,
@@ -246,7 +517,7 @@ describe('resolveVoiceIntent', () => {
expect(result.treatmentType).toBe('restoration');
expect(result.teeth).toEqual(['14', '15']);
expect(result.comment).toBe('حساسیت به سرما');
expect(result.prosthesis).toBeNull();
expect(result.prosthesisAssignments).toEqual([]);
expect(result.unresolved).toEqual([]);
});
@@ -299,27 +570,150 @@ describe('resolveVoiceIntent', () => {
expect(result.labMatchExact).toBe(false);
});
it('applies prosthesis over the span-expanded tooth set', () => {
it('forces treatmentType to prosthesis when an assignment resolves', () => {
const result = resolveVoiceIntent(
{
...base,
treatmentType: 'prosthesis',
teeth: [tooth('14')],
connectedSpans: [{ from: tooth('14'), to: tooth('16') }],
prosthesis: { defaultType: 'monolithic_zirconia', overrides: [] },
treatmentType: 'restoration',
prosthesis: [
{
targets: [tooth('12')],
types: ['pfm_crown'],
spoken: 'روکش برای ۱۲',
},
],
},
CTX,
);
// 15 was never spoken but is part of the bridge, so it must carry a type too.
expect(result.teeth).toEqual(['14', '15', '16']);
expect(result.prosthesis?.complete).toBe(true);
expect(Object.keys(result.prosthesis!.byTooth).sort()).toEqual([
'14',
'15',
'16',
expect(result.treatmentType).toBe('prosthesis');
expect(result.prosthesisAssignments).toEqual([
{ targets: ['12'], types: ['pfm_crown'], spoken: 'روکش برای ۱۲' },
]);
});
it('does not force prosthesis when every assignment resolved no target', () => {
const result = resolveVoiceIntent(
{
...base,
prosthesis: [{ targets: [], types: ['pfm_crown'], spoken: '' }],
},
CTX,
);
expect(result.treatmentType).toBe('restoration');
});
it('does not treat a spoken jaw as a broken tooth', () => {
// "یه کامپلیت دنچر برای فک بالا" — the model names the jaw in `teeth` as well as in the
// assignment target. The arch reached the form correctly, but the duplicate reported
// `position_out_of_range`, so the sheet asked which tooth was meant. No tooth was said.
const result = resolveVoiceIntent(
{
...base,
treatmentType: null,
teeth: [positional({ arch: 'upper', spoken: 'فک بالا' })],
prosthesis: [
{
targets: [positional({ arch: 'upper', spoken: 'فک بالا' })],
types: ['complete_denture'],
spoken: 'یه کامپلیت دنچر برای فک بالا',
},
],
},
CTX,
);
expect(result.teeth).toEqual([]);
expect(result.unresolved).toEqual([]);
expect(result.prosthesisAssignments[0].targets).toEqual([ARCH_TOOTH_UPPER]);
expect(result.treatmentType).toBe('prosthesis');
});
it('drops a both-jaws reference from the teeth list too', () => {
const result = resolveVoiceIntent(
{
...base,
teeth: [positional({ arch: 'both', spoken: 'هر دو فک' })],
},
CTX,
);
expect(result.teeth).toEqual([]);
expect(result.unresolved).toEqual([]);
});
it('still reports a tooth whose position is out of range', () => {
// The jaw filter must not swallow a real fault: a position was given, and it is wrong.
const result = resolveVoiceIntent(
{
...base,
teeth: [
positional({
arch: 'upper',
side: 'patient_right',
position: 9,
spoken: 'دندون نه',
}),
],
},
CTX,
);
expect(result.teeth).toEqual([]);
expect(result.unresolved).toContainEqual({
spoken: 'دندون نه',
reason: 'position_out_of_range',
});
});
it('still offers candidates for a tooth described without its quadrant', () => {
// arch + position, no side: a real tooth, under-specified. Must survive the filter.
const result = resolveVoiceIntent(
{
...base,
teeth: [positional({ arch: 'upper', position: 2, spoken: 'دو بالا' })],
},
CTX,
);
expect(result.unresolved).toContainEqual(
expect.objectContaining({
spoken: 'دو بالا',
reason: 'tooth_missing_quadrant',
candidates: ['12', '22'],
}),
);
});
it('drops a note the clinician never asked for', () => {
// The whole point: leftover speech the model decided was a note is discarded. Before this
// rule the prompt told it to sweep up "the parts already captured" as a comment.
const result = resolveVoiceIntent(
{ ...base, comment: 'بیمار عصبی بود', commentTrigger: null },
CTX,
);
expect(result.comment).toBeNull();
});
it('drops a note whose trigger is only whitespace', () => {
const result = resolveVoiceIntent(
{ ...base, comment: 'بیمار عصبی بود', commentTrigger: ' ' },
CTX,
);
expect(result.comment).toBeNull();
});
it('keeps a note that was asked for, trimmed', () => {
const result = resolveVoiceIntent(
{ ...base, comment: ' حساسیت به سرما ', commentTrigger: 'بنویس که' },
CTX,
);
expect(result.comment).toBe('حساسیت به سرما');
});
it('reports no note when the trigger was heard but nothing followed it', () => {
const result = resolveVoiceIntent(
{ ...base, comment: ' ', commentTrigger: 'بنویس که' },
CTX,
);
expect(result.comment).toBeNull();
});
it('resolves a due date through the same context', () => {
const result = resolveVoiceIntent(
{ ...base, due: { kind: 'weekday', weekday: 'thursday', which: 'this' } },
@@ -328,7 +722,7 @@ describe('resolveVoiceIntent', () => {
expect(result.dueDate).toBe('2025-10-16');
});
it('collects unresolved items from every stage', () => {
it('collects unresolved items from every stage, and only assignment items carry an index', () => {
const result = resolveVoiceIntent(
{
...base,
@@ -336,16 +730,34 @@ describe('resolveVoiceIntent', () => {
teeth: [tooth('51', 'شیری')],
connectedSpans: [{ from: tooth('14'), to: tooth('44') }],
due: { kind: 'jalali', jy: 1404, jm: 12, jd: 30 },
prosthesis: [
{
targets: [tooth('99', 'نود و نه')],
types: ['pfm_crown'],
spoken: '',
},
],
},
CTX,
);
const reasons = result.unresolved.map((u) => u.reason).sort();
expect(reasons).toEqual([
'invalid_date',
'malformed',
'not_permanent_tooth',
'span_not_same_arch',
'unknown_catalog_code',
]);
const outsideAssignment = result.unresolved.filter(
(u) => u.reason === 'not_permanent_tooth',
);
expect(outsideAssignment[0].assignmentIndex).toBeUndefined();
const insideAssignment = result.unresolved.filter(
(u) => u.reason === 'malformed',
);
expect(insideAssignment[0].assignmentIndex).toBe(0);
});
it('treats an empty comment as absent', () => {

View File

@@ -1,7 +1,14 @@
import {
ARCH_TOOTH_LOWER,
ARCH_TOOTH_UPPER,
isFdiTooth,
normalizeFdiCode,
sameArch,
sortInArchOrder,
teethBetweenInclusive,
toFdi,
type Arch,
type PatientSide,
} from '../../common/fdi';
import { resolveDueDate } from './due-date.resolver';
import {
@@ -10,9 +17,10 @@ import {
} from './tooth-intent.resolver';
import type {
ConnectedSpanIntent,
ProsthesisIntent,
ProsthesisAssignment,
ToothIntent,
UnresolvedItem,
UnresolvedReason,
VoiceIntent,
} from './voice.types';
@@ -22,16 +30,16 @@ export type ResolvedToothGroup = {
teeth: string[];
};
export type ResolvedProsthesis = {
/** FDI code → prosthesis type code. */
byTooth: Record<string, string>;
/**
* True when every selected tooth carries a code. A prosthesis detail cannot be shipped
* otherwise (`assertCompleteToothProsthesisMap`), so the review sheet surfaces the gap
* here rather than letting it fail at dispatch.
*/
complete: boolean;
missingTeeth: string[];
/**
* One spoken instruction, resolved: these targets (FDI codes, or `'UA'`/`'LA'` jaw sentinels —
* mirrors the manual chart's own convention) get these leaf codes. Empty `targets` or `types`
* means every target, or every type, this assignment named turned out unresolved; the
* assignment still appears so its index keeps meaning for `UnresolvedItem.assignmentIndex`.
*/
export type ResolvedProsthesisAssignment = {
targets: string[];
types: string[];
spoken: string;
};
export type ResolvedExtraction = {
@@ -39,23 +47,33 @@ export type ResolvedExtraction = {
teeth: string[];
toothSelectionGroups: ResolvedToothGroup[];
comment: string | null;
prosthesis: ResolvedProsthesis | null;
prosthesisAssignments: ResolvedProsthesisAssignment[];
labId: string | null;
labMatchExact: boolean;
dueDate: string | null;
unresolved: UnresolvedItem[];
};
/** A leaf prosthesis catalog entry, as `ProsthesisCatalogService.list()` already returns it. */
export type ProsthesisLeaf = {
code: string;
category: string;
subcategory: string;
chartRegion: string;
};
export type ResolveContext = {
todayIso: string;
/** JS weekday index the clinician's week starts on — see weekStartForLocale. */
weekStartJs: number;
treatmentTypeCodes: ReadonlySet<string>;
prosthesisTypeCodes: ReadonlySet<string>;
prosthesisLeaves: readonly ProsthesisLeaf[];
prosthesisCategoryCodes: ReadonlySet<string>;
prosthesisSubcategoryCodes: ReadonlySet<string>;
linkedLabIds: ReadonlySet<string>;
};
function spokenOf(intent: ToothIntent): string {
function spokenOf(intent: unknown): string {
const spoken = (intent as { spoken?: unknown })?.spoken;
return typeof spoken === 'string' && spoken.trim() ? spoken.trim() : '';
}
@@ -165,76 +183,323 @@ export function resolveConnectedSpans(
};
}
// --- Prosthesis assignments -------------------------------------------------------------
type TargetResolution =
| { kind: 'tooth'; fdi: string }
| { kind: 'jaw'; arch: 'upper' | 'lower' | 'both' }
| { kind: 'unresolved'; reason: UnresolvedReason; candidates?: string[] };
const QUADRANT_ARCHES: readonly Arch[] = ['upper', 'lower'];
const QUADRANT_SIDES: readonly PatientSide[] = [
'patient_right',
'patient_left',
];
/** The teeth still consistent with what *was* heard — "دو" leaves four, "دو بالا" two. */
function quadrantCandidatesForTarget(
target: Extract<ToothIntent, { kind: 'positional' }>,
): string[] {
const arches =
target.arch === 'upper' || target.arch === 'lower'
? [target.arch]
: QUADRANT_ARCHES;
const sides =
target.side === 'patient_right' || target.side === 'patient_left'
? [target.side]
: QUADRANT_SIDES;
const codes: string[] = [];
for (const arch of arches) {
for (const side of sides) {
const fdi = toFdi(arch, side, target.position);
if (fdi) codes.push(fdi);
}
}
return codes.sort();
}
/**
* A default across the selection, then per-tooth overrides — "همه زیرکونیا، ۲۶ پی‌اف‌ام" is
* how clinicians actually speak.
* A prosthesis assignment target is a tooth or a jaw — nothing on the wire declares which.
* A position with no quadrant is the familiar "دو" ambiguity; no position at all, with an
* arch, names a jaw; no position AND no arch means the jaw itself was never spoken.
*/
export function resolveProsthesis(
intent: ProsthesisIntent | null | undefined,
teeth: readonly string[],
allowed: ReadonlySet<string>,
): { prosthesis: ResolvedProsthesis | null; unresolved: UnresolvedItem[] } {
if (!intent || typeof intent !== 'object')
return { prosthesis: null, unresolved: [] };
const unresolved: UnresolvedItem[] = [];
const defaultType = resolveCatalogCode(intent.defaultType, allowed);
if (intent.defaultType != null && !defaultType) {
unresolved.push({
spoken: String(intent.defaultType),
reason: 'unknown_catalog_code',
});
function resolveAssignmentTarget(target: ToothIntent): TargetResolution {
if (!target || typeof target !== 'object') {
return { kind: 'unresolved', reason: 'malformed' };
}
const byTooth: Record<string, string> = {};
const selected = new Set(teeth);
if (defaultType) {
for (const tooth of teeth) byTooth[tooth] = defaultType;
if (target.kind === 'explicit') {
const fdi = normalizeFdiCode((target as { fdi?: unknown }).fdi);
if (isFdiTooth(fdi)) return { kind: 'tooth', fdi };
// Quadrants 1-4 are permanent; a well-formed quadrant+position reaching here is 5-8:
// deciduous. Anything else is noise.
return {
kind: 'unresolved',
reason: /^[1-8][1-8]$/.test(fdi) ? 'not_permanent_tooth' : 'malformed',
};
}
const overrides: ProsthesisIntent['overrides'] = Array.isArray(
intent.overrides,
)
? intent.overrides
if (target.kind === 'positional') {
const positionGiven =
typeof target.position === 'number' && !Number.isNaN(target.position);
const archGiven =
target.arch === 'upper' ||
target.arch === 'lower' ||
target.arch === 'both';
const sideGiven =
target.side === 'patient_right' || target.side === 'patient_left';
if (positionGiven) {
if (
!Number.isInteger(target.position) ||
target.position < 1 ||
target.position > 8
) {
return { kind: 'unresolved', reason: 'position_out_of_range' };
}
if (archGiven && target.arch !== 'both' && sideGiven) {
const fdi = toFdi(target.arch, target.side, target.position);
if (fdi) return { kind: 'tooth', fdi };
}
// A position was said but the tooth's own quadrant was not (or "both" was said for
// what must be a single tooth) — the familiar "دو" ambiguity.
return {
kind: 'unresolved',
reason: 'tooth_missing_quadrant',
candidates: quadrantCandidatesForTarget(target),
};
}
if (archGiven) return { kind: 'jaw', arch: target.arch };
// Neither a tooth position nor a jaw was said.
return {
kind: 'unresolved',
reason: 'arch_not_spoken',
candidates: ['upper', 'lower'],
};
}
return { kind: 'unresolved', reason: 'malformed' };
}
function archSentinels(arch: 'upper' | 'lower' | 'both'): string[] {
if (arch === 'upper') return [ARCH_TOOTH_UPPER];
if (arch === 'lower') return [ARCH_TOOTH_LOWER];
return [ARCH_TOOTH_UPPER, ARCH_TOOTH_LOWER];
}
type TypeClassification =
| { kind: 'leaf'; code: string; chartRegion: string }
| {
kind: 'category' | 'subcategory';
code: string;
leafCodes: string[];
regions: Set<string>;
}
| { kind: 'unknown' };
/** `types[]` may hold a leaf, or one category / subcategory code — the namespaces are disjoint. */
function classifyTypeCode(
rawCode: unknown,
ctx: ResolveContext,
): TypeClassification {
if (typeof rawCode !== 'string' || !rawCode.trim())
return { kind: 'unknown' };
const code = rawCode.trim();
const leaf = ctx.prosthesisLeaves.find((l) => l.code === code);
if (leaf) return { kind: 'leaf', code, chartRegion: leaf.chartRegion };
if (ctx.prosthesisCategoryCodes.has(code)) {
const leaves = ctx.prosthesisLeaves.filter((l) => l.category === code);
return {
kind: 'category',
code,
leafCodes: leaves.map((l) => l.code),
regions: new Set(leaves.map((l) => l.chartRegion)),
};
}
if (ctx.prosthesisSubcategoryCodes.has(code)) {
const leaves = ctx.prosthesisLeaves.filter((l) => l.subcategory === code);
return {
kind: 'subcategory',
code,
leafCodes: leaves.map((l) => l.code),
regions: new Set(leaves.map((l) => l.chartRegion)),
};
}
return { kind: 'unknown' };
}
const TOOTH_REGIONS = new Set(['crown', 'root']);
const JAW_REGIONS = new Set(['arch']);
function acceptableRegions(kind: 'tooth' | 'jaw'): ReadonlySet<string> {
return kind === 'tooth' ? TOOTH_REGIONS : JAW_REGIONS;
}
/**
* One spoken instruction, resolved. Stack legality (`canStackLeaf`, screw-retained exclusion)
* is never checked here — that lives in the frontend's `prosthesisTree.ts` (decision 38); this
* only confirms a code exists and that its region suits its target.
*/
export function resolveProsthesisAssignment(
assignment: ProsthesisAssignment,
index: number,
ctx: ResolveContext,
unresolved: UnresolvedItem[],
): ResolvedProsthesisAssignment {
// Targets are resolved BEFORE types, because the target kind is what narrows a spoken
// category. "پروتز متحرک برای دندون ۱۲" must offer only `partial_denture` — the one
// `removable` leaf with a tooth region — not the whole category. Deferring the region check
// until a leaf is picked (the earlier reading of decision 49) left nothing to complete it,
// and wrote a complete denture onto a single tooth.
const rawTargets = Array.isArray(assignment?.targets)
? assignment.targets
: [];
for (const override of overrides) {
const tooth = resolveToothIntent(override?.tooth);
const type = resolveCatalogCode(override?.type, allowed);
const spoken = spokenOf(override?.tooth) || String(override?.type ?? '');
if (!tooth) {
unresolved.push({ spoken, reason: 'malformed' });
const resolvedTargets: {
kind: 'tooth' | 'jaw';
spoken: string;
codes: string[];
}[] = [];
for (const rawTarget of rawTargets) {
const resolution = resolveAssignmentTarget(rawTarget);
const spoken = spokenOf(rawTarget);
if (resolution.kind === 'unresolved') {
unresolved.push(
resolution.candidates
? {
spoken,
reason: resolution.reason,
candidates: resolution.candidates,
assignmentIndex: index,
}
: { spoken, reason: resolution.reason, assignmentIndex: index },
);
continue;
}
// A tooth we understood perfectly well but which is not part of this detail. Saying
// so is actionable ("add tooth 37, or drop it"); calling it malformed is not.
if (!selected.has(tooth)) {
unresolved.push({ spoken, reason: 'tooth_not_selected' });
continue;
}
if (!type) {
unresolved.push({ spoken, reason: 'unknown_catalog_code' });
continue;
}
byTooth[tooth] = type;
resolvedTargets.push(
resolution.kind === 'tooth'
? { kind: 'tooth', spoken, codes: [resolution.fdi] }
: { kind: 'jaw', spoken, codes: archSentinels(resolution.arch) },
);
}
// Nothing usable was said about prosthesis. Returning an empty-but-present map would
// paint a plain restoration with a fabricated "incomplete, cannot ship" warning.
if (Object.keys(byTooth).length === 0) {
return { prosthesis: null, unresolved };
// The regions any resolved target of this assignment can carry. Empty means no target
// resolved yet — a bare "نایت گارد" with no jaw — and then nothing can be narrowed or
// validated, so every leaf stays on offer.
const allowedRegions = new Set<string>();
for (const target of resolvedTargets) {
for (const region of acceptableRegions(target.kind))
allowedRegions.add(region);
}
const suits = (region: string) =>
allowedRegions.size === 0 || allowedRegions.has(region);
const rawTypes = Array.isArray(assignment?.types) ? assignment.types : [];
const leafCodes = new Set<string>();
const leafRegions = new Set<string>();
// Set when a named type cannot suit this assignment's targets at all. The targets are then
// dropped without a second report — one `code_not_valid_for_target` per contradiction, not
// one per target — so the assignment applies nothing rather than half of what was said.
let regionConflict = false;
for (const rawCode of rawTypes) {
const classification = classifyTypeCode(rawCode, ctx);
if (classification.kind === 'leaf') {
leafCodes.add(classification.code);
leafRegions.add(classification.chartRegion);
continue;
}
if (
classification.kind === 'category' ||
classification.kind === 'subcategory'
) {
const leaves = classification.leafCodes.filter((code) => {
const leaf = ctx.prosthesisLeaves.find((l) => l.code === code);
return leaf ? suits(leaf.chartRegion) : false;
});
// A category with no leaf this target can carry is a contradiction, not an ambiguity:
// `implant` aimed at a jaw, or `appliance` aimed at a tooth.
if (leaves.length === 0) {
unresolved.push({
spoken: spokenOf(assignment) || classification.code,
reason: 'code_not_valid_for_target',
assignmentIndex: index,
});
regionConflict = true;
continue;
}
unresolved.push({
spoken: spokenOf(assignment) || classification.code,
reason: 'prosthesis_type_ambiguous',
candidates: [...leaves].sort(),
assignmentIndex: index,
});
continue;
}
if (typeof rawCode === 'string' && rawCode.trim()) {
unresolved.push({
spoken: rawCode,
reason: 'unknown_catalog_code',
assignmentIndex: index,
});
}
}
const targets = new Set<string>();
for (const target of resolvedTargets) {
// EVERY named leaf must suit the target, not merely one of them. `some` let a legal
// crown carry an arch-only appliance onto the same tooth.
if (regionConflict) continue;
const accepted = acceptableRegions(target.kind);
const valid = [...leafRegions].every((region) => accepted.has(region));
if (!valid) {
unresolved.push({
spoken: target.spoken,
reason: 'code_not_valid_for_target',
assignmentIndex: index,
});
continue;
}
for (const code of target.codes) targets.add(code);
}
const missingTeeth = teeth.filter((tooth) => !byTooth[tooth]);
return {
prosthesis: {
byTooth,
complete: missingTeeth.length === 0,
missingTeeth,
},
unresolved,
targets: [...targets],
types: [...leafCodes],
spoken: spokenOf(assignment),
};
}
/**
* A whole jaw is not a tooth, and the model often names one in `teeth` as well as in the
* prosthesis target it belongs to. An arch with no position then reaches `unresolvedReason`,
* which reports `position_out_of_range` — a range fault for a value that was never a number —
* and the sheet asks the clinician to repair a tooth nobody said. The jaw already reaches the
* form through its assignment, so drop the duplicate instead.
*
* A position that IS given stays: arch + position without a side is a real tooth described
* without its quadrant, and must keep offering its candidate chips.
*/
function isJawReference(intent: ToothIntent): boolean {
if (intent?.kind !== 'positional') return false;
const archGiven =
intent.arch === 'upper' ||
intent.arch === 'lower' ||
intent.arch === 'both';
return archGiven && !Number.isInteger(intent.position);
}
/** Compose every resolver into the payload the review sheet renders. */
export function resolveVoiceIntent(
intent: VoiceIntent,
@@ -242,7 +507,9 @@ export function resolveVoiceIntent(
): ResolvedExtraction {
const unresolved: UnresolvedItem[] = [];
const toothResult = resolveToothIntents(intent?.teeth ?? []);
const toothResult = resolveToothIntents(
(intent?.teeth ?? []).filter((tooth) => !isJawReference(tooth)),
);
unresolved.push(...toothResult.unresolved);
const spanResult = resolveConnectedSpans(
@@ -262,18 +529,31 @@ export function resolveVoiceIntent(
});
}
const prosthesisResult = resolveProsthesis(
intent?.prosthesis,
spanResult.teeth,
ctx.prosthesisTypeCodes,
const rawAssignments = Array.isArray(intent?.prosthesis)
? intent.prosthesis
: [];
const prosthesisAssignments = rawAssignments.map((assignment, index) =>
resolveProsthesisAssignment(assignment, index, ctx, unresolved),
);
// `prosthesis` is the only labDependent treatment type — any assignment that actually
// landed on a target forces it, and the sheet locks that row while it is ticked (decision 41).
const hasResolvedAssignment = prosthesisAssignments.some(
(a) => a.targets.length > 0,
);
unresolved.push(...prosthesisResult.unresolved);
const due = resolveDueDate(intent?.due, ctx.todayIso, ctx.weekStartJs);
if (due.unresolved) unresolved.push(due.unresolved);
// A note is written only when the clinician asked for one. The model reports the words that
// asked ("بنویس که", "write this in the notes"); without them, whatever it put in `comment`
// is leftover speech it decided was a note, and it is dropped. Matching the phrase itself
// stays in the prompt, so this check needs no per-locale vocabulary.
const commentAsked =
typeof intent?.commentTrigger === 'string' &&
intent.commentTrigger.trim().length > 0;
const comment =
typeof intent?.comment === 'string' && intent.comment.trim()
commentAsked && typeof intent?.comment === 'string' && intent.comment.trim()
? intent.comment.trim()
: null;
@@ -287,11 +567,11 @@ export function resolveVoiceIntent(
}
return {
treatmentType,
treatmentType: hasResolvedAssignment ? 'prosthesis' : treatmentType,
teeth: spanResult.teeth,
toothSelectionGroups: spanResult.groups,
comment,
prosthesis: prosthesisResult.prosthesis,
prosthesisAssignments,
labId,
labMatchExact: labId ? intent?.labMatchExact === true : false,
dueDate: due.dueDate,

View File

@@ -23,8 +23,8 @@ const wire = (overrides: Partial<WireVoiceIntent> = {}): WireVoiceIntent => ({
teeth: [],
connectedSpans: [],
comment: null,
prosthesisDefaultType: null,
prosthesisOverrides: [],
commentTrigger: null,
prosthesis: [],
labId: null,
labMatchExact: false,
due: emptyDue,
@@ -177,26 +177,70 @@ describe('toVoiceIntent', () => {
expect(result.due).toEqual({ kind: 'lunar_month' });
});
it('reports no prosthesis when neither a default nor an override was given', () => {
expect(toVoiceIntent(wire()).prosthesis).toBeNull();
it('yields an empty array, never null, when nothing was spoken about prosthesis', () => {
expect(toVoiceIntent(wire()).prosthesis).toEqual([]);
});
it('builds a prosthesis intent from a default alone', () => {
const result = toVoiceIntent(wire({ prosthesisDefaultType: 'pfm_crown' }));
expect(result.prosthesis).toEqual({
defaultType: 'pfm_crown',
overrides: [],
it('narrows a prosthesis assignment, targets and types alike', () => {
const result = toVoiceIntent(
wire({
prosthesis: [
{
targets: [{ ...positionalTooth, fdi: '12' }],
types: ['zirconia_abutment', 'monolithic_zirconia'],
spoken: 'ایمپلنت با روکش زیرکونیا روی ۱۲',
},
],
}),
);
expect(result.prosthesis).toEqual([
{
targets: [{ kind: 'explicit', fdi: '12', spoken: 'شش بالا راست' }],
types: ['zirconia_abutment', 'monolithic_zirconia'],
spoken: 'ایمپلنت با روکش زیرکونیا روی ۱۲',
},
]);
});
it('narrows a jaw target — arch given, position null', () => {
const result = toVoiceIntent(
wire({
prosthesis: [
{
targets: [
{
spoken: 'فک بالا',
fdi: null,
arch: 'both',
side: null,
position: null,
},
],
types: ['night_guard_soft'],
spoken: 'نایت گارد هر دو فک',
},
],
}),
);
expect(result.prosthesis[0].targets[0]).toEqual({
kind: 'positional',
arch: 'both',
side: null,
position: Number.NaN,
spoken: 'فک بالا',
});
});
it('builds a prosthesis intent from overrides alone', () => {
it('survives a malformed prosthesis entry', () => {
expect(() =>
toVoiceIntent(
wire({ prosthesis: [{ targets: 'nope', types: 5 } as never] }),
),
).not.toThrow();
const result = toVoiceIntent(
wire({
prosthesisOverrides: [{ tooth: positionalTooth, type: 'pfm_crown' }],
}),
wire({ prosthesis: [{ targets: 'nope', types: 5 } as never] }),
);
expect(result.prosthesis?.defaultType).toBeNull();
expect(result.prosthesis?.overrides).toHaveLength(1);
expect(result.prosthesis).toEqual([{ targets: [], types: [], spoken: '' }]);
});
it('narrows connected spans', () => {

View File

@@ -2,7 +2,7 @@ import { normalizeFdiCode } from '../../common/fdi';
import type {
ConnectedSpanIntent,
DueIntent,
ProsthesisIntent,
ProsthesisAssignment,
ToothIntent,
VoiceIntent,
Weekday,
@@ -20,7 +20,7 @@ export type WireToothIntent = {
spoken: string;
/** The two-digit FDI code the clinician spoke; null when the tooth was described. */
fdi: string | null;
arch: 'upper' | 'lower' | null;
arch: 'upper' | 'lower' | 'both' | null;
side: 'patient_right' | 'patient_left' | null;
position: number | null;
};
@@ -39,13 +39,19 @@ export type WireDue = {
d: number | null;
};
export type WireProsthesisAssignment = {
targets: WireToothIntent[];
types: string[];
spoken: string;
};
export type WireVoiceIntent = {
treatmentType: string | null;
teeth: WireToothIntent[];
connectedSpans: { from: WireToothIntent; to: WireToothIntent }[];
comment: string | null;
prosthesisDefaultType: string | null;
prosthesisOverrides: { tooth: WireToothIntent; type: string }[];
commentTrigger: string | null;
prosthesis: WireProsthesisAssignment[];
labId: string | null;
labMatchExact: boolean;
due: WireDue;
@@ -58,15 +64,21 @@ const TOOTH_SCHEMA = {
properties: {
spoken: {
type: 'string',
description: 'The exact transcript words for this tooth.',
description: 'The exact transcript words for this tooth (or jaw).',
},
fdi: {
type: ['string', 'null'],
description:
'The two-digit FDI code the clinician said for this tooth, e.g. "26". Null only ' +
'when the tooth was described in words instead of numbered.',
'when the tooth was described in words instead of numbered, or the target is a jaw.',
},
arch: {
type: ['string', 'null'],
enum: ['upper', 'lower', 'both', null],
description:
'"both" is only ever used for a jaw-level prosthesis target (e.g. an appliance for ' +
'both jaws), never for a single tooth.',
},
arch: { type: ['string', 'null'], enum: ['upper', 'lower', null] },
side: {
type: ['string', 'null'],
enum: ['patient_right', 'patient_left', null],
@@ -75,7 +87,33 @@ const TOOTH_SCHEMA = {
position: {
type: ['integer', 'null'],
description:
'Position from the midline: 1 = central incisor … 8 = third molar. Never an FDI code.',
'Position from the midline: 1 = central incisor … 8 = third molar. Never an FDI ' +
'code. Null when this target is a jaw rather than a tooth.',
},
},
} as const;
const PROSTHESIS_ASSIGNMENT_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['targets', 'types', 'spoken'],
properties: {
targets: {
type: 'array',
description: 'The teeth or jaws this instruction applies to.',
items: TOOTH_SCHEMA,
},
types: {
type: 'array',
description:
'Prosthesis type codes to apply to every target above — a stack, e.g. an abutment ' +
'plus a crown on the same tooth. A code from the CATEGORY or SUBCATEGORY lists is ' +
'fine when only the general term was said.',
items: { type: 'string' },
},
spoken: {
type: 'string',
description: 'The exact transcript words for this instruction.',
},
},
} as const;
@@ -88,8 +126,8 @@ export const VOICE_INTENT_JSON_SCHEMA = {
'teeth',
'connectedSpans',
'comment',
'prosthesisDefaultType',
'prosthesisOverrides',
'commentTrigger',
'prosthesis',
'labId',
'labMatchExact',
'due',
@@ -99,7 +137,13 @@ export const VOICE_INTENT_JSON_SCHEMA = {
type: ['string', 'null'],
description: 'A treatment type CODE from the supplied list, or null.',
},
teeth: { type: 'array', items: TOOTH_SCHEMA },
teeth: {
type: 'array',
description:
'Individual teeth only. A whole jaw NEVER belongs here — put it in ' +
'prosthesis[].targets with "arch" set and "position" null.',
items: TOOTH_SCHEMA,
},
connectedSpans: {
type: 'array',
description: 'Bridges / splinted units. Endpoints inclusive.',
@@ -112,21 +156,24 @@ export const VOICE_INTENT_JSON_SCHEMA = {
},
comment: {
type: ['string', 'null'],
description: 'Clinical notes, in the spoken language.',
description:
'The note the clinician explicitly dictated, in the spoken language, WITHOUT the ' +
'words that asked for it. Null unless they actually asked for a note. Never put ' +
'leftover speech here.',
},
prosthesisDefaultType: {
commentTrigger: {
type: ['string', 'null'],
description:
'A prosthesis type CODE applied to every tooth unless overridden.',
'The exact words that asked for a note, copied from the transcript (e.g. ' +
'"بنویس که", "write this in the notes"). Null when nobody asked. A comment with ' +
'no trigger is discarded.',
},
prosthesisOverrides: {
prosthesis: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
required: ['tooth', 'type'],
properties: { tooth: TOOTH_SCHEMA, type: { type: 'string' } },
},
description:
'One entry per spoken instruction: these targets get these jobs. No default and no ' +
'overrides — every entry names its own targets.',
items: PROSTHESIS_ASSIGNMENT_SCHEMA,
},
labId: {
type: ['string', 'null'],
@@ -190,7 +237,7 @@ function toToothIntent(wire: WireToothIntent | undefined | null): ToothIntent {
}
return {
kind: 'positional',
arch: wire?.arch as 'upper' | 'lower',
arch: wire?.arch as 'upper' | 'lower' | 'both',
side: wire?.side as 'patient_right' | 'patient_left',
position: typeof wire?.position === 'number' ? wire.position : Number.NaN,
spoken,
@@ -236,36 +283,35 @@ function toDueIntent(wire: WireDue | undefined | null): DueIntent | null {
}
}
function toProsthesisAssignment(
wire: WireProsthesisAssignment | undefined | null,
): ProsthesisAssignment {
const targets = Array.isArray(wire?.targets) ? wire.targets : [];
const types = Array.isArray(wire?.types) ? wire.types : [];
return {
targets: targets.map(toToothIntent),
types: types.filter((code): code is string => typeof code === 'string'),
spoken: typeof wire?.spoken === 'string' ? wire.spoken : '',
};
}
export function toVoiceIntent(wire: WireVoiceIntent): VoiceIntent {
const teeth = Array.isArray(wire?.teeth) ? wire.teeth : [];
const spans = Array.isArray(wire?.connectedSpans) ? wire.connectedSpans : [];
const overrides = Array.isArray(wire?.prosthesisOverrides)
? wire.prosthesisOverrides
: [];
const assignments = Array.isArray(wire?.prosthesis) ? wire.prosthesis : [];
const connectedSpans: ConnectedSpanIntent[] = spans.map((span) => ({
from: toToothIntent(span?.from),
to: toToothIntent(span?.to),
}));
const hasProsthesis =
wire?.prosthesisDefaultType != null || overrides.length > 0;
const prosthesis: ProsthesisIntent | null = hasProsthesis
? {
defaultType: wire?.prosthesisDefaultType ?? null,
overrides: overrides.map((o) => ({
tooth: toToothIntent(o?.tooth),
type: o?.type,
})),
}
: null;
return {
treatmentType: wire?.treatmentType ?? null,
teeth: teeth.map(toToothIntent),
connectedSpans,
comment: wire?.comment ?? null,
prosthesis,
commentTrigger: wire?.commentTrigger ?? null,
prosthesis: assignments.map(toProsthesisAssignment),
labId: wire?.labId ?? null,
labMatchExact: wire?.labMatchExact === true,
due: toDueIntent(wire?.due),

View File

@@ -12,7 +12,18 @@ const CONFIG = {
const CATALOG = {
treatmentTypes: [{ code: 'prosthesis', label: 'پروتز' }],
prosthesisTypes: [{ code: 'pfm_crown', label: 'روکش پی‌اف‌ام' }],
prosthesisTypes: [
{
code: 'pfm_crown',
label: 'روکش پی‌اف‌ام',
category: 'crown',
subcategory: '',
chartRegion: 'crown',
stackGroup: 'restoration',
},
],
prosthesisCategories: [{ code: 'crown', label: 'روکش‌ها' }],
prosthesisSubcategories: [],
labs: [{ id: 'lab-1', name: 'لابراتوار سینا' }],
};
@@ -111,8 +122,21 @@ describe('OpenRouterExtractionProvider', () => {
],
connectedSpans: [],
comment: null,
prosthesisDefaultType: 'pfm_crown',
prosthesisOverrides: [],
prosthesis: [
{
targets: [
{
spoken: 'یک چهار',
fdi: '14',
arch: null,
side: null,
position: null,
},
],
types: ['pfm_crown'],
spoken: 'روکش پی‌اف‌ام روی ۱۴',
},
],
labId: 'lab-1',
labMatchExact: true,
due: {

View File

@@ -34,7 +34,22 @@ export interface AsrProvider {
export type ExtractionCatalog = {
/** Catalog codes with their labels in the actor's locale, so the model matches spoken words. */
treatmentTypes: { code: string; label: string }[];
prosthesisTypes: { code: string; label: string }[];
/**
* Leaf prosthesis types — the full tree shape, not just code/label, so the prompt can
* present it as a tree and mark which codes are jaw-level (`chartRegion: 'arch'`).
*/
prosthesisTypes: {
code: string;
label: string;
category: string;
subcategory: string;
chartRegion: string;
stackGroup: string;
}[];
/** The 7 category codes, so "روکش" resolves to `crown` rather than a guessed leaf. */
prosthesisCategories: { code: string; label: string }[];
/** The 5 subcategory codes (veneer, inlay, onlay, overlay, night_guard). */
prosthesisSubcategories: { code: string; label: string }[];
/** The clinic's linked labs — a closed choice list. */
labs: { id: string; name: string }[];
};

View File

@@ -35,9 +35,12 @@ export type VoiceAvailability = {
maxRecordingMs: number | null;
};
export type VoiceExtractionResponse = ResolvedExtraction & {
transcript: string;
};
/**
* The transcript is deliberately absent. A raw dictation can carry the patient's spoken name, so
* it never leaves the server — it is logged there instead (§10). Nothing the client renders needs
* it, and what is not sent cannot leak through the network tab or an error reporter.
*/
export type VoiceExtractionResponse = ResolvedExtraction;
@Injectable()
export class VoiceService {
@@ -119,13 +122,20 @@ export class VoiceService {
);
}
// Stage 2 — structure it. On failure the transcript still goes back to the client so
// the words the clinician already paid for are not lost (transcript salvage).
// The transcript's only destination. Logged before extraction so it survives an extraction
// failure too, and kept out of logTelemetry so that method's patient-free guarantee stays
// true. This line DOES carry what the clinician said, which may include a patient's name.
this.logger.log(
`voice transcript [${catalogLocale}]: ${transcript.trim()}`,
);
// Stage 2 — structure it. A failure here returns a code only; the transcript stays in the
// server log above, never in the response.
let resolved: ResolvedExtraction;
let llmCost: number | null = null;
try {
// Inside the try: the transcript is already paid for, so a catalog/DB failure here
// must still salvage it rather than becoming a generic 500 that throws it away.
// Inside the try: a catalog/DB failure here must surface as VOICE_EXTRACT_FAILED, which
// the clinician can act on, rather than a generic 500.
const catalog = await this.buildCatalog(organizationId, catalogLocale);
const result = await extraction.extract(
transcript,
@@ -138,13 +148,17 @@ export class VoiceService {
todayIso,
weekStartJs: weekStartForLocale(catalogLocale),
treatmentTypeCodes: new Set(catalog.treatmentTypes.map((t) => t.code)),
prosthesisTypeCodes: new Set(
catalog.prosthesisTypes.map((t) => t.code),
prosthesisLeaves: catalog.prosthesisTypes,
prosthesisCategoryCodes: new Set(
catalog.prosthesisCategories.map((c) => c.code),
),
prosthesisSubcategoryCodes: new Set(
catalog.prosthesisSubcategories.map((c) => c.code),
),
linkedLabIds: new Set(catalog.labs.map((l) => l.id)),
});
} catch (error) {
throw this.toAppException(error, 'extraction', transcript);
throw this.toAppException(error, 'extraction');
}
this.logTelemetry({
@@ -156,7 +170,7 @@ export class VoiceService {
resolved,
});
return { ...resolved, transcript };
return resolved;
}
private assertOrganization(user: { organizationId?: string }): string {
@@ -244,9 +258,17 @@ export class VoiceService {
organizationId: string,
locale: string,
): Promise<ExtractionCatalog> {
const [treatmentTypes, prosthesisTypes, labs] = await Promise.all([
const [
treatmentTypes,
prosthesisTypes,
prosthesisCategories,
prosthesisSubcategories,
labs,
] = await Promise.all([
this.treatmentCatalog.list(locale, null),
this.prosthesisCatalog.list(locale),
this.prosthesisCatalog.listCategories(locale),
this.prosthesisCatalog.listSubcategories(locale),
this.listLinkedLabs(organizationId),
]);
@@ -254,10 +276,11 @@ export class VoiceService {
treatmentTypes: treatmentTypes
.filter((entry) => entry.availableInTreatment)
.map((entry) => ({ code: entry.code, label: entry.label })),
prosthesisTypes: prosthesisTypes.map((entry) => ({
code: entry.code,
label: entry.label,
})),
// `buildCatalog` used to throw away category/subcategory/chartRegion/stackGroup here —
// the prompt now presents the catalog as the tree it is (§5).
prosthesisTypes,
prosthesisCategories,
prosthesisSubcategories,
labs,
};
}
@@ -290,7 +313,6 @@ export class VoiceService {
private toAppException(
error: unknown,
stage: 'asr' | 'extraction',
transcript?: string,
): AppException {
if (error instanceof Error && error.name === 'AbortError') {
// The clinician cancelled; not a failure worth a translated message.
@@ -305,11 +327,9 @@ export class VoiceService {
stage === 'asr'
? ErrorCode.VOICE_ASR_FAILED
: ErrorCode.VOICE_EXTRACT_FAILED;
return new AppException(
code,
HttpStatus.BAD_GATEWAY,
transcript ? { transcript } : undefined,
);
// No details: the transcript used to ride along here for a salvage dialog that was never
// built, so it was serialized onto the wire and dropped. It stays on the server now.
return new AppException(code, HttpStatus.BAD_GATEWAY);
}
/** Structured and patient-free: never the transcript, never audio, never a patient id. */
@@ -333,7 +353,7 @@ export class VoiceService {
treatmentType: resolved.treatmentType != null,
teeth: resolved.teeth.length,
comment: resolved.comment != null,
prosthesisComplete: resolved.prosthesis?.complete ?? null,
prosthesisAssignments: resolved.prosthesisAssignments.length,
lab: resolved.labId != null,
dueDate: resolved.dueDate != null,
},

View File

@@ -8,14 +8,19 @@ import type { Arch, PatientSide } from '../../common/fdi';
* mappings — quadrant mirroring and Jalali conversion — are testable rather than hopeful.
*/
/** A single spoken tooth reference. `spoken` is the transcript span, echoed back to the user. */
/**
* A single spoken tooth reference, OR — inside a prosthesis assignment's `targets` — a jaw.
* Nothing on the wire declares "this is a jaw": a positional reference with no `position` at
* all names an arch instead of a tooth, and the resolver tells the two apart (§5, §6).
* `arch: 'both'` only ever appears here, never on a resolved single tooth.
*/
export type ToothIntent =
| { kind: 'explicit'; fdi: string; spoken: string }
| {
kind: 'positional';
arch: Arch;
arch: Arch | 'both';
side: PatientSide;
/** 1 = central incisor … 8 = third molar. */
/** 1 = central incisor … 8 = third molar. Absent (NaN) when the target is a jaw. */
position: number;
spoken: string;
};
@@ -42,10 +47,18 @@ export type Weekday = (typeof WEEKDAYS)[number];
/** Two teeth defining an inclusive connected (bridge) span. */
export type ConnectedSpanIntent = { from: ToothIntent; to: ToothIntent };
export type ProsthesisIntent = {
/** Catalog code applied to every tooth unless overridden. */
defaultType: string | null;
overrides: { tooth: ToothIntent; type: string }[];
/**
* One spoken instruction: these targets get these jobs. Replaces the old
* `prosthesisDefaultType` + `prosthesisOverrides` pair — a default with per-tooth overrides
* has a precedence rule, and a precedence rule has a wrong side (decision 35).
*/
export type ProsthesisAssignment = {
/** Each one a tooth or a jaw — see `ToothIntent`. */
targets: ToothIntent[];
/** Leaf codes, or one category / subcategory code the clinician named generically. */
types: string[];
/** The transcript span, echoed back to the clinician. */
spoken: string;
};
export type VoiceIntent = {
@@ -53,7 +66,14 @@ export type VoiceIntent = {
teeth: ToothIntent[];
connectedSpans: ConnectedSpanIntent[];
comment: string | null;
prosthesis: ProsthesisIntent | null;
/**
* The exact spoken words that asked for a note — "بنویس که", "write this in the notes".
* Null when nothing asked. The resolver keeps `comment` only when this is present, so the
* model cannot decide on its own that leftover speech was a note.
*/
commentTrigger: string | null;
/** Empty array, never null. */
prosthesis: ProsthesisAssignment[];
/** Must be one of the linked-lab ids supplied in the prompt, or null. */
labId: string | null;
/** False when the spoken name only approximately matched — the UI then requires an explicit tick. */
@@ -67,10 +87,15 @@ export type UnresolvedReason =
| 'position_out_of_range'
/** A position was understood but no quadrant was spoken — four teeth match. */
| 'tooth_missing_quadrant'
/** A category or subcategory was heard, not a material. */
| 'prosthesis_type_ambiguous'
/** A jaw-level appliance with no jaw spoken. */
| 'arch_not_spoken'
/** An arch code aimed at a tooth, or a tooth code aimed at a jaw. */
| 'code_not_valid_for_target'
| 'malformed'
| 'span_not_same_arch'
| 'unknown_catalog_code'
| 'tooth_not_selected'
| 'invalid_date';
export type UnresolvedItem = {
@@ -78,8 +103,15 @@ export type UnresolvedItem = {
spoken: string;
reason: UnresolvedReason;
/**
* FDI codes still consistent with what was heard — "دو" leaves four, "دو بالا" two. Only
* `tooth_missing_quadrant` carries them; the sheet offers them as chips.
* Values still consistent with what was heard: FDI codes for `tooth_missing_quadrant`,
* leaf codes for `prosthesis_type_ambiguous`, `'upper'`/`'lower'` for `arch_not_spoken`. The
* sheet offers these as chips.
*/
candidates?: string[];
/**
* Set only when this item was raised while resolving a `prosthesis` assignment's targets or
* types. A picked chip then inherits that assignment's `types[]` (or supplies the missing
* leaf to it) instead of resolving to a jobless tooth (decision 50).
*/
assignmentIndex?: number;
};

View File

@@ -0,0 +1,532 @@
---
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 3446). v1 shipped on `master`; this branch revises it.
Status legend: ⬜ not started · 🟡 in progress · ✅ done · ⛔ blocked
| # | Work item | Repo | Status | Notes / refs |
|---|-----------|------|--------|--------------|
| 1 | `pickRecordingMimeType` falls back to the empty hint instead of `null` | `dyolink` | ✅ | §9. `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 68, 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 3446. The load-bearing ones:
- **One assignment list, no default type.** A default plus per-tooth overrides has a
precedence rule, and that rule already mis-filled tooth 13 once.
- **An arch target is derived from the code's `chartRegion`, not declared on the wire.**
The catalog already answers it, and `partial_denture` already carries `chartRegion: 'crown'`.
- **Stack rules stay frontend-only.** The backend's dispatch check knows nothing about
stacking; porting `canStackLeaf` would make voice stricter than the manual path and create
a second copy of a rule that must never disagree.
- **Teeth and prosthesis merge into one sheet row for lab-dependent types.** Two ticks can
save an empty detail today, because `persistDraft` prunes lab-dependent details to their jobs.
- **Nothing is guessed.** A missing material, a missing jaw and a missing quadrant all become
chips. A jobless tooth is named and left out.
- **Categories become real catalog entities** (decision 47). Two new `CatalogEntityKind` values
and seeded translations, rather than bare untranslated codes in the prompt on `fa`.
- **A chip must inherit its assignment's jobs** (decision 50), or it resolves to a jobless tooth
and the tap does nothing.
## Deviations from spec
(none yet)
## Next steps / open questions
- Re-run `/orchestrate --dry` with a `gapNote`, so the surveyor re-runs instead of replaying its
cached `gaps_found` verdict.
- Amin reviews and approves this spec revision. That approval is the only human gate before
`/orchestrate --dry`.
- Open items 15 and 16 in §11 are carried forward unchanged and are **not** in this branch:
the pre-authentication body limit on the voice path, and transcript salvage still being
specified but not built.
- `feat/voice-treatment-entry` and `backup/pre-rebase-voice` are stale local branches from the
v1 work. Safe to delete once this lands.
---
## 2026-09-07 — Gap-check halted (nothing built, nothing pushed)
- **Phase:** Gap-check
- **Reason:** the spec has 1 gap that blocks implementation — nothing was built.
- **Repo:** dyolink @ `feat/treatment/add-voice-input-for-new-treatment-form`
### 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 4748 |
| Note 1 — chip with no assignment link | Unresolved items raised inside an assignment carry `assignmentIndex`; a picked chip inherits that assignment's `types[]` | §6, decision 50 |
| Note 2 — mixed-region category | `removable` defers its region check to the picked leaf; never `code_not_valid_for_target` while still a category | §5, decision 49 |
| Note 3 — wrong endpoint path in §3 | Corrected to `POST /voice/extract`, with the `body-parsers.ts` coupling called out. No work item moves the route | §3 |
| Note 4 — availability endpoint contradiction | §11 item 13 corrected: the endpoint exists, only the plan check is deferred | §11 |
| Note 5 — disjointness counts | Verified independently: **42** leaf codes (the surveyor's 41 missed `screw_retained`, a multi-line `implant(` call) and **5** subcategories (the spec said 4, missing `night_guard`). The test now asserts against the live catalog, not a written count | §5, §12 |
Referee relays: 0. Gate repairs: 0. Neither phase was reached.
---
## 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.

View File

@@ -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.
@@ -247,7 +272,14 @@ mirroring and Jalali conversion — testable instead of hopeful.
### Endpoint
`POST /treatments/voice-extract`
`POST /voice/extract``@Controller('voice')` + `@Post('extract')`, so the full path behind the
global prefix is `/api/voice/extract`. Earlier revisions of this spec named it
`/treatments/voice-extract`, which was never the shipped route. **No work item moves it.**
> ⚠ The path is duplicated in `common/body-parsers.ts`, which matches `/api/voice/extract` to
> select the large JSON body limit. Moving the route without moving `VOICE_EXTRACT_PATH` makes
> every real recording 413 against the 100 kb default, which reads as a broken microphone rather
> than a routing change.
- Guards: `JwtAuthGuard` + `ClinicOrgGuard`.
- Service-level check of `TAB_TREATMENT_EDIT`. The plan flag is **not** checked in v1
@@ -379,19 +411,24 @@ type VoiceIntent = {
treatmentType: string | null; // catalog code, from the supplied closed list
teeth: ToothIntent[];
connectedSpans: { from: ToothIntent; to: ToothIntent }[];
comment: string | null;
prosthesis: {
defaultType: string | null; // catalog code
overrides: { tooth: ToothIntent; type: string }[];
} | null;
comment: string | null; // only when asked for — see below
commentTrigger: string | null; // the words that asked; null discards the comment
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 +438,126 @@ 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).
**A category whose leaves span both regions defers the check.** `removable` is the only one
today: `complete_denture` and `overdenture` are `chartRegion: 'arch'` while `partial_denture` is
`'crown'`. There is no region to check until a leaf is chosen, so such an assignment resolves to
`prosthesis_type_ambiguous` and the region is confirmed against the leaf the clinician picks —
never emitted as `code_not_valid_for_target` while it is still a category.
### `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 **5** subcategory names (`veneer`, `inlay`, `onlay`, `overlay`,
`night_guard`), no collisions. The resolver classifies by lookup. The test asserts disjointness
against the **live catalog**, not against these counts, because a future entry named `crown`
would quietly turn a leaf into an ambiguity and a hardcoded count would not notice.
#### Category labels need a backend source — build it
The prompt cannot offer a category the model can name unless that category has a label in the
actor's locale, and today it has none. `CatalogEntityKind` covers only `TREATMENT_TYPE`,
`PROSTHESIS_TYPE` and `LAB_WORKFLOW_STEP` (`schema.prisma`), so `CatalogLabelService` cannot
resolve a category at all. The only category labels in the repo are the `prosthesis.category_*`
and `prosthesis.sub_*` keys in `frontend/messages/*.json`, plus a hardcoded single-locale map in
the dev export script. Sending bare codes would leave `crown` and `post_core` untranslated
beside fully-labelled leaves, on the locale this feature exists for.
So this branch adds them as first-class catalog entities:
- add `PROSTHESIS_CATEGORY` and `PROSTHESIS_SUBCATEGORY` to `CatalogEntityKind` — one
migration, no data loss, the enum is additive;
- seed `CatalogTranslation` rows for the 7 categories and the 5 subcategories in `fa`, `en` and
`nl`, taking the wording from the existing `prosthesis.category_*` / `prosthesis.sub_*` keys
so the two surfaces read identically on day one;
- resolve them through `CatalogLabelService` like every other catalog label. No label is
hardcoded in backend code, and the house rule holds unchanged.
The `sub_*` key set is wider than the subcategory set — it also carries technique names
(`sub_full_contour`, `sub_layered`) and two leaf codes. Seed only the five real subcategories.
> **Accepted consequence:** the frontend keeps its own `category_*` / `sub_*` message keys, so
> the wording now lives in two places. Migrating the frontend to read these labels from the API
> is **out of scope for this branch** — it would touch the prosthesis picker, the case sheet and
> the print layout, none of which this task otherwise opens. The seed is written from the message
> files precisely so the two agree at the point they diverge.
### A note is written only when the clinician asks for one
The model does not decide that something was a note. It reports the exact words that asked —
«بنویس که», "write this in the notes" — in `commentTrigger`, and `comment` holds what was
dictated after them, without the asking words. The resolver keeps `comment` only when a trigger
is present, so the rule is enforced by code rather than trusted to the prompt.
The instruction this replaces said "clinical notes, in the language spoken. Omit the parts
already captured as treatment type, teeth, prosthesis work or deadline" — which told the model to
sweep up whatever was left over. Filler, small talk and an unrequested diagnosis all became a
persisted clinical note, and `comment` is the one durable trace of a recording (§10).
Speech that fits no field and was not asked to be a note is simply not reported. The sheet shows
what was captured, so nothing is hidden by leaving it out.
Per-locale trigger vocabulary lives in the prompt's locale notes, exactly like the tooth
vocabulary. The resolver only checks that a trigger was reported, so it needs no per-locale
knowledge and stays locale-neutral by construction (§6).
### 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 +567,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 +619,60 @@ 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 18 | — |
| `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.
**An unresolved item that came from an assignment carries that assignment's index.**
Without it a chip is decorative: picking a quadrant for "دندون دو" on a prosthesis detail
produces a tooth with no job, which the *jobless tooth is left out* rule above then discards, so
the tap changes nothing. `tooth_missing_quadrant`, `arch_not_spoken` and
`prosthesis_type_ambiguous` therefore carry `assignmentIndex`, and a picked chip inherits that
assignment's `types[]` — or, for `prosthesis_type_ambiguous`, supplies the missing leaf to that
assignment's existing targets. An item raised outside any assignment (a tooth spoken in the
`teeth` array alone) carries no index and folds into `teeth` as it does today.
---
## 7. Review sheet
@@ -487,34 +684,89 @@ that justified this whole design.
> same constraint the realtime soft-refresh already lives under: never remount the
> treatment form, never clear a draft.
- Renders the transcript, then one row per extracted field in the app's own vocabulary:
translated catalog labels, and a mini FDI chart for the teeth rather than a list of
numbers.
- Renders one row per extracted field in the app's own vocabulary: translated catalog labels,
and a mini FDI chart for the teeth rather than a list of numbers. **The transcript is not
shown** — it never reaches the browser at all (§10).
- 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.
> **Accepted consequence:** on a prosthesis dictation the teeth are reachable only through this
> row. `voiceRowAvailability` sets `teeth: false` for a lab-dependent result, so no plain teeth
> row exists and `selection.teeth` never becomes true. Untick the merged row — which releases the
> decision-41 lock — then untick the type row, and the detail applies with no teeth at all.
> `master` kept them, because the rows were separate.
>
> This is decision 39 doing what it was chosen for, and it follows from *the sheet is a contract*
> below: the row that displayed those teeth was unticked, so applying them anyway would apply
> something the clinician refused. The cost is real and accepted — a clinician who wants only the
> teeth from a prosthesis dictation, intending to pick materials by hand, has to tap them in. If
> that turns out to matter in use, the fix is a spec change, not a patch.
### 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,26 +833,25 @@ 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.
**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
reads `details`, so the transcript is shipped in an error body and dropped. Either build the
dialog below or stop returning the transcript; shipping dictation to the client and
discarding it is the worst of both.
**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.
When ASR succeeded and only extraction failed, the response still
carries the transcript and the failure dialog offers *"افزودن به یادداشت"*. That action
**creates a new detail with only `comment` set to the transcript** — everything else left
at `newDetail()` defaults. The words were captured and paid for; only the structure was
lost.
**Transcript salvage — dropped, not deferred.** `VOICE_EXTRACT_FAILED` used to carry
`details.transcript` so a failure dialog could offer the words back as a note. It was never
built, and the transcript no longer reaches the client at all (§10), so the dialog as specified
cannot be built either. `toAppException` now returns a code and no `details`.
This keeps the feature's one invariant intact: **voice never writes into an existing
detail.** Dictating into an already-filled detail is a separate, later feature with its
own voice-to-text control scoped to that field (§1, out of scope).
The trade, stated plainly: when ASR succeeded and only extraction failed, the words were
captured and paid for, and the clinician cannot be offered them. They are in the server log,
readable by an operator, not by the person who spoke them. The clinician re-dictates.
It also does not bypass the confirmation rule — the dialog shows the transcript, and the
dentist taps to accept it. That review matters, because a raw transcript carries ASR
errors and may contain the patient's spoken name, and `comment` is persisted (§10).
Reversing this needs a decision about the transcript leaving the server, not just client code.
---
@@ -609,7 +860,20 @@ errors and may contain the patient's spoken name, and `comment` is persisted (§
- Audio is held **in memory for the request only**. Never written to disk, never a Prisma
row. Note this is deliberately unlike treatment attachments, which do persist to
`backend/uploads/treatments`.
- The transcript goes to the browser for the review sheet and dies with it.
- **The transcript never leaves the server.** It is not in the success response and not in any
error body. A raw dictation can carry the patient's spoken name, and the review sheet has no
need of it — the resolved rows are what the clinician confirms.
- The transcript **is** written to the server log, once per recording, at **info** level
(`voice.service.ts`, immediately after the emptiness check so an extraction failure still
records it). This is a deliberate exception to the rule below, and the only durable trace
besides `comment`. It is logged before extraction, not inside `logTelemetry`, so that
method's patient-free guarantee stays literally true.
> ⚠ Consequence to accept: patient words persist in production server logs at default level.
> Whatever retention and access control applies to those logs now applies to dictation. The
> repo's other sensitive-text path — a vendor error body that can echo the request back —
> uses `debug` level and truncates to 500 characters
> (`openrouter.provider.ts`). Moving this line to `debug` is a one-word change.
- The `comment` field persists a cleaned version of what was said — that is legitimate
clinical record-keeping and is the only durable trace.
- Telemetry is **structured and patient-free**: clip duration, which fields resolved,
@@ -699,8 +963,13 @@ enabling this for real clinics.
(§2). Still worth timing a realistic worst-case prosthesis dictation during item 1 to
confirm 2 minutes is comfortable rather than tight.
13. ~~**Availability API**~~**resolved for v1:** voice ships **open to everyone** with a
configured locale profile. No plan check, no availability endpoint. The `Plan.features`
design in §8 is deferred, not dropped.
configured locale profile. **No plan check.** The `Plan.features` design in §8 is deferred,
not dropped.
*Corrected 2026-09-07:* this item previously read "no availability endpoint", which
contradicted §4 and the shipped code. `GET /voice/availability` **does** exist and is what
`voiceForEditor` reads; it reports the configured locales and `maxRecordingMs`, and carries
no plan check. Only the plan gate is deferred, not the endpoint.
### New
@@ -715,39 +984,99 @@ 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.**~~**resolved 2026-09-10:** of
its two options, "stop returning the transcript" was taken. The transcript is no longer in
the success response or in `details`, the review sheet no longer shows it, and it is logged
on the server instead (§9, §10). The `ApiError['details']` shape mismatch goes away with it.
---
## 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;
- a `removable` category defers its region check instead of emitting
`code_not_valid_for_target`;
- every one of the 7 categories and 5 subcategories resolves a non-empty label in `fa`, `en`
and `nl` — the check that catches a missed `CatalogTranslation` seed row;
- an unresolved item raised inside an assignment carries that assignment's index, and one
raised outside any assignment does not.
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 backend && npm run prisma:migrate && npm run prisma:seed` — the new `CatalogEntityKind`
values and their translation rows. The seed never wipes, so re-running it is safe.
- `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, and the row
reads *روکش* in Persian, not `crown`. Nothing is applied until one is picked;
- **quadrant chip on a prosthesis detail** — "دندون دو روکش زیرکونیا" → picking a quadrant chip
produces a tooth **carrying that assignment's job**, not a jobless tooth that is then
discarded;
- **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.
---
@@ -766,14 +1095,14 @@ Settled in a grilling session on 2026-08-20.
| 7 | Due date | Intent + deterministic resolver |
| 8 | Resolver location | Backend, Jalali math ported |
| 9 | Prosthesis | Default type + overrides, all-or-nothing |
| 10 | Retention | Discard audio and transcript, non-PHI telemetry only |
| 10 | Retention | Discard audio and transcript, non-PHI telemetry only **partly superseded by 52**: the transcript is now logged on the server at info level |
| 11 | Capture | Tap to start/stop, hard cap (see 27) |
| 12 | Failure UX | Stage-aware codes, transcript salvage |
| 13 | Locales | Provider registry per locale; all three locales enabled |
| 14 | Reachability | Registry now, slots filled per deployment |
| 23 | ASR model | `openai/whisper-1` for **every** locale; registry kept so `fa` can diverge |
| 24 | Extraction model | **`google/gemini-3.7-flash`**; escalation path documented in §4 |
| 25 | Salvage target | Creates a new detail with only `comment` set — voice never writes into an existing detail |
| 25 | Salvage target | Creates a new detail with only `comment` set — voice never writes into an existing detail. **Superseded by 53**: salvage is dropped |
| 26 | Throttle | Configurable; v1 default 6 requests / 60s per user |
| 27 | Duration cap | **2 minutes**, configurable via `maxMs` |
| 28 | Review sheet | Modal on desktop, full-screen overlay (not a route) on mobile; candidate chips are its only interactive part |
@@ -800,3 +1129,49 @@ 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 |
Closing the gaps the `/orchestrate` surveyor found on 2026-09-07, before any code was written.
| # | Question | Decision |
|---|---|---|
| 47 | Category labels | Add `PROSTHESIS_CATEGORY` and `PROSTHESIS_SUBCATEGORY` to `CatalogEntityKind`, one migration, translations seeded from the existing `prosthesis.category_*` / `sub_*` message keys, resolved through `CatalogLabelService`. Bare codes were the alternative and would have been weakest on `fa` (§5) |
| 48 | Frontend label source | Out of scope. The frontend keeps its own message keys; the wording lives in two places by choice, and the seed is written from the message files so they agree (§5) |
| 49 | Mixed-region categories | A category whose leaves span `crown` and `arch` — only `removable` today — defers its region check to the picked leaf, and is never reported as `code_not_valid_for_target` while still a category (§5) |
| 50 | Chips that came from an assignment | `tooth_missing_quadrant`, `arch_not_spoken` and `prosthesis_type_ambiguous` carry `assignmentIndex`, so a picked chip inherits that assignment's jobs. Without it the chip resolves to a jobless tooth, which decision 40 discards — a chip that does nothing (§6) |
Corrections to the v1 text found in the same pass: the endpoint is `POST /voice/extract`, not
`/treatments/voice-extract` (§3); `GET /voice/availability` does exist and only the plan check is
deferred (§11 item 13); the catalog has 5 subcategories, not 4, and the disjointness test asserts
against the live catalog rather than a written count (§5).
Transcript handling revised on 2026-09-10.
| # | Question | Decision |
|---|---|---|
| 51 | Who sees the transcript | Nobody outside the server. It is absent from the success response and from every error body, and the review sheet does not render it — a raw dictation can carry the patient's spoken name, and what is not sent cannot leak through the network tab or an error reporter (§7, §10) |
| 52 | Where it goes instead | One **info**-level server log line per recording, written before extraction so a failed extraction still records it, and outside `logTelemetry` so that method stays patient-free. Accepted consequence: patient words persist in production logs at default level; `debug` is a one-word change (§10) |
| 53 | Transcript salvage | Dropped, not deferred. Reversing it needs a decision about the transcript leaving the server, not just client code. This supersedes decision 25 (§9) |
Notes made explicit on 2026-09-10.
| # | Question | Decision |
|---|---|---|
| 54 | What becomes a note | Only what the clinician explicitly asked to be written. The model reports the asking words in `commentTrigger`; the resolver discards `comment` without one, so a prompt drift cannot quietly persist unrequested speech as a clinical note (§5) |

View File

@@ -926,20 +926,26 @@
"voiceProcessing": "Reading the recording…",
"voiceReviewTitle": "Check what was understood",
"voiceNothingExtracted": "Nothing usable was picked up from that recording.",
"voiceProsthesisIncomplete": "No prosthesis type for {teeth} — the case cannot be sent until every tooth has one.",
"voiceLabInexact": "The spoken name only partly matched this lab. Confirm before sending.",
"voiceNotUnderstood": "Not understood",
"voicePickTooth": "Which tooth?",
"voicePickJaw": "Which jaw?",
"voiceTypeForcedByProsthesis": "Prosthesis work sets this type — it cannot be unticked.",
"voiceTeethAndProsthesis": "Teeth and prosthesis",
"voiceNoProsthesisHeard": "no prosthesis heard",
"voiceStackRefused": "not added — cannot be combined",
"voiceDiscard": "Discard",
"voiceApply": "{count, plural, one {Apply # field} other {Apply # fields}}",
"voiceUnresolved": {
"not_permanent_tooth": "not a permanent tooth",
"position_out_of_range": "not a valid tooth position",
"tooth_missing_quadrant": "not a whole tooth number — say e.g. “twenty-six”",
"prosthesis_type_ambiguous": "a general term, not a specific material — pick one below",
"arch_not_spoken": "which jaw was this for? — pick below",
"code_not_valid_for_target": "does not fit that tooth or jaw",
"malformed": "could not be read",
"span_not_same_arch": "a bridge cannot span both jaws",
"unknown_catalog_code": "not in this clinics list",
"tooth_not_selected": "that tooth is not part of this detail",
"invalid_date": "not a usable date"
},
"voiceFailed": "Voice entry failed. Please try again."

View File

@@ -927,20 +927,26 @@
"voiceProcessing": "در حال پردازش گفتار…",
"voiceReviewTitle": "بررسی آنچه دریافت شد",
"voiceNothingExtracted": "از این ضبط چیز قابل استفاده‌ای برداشت نشد.",
"voiceProsthesisIncomplete": "برای {teeth} نوع پروتز مشخص نشده — تا زمانی که همه دندان‌ها نوع داشته باشند، کیس ارسال نمی‌شود.",
"voiceLabInexact": "نام گفته‌شده فقط تا حدی با این لابراتوار مطابقت داشت. پیش از ارسال تأیید کنید.",
"voiceNotUnderstood": "شناسایی نشد",
"voicePickTooth": "کدام دندان؟",
"voicePickJaw": "کدام فک؟",
"voiceTypeForcedByProsthesis": "کار پروتز این نوع درمان را تعیین می‌کند — قابل برداشتن نیست.",
"voiceTeethAndProsthesis": "دندان‌ها و پروتز",
"voiceNoProsthesisHeard": "پروتزی شنیده نشد",
"voiceStackRefused": "افزوده نشد — قابل ترکیب نیست",
"voiceDiscard": "انصراف",
"voiceApply": "{count, plural, one {اعمال # مورد} other {اعمال # مورد}}",
"voiceUnresolved": {
"not_permanent_tooth": "دندان دائمی نیست",
"position_out_of_range": "شماره دندان معتبر نیست",
"tooth_missing_quadrant": "شماره کامل دندان نیست — مثلاً «بیست و شش»",
"prosthesis_type_ambiguous": "یک عنوان کلی است، نه یک متریال مشخص — یکی را از پایین انتخاب کنید",
"arch_not_spoken": "برای کدام فک بود؟ — از پایین انتخاب کنید",
"code_not_valid_for_target": "با این دندان یا فک همخوانی ندارد",
"malformed": "قابل خواندن نبود",
"span_not_same_arch": "بریج نمی‌تواند بین دو فک باشد",
"unknown_catalog_code": "در فهرست این مطب نیست",
"tooth_not_selected": "این دندان بخشی از این مورد نیست",
"invalid_date": "تاریخ قابل استفاده نیست"
},
"voiceFailed": "ثبت گفتاری انجام نشد. لطفاً دوباره تلاش کنید."

View File

@@ -926,20 +926,26 @@
"voiceProcessing": "Opname wordt gelezen…",
"voiceReviewTitle": "Controleer wat is begrepen",
"voiceNothingExtracted": "Uit deze opname is niets bruikbaars opgepikt.",
"voiceProsthesisIncomplete": "Geen prothesetype voor {teeth} — de casus kan pas worden verstuurd als elk element er een heeft.",
"voiceLabInexact": "De uitgesproken naam kwam slechts deels overeen met dit lab. Bevestig voor verzending.",
"voiceNotUnderstood": "Niet begrepen",
"voicePickTooth": "Welk element?",
"voicePickJaw": "Welke kaak?",
"voiceTypeForcedByProsthesis": "Prothesewerk bepaalt dit type — dit kan niet worden uitgevinkt.",
"voiceTeethAndProsthesis": "Elementen en prothese",
"voiceNoProsthesisHeard": "geen prothese gehoord",
"voiceStackRefused": "niet toegevoegd — kan niet worden gecombineerd",
"voiceDiscard": "Verwerpen",
"voiceApply": "{count, plural, one {# veld toepassen} other {# velden toepassen}}",
"voiceUnresolved": {
"not_permanent_tooth": "geen blijvend element",
"position_out_of_range": "geen geldige elementpositie",
"tooth_missing_quadrant": "geen volledig elementnummer — bijv. “zesentwintig”",
"prosthesis_type_ambiguous": "een algemene term, geen specifiek materiaal — kies hieronder",
"arch_not_spoken": "voor welke kaak was dit? — kies hieronder",
"code_not_valid_for_target": "past niet bij dat element of die kaak",
"malformed": "kon niet worden gelezen",
"span_not_same_arch": "een brug kan niet over beide kaken lopen",
"unknown_catalog_code": "staat niet in de lijst van deze praktijk",
"tooth_not_selected": "dat element hoort niet bij dit onderdeel",
"invalid_date": "geen bruikbare datum"
},
"voiceFailed": "Spraakinvoer is mislukt. Probeer het opnieuw."

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,8 @@
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start -p 3001",
"lint": "next lint"
"lint": "next lint",
"test": "vitest run"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
@@ -36,6 +37,7 @@
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"typescript": "^5"
"typescript": "^5",
"vitest": "^3.2.7"
}
}

View File

@@ -0,0 +1,203 @@
import { describe, expect, it } from 'vitest';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import {
ARCH_TOOTH_LOWER,
ARCH_TOOTH_UPPER,
applyLeafToJobs,
canStackLeaf,
catalogByCode,
effectiveChartRegion,
PARTIAL_DENTURE_CODE,
removeTeethFromGroups,
toothRegionColors,
} from './prosthesisTree';
import type { FdiToothId } from '@/types/treatment';
import type { ToothSelectionGroup } from './toothSelectionGroups';
const CATALOG: ProsthesisCatalogEntry[] = [
{
code: 'pfm_crown',
sortOrder: 1,
label: 'PFM Crown',
category: 'crown',
subcategory: '',
chartRegion: 'crown',
stackGroup: 'restoration',
},
{
code: 'monolithic_zirconia',
sortOrder: 2,
label: 'Monolithic Zirconia',
category: 'crown',
subcategory: '',
chartRegion: 'crown',
stackGroup: 'restoration',
},
{
code: 'zirconia_abutment',
sortOrder: 3,
label: 'Zirconia Abutment',
category: 'implant',
subcategory: '',
chartRegion: 'root',
stackGroup: 'implant',
},
{
code: 'screw_retained',
sortOrder: 4,
label: 'Screw Retained',
category: 'implant',
subcategory: '',
chartRegion: 'crown',
stackGroup: 'implant',
},
{
code: 'cast_post_core',
sortOrder: 5,
label: 'Cast Post & Core',
category: 'post_core',
subcategory: '',
chartRegion: 'root',
stackGroup: 'post_core',
},
{
code: PARTIAL_DENTURE_CODE,
sortOrder: 6,
label: 'Partial Denture',
category: 'removable',
subcategory: '',
chartRegion: 'arch',
stackGroup: 'arch',
},
{
code: 'night_guard_soft',
sortOrder: 7,
label: 'Night Guard',
category: 'appliance',
subcategory: 'night_guard',
chartRegion: 'arch',
stackGroup: 'arch',
},
];
const byCode = catalogByCode(CATALOG);
describe('canStackLeaf', () => {
it('allows an implant plus a crown restoration on the same tooth', () => {
expect(canStackLeaf(['zirconia_abutment'], 'pfm_crown', byCode)).toBe(true);
});
it('refuses a post & core alongside an implant', () => {
expect(canStackLeaf(['zirconia_abutment'], 'cast_post_core', byCode)).toBe(false);
});
it('refuses an implant alongside a post & core', () => {
expect(canStackLeaf(['cast_post_core'], 'zirconia_abutment', byCode)).toBe(false);
});
it('refuses a second restoration once screw-retained already paints the crown', () => {
expect(canStackLeaf(['screw_retained'], 'pfm_crown', byCode)).toBe(false);
});
it('allows two restorations to replace each other (no illegal stack)', () => {
expect(canStackLeaf(['pfm_crown'], 'monolithic_zirconia', byCode)).toBe(true);
});
it('refuses a code the catalog does not have', () => {
expect(canStackLeaf([], 'gold_foil', byCode)).toBe(false);
});
});
describe('applyLeafToJobs', () => {
it('stacks an implant and a crown restoration on one tooth', () => {
const jobs = applyLeafToJobs(['zirconia_abutment'], 'pfm_crown', byCode);
expect(jobs.sort()).toEqual(['pfm_crown', 'zirconia_abutment'].sort());
});
it('a same-stack-group leaf replaces rather than stacking beside the old one', () => {
// PFM previewed on 13, then PFZ heard for the same tooth: the second live test this repo
// ran on real recordings — the first stack rule bug that had to be fixed.
const jobs = applyLeafToJobs(['pfm_crown'], 'monolithic_zirconia', byCode);
expect(jobs).toEqual(['monolithic_zirconia']);
});
it('leaves the jobs untouched when the stack rules refuse the leaf', () => {
const jobs = applyLeafToJobs(['zirconia_abutment'], 'cast_post_core', byCode);
expect(jobs).toEqual(['zirconia_abutment']);
});
});
describe('effectiveChartRegion', () => {
it('overrides partial_denture to crown even though its catalog chartRegion is arch', () => {
expect(effectiveChartRegion({ code: PARTIAL_DENTURE_CODE, chartRegion: 'arch' })).toBe('crown');
});
it('leaves every other code as the catalog says', () => {
expect(effectiveChartRegion({ code: 'zirconia_abutment', chartRegion: 'root' })).toBe('root');
});
});
describe('toothRegionColors', () => {
it('paints a crown-region code into crownColors only', () => {
const { crown, root } = toothRegionColors(
[{ tooth: '12', prosthesisTypeCode: 'pfm_crown' }],
CATALOG,
);
expect(crown['12']).toBeTruthy();
expect(root['12']).toBeUndefined();
});
it('paints a root-region code into rootColors only', () => {
const { crown, root } = toothRegionColors(
[{ tooth: '12', prosthesisTypeCode: 'zirconia_abutment' }],
CATALOG,
);
expect(root['12']).toBeTruthy();
expect(crown['12']).toBeUndefined();
});
it('paints both crown and root for an arch-region code on a real tooth', () => {
const { crown, root } = toothRegionColors(
[{ tooth: '12', prosthesisTypeCode: 'night_guard_soft' }],
CATALOG,
);
expect(crown['12']).toBeTruthy();
expect(root['12']).toBeTruthy();
});
it('skips jaw sentinel rows — they have no crown or root to paint', () => {
const { crown, root } = toothRegionColors(
[
{ tooth: ARCH_TOOTH_UPPER, prosthesisTypeCode: 'night_guard_soft' },
{ tooth: ARCH_TOOTH_LOWER, prosthesisTypeCode: 'night_guard_soft' },
],
CATALOG,
);
expect(Object.keys(crown)).toHaveLength(0);
expect(Object.keys(root)).toHaveLength(0);
});
});
describe('removeTeethFromGroups', () => {
const bridge: ToothSelectionGroup = {
groupId: 'g1',
kind: 'connected',
teeth: ['14', '13', '12'] as FdiToothId[],
};
it('does not leave a pontic-less bridge when a middle tooth goes', () => {
// pruneDetailTeethToJobs routes through here, so this is the voice path: a job removed
// from 13 must not leave 12 and 14 wired together as one unit.
const out = removeTeethFromGroups([bridge], ['13'] as FdiToothId[]);
expect(out.every((g) => g.kind === 'single')).toBe(true);
expect(new Set(out.flatMap((g) => g.teeth))).toEqual(new Set(['12', '14']));
expect(new Set(out.map((g) => g.groupId)).size).toBe(2);
});
it('keeps the remaining span when an end tooth goes', () => {
const out = removeTeethFromGroups([bridge], ['12'] as FdiToothId[]);
expect(out).toHaveLength(1);
expect(out[0].kind).toBe('connected');
expect(out[0].teeth).toEqual(['14', '13']);
});
});

View File

@@ -6,6 +6,7 @@ import {
deriveTeethFromGroups,
groupsFromFlatTeeth,
newGroupId,
splitDisconnectedRuns,
} from '@/components/treatment/toothSelectionGroups';
const PROSTHESIS_CATEGORY_ORDER = [
@@ -337,11 +338,9 @@ export function removeTeethFromGroups(
for (const group of groups) {
const kept = group.teeth.filter((t) => !drop.has(t));
if (kept.length === 0) continue;
if (kept.length === 1) {
next.push({ groupId: group.groupId, kind: 'single', teeth: kept });
} else {
next.push({ ...group, teeth: kept, kind: group.kind === 'connected' ? 'connected' : 'single' });
}
// Removing a middle tooth can leave a connected group non-contiguous, which is no longer
// one bridge. splitDisconnectedRuns keeps the runs that are still spans.
next.push(...splitDisconnectedRuns({ ...group, teeth: kept }));
}
return next.length > 0 ? next : groupsFromFlatTeeth([]);
}

View File

@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest';
import type { FdiToothId } from '@/types/treatment';
import type { ToothSelectionGroup } from './toothSelectionGroups';
import {
areArchNeighbors,
connectedTeethSet,
groupsFromFlatTeeth,
linkedEdgesFromGroups,
splitDisconnectedRuns,
} from './toothSelectionGroups';
const bridge = (teeth: string[], groupId = 'g1'): ToothSelectionGroup => ({
groupId,
kind: 'connected',
teeth: teeth as FdiToothId[],
});
describe('splitDisconnectedRuns', () => {
it('leaves a contiguous bridge alone, keeping its id', () => {
// 14-13-12 are consecutive in FDI_UPPER_LEFT_TO_RIGHT.
const out = splitDisconnectedRuns(bridge(['12', '13', '14']));
expect(out).toEqual([
{ groupId: 'g1', kind: 'connected', teeth: ['14', '13', '12'] },
]);
});
it('splits a bridge that lost its middle tooth into two singles', () => {
// The reported case: a 12-13-14 span reduced to 12 and 14 is not a bridge, it is two
// separate crowns. 13 is the pontic, and it is gone.
const out = splitDisconnectedRuns(bridge(['12', '14']));
expect(out.map((g) => [g.kind, g.teeth])).toEqual([
['single', ['14']],
['single', ['12']],
]);
});
it('keeps each side that is still a span', () => {
// 16-15-14 | gap at 13 | 12-11
const out = splitDisconnectedRuns(bridge(['11', '12', '14', '15', '16']));
expect(out.map((g) => [g.kind, g.teeth])).toEqual([
['connected', ['16', '15', '14']],
['connected', ['12', '11']],
]);
});
it('gives the first run the original id and the rest fresh ids', () => {
const out = splitDisconnectedRuns(bridge(['11', '12', '14', '15'], 'keep-me'));
expect(out[0].groupId).toBe('keep-me');
expect(out[1].groupId).not.toBe('keep-me');
expect(out[1].groupId).toBeTruthy();
});
it('never emits a one-tooth connected group', () => {
const out = splitDisconnectedRuns(bridge(['12']));
expect(out).toEqual([{ groupId: 'g1', kind: 'single', teeth: ['12'] }]);
});
it('splits a bridge spanning two arches, which is never one span', () => {
// areArchNeighbors is false across arches, so an upper tooth and a lower one cannot be
// consecutive whatever their numbers.
const out = splitDisconnectedRuns(bridge(['12', '42']));
expect(out.every((g) => g.kind === 'single')).toBe(true);
expect(out).toHaveLength(2);
});
it('passes a single group through untouched', () => {
const single: ToothSelectionGroup = {
groupId: 'g2',
kind: 'single',
teeth: ['26'] as FdiToothId[],
};
expect(splitDisconnectedRuns(single)).toEqual([single]);
});
});
describe('groupsFromFlatTeeth', () => {
it('does not keep a bridge across a tooth that is no longer selected', () => {
// This is what produced a pontic-less bridge: the filter dropped 13 and the group kept
// its `connected` kind, so both crowns shared one selectionGroupId.
const out = groupsFromFlatTeeth(['12', '14'] as FdiToothId[], [
bridge(['12', '13', '14']),
]);
expect(out.some((g) => g.kind === 'connected')).toBe(false);
expect(connectedTeethSet(out).size).toBe(0);
expect(new Set(out.flatMap((g) => g.teeth))).toEqual(new Set(['12', '14']));
// Two crowns must not share a selection group, or the lab builds them as one unit.
expect(new Set(out.map((g) => g.groupId)).size).toBe(2);
});
it('keeps a bridge whose teeth all survive', () => {
const out = groupsFromFlatTeeth(['12', '13', '14'] as FdiToothId[], [
bridge(['12', '13', '14']),
]);
expect(out).toHaveLength(1);
expect(out[0].kind).toBe('connected');
});
it('adds a tooth no existing group covers', () => {
const out = groupsFromFlatTeeth(['12', '13', '26'] as FdiToothId[], [
bridge(['12', '13']),
]);
expect(connectedTeethSet(out)).toEqual(new Set(['12', '13']));
expect(out.find((g) => g.teeth.includes('26' as FdiToothId))?.kind).toBe('single');
});
it('makes one single group per tooth when there is nothing to preserve', () => {
const out = groupsFromFlatTeeth(['12', '14'] as FdiToothId[]);
expect(out.map((g) => g.kind)).toEqual(['single', 'single']);
});
});
describe('linkedEdgesFromGroups', () => {
it('draws no edge across a gap, so the split matches what was already drawn', () => {
// The marks were already correct before the split — only the group kind and its shared
// id were wrong. This pins that the two now agree.
expect(linkedEdgesFromGroups([bridge(['12', '14'])]).size).toBe(0);
expect(linkedEdgesFromGroups([bridge(['12', '13'])]).size).toBe(1);
});
});
describe('areArchNeighbors', () => {
it('reads adjacency from arch order, not from the numbers', () => {
// 12 and 13 are neighbours; 12 and 14 are two apart with 13 between them.
expect(areArchNeighbors('12' as FdiToothId, '13' as FdiToothId)).toBe(true);
expect(areArchNeighbors('12' as FdiToothId, '14' as FdiToothId)).toBe(false);
// Across the midline, 11 and 21 are adjacent even though the numbers jump.
expect(areArchNeighbors('11' as FdiToothId, '21' as FdiToothId)).toBe(true);
// Across arches, never.
expect(areArchNeighbors('12' as FdiToothId, '42' as FdiToothId)).toBe(false);
});
});

View File

@@ -58,11 +58,8 @@ export function groupsFromFlatTeeth(
teeth: g.teeth.filter((t) => flat.has(t)) as FdiToothId[],
}))
.filter((g) => g.teeth.length > 0)
.map((g) => ({
...g,
kind: normalizeGroupKind(g.kind, g.teeth),
teeth: sortInArchOrder(g.teeth),
}));
.map((g) => ({ ...g, teeth: sortInArchOrder(g.teeth) }))
.flatMap(splitDisconnectedRuns);
const covered = new Set(next.flatMap((g) => g.teeth));
for (const tooth of teeth) {
if (!covered.has(tooth)) {
@@ -121,6 +118,37 @@ export function areArchNeighbors(a: FdiToothId, b: FdiToothId): boolean {
return Math.abs(arch.indexOf(a) - arch.indexOf(b)) === 1;
}
/**
* A bridge is a contiguous span, so a connected group that has lost a middle tooth is no longer
* one bridge. `linkedEdgesFromGroups` already refuses to draw an edge between non-neighbours, but
* the group keeps its `connected` kind and its single `groupId` — and that id becomes the
* `selectionGroupId` on every lab row, so task generation would build one unit spanning the gap.
* A "bridge" from 12 to 14 with no pontic is work no lab can make.
*
* Split what is left into contiguous runs; a run of one tooth becomes a single. The first run
* keeps the original `groupId`, so lab rows already pointing at it stay valid, and
* `pruneToothProsthesisForGroups` re-maps the rest.
*/
export function splitDisconnectedRuns(group: ToothSelectionGroup): ToothSelectionGroup[] {
if (group.kind !== 'connected' || group.teeth.length < 2) {
return [{ ...group, kind: normalizeGroupKind(group.kind, group.teeth) }];
}
const ordered = sortInArchOrder(group.teeth);
const runs: FdiToothId[][] = [[ordered[0]]];
for (let i = 1; i < ordered.length; i++) {
if (areArchNeighbors(ordered[i - 1], ordered[i])) {
runs[runs.length - 1].push(ordered[i]);
} else {
runs.push([ordered[i]]);
}
}
return runs.map((teeth, index) => ({
groupId: index === 0 ? group.groupId : newGroupId(),
kind: teeth.length >= 2 ? ('connected' as const) : ('single' as const),
teeth,
}));
}
function archOrder(tooth: FdiToothId): FdiToothId[] | null {
if ((FDI_UPPER_LEFT_TO_RIGHT as readonly string[]).includes(tooth)) {
return FDI_UPPER_LEFT_TO_RIGHT;
@@ -285,10 +313,7 @@ export function applyShiftRange(
teeth: g.teeth.filter((t) => !union.has(t)) as FdiToothId[],
}))
.filter((g) => g.teeth.length > 0)
.map((g) => ({
...g,
kind: normalizeGroupKind(g.kind, g.teeth),
}));
.flatMap(splitDisconnectedRuns);
for (const tooth of unionTeeth) {
next.push({ groupId: newGroupId(), kind: 'single', teeth: [tooth] });

View File

@@ -0,0 +1,273 @@
import { describe, expect, it } from 'vitest';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import type { VoiceExtractionResult } from '@/types/voice';
import { ARCH_TOOTH_LOWER, ARCH_TOOTH_UPPER } from './prosthesisTree';
import {
countSelected,
hasAnythingToApply,
initialVoiceSelection,
isLabDependentResult,
joblessProsthesisTargets,
prosthesisChartData,
prosthesisTargetLines,
voiceRowAvailability,
withChosenArch,
withChosenProsthesisLeaf,
withChosenTeeth,
} from './voiceReviewRows';
const CATALOG: ProsthesisCatalogEntry[] = [
{
code: 'pfm_crown',
sortOrder: 1,
label: 'PFM Crown',
category: 'crown',
subcategory: '',
chartRegion: 'crown',
stackGroup: 'restoration',
},
{
code: 'zirconia_abutment',
sortOrder: 2,
label: 'Zirconia Abutment',
category: 'implant',
subcategory: '',
chartRegion: 'root',
stackGroup: 'implant',
},
{
code: 'cast_post_core',
sortOrder: 3,
label: 'Cast Post & Core',
category: 'post_core',
subcategory: '',
chartRegion: 'root',
stackGroup: 'post_core',
},
{
code: 'night_guard_soft',
sortOrder: 4,
label: 'Night Guard',
category: 'appliance',
subcategory: 'night_guard',
chartRegion: 'arch',
stackGroup: 'arch',
},
];
const LAB_DEPENDENT = new Set(['prosthesis']);
function baseResult(overrides: Partial<VoiceExtractionResult> = {}): VoiceExtractionResult {
return {
treatmentType: 'restoration',
teeth: [],
toothSelectionGroups: [],
comment: null,
prosthesisAssignments: [],
labId: null,
labMatchExact: false,
dueDate: null,
unresolved: [],
...overrides,
};
}
describe('isLabDependentResult', () => {
it('is true only when the resolved type is in the labDependent set', () => {
expect(isLabDependentResult(baseResult({ treatmentType: 'prosthesis' }), LAB_DEPENDENT)).toBe(
true,
);
expect(isLabDependentResult(baseResult({ treatmentType: 'restoration' }), LAB_DEPENDENT)).toBe(
false,
);
});
});
describe('voiceRowAvailability', () => {
it('shows a plain teeth row for a non-lab-dependent type', () => {
const result = baseResult({ teeth: ['14'] });
const available = voiceRowAvailability(result, LAB_DEPENDENT);
expect(available.teeth).toBe(true);
expect(available.prosthesis).toBe(false);
});
it('merges teeth and prosthesis into one row for a lab-dependent type', () => {
const result = baseResult({
treatmentType: 'prosthesis',
prosthesisAssignments: [{ targets: ['12'], types: ['pfm_crown'], spoken: '' }],
});
const available = voiceRowAvailability(result, LAB_DEPENDENT);
expect(available.teeth).toBe(false);
expect(available.prosthesis).toBe(true);
});
it('shows the merged row for a jaw appliance with no teeth at all', () => {
const result = baseResult({
treatmentType: 'prosthesis',
prosthesisAssignments: [
{ targets: [ARCH_TOOTH_UPPER], types: ['night_guard_soft'], spoken: '' },
],
});
expect(voiceRowAvailability(result, LAB_DEPENDENT).prosthesis).toBe(true);
});
});
describe('initialVoiceSelection', () => {
it('ticks an available lab-dependent prosthesis row even when the stack is incomplete', () => {
// All-or-nothing is retired (decision 40) — an incomplete map no longer blocks a tick.
const result = baseResult({
treatmentType: 'prosthesis',
prosthesisAssignments: [{ targets: ['12'], types: [], spoken: '' }],
});
expect(initialVoiceSelection(result, LAB_DEPENDENT).prosthesis).toBe(true);
});
it('never ticks lab when the match was inexact', () => {
const result = baseResult({ labId: 'lab-1', labMatchExact: false });
expect(initialVoiceSelection(result, LAB_DEPENDENT).lab).toBe(false);
});
});
describe('countSelected', () => {
it('intersects the selection with availability rather than counting raw ticks', () => {
const selection = { treatmentType: true, teeth: true, comment: true, prosthesis: true, lab: true, dueDate: true };
const available = { treatmentType: true, teeth: false, comment: true, prosthesis: false, lab: true, dueDate: false };
expect(countSelected(selection, available)).toBe(3);
});
});
describe('withChosenTeeth', () => {
it('folds a plain candidate into the top-level teeth list', () => {
const result = baseResult({ teeth: ['14'] });
const next = withChosenTeeth(result, ['26']);
expect(next.teeth).toEqual(['14', '26']);
});
it('folds an assignment-scoped candidate into that assignment target list, not the plain list', () => {
const result = baseResult({
treatmentType: 'prosthesis',
prosthesisAssignments: [{ targets: [], types: ['pfm_crown'], spoken: 'دندون دو روکش' }],
});
const next = withChosenTeeth(result, ['12'], 0);
expect(next.prosthesisAssignments[0].targets).toEqual(['12']);
expect(next.teeth).toEqual([]);
});
});
describe('withChosenArch', () => {
it('folds a picked jaw into the assignment that named no jaw at all', () => {
const result = baseResult({
treatmentType: 'prosthesis',
prosthesisAssignments: [{ targets: [], types: ['night_guard_soft'], spoken: 'نایت گارد' }],
});
const next = withChosenArch(result, 0, 'upper');
expect(next.prosthesisAssignments[0].targets).toEqual([ARCH_TOOTH_UPPER]);
});
it('picking both jaws is how a both-jaw appliance is expressed', () => {
let next = withChosenArch(
baseResult({ prosthesisAssignments: [{ targets: [], types: [], spoken: '' }] }),
0,
'upper',
);
next = withChosenArch(next, 0, 'lower');
expect(next.prosthesisAssignments[0].targets.sort()).toEqual(
[ARCH_TOOTH_UPPER, ARCH_TOOTH_LOWER].sort(),
);
});
});
describe('withChosenProsthesisLeaf', () => {
it('supplies the missing leaf to the assignment that only named a category', () => {
const result = baseResult({
prosthesisAssignments: [{ targets: ['12'], types: [], spoken: 'روکش' }],
});
const next = withChosenProsthesisLeaf(result, 0, 'pfm_crown');
expect(next.prosthesisAssignments[0].types).toEqual(['pfm_crown']);
// A resolved assignment forces the type — the row locks exactly as the backend does.
expect(next.treatmentType).toBe('prosthesis');
});
});
describe('prosthesisTargetLines', () => {
it('previews the stack that will actually land, through applyLeafToJobs', () => {
const lines = prosthesisTargetLines(
[{ targets: ['12'], types: ['zirconia_abutment', 'pfm_crown'], spoken: '' }],
CATALOG,
);
expect(lines).toEqual([{ target: '12', isJaw: false, applied: ['zirconia_abutment', 'pfm_crown'], refused: [] }]);
});
it('names a refused job rather than silently dropping or silently applying it', () => {
const lines = prosthesisTargetLines(
[{ targets: ['12'], types: ['zirconia_abutment', 'cast_post_core'], spoken: '' }],
CATALOG,
);
expect(lines[0].applied).toEqual(['zirconia_abutment']);
expect(lines[0].refused).toEqual(['cast_post_core']);
});
it('marks a jaw target as such', () => {
const lines = prosthesisTargetLines(
[{ targets: [ARCH_TOOTH_UPPER], types: ['night_guard_soft'], spoken: '' }],
CATALOG,
);
expect(lines[0].isJaw).toBe(true);
});
});
describe('joblessProsthesisTargets', () => {
it('names a tooth left in the plain teeth list with no matching assignment', () => {
const result = baseResult({
teeth: ['12', '13'],
prosthesisAssignments: [{ targets: ['12'], types: ['pfm_crown'], spoken: '' }],
});
expect(joblessProsthesisTargets(result)).toEqual(['13']);
});
it('names an assignment target whose types were empty from the start', () => {
const result = baseResult({
prosthesisAssignments: [{ targets: ['13'], types: [], spoken: '' }],
});
expect(joblessProsthesisTargets(result)).toEqual(['13']);
});
it('does not call a target jobless while it is pending a material pick', () => {
const result = baseResult({
prosthesisAssignments: [{ targets: ['13'], types: [], spoken: 'روکش' }],
unresolved: [{ spoken: 'روکش', reason: 'prosthesis_type_ambiguous', assignmentIndex: 0 }],
});
expect(joblessProsthesisTargets(result)).toEqual([]);
});
});
describe('prosthesisChartData', () => {
it('derives the arch highlight from applied jaw jobs, upper and lower alike', () => {
const lines = prosthesisTargetLines(
[{ targets: [ARCH_TOOTH_UPPER, ARCH_TOOTH_LOWER], types: ['night_guard_soft'], spoken: '' }],
CATALOG,
);
const data = prosthesisChartData(lines, [], CATALOG);
expect(data.archHighlight).toBe('both');
expect(data.selectedTeeth.size).toBe(0);
});
it('collects every real tooth, applied or jobless, into selectedTeeth', () => {
const lines = prosthesisTargetLines(
[{ targets: ['12'], types: ['pfm_crown'], spoken: '' }],
CATALOG,
);
const data = prosthesisChartData(lines, ['13'], CATALOG);
expect([...data.selectedTeeth].sort()).toEqual(['12', '13']);
});
});
describe('hasAnythingToApply', () => {
it('is false when the recording produced nothing usable', () => {
expect(hasAnythingToApply(baseResult({ treatmentType: null }), LAB_DEPENDENT)).toBe(false);
});
it('is true once any row is available', () => {
expect(hasAnythingToApply(baseResult({ teeth: ['14'] }), LAB_DEPENDENT)).toBe(true);
});
});

View File

@@ -1,35 +1,73 @@
import { groupsFromFlatTeeth } from '@/components/treatment/toothSelectionGroups';
import {
ARCH_TOOTH_LOWER,
ARCH_TOOTH_UPPER,
applyLeafToJobs,
archSentinels,
canStackLeaf,
catalogByCode,
isArchSentinel,
toothRegionColors,
type ArchTarget,
} from '@/components/treatment/prosthesisTree';
import type { FdiToothId } from '@/types/treatment';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import type {
VoiceApplySelection,
VoiceExtractionResult,
VoiceProsthesisResult,
VoiceProsthesisAssignment,
VoiceUnresolvedItem,
} from '@/types/voice';
/** Which rows the review sheet renders at all — a row with nothing extracted is noise. */
export function voiceRowAvailability(result: VoiceExtractionResult) {
/** `prosthesis` is the app's only labDependent treatment type today, but this stays generic. */
export function isLabDependentResult(
result: VoiceExtractionResult,
labDependentCodes: ReadonlySet<string>,
): boolean {
return Boolean(result.treatmentType && labDependentCodes.has(result.treatmentType));
}
function hasProsthesisWork(result: VoiceExtractionResult): boolean {
return result.prosthesisAssignments.some((a) => a.targets.length > 0 || a.types.length > 0);
}
/**
* Which rows the review sheet renders at all — a row with nothing extracted is noise.
*
* Teeth and prosthesis are never both available: a lab-dependent type merges them into one
* `prosthesis` row (decision 39), because two independent ticks can save an empty detail —
* `persistDraft` prunes a lab-dependent detail to its jobs.
*/
export function voiceRowAvailability(
result: VoiceExtractionResult,
labDependentCodes: ReadonlySet<string>,
) {
const labDependent = isLabDependentResult(result, labDependentCodes);
return {
treatmentType: result.treatmentType != null,
teeth: result.teeth.length > 0,
teeth: !labDependent && result.teeth.length > 0,
prosthesis: labDependent && (result.teeth.length > 0 || hasProsthesisWork(result)),
comment: Boolean(result.comment?.trim()),
prosthesis: result.prosthesis != null,
lab: result.labId != null,
dueDate: result.dueDate != null,
};
}
/**
* Everything available ticks itself, with two exceptions: an inexactly-matched lab, because
* it is the one extracted value whose error leaves the building; and an incomplete
* prosthesis map, which cannot ship at all and would just move the failure to dispatch.
* Everything available ticks itself, with one exception: an inexactly-matched lab, because it
* is the one extracted value whose error leaves the building. All-or-nothing prosthesis maps
* are retired (decision 40) — an incomplete stack no longer blocks a tick.
*/
export function initialVoiceSelection(result: VoiceExtractionResult): VoiceApplySelection {
const available = voiceRowAvailability(result);
export function initialVoiceSelection(
result: VoiceExtractionResult,
labDependentCodes: ReadonlySet<string>,
): VoiceApplySelection {
const available = voiceRowAvailability(result, labDependentCodes);
return {
treatmentType: available.treatmentType,
teeth: available.teeth,
comment: available.comment,
prosthesis: available.prosthesis && result.prosthesis?.complete === true,
prosthesis: available.prosthesis,
lab: available.lab && result.labMatchExact,
dueDate: available.dueDate,
};
@@ -48,37 +86,75 @@ export function countSelected(
).length;
}
/** Mirrors the backend's rule: every selected tooth needs a code, or the case cannot ship. */
function recheckProsthesis(
prosthesis: VoiceProsthesisResult,
teeth: readonly FdiToothId[],
): VoiceProsthesisResult {
const missingTeeth = teeth.filter((tooth) => !prosthesis.byTooth[tooth]);
return { ...prosthesis, missingTeeth, complete: missingTeeth.length === 0 };
/** Every resolved assignment target, across every assignment. */
function allAssignmentTargets(result: VoiceExtractionResult): Set<string> {
return new Set(result.prosthesisAssignments.flatMap((a) => a.targets));
}
/** Forces treatmentType to `prosthesis` the moment any assignment carries a real target. */
function withProsthesisForced(result: VoiceExtractionResult): VoiceExtractionResult {
const forced = result.prosthesisAssignments.some((a) => a.targets.length > 0);
return forced ? { ...result, treatmentType: 'prosthesis' } : result;
}
/**
* Fold the candidate picks into the result, so nothing downstream has to know chips exist.
* Fold a picked FDI/quadrant candidate into the result.
*
* `assignmentIndex` set: the chip came from resolving a `prosthesisAssignments` entry, so the
* pick becomes that assignment's target — inheriting its `types`, not a jobless tooth
* (decision 50). `assignmentIndex` absent: the chip came from the plain `teeth` list and folds
* in there, exactly as before.
*
* Union rather than toggle: a candidate can coincidentally be a tooth the recording already
* produced ("۱۲ و دو"), and tapping it must not deselect that one.
* produced, and tapping it must not deselect that one.
*/
export function withChosenTeeth(
result: VoiceExtractionResult,
chosen: readonly FdiToothId[],
assignmentIndex?: number,
): VoiceExtractionResult {
if (chosen.length === 0) return result;
const teeth = [...new Set([...result.teeth, ...chosen])].sort() as FdiToothId[];
if (assignmentIndex != null) {
const assignments = result.prosthesisAssignments.map((a, i) =>
i === assignmentIndex ? { ...a, targets: [...new Set([...a.targets, ...chosen])] } : a,
);
return withProsthesisForced({ ...result, prosthesisAssignments: assignments });
}
const teeth = [...new Set([...result.teeth, ...chosen])].sort() as FdiToothId[];
return {
...result,
teeth,
toothSelectionGroups: groupsFromFlatTeeth(teeth, result.toothSelectionGroups),
prosthesis: result.prosthesis ? recheckProsthesis(result.prosthesis, teeth) : null,
};
}
/** Fold a picked jaw ('upper' / 'lower') into the assignment that named no jaw at all. */
export function withChosenArch(
result: VoiceExtractionResult,
assignmentIndex: number,
arch: 'upper' | 'lower',
): VoiceExtractionResult {
const sentinel = arch === 'upper' ? ARCH_TOOTH_UPPER : ARCH_TOOTH_LOWER;
const assignments = result.prosthesisAssignments.map((a, i) =>
i === assignmentIndex ? { ...a, targets: [...new Set([...a.targets, sentinel])] } : a,
);
return withProsthesisForced({ ...result, prosthesisAssignments: assignments });
}
/** Fold a picked leaf into the assignment that only named a category or subcategory. */
export function withChosenProsthesisLeaf(
result: VoiceExtractionResult,
assignmentIndex: number,
leafCode: string,
): VoiceExtractionResult {
const assignments = result.prosthesisAssignments.map((a, i) =>
i === assignmentIndex ? { ...a, types: [...new Set([...a.types, leafCode])] } : a,
);
return withProsthesisForced({ ...result, prosthesisAssignments: assignments });
}
/** Teeth that are part of a bridge, for the read-only chart's connection marks. */
export function connectedTeethFromResult(result: VoiceExtractionResult): Set<FdiToothId> {
const connected = new Set<FdiToothId>();
@@ -89,7 +165,138 @@ export function connectedTeethFromResult(result: VoiceExtractionResult): Set<Fdi
return connected;
}
/** A recording that produced nothing should say so, not show an empty form of checkboxes. */
export function hasAnythingToApply(result: VoiceExtractionResult): boolean {
return Object.values(voiceRowAvailability(result)).some(Boolean);
export type VoiceProsthesisTargetLine = {
target: string;
isJaw: boolean;
/** Leaf codes that will actually land, in landing order. */
applied: string[];
/** Leaf codes the stack rules refused — shown struck through, never silently dropped. */
refused: string[];
};
/**
* The stack that will actually land, built through `applyLeafToJobs` — the same function the
* manual chart writes through (§7). A code the stack rules refuse (an implant plus a post &
* core on one tooth) is named as refused rather than silently dropped or silently applied.
*/
export function prosthesisTargetLines(
assignments: readonly VoiceProsthesisAssignment[],
catalog: readonly ProsthesisCatalogEntry[],
): VoiceProsthesisTargetLine[] {
const byCode = catalogByCode(catalog);
const byTarget = new Map<string, { applied: string[]; refused: string[] }>();
for (const assignment of assignments) {
for (const target of assignment.targets) {
const entry = byTarget.get(target) ?? { applied: [], refused: [] };
for (const code of assignment.types) {
if (canStackLeaf(entry.applied, code, byCode)) {
entry.applied = applyLeafToJobs(entry.applied, code, byCode);
} else {
entry.refused.push(code);
}
}
byTarget.set(target, entry);
}
}
return [...byTarget.entries()].map(([target, { applied, refused }]) => ({
target,
isJaw: isArchSentinel(target),
applied,
refused,
}));
}
/**
* FDI codes / jaw sentinels named somewhere (an assignment target, or the plain `teeth` list)
* but ending up with no job at all — struck through in the sheet reading "no prosthesis heard"
* (decision 40). A target still pending a material pick (a `prosthesis_type_ambiguous` chip
* for its assignment) is not jobless; it is simply not resolved yet.
*/
export function joblessProsthesisTargets(result: VoiceExtractionResult): string[] {
const pendingIndexes = new Set(
result.unresolved
.filter((u) => u.reason === 'prosthesis_type_ambiguous' && u.assignmentIndex != null)
.map((u) => u.assignmentIndex as number),
);
const jobless = new Set<string>();
const covered = new Set<string>();
const pending = new Set<string>();
result.prosthesisAssignments.forEach((assignment, index) => {
if (pendingIndexes.has(index)) {
for (const target of assignment.targets) pending.add(target);
} else if (assignment.types.length === 0) {
for (const target of assignment.targets) jobless.add(target);
} else {
for (const target of assignment.targets) covered.add(target);
}
});
for (const tooth of result.teeth) {
if (!covered.has(tooth) && !pending.has(tooth)) jobless.add(tooth);
}
return [...jobless];
}
export type VoiceProsthesisChartData = {
crownColors: Partial<Record<FdiToothId, string>>;
rootColors: Partial<Record<FdiToothId, string>>;
archHighlight: ArchTarget | null;
/** Every real tooth involved — a target with a job, or a jobless one named alongside it. */
selectedTeeth: Set<FdiToothId>;
};
/** Feeds the merged row's `FdiToothChart` — crown/root tints plus the arch highlight. */
export function prosthesisChartData(
lines: readonly VoiceProsthesisTargetLine[],
jobless: readonly string[],
catalog: readonly ProsthesisCatalogEntry[],
): VoiceProsthesisChartData {
const rows = lines.flatMap((line) =>
line.applied.map((code) => ({ tooth: line.target, prosthesisTypeCode: code })),
);
const { crown, root } = toothRegionColors(rows, catalog);
const hasUpper = lines.some((l) => l.target === ARCH_TOOTH_UPPER && l.applied.length > 0);
const hasLower = lines.some((l) => l.target === ARCH_TOOTH_LOWER && l.applied.length > 0);
const archHighlight: ArchTarget | null =
hasUpper && hasLower ? 'both' : hasUpper ? 'upper' : hasLower ? 'lower' : null;
const selectedTeeth = new Set<FdiToothId>();
for (const line of lines) {
if (!line.isJaw) selectedTeeth.add(line.target as FdiToothId);
}
for (const tooth of jobless) {
if (!isArchSentinel(tooth)) selectedTeeth.add(tooth as FdiToothId);
}
return { crownColors: crown, rootColors: root, archHighlight, selectedTeeth };
}
/** Which unresolved items belong to which chip section — the sheet renders both identically. */
export function unresolvedWithoutAssignment(
result: VoiceExtractionResult,
): VoiceUnresolvedItem[] {
return result.unresolved.filter((u) => u.assignmentIndex == null);
}
export function unresolvedForAssignment(
result: VoiceExtractionResult,
assignmentIndex: number,
): VoiceUnresolvedItem[] {
return result.unresolved.filter((u) => u.assignmentIndex === assignmentIndex);
}
/** A recording that produced nothing should say so, not show an empty form of checkboxes. */
export function hasAnythingToApply(
result: VoiceExtractionResult,
labDependentCodes: ReadonlySet<string>,
): boolean {
return Object.values(voiceRowAvailability(result, labDependentCodes)).some(Boolean);
}
export { archSentinels, allAssignmentTargets };

View File

@@ -31,6 +31,7 @@ import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
import { voiceApi } from '@/lib/api/voice';
import { useVoiceCapture } from '@/lib/voice/useVoiceCapture';
import { isMediaRecorderSupported } from '@/lib/voice/audioFormat';
import { VoiceReviewSheet } from '@/components/ui/treatment/VoiceReviewSheet';
import type {
VoiceApplySelection,
@@ -61,6 +62,7 @@ import {
unlinkAdjacentTeeth,
} from '@/components/treatment/toothSelectionGroups';
import { hasArchJobs, pruneDetailTeethToJobs } from '@/components/treatment/prosthesisTree';
import { prosthesisTargetLines } from '@/components/treatment/voiceReviewRows';
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
import {
@@ -549,6 +551,13 @@ export function TreatmentWorkspace({
const draftHydratingRef = useRef(false);
const workspaceModeRef = useRef(workspaceMode);
workspaceModeRef.current = workspaceMode;
/**
* `persistDraft` prunes a lab-dependent detail down to the teeth its jobs cover, and it reads
* this ref — not React state. So any handler that changes the drafts and then saves in the
* same tick must write the ref beside `setLabCaseDrafts`; the render-time assignment below has
* not run yet. Skipping it saves the detail with no teeth, and the lab-case request that
* follows is then rejected with TREATMENT_TOOTH_NOT_ON_DETAIL.
*/
const labCaseDraftsRef = useRef(labCaseDrafts);
labCaseDraftsRef.current = labCaseDrafts;
const skipNextGetDraftRef = useRef(false);
@@ -621,9 +630,18 @@ export function TreatmentWorkspace({
onError: (error) => showError(getUserFacingError(error, tErrors, t('voiceFailed'))),
});
/** Absence is the unavailable state — the Add button then renders unsplit. */
/**
* Absence is the unavailable state — the Add button then renders unsplit. Gated on both the
* server's availability response AND the browser's own recording support: without the
* latter check the control rendered on a browser that cannot record and failed on tap
* (the Safari report that started this revision, §2).
*/
const voiceForEditor =
voiceAvailability?.enabled && voiceAvailability.locales.includes(locale) ? voice : undefined;
voiceAvailability?.enabled &&
voiceAvailability.locales.includes(locale) &&
isMediaRecorderSupported()
? voice
: undefined;
const selectedStandalone = useMemo(
() => standaloneTreatments.find((t) => t.id === selectedStandaloneId) ?? null,
@@ -2192,7 +2210,16 @@ export function TreatmentWorkspace({
if (selection.treatmentType && result.treatmentType) {
detail.treatmentType = result.treatmentType;
}
if (selection.teeth) {
// Teeth and prosthesis are one row for a lab-dependent type (decision 39) — ticking
// "teeth" independently of "prosthesis" could save an empty detail, since
// `persistDraft` prunes a lab-dependent detail down to its jobs. `selection.prosthesis`
// alone drives both below; `selection.teeth` only ever applies to the plain row.
// Derived from the detail actually being written, never from `result.treatmentType`.
// Reading the result meant that unticking the type row still took the prosthesis branch,
// saving lab rows on whatever type the appointment purpose had seeded (decision 41).
const labDependent = labDependentCodes.has(detail.treatmentType);
if (!labDependent && selection.teeth) {
detail.teeth = [...result.teeth];
detail.toothSelectionGroups = result.toothSelectionGroups.map((group) => ({
...group,
@@ -2203,6 +2230,25 @@ export function TreatmentWorkspace({
detail.comment = result.comment;
}
// The stack that will actually land, built through the same `applyLeafToJobs` the
// manual chart writes through (§7) — a code the stack rules refuse is not applied.
const prosthesisLines =
labDependent && selection.prosthesis
? prosthesisTargetLines(result.prosthesisAssignments, prosthesisCatalog)
: [];
// An assignment target is a selection: a tooth exists on a prosthesis detail only by
// carrying a job (decision 40) — never from the plain `teeth` field.
const prosthesisTeeth = prosthesisLines
.filter((line) => !line.isJaw && line.applied.length > 0)
.map((line) => line.target as FdiToothId);
if (labDependent && selection.prosthesis) {
detail.teeth = prosthesisTeeth;
detail.toothSelectionGroups = groupsFromFlatTeeth(
prosthesisTeeth,
result.toothSelectionGroups,
);
}
const nextDetails = [...detailsRef.current, detail];
setDetails(nextDetails);
// persistDraft reads detailsRef, and setDetails has not rendered yet.
@@ -2212,7 +2258,7 @@ export function TreatmentWorkspace({
// Lab-side rows ride on a lab case draft keyed by the detail's *client* id, so a
// brand-new unsaved detail can still carry one; it is persisted after the detail is.
const wantsLabDraft =
(selection.prosthesis && result.prosthesis) ||
prosthesisLines.some((line) => line.applied.length > 0) ||
(selection.lab && result.labId) ||
(selection.dueDate && result.dueDate);
@@ -2225,27 +2271,21 @@ export function TreatmentWorkspace({
if (selection.dueDate && result.dueDate) {
draft.dueDate = result.dueDate;
}
if (selection.prosthesis && result.prosthesis) {
// byTooth keys are plain strings; the group's teeth are FdiToothId.
const groupOf = (tooth: string) =>
result.toothSelectionGroups.find((group) =>
(group.teeth as readonly string[]).includes(tooth),
)?.groupId ?? '';
// Only teeth that actually landed on the detail. Unticking "teeth" while
// leaving "prosthesis" ticked would otherwise attach prosthesis rows for teeth
// the treatment does not contain — nothing downstream filters them, and they
// would reach task generation as work for teeth nobody is treating.
const detailTeeth = new Set<string>(detail.teeth);
draft.toothProsthesis = Object.entries(result.prosthesis.byTooth)
.filter(([tooth]) => detailTeeth.has(tooth))
.map(([tooth, prosthesisTypeCode]) => ({
detailClientId: detail.clientId,
tooth,
prosthesisTypeCode,
selectionGroupId: groupOf(tooth),
}));
}
draft.toothProsthesis = prosthesisLines.flatMap((line) => {
const groupId = line.isJaw
? ''
: (detail.toothSelectionGroups.find((group) =>
(group.teeth as readonly string[]).includes(line.target),
)?.groupId ?? '');
return line.applied.map((prosthesisTypeCode) => ({
detailClientId: detail.clientId,
tooth: line.target,
prosthesisTypeCode,
selectionGroupId: groupId,
}));
});
const updatedLabCases = [...labCaseDrafts, draft];
labCaseDraftsRef.current = updatedLabCases;
setLabCaseDrafts(updatedLabCases);
// Persist the new detail first so lab-case rows can use real treatmentDetailIds.
@@ -2269,8 +2309,10 @@ export function TreatmentWorkspace({
},
[
labCaseDrafts,
labDependentCodes,
persistDraft,
persistLabCases,
prosthesisCatalog,
selectedAppointment?.purpose,
showError,
t,
@@ -2398,6 +2440,7 @@ export function TreatmentWorkspace({
const updatedLabCases = cleaned.map((lc) =>
lc.clientId === orphan.clientId ? { ...lc, detailClientId: activeDetailId } : lc,
);
labCaseDraftsRef.current = updatedLabCases;
setLabCaseDrafts(updatedLabCases);
setActiveLabCaseId(orphan.clientId);
@@ -2416,6 +2459,7 @@ export function TreatmentWorkspace({
attachmentIds: activeDetail?.attachmentMetas.map((a) => a.id) ?? [],
};
const updatedLabCases = [...cleaned, next];
labCaseDraftsRef.current = updatedLabCases;
setLabCaseDrafts(updatedLabCases);
setActiveLabCaseId(next.clientId);
@@ -3202,6 +3246,7 @@ export function TreatmentWorkspace({
result={voiceResult}
treatmentCatalog={treatmentCatalog}
prosthesisCatalog={prosthesisCatalog}
labDependentCodes={labDependentCodes}
labs={orgs}
onApply={(selection, applied) => applyVoiceResult(applied, selection)}
onDiscard={() => setVoiceResult(null)}

View File

@@ -10,31 +10,44 @@ import {
ResponsiveDialogPanel,
} from '@/components/ui/shared/ResponsiveDialog';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
import { ARCH_TOOTH_LOWER, ARCH_TOOTH_UPPER } from '@/components/treatment/prosthesisTree';
import {
connectedTeethFromResult,
countSelected,
hasAnythingToApply,
initialVoiceSelection,
joblessProsthesisTargets,
prosthesisChartData,
prosthesisTargetLines,
voiceRowAvailability,
withChosenArch,
withChosenProsthesisLeaf,
withChosenTeeth,
} from '@/components/treatment/voiceReviewRows';
import { useLocale } from 'next-intl';
import { useAppFormatters } from '@/lib/hooks/useAppFormatters';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import type { FdiToothId, LinkedOrganizationOption } from '@/types/treatment';
import type { VoiceApplySelection, VoiceExtractionResult } from '@/types/voice';
import type {
VoiceApplySelection,
VoiceExtractionResult,
VoiceUnresolvedItem,
} from '@/types/voice';
interface VoiceReviewSheetProps {
result: VoiceExtractionResult;
treatmentCatalog: TreatmentCatalogEntry[];
prosthesisCatalog: ProsthesisCatalogEntry[];
/** `prosthesis` today, but kept generic — the same set the workspace already tracks. */
labDependentCodes: ReadonlySet<string>;
labs: LinkedOrganizationOption[];
/** The result is handed back because the sheet may have added teeth the model missed. */
/** The result is handed back because the sheet may have added teeth or jobs the model missed. */
onApply: (selection: VoiceApplySelection, result: VoiceExtractionResult) => void;
onDiscard: () => void;
}
type ArchPick = 'upper' | 'lower';
/**
* Confirmation step between the model's output and the form.
*
@@ -45,50 +58,152 @@ export function VoiceReviewSheet({
result,
treatmentCatalog,
prosthesisCatalog,
labDependentCodes,
labs,
onApply,
onDiscard,
}: VoiceReviewSheetProps) {
const t = useTranslations('treatment');
const locale = useLocale();
const { formatDate } = useAppFormatters();
const [selection, setSelection] = useState<VoiceApplySelection>(() =>
initialVoiceSelection(result),
initialVoiceSelection(result, labDependentCodes),
);
const [chosen, setChosen] = useState<FdiToothId[]>([]);
// Everything below renders from `effective`, never from `result` — a tooth picked from
// the candidate chips has to reach the rows, the chart and the apply count alike.
const effective = useMemo(() => withChosenTeeth(result, chosen), [result, chosen]);
// Every kind of candidate chip the sheet can offer, tracked separately because each folds
// into the result a different way (decision 50). Toggling off removes only the clinician's
// own pick — nothing the recording already produced is ever un-added.
const [chosenTeeth, setChosenTeeth] = useState<FdiToothId[]>([]);
const [chosenAssignmentTeeth, setChosenAssignmentTeeth] = useState<Record<number, FdiToothId[]>>(
{},
);
const [chosenArches, setChosenArches] = useState<Record<number, ArchPick[]>>({});
const [chosenLeaves, setChosenLeaves] = useState<Record<number, string[]>>({});
const available = useMemo(() => voiceRowAvailability(effective), [effective]);
// Everything below renders from `effective`, never from `result` — a candidate picked from
// the chips has to reach the rows, the chart and the apply count alike.
const effective = useMemo(() => {
let next = withChosenTeeth(result, chosenTeeth);
for (const [index, teeth] of Object.entries(chosenAssignmentTeeth)) {
next = withChosenTeeth(next, teeth, Number(index));
}
for (const [index, arches] of Object.entries(chosenArches)) {
for (const arch of arches) next = withChosenArch(next, Number(index), arch);
}
for (const [index, leaves] of Object.entries(chosenLeaves)) {
for (const leaf of leaves) next = withChosenProsthesisLeaf(next, Number(index), leaf);
}
return next;
}, [result, chosenTeeth, chosenAssignmentTeeth, chosenArches, chosenLeaves]);
const available = useMemo(
() => voiceRowAvailability(effective, labDependentCodes),
[effective, labDependentCodes],
);
const connectedTeeth = useMemo(() => connectedTeethFromResult(effective), [effective]);
const selectedTeeth = useMemo(() => new Set(effective.teeth), [effective.teeth]);
const nothingToApply = !hasAnythingToApply(effective);
const selectedCount = countSelected(selection, available);
const prosthesisLines = useMemo(
() => prosthesisTargetLines(effective.prosthesisAssignments, prosthesisCatalog),
[effective, prosthesisCatalog],
);
const joblessTargets = useMemo(() => joblessProsthesisTargets(effective), [effective]);
const chartData = useMemo(
() => prosthesisChartData(prosthesisLines, joblessTargets, prosthesisCatalog),
[prosthesisLines, joblessTargets, prosthesisCatalog],
);
// Decision 41: prosthesis jobs exist on one treatment type only, so a ticked prosthesis row
// pins the type row. Locking it is what makes "restoration detail carrying lab rows"
// unreachable rather than merely discouraged.
const typeForcedByProsthesis =
available.prosthesis && selection.prosthesis && effective.treatmentType != null;
const pickCandidate = (tooth: FdiToothId) => {
const nextChosen = chosen.includes(tooth)
? chosen.filter((t) => t !== tooth)
: [...chosen, tooth];
setChosen(nextChosen);
setSelection((prev) => ({
...prev,
// The teeth row starts unticked whenever the recording produced no teeth of its own,
// and a picked tooth that is not ticked applies nothing.
teeth: true,
// A picked tooth has no prosthesis type, so the map is no longer shippable — leaving the
// row ticked would apply a map dispatch rejects. Only ever unticks; re-ticking is the
// clinician's call.
prosthesis:
prev.prosthesis &&
withChosenTeeth(result, nextChosen).prosthesis?.complete !== false,
}));
// What Apply actually sends. The locked type row is ticked on screen, so it has to be
// ticked in the count and in the payload alike — counting the raw `selection` showed
// "Apply 1" while two fields landed.
const effectiveSelection: VoiceApplySelection = typeForcedByProsthesis
? { ...selection, treatmentType: true }
: selection;
const nothingToApply = !hasAnythingToApply(effective, labDependentCodes);
const selectedCount = countSelected(effectiveSelection, available);
const targetLabel = (target: string): string => {
if (target === ARCH_TOOTH_UPPER) return t('upperArch');
if (target === ARCH_TOOTH_LOWER) return t('lowerArch');
return target;
};
const labelFor = (code: string | null, catalog: { code: string; label: string }[]) =>
catalog.find((entry) => entry.code === code)?.label ?? code ?? '';
const isPicked = (item: VoiceUnresolvedItem, code: string): boolean => {
const index = item.assignmentIndex;
if (item.reason === 'arch_not_spoken') {
return index != null && (chosenArches[index] ?? []).includes(code as ArchPick);
}
if (item.reason === 'prosthesis_type_ambiguous') {
return index != null && (chosenLeaves[index] ?? []).includes(code);
}
if (index != null) return (chosenAssignmentTeeth[index] ?? []).includes(code as FdiToothId);
return chosenTeeth.includes(code as FdiToothId);
};
/**
* A picked candidate has no meaning unless its own row is ticked, so each branch ticks the
* row it feeds. Never derive that from `available`: it is memoised from `effective`, which
* this handler is in the middle of changing, so it still reports the row as unavailable and
* the pick would apply nothing.
*/
const pickCandidate = (item: VoiceUnresolvedItem, code: string) => {
const index = item.assignmentIndex;
if (item.reason === 'arch_not_spoken' && index != null) {
setChosenArches((prev) => {
const cur = prev[index] ?? [];
const arch = code as ArchPick;
return {
...prev,
[index]: cur.includes(arch) ? cur.filter((a) => a !== arch) : [...cur, arch],
};
});
setSelection((prev) => ({ ...prev, prosthesis: true }));
return;
}
if (item.reason === 'prosthesis_type_ambiguous' && index != null) {
setChosenLeaves((prev) => {
const cur = prev[index] ?? [];
return { ...prev, [index]: cur.includes(code) ? cur.filter((l) => l !== code) : [...cur, code] };
});
setSelection((prev) => ({ ...prev, prosthesis: true }));
return;
}
if (index != null) {
setChosenAssignmentTeeth((prev) => {
const cur = prev[index] ?? [];
const tooth = code as FdiToothId;
return {
...prev,
[index]: cur.includes(tooth) ? cur.filter((t2) => t2 !== tooth) : [...cur, tooth],
};
});
setSelection((prev) => ({ ...prev, prosthesis: true }));
return;
}
const tooth = code as FdiToothId;
setChosenTeeth((prev) => (prev.includes(tooth) ? prev.filter((t2) => t2 !== tooth) : [...prev, tooth]));
setSelection((prev) => ({ ...prev, teeth: true }));
};
const candidateLabel = (item: VoiceUnresolvedItem, code: string): string => {
if (item.reason === 'arch_not_spoken') return code === 'upper' ? t('upperArch') : t('lowerArch');
if (item.reason === 'prosthesis_type_ambiguous') return labelFor(code, prosthesisCatalog);
return code;
};
const promptFor = (item: VoiceUnresolvedItem): string | null => {
if (item.reason === 'arch_not_spoken') return t('voicePickJaw');
if (item.reason === 'tooth_missing_quadrant') return t('voicePickTooth');
return null;
};
const toggle = (key: keyof VoiceApplySelection) => (checked: boolean) =>
setSelection((prev) => ({ ...prev, [key]: checked }));
@@ -104,10 +219,6 @@ export function VoiceReviewSheet({
{t('voiceReviewTitle')}
</h2>
<p className="mt-2 rounded-[var(--radius-md)] bg-background-card/60 px-3 py-2 text-sm text-text-secondary">
{effective.transcript}
</p>
{nothingToApply ? (
<p className="mt-4 text-sm text-text-secondary">{t('voiceNothingExtracted')}</p>
) : (
@@ -115,8 +226,10 @@ export function VoiceReviewSheet({
{available.treatmentType ? (
<Row
label={t('treatmentType')}
checked={selection.treatmentType}
checked={selection.treatmentType || typeForcedByProsthesis}
onChange={toggle('treatmentType')}
locked={typeForcedByProsthesis}
warning={typeForcedByProsthesis ? t('voiceTypeForcedByProsthesis') : undefined}
>
<span className="text-sm text-text-primary">
{labelFor(effective.treatmentType, treatmentCatalog)}
@@ -138,6 +251,59 @@ export function VoiceReviewSheet({
</Row>
) : null}
{available.prosthesis ? (
<Row
label={t('voiceTeethAndProsthesis')}
checked={selection.prosthesis}
onChange={toggle('prosthesis')}
>
<div className="mt-1">
<FdiToothChart
readOnly
compact
scale={0.55}
selected={chartData.selectedTeeth}
crownColors={chartData.crownColors}
rootColors={chartData.rootColors}
archHighlight={chartData.archHighlight}
/>
</div>
<p className="mt-1 text-sm text-text-primary">
{prosthesisLines.map((line, i) => (
<span key={`line-${line.target}`}>
{i > 0 ? ' · ' : ''}
{targetLabel(line.target)}:{' '}
{line.applied.map((code, j) => (
<span key={code}>
{j > 0 ? ' + ' : ''}
{labelFor(code, prosthesisCatalog)}
</span>
))}
{line.refused.map((code) => (
<span
key={code}
title={t('voiceStackRefused')}
className="text-text-muted line-through"
>
{' + '}
{labelFor(code, prosthesisCatalog)}
</span>
))}
</span>
))}
{joblessTargets.map((target, i) => (
<span
key={`jobless-${target}`}
className="text-text-muted line-through"
>
{prosthesisLines.length > 0 || i > 0 ? ' · ' : ''}
{targetLabel(target)}: {t('voiceNoProsthesisHeard')}
</span>
))}
</p>
</Row>
) : null}
{available.comment ? (
<Row
label={t('comments')}
@@ -150,29 +316,6 @@ export function VoiceReviewSheet({
</Row>
) : null}
{available.prosthesis && effective.prosthesis ? (
<Row
label={t('prosthesisColType')}
checked={selection.prosthesis}
onChange={toggle('prosthesis')}
warning={
effective.prosthesis.complete
? undefined
: t('voiceProsthesisIncomplete', {
teeth: formatToothList(effective.prosthesis.missingTeeth, locale),
})
}
>
<span className="text-sm text-text-primary">
{Object.entries(effective.prosthesis.byTooth)
.map(
([tooth, code]) => `${tooth}: ${labelFor(code, prosthesisCatalog)}`,
)
.join(' · ')}
</span>
</Row>
) : null}
{available.lab ? (
<Row
label={t('entryStepLab')}
@@ -212,23 +355,29 @@ export function VoiceReviewSheet({
{t(`voiceUnresolved.${item.reason}`)}
{item.candidates && item.candidates.length > 0 ? (
<span className="mt-1 flex flex-wrap items-center gap-1">
<span className="text-text-muted">{t('voicePickTooth')}</span>
{item.candidates.map((tooth) => {
const picked = chosen.includes(tooth as FdiToothId);
{promptFor(item) ? (
<span className="text-text-muted">{promptFor(item)}</span>
) : null}
{item.candidates.map((code) => {
const picked = isPicked(item, code);
return (
<button
key={tooth}
key={code}
type="button"
aria-pressed={picked}
aria-label={t('toothAria', { fdi: tooth })}
onClick={() => pickCandidate(tooth as FdiToothId)}
aria-label={
item.reason === 'tooth_missing_quadrant'
? t('toothAria', { fdi: code })
: candidateLabel(item, code)
}
onClick={() => pickCandidate(item, code)}
className={`rounded-full border px-2 py-0.5 text-xs transition-colors ${
picked
? 'border-transparent bg-primary text-white'
: 'border-border text-text-primary hover:border-border-strong'
}`}
>
{tooth}
{candidateLabel(item, code)}
</button>
);
})}
@@ -248,7 +397,7 @@ export function VoiceReviewSheet({
type="button"
variant="primary"
disabled={selectedCount === 0}
onClick={() => onApply(selection, effective)}
onClick={() => onApply(effectiveSelection, effective)}
fullWidth
className="sm:w-auto"
>
@@ -265,17 +414,20 @@ function Row({
checked,
onChange,
warning,
locked,
children,
}: {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
warning?: string;
/** Ticked and not untickable — the value is implied by another row (decision 41). */
locked?: boolean;
children: React.ReactNode;
}) {
return (
<div className="rounded-[var(--radius-md)] border border-border/70 px-3 py-2">
<Checkbox checked={checked} onChange={onChange} label={label} />
<Checkbox checked={checked} onChange={onChange} label={label} disabled={locked} />
<div className="mt-1 ps-7 min-w-0">{children}</div>
{warning ? (
<p className="mt-1 ps-7 flex items-start gap-1 text-xs text-amber-700 dark:text-amber-400">
@@ -295,12 +447,3 @@ function civilDateToLocalDate(iso: string): Date {
const [year, month, day] = iso.split('-').map(Number);
return new Date(year, (month ?? 1) - 1, day ?? 1);
}
/** Locale-aware list separator — the Arabic comma is not correct in en or nl. */
function formatToothList(teeth: readonly string[], locale: string): string {
try {
return new Intl.ListFormat(locale, { style: 'short', type: 'unit' }).format([...teeth]);
} catch {
return teeth.join(', ');
}
}

View File

@@ -22,7 +22,13 @@ export function pickRecordingMimeType(): string | null {
for (const type of PREFERRED_MIME_TYPES) {
if (MediaRecorder.isTypeSupported(type)) return type;
}
return null;
// None of the preferred containers passed `isTypeSupported` — a Safari version whose check
// exists but answers false for a container it can still record (e.g. plain `audio/mp4`).
// The preference list is not a requirement: fall back to the "let the browser choose" hint
// rather than refusing outright. `onstop` derives the real container from
// `recorder.mimeType`, so this is only wrong when the browser genuinely cannot record at
// all — and `new MediaRecorder()` / `recorder.start()` throwing is handled at the call site.
return '';
}
/** `audio/webm;codecs=opus` → `webm`, which is what the API's `format` field wants. */

View File

@@ -6,10 +6,12 @@ export type VoiceUnresolvedReason =
| 'not_permanent_tooth'
| 'position_out_of_range'
| 'tooth_missing_quadrant'
| 'prosthesis_type_ambiguous'
| 'arch_not_spoken'
| 'code_not_valid_for_target'
| 'malformed'
| 'span_not_same_arch'
| 'unknown_catalog_code'
| 'tooth_not_selected'
| 'invalid_date';
export interface VoiceUnresolvedItem {
@@ -17,26 +19,42 @@ export interface VoiceUnresolvedItem {
spoken: string;
reason: VoiceUnresolvedReason;
/**
* FDI codes still consistent with what was heard, when a choice would settle it — the
* review sheet offers them as chips. Only `tooth_missing_quadrant` carries these.
* Values still consistent with what was heard, when a choice would settle it — the review
* sheet offers them as chips. FDI codes for `tooth_missing_quadrant`, leaf codes for
* `prosthesis_type_ambiguous`, `'upper'`/`'lower'` for `arch_not_spoken`.
*/
candidates?: string[];
/**
* Set only when this item came from resolving a `prosthesisAssignments` entry. A picked
* chip then inherits that assignment's `types` (or supplies the missing leaf to it) instead
* of resolving to a jobless tooth.
*/
assignmentIndex?: number;
}
export interface VoiceProsthesisResult {
byTooth: Record<string, string>;
/** False means the case cannot ship — every tooth needs a prosthesis type. */
complete: boolean;
missingTeeth: FdiToothId[];
/**
* One spoken instruction, resolved: these targets — FDI codes, or `'UA'`/`'LA'` jaw sentinels
* (`ARCH_TOOTH_UPPER`/`ARCH_TOOTH_LOWER` in `prosthesisTree.ts`) — get these leaf codes. Empty
* `types` means the target was named with no job at all (struck through in the sheet); empty
* `targets` with a `prosthesis_type_ambiguous` unresolved item at this index means the target
* is pending a material pick.
*/
export interface VoiceProsthesisAssignment {
targets: string[];
types: string[];
spoken: string;
}
export interface VoiceExtractionResult {
transcript: string;
/**
* No transcript. A raw dictation can carry the patient's spoken name, so the server never
* sends it — it is logged there instead (spec §10).
*/
treatmentType: string | null;
teeth: FdiToothId[];
toothSelectionGroups: ToothSelectionGroup[];
comment: string | null;
prosthesis: VoiceProsthesisResult | null;
prosthesisAssignments: VoiceProsthesisAssignment[];
labId: string | null;
/** When false, the lab row must not tick itself — the name only approximately matched. */
labMatchExact: boolean;

15
frontend/vitest.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import path from 'node:path';
import { defineConfig } from 'vitest/config';
/**
* Covers the pure helpers only — no React, no DOM. `@/*` mirrors `tsconfig.json`'s path so a
* spec can import the same modules the app does.
*/
export default defineConfig({
resolve: {
alias: { '@': path.resolve(__dirname, 'src') },
},
test: {
include: ['src/**/*.spec.ts'],
},
});