From 648e8ed1f2314c030bc2a6975cc1ba86b01bd9a9 Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Thu, 20 Aug 2026 16:51:57 +0330 Subject: [PATCH 01/32] docs: spec for voice-driven treatment detail entry Design spec for filling a TreatmentDetail by voice, settled across three grilling sessions (30 decisions, logged in the spec). Key shape: - two-stage pipeline: OpenRouter whisper-1 -> gemini-3.7-flash - the LLM emits *intents*, never FDI codes or ISO dates; pure Jest-tested backend resolvers own quadrant mapping and Jalali conversion - provider registry keyed by locale so fa can diverge from en/nl - review sheet confirms before anything touches the form - audio and transcripts are never persisted Co-Authored-By: Claude Opus 5 (1M context) --- docs/specs/voice-treatment-entry/spec.md | 709 +++++++++++++++++++++++ 1 file changed, 709 insertions(+) create mode 100644 docs/specs/voice-treatment-entry/spec.md diff --git a/docs/specs/voice-treatment-entry/spec.md b/docs/specs/voice-treatment-entry/spec.md new file mode 100644 index 0000000..8bbca52 --- /dev/null +++ b/docs/specs/voice-treatment-entry/spec.md @@ -0,0 +1,709 @@ +# Voice treatment entry + +**Status:** Draft — not started +**Area:** Treatment workspace (CLINIC orgs) +**Created:** 2026-08-20 + +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 +picker. + +--- + +## 1. Goal + +A clinician on the Treatment tab taps a microphone, describes the treatment for the +already-selected patient in one utterance, and is shown a **review sheet** of what was +understood. Fields they tick are applied to the open detail chip. Nothing is written to +the form without confirmation. + +### In scope + +One recording produces **exactly one** `TreatmentDetail`, and may fill every field of it: + +| Field | Source | +|---|---| +| `treatmentType` | catalog code, matched against locale labels | +| `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: `destinationOrganizationId` | matched against the clinic's linked labs | +| lab: `dueDate` | via due-date intent resolver | + +### Out of scope (v1) + +- Multiple detail chips from one recording. +- `attachmentIds` — files cannot be dictated. +- Editing an existing detail by voice ("no, make that 15"). Confirming a recording always + creates a **new** detail (see §2). +- Creating the treatment or selecting the patient by voice. A patient is already + selected; voice only fills the form. +- Lab-side (`LAB` org) usage. Clinic only. + +--- + +## 2. User flow and UI integration + +### The control: Add detail, split + +The `Add detail` button gains a second segment holding the mic. The halves read as +siblings — both end in a new detail — but they are **independent actions**: + +- **Add half — unchanged.** Same `onAddDetail`, same seeding, same `setEntryStep`. It + gains a neighbour and nothing else. Its logic is not modified, wrapped or made + conditional. +- **Mic half** — starts a recording. Nothing is created until confirm (below). + +The `Add detail` ` + {voice ? ( + + ) : ( + + )} + {voice ? : null} +
{details.map((d, idx) => { const detailLocked = isDetailLocked(d); @@ -312,3 +334,82 @@ function NotesField({ ); } + +/** + * "Add detail", split into two segments with the microphone at the logical end. + * + * Built like the detail chip's trash affordance in this same file — an + * `inline-flex items-stretch overflow-hidden rounded` wrapper holding two raw ` + +
+ ); +} diff --git a/frontend/src/components/ui/treatment/VoiceRecordingBar.tsx b/frontend/src/components/ui/treatment/VoiceRecordingBar.tsx new file mode 100644 index 0000000..30ad007 --- /dev/null +++ b/frontend/src/components/ui/treatment/VoiceRecordingBar.tsx @@ -0,0 +1,90 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { Loader2, X } from 'lucide-react'; +import type { VoiceCaptureState } from '@/lib/voice/useVoiceCapture'; + +const METER_BARS = 9; + +function formatElapsed(ms: number): string { + const totalSeconds = Math.floor(Math.max(0, ms) / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${String(seconds).padStart(2, '0')}`; +} + +/** + * Live recording / processing strip. + * + * Sits between the header row and the chip strip rather than inside the segmented + * control: the header is `sm:justify-between`, so growing the button mid-recording would + * shove the row on every start and every stop. + */ +export function VoiceRecordingBar({ voice }: { voice: VoiceCaptureState }) { + const t = useTranslations('treatment'); + + if (voice.phase === 'idle') return null; + + const isRecording = voice.phase === 'recording'; + + return ( +
+ {isRecording ? ( + <> + + + {formatElapsed(voice.elapsedMs)} + {voice.maxMs != null ? ( + / {formatElapsed(voice.maxMs)} + ) : null} + + + + ) : ( + <> + + {t('voiceProcessing')} + + )} + + +
+ ); +} + +/** Proves the microphone is actually hearing something — silence looks identical otherwise. */ +function LevelMeter({ level }: { level: number }) { + return ( + + {Array.from({ length: METER_BARS }, (_, index) => { + // Bars light up left to right as the level rises, with a floor so the meter never + // looks dead while a quiet voice is still being captured. + const threshold = (index + 1) / METER_BARS; + const active = level >= threshold * 0.9; + const height = active ? 30 + threshold * 70 : 20; + return ( + + ); + })} + + ); +} diff --git a/frontend/src/lib/voice/useVoiceCapture.ts b/frontend/src/lib/voice/useVoiceCapture.ts index 37574d7..40983e0 100644 --- a/frontend/src/lib/voice/useVoiceCapture.ts +++ b/frontend/src/lib/voice/useVoiceCapture.ts @@ -65,6 +65,12 @@ export function useVoiceCapture({ const cancelledRef = useRef(false); /** getUserMedia is async; without this a permission granted after unmount leaks the mic. */ const mountedRef = useRef(true); + /** + * Set synchronously on click. `phase` does not become 'recording' until getUserMedia + * resolves, so without this a second click during the permission prompt would start a + * second stream and orphan the first — mic indicator lit, interval leaked. + */ + const startingRef = useRef(false); const teardown = useCallback(() => { if (timerRef.current) { @@ -81,16 +87,21 @@ export function useVoiceCapture({ // Releasing the microphone on unmount matters: the browser shows a recording indicator // for as long as the track is live, and an orphaned one looks like the app is listening. - useEffect(() => () => { - mountedRef.current = false; - cancelledRef.current = true; - abortRef.current?.abort(); - try { - recorderRef.current?.stop(); - } catch { - // already stopped - } - teardown(); + useEffect(() => { + // Re-armed on every mount: React StrictMode runs mount → unmount → mount in dev, and + // a ref that is only ever set false would leave the hook permanently "unmounted". + mountedRef.current = true; + return () => { + mountedRef.current = false; + cancelledRef.current = true; + abortRef.current?.abort(); + try { + recorderRef.current?.stop(); + } catch { + // already stopped + } + teardown(); + }; }, [teardown]); const send = useCallback( @@ -133,71 +144,80 @@ export function useVoiceCapture({ }, [teardown]); const onStart = useCallback(() => { - if (phase !== 'idle') return; + if (phase !== 'idle' || startingRef.current) return; if (!isMediaRecorderSupported()) { onError(clientError('VOICE_MIC_DENIED')); return; } cancelledRef.current = false; + startingRef.current = true; + void (async () => { - let stream: MediaStream; try { - stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - } catch { - // Permission refused, or no input device. Never a server round-trip. - onError(clientError('VOICE_MIC_DENIED')); - return; - } - - if (!mountedRef.current) { - // Permission resolved after the component went away — release it immediately - // rather than leaving the browser's recording indicator lit. - stream.getTracks().forEach((track) => track.stop()); - return; - } - - const mimeType = pickRecordingMimeType(); - if (mimeType === null) { - stream.getTracks().forEach((track) => track.stop()); - onError(clientError('VOICE_MIC_DENIED')); - return; - } - - streamRef.current = stream; - chunksRef.current = []; - const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined); - recorderRef.current = recorder; - - recorder.ondataavailable = (event) => { - if (event.data.size > 0) chunksRef.current.push(event.data); - }; - recorder.onstop = () => { - const durationMs = Date.now() - startedAtRef.current; - const blob = new Blob(chunksRef.current, { type: recorder.mimeType || mimeType }); - teardown(); - if (cancelledRef.current || blob.size === 0) { - setPhase('idle'); - setElapsedMs(0); + let stream: MediaStream; + try { + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + } catch { + // Permission refused, or no input device. Never a server round-trip. + onError(clientError('VOICE_MIC_DENIED')); return; } - void send(blob, recorder.mimeType || mimeType || 'audio/webm', durationMs); - }; - attachLevelMeter(stream, audioContextRef, setLevel); + if (!mountedRef.current) { + // Permission resolved after the component went away — release it immediately + // rather than leaving the browser's recording indicator lit. + stream.getTracks().forEach((track) => track.stop()); + return; + } - startedAtRef.current = Date.now(); - recorder.start(); - setPhase('recording'); - setElapsedMs(0); + const mimeType = pickRecordingMimeType(); + if (mimeType === null) { + stream.getTracks().forEach((track) => track.stop()); + onError(clientError('VOICE_MIC_DENIED')); + return; + } - timerRef.current = setInterval(() => { - const elapsed = Date.now() - startedAtRef.current; - setElapsedMs(elapsed); - // Auto-stop proceeds to processing with what was captured; discarding two minutes - // of dictation because a timer expired would be the worst possible failure. - if (maxMs != null && elapsed >= maxMs) stop(); - }, LEVEL_POLL_MS); + streamRef.current = stream; + chunksRef.current = []; + const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined); + recorderRef.current = recorder; + + recorder.ondataavailable = (event) => { + if (event.data.size > 0) chunksRef.current.push(event.data); + }; + recorder.onstop = () => { + const durationMs = Date.now() - startedAtRef.current; + const blob = new Blob(chunksRef.current, { type: recorder.mimeType || mimeType }); + teardown(); + if (cancelledRef.current || blob.size === 0) { + setPhase('idle'); + setElapsedMs(0); + return; + } + // Prefer what the recorder actually produced, then the blob's own type. Old + // Safari accepts no mimeType hint, and defaulting to webm would mislabel its + // mp4/aac clips as something they are not. + void send(blob, recorder.mimeType || blob.type || mimeType || 'audio/webm', durationMs); + }; + + attachLevelMeter(stream, audioContextRef, setLevel); + + startedAtRef.current = Date.now(); + recorder.start(); + setPhase('recording'); + setElapsedMs(0); + + timerRef.current = setInterval(() => { + const elapsed = Date.now() - startedAtRef.current; + setElapsedMs(elapsed); + // Auto-stop proceeds to processing with what was captured; discarding two + // minutes of dictation because a timer expired would be the worst failure. + if (maxMs != null && elapsed >= maxMs) stop(); + }, LEVEL_POLL_MS); + } finally { + startingRef.current = false; + } })(); }, [maxMs, onError, phase, send, stop, teardown]); -- 2.53.0.windows.1 From 3f97940a1622f374babe0671e7b63d37c621b153 Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Thu, 20 Aug 2026 20:22:42 +0330 Subject: [PATCH 12/32] feat: wire voice entry into the treatment workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the feature reachable end to end: availability is fetched alongside the catalogs, the capture hook drives the segmented control, and confirming the review sheet appends a new detail. Confirm always appends — it never edits an existing detail and never calls onAddDetail. Ticked rows land on top of the seeded defaults, so unticking the type row leaves the appointment-purpose default rather than a blank. Lab-side rows ride on a lab case draft keyed by the detail's *client* id, so a brand-new unsaved detail can carry a lab, due date and per-tooth prosthesis map. Availability comes from the API rather than a NEXT_PUBLIC_* var, since those are baked in at build time; a failure fetching it degrades to no microphone rather than taking the treatment tab down. From review of this commit: - Unticking "teeth" while leaving "prosthesis" ticked attached prosthesis rows for teeth the detail does not contain. Nothing downstream filters them — assertCompleteToothProsthesisMap only checks detail-teeth ⊆ map, never the reverse — so they would have reached task generation as lab work for teeth nobody is treating. The map is now filtered to the detail's own teeth. - The microphone was gated on the URL locale while the server resolved everything from req.user.language. Those diverge (a bookmarked /fa/ URL, a language toggle whose save failed), which would transcribe Persian with an English hint and anchor "next Thursday" to a Monday week instead of a Saturday one — or 403 from a visibly-enabled button. The client now sends the locale the microphone was offered in, so the gate and the request agree by construction. Also fixed from the previous review: a civil YYYY-MM-DD date rendered a day early west of Greenwich (parsed as UTC midnight); the missing-teeth list hardcoded the Arabic comma for all locales; and voiceApply had no ICU plural, so the common single-field case read "Apply 1 fields". Co-Authored-By: Claude Opus 5 (1M context) --- backend/src/modules/voice/dto/voice.dto.ts | 14 + .../modules/voice/extraction.resolver.spec.ts | 3 +- .../src/modules/voice/extraction.resolver.ts | 8 +- backend/src/modules/voice/voice.controller.ts | 10 +- backend/src/modules/voice/voice.service.ts | 4 +- frontend/messages/en.json | 19 +- frontend/messages/fa.json | 19 +- frontend/messages/nl.json | 19 +- .../components/treatment/voiceReviewRows.ts | 61 +++++ .../ui/treatment/TreatmentWorkspace.tsx | 131 ++++++++- .../ui/treatment/VoiceReviewSheet.tsx | 258 ++++++++++++++++++ frontend/src/lib/api/voice.ts | 2 + frontend/src/lib/voice/useVoiceCapture.ts | 6 +- 13 files changed, 533 insertions(+), 21 deletions(-) create mode 100644 frontend/src/components/treatment/voiceReviewRows.ts create mode 100644 frontend/src/components/ui/treatment/VoiceReviewSheet.tsx diff --git a/backend/src/modules/voice/dto/voice.dto.ts b/backend/src/modules/voice/dto/voice.dto.ts index 2a5a32d..41b3b40 100644 --- a/backend/src/modules/voice/dto/voice.dto.ts +++ b/backend/src/modules/voice/dto/voice.dto.ts @@ -21,6 +21,9 @@ export const VOICE_AUDIO_FORMATS = [ export type VoiceAudioFormat = (typeof VOICE_AUDIO_FORMATS)[number]; +/** Locales the app ships; a profile still has to be configured for one to be usable. */ +export const VOICE_LOCALES = ['en', 'fa', 'nl'] as const; + export class ExtractVoiceDto { /** * Base64 audio, no data: prefix. Capped well above a 2-minute opus clip (~400 KB) but @@ -52,4 +55,15 @@ export class ExtractVoiceDto { @IsInt() @Min(0) durationMs: number; + + /** + * The locale the clinician is actually speaking, as the UI offered the microphone. + * + * Sent explicitly rather than read from `user.language`: the two can diverge (a + * bookmarked /fa/ URL, a language toggle whose save failed), and a mismatch would + * transcribe Persian with an English hint and anchor "next Thursday" to the wrong + * week start. Gating the button and resolving the request must agree by construction. + */ + @IsIn(VOICE_LOCALES) + locale: string; } diff --git a/backend/src/modules/voice/extraction.resolver.spec.ts b/backend/src/modules/voice/extraction.resolver.spec.ts index dc8f67c..b86f577 100644 --- a/backend/src/modules/voice/extraction.resolver.spec.ts +++ b/backend/src/modules/voice/extraction.resolver.spec.ts @@ -284,8 +284,9 @@ describe('resolveVoiceIntent', () => { it('reports a hallucinated lab rather than dropping it silently', () => { // A near-miss lab id must not look identical to "no lab was spoken". const result = resolveVoiceIntent({ ...base, labId: 'lab-elsewhere' }, CTX); + // The id is not what the clinician said — quoting it back shows them a raw UUID. expect(result.unresolved).toContainEqual({ - spoken: 'lab-elsewhere', + spoken: '', reason: 'unknown_catalog_code', }); }); diff --git a/backend/src/modules/voice/extraction.resolver.ts b/backend/src/modules/voice/extraction.resolver.ts index 96bf616..2d92f27 100644 --- a/backend/src/modules/voice/extraction.resolver.ts +++ b/backend/src/modules/voice/extraction.resolver.ts @@ -287,10 +287,10 @@ export function resolveVoiceIntent( // reported: a hallucinated lab must not look identical to "no lab was spoken". const labId = resolveCatalogCode(intent?.labId, ctx.linkedLabIds); if (intent?.labId != null && !labId) { - unresolved.push({ - spoken: String(intent.labId), - reason: 'unknown_catalog_code', - }); + // `spoken` means "what the clinician said". A rejected lab id is an opaque + // identifier the model invented, so quoting it back would put a raw UUID in front + // of the user; the reason alone carries the meaning. + unresolved.push({ spoken: '', reason: 'unknown_catalog_code' }); } return { diff --git a/backend/src/modules/voice/voice.controller.ts b/backend/src/modules/voice/voice.controller.ts index 9c63b5f..ae15a44 100644 --- a/backend/src/modules/voice/voice.controller.ts +++ b/backend/src/modules/voice/voice.controller.ts @@ -15,11 +15,7 @@ import { ExtractVoiceDto } from './dto/voice.dto'; import { VoiceThrottlerGuard } from './voice-throttler.guard'; import { VoiceService } from './voice.service'; -type VoiceRequestUser = { - id: string; - organizationId?: string; - language?: string | null; -}; +type VoiceRequestUser = { id: string; organizationId?: string }; @ApiTags('voice') @ApiBearerAuth('JWT-auth') @@ -60,10 +56,12 @@ export class VoiceController { if (!res.writableFinished) aborter.abort(); }); + // dto.locale, not req.user.language: the client sends the locale the microphone was + // actually offered in, so the ASR hint, catalog labels and week start all match it. const data = await this.voiceService.extract( req.user, dto, - req.user?.language ?? 'en', + dto.locale, aborter.signal, ); return { success: true, data }; diff --git a/backend/src/modules/voice/voice.service.ts b/backend/src/modules/voice/voice.service.ts index 79d1819..bfeb7e6 100644 --- a/backend/src/modules/voice/voice.service.ts +++ b/backend/src/modules/voice/voice.service.ts @@ -124,10 +124,12 @@ 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). - const catalog = await this.buildCatalog(organizationId, catalogLocale); 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. + const catalog = await this.buildCatalog(organizationId, catalogLocale); const result = await extraction.extract( transcript, catalog, diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 7465c24..fea502a 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -903,7 +903,24 @@ "voiceStart": "Record treatment", "voiceStop": "Stop recording", "voiceCancel": "Cancel", - "voiceProcessing": "Reading the recording…" + "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", + "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", + "malformed": "could not be read", + "span_not_same_arch": "a bridge cannot span both jaws", + "unknown_catalog_code": "not in this clinic’s 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." }, "organizations": { "loadingOrganization": "Loading organization...", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index bac7e91..bc80145 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -904,7 +904,24 @@ "voiceStart": "ثبت گفتاری درمان", "voiceStop": "توقف ضبط", "voiceCancel": "لغو", - "voiceProcessing": "در حال پردازش گفتار…" + "voiceProcessing": "در حال پردازش گفتار…", + "voiceReviewTitle": "بررسی آنچه دریافت شد", + "voiceNothingExtracted": "از این ضبط چیز قابل استفاده‌ای برداشت نشد.", + "voiceProsthesisIncomplete": "برای {teeth} نوع پروتز مشخص نشده — تا زمانی که همه دندان‌ها نوع داشته باشند، کیس ارسال نمی‌شود.", + "voiceLabInexact": "نام گفته‌شده فقط تا حدی با این لابراتوار مطابقت داشت. پیش از ارسال تأیید کنید.", + "voiceNotUnderstood": "شناسایی نشد", + "voiceDiscard": "انصراف", + "voiceApply": "{count, plural, one {اعمال # مورد} other {اعمال # مورد}}", + "voiceUnresolved": { + "not_permanent_tooth": "دندان دائمی نیست", + "position_out_of_range": "شماره دندان معتبر نیست", + "malformed": "قابل خواندن نبود", + "span_not_same_arch": "بریج نمی‌تواند بین دو فک باشد", + "unknown_catalog_code": "در فهرست این مطب نیست", + "tooth_not_selected": "این دندان بخشی از این مورد نیست", + "invalid_date": "تاریخ قابل استفاده نیست" + }, + "voiceFailed": "ثبت گفتاری انجام نشد. لطفاً دوباره تلاش کنید." }, "organizations": { "loadingOrganization": "در حال بارگذاری سازمان...", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 1839a3f..d2e87e6 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -903,7 +903,24 @@ "voiceStart": "Behandeling inspreken", "voiceStop": "Opname stoppen", "voiceCancel": "Annuleren", - "voiceProcessing": "Opname wordt gelezen…" + "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", + "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", + "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." }, "organizations": { "loadingOrganization": "Organisatie laden...", diff --git a/frontend/src/components/treatment/voiceReviewRows.ts b/frontend/src/components/treatment/voiceReviewRows.ts new file mode 100644 index 0000000..f67537c --- /dev/null +++ b/frontend/src/components/treatment/voiceReviewRows.ts @@ -0,0 +1,61 @@ +import type { FdiToothId } from '@/types/treatment'; +import type { VoiceApplySelection, VoiceExtractionResult } from '@/types/voice'; + +/** Which rows the review sheet renders at all — a row with nothing extracted is noise. */ +export function voiceRowAvailability(result: VoiceExtractionResult) { + return { + treatmentType: result.treatmentType != null, + teeth: result.teeth.length > 0, + comment: Boolean(result.comment?.trim()), + prosthesis: result.prosthesis != null, + lab: result.labId != null, + dueDate: result.dueDate != null, + }; +} + +/** + * Which rows start ticked. + * + * Everything available ticks itself, with two deliberate exceptions: + * + * - **lab, when the name only approximately matched.** Shipping a case to a lab is the one + * extracted value whose error leaves the building, so it always requires a deliberate tick. + * - **prosthesis, when the map is incomplete.** A prosthesis detail with an untyped tooth + * cannot ship at all, so applying it would just move the failure to dispatch. + */ +export function initialVoiceSelection(result: VoiceExtractionResult): VoiceApplySelection { + const available = voiceRowAvailability(result); + return { + treatmentType: available.treatmentType, + teeth: available.teeth, + comment: available.comment, + prosthesis: available.prosthesis && result.prosthesis?.complete === true, + lab: available.lab && result.labMatchExact, + dueDate: available.dueDate, + }; +} + +/** How many rows will actually be applied — drives the confirm button's label. */ +export function countSelected(selection: VoiceApplySelection): number { + return Object.values(selection).filter(Boolean).length; +} + +/** Teeth that are part of a bridge, for the read-only chart's connection marks. */ +export function connectedTeethFromResult(result: VoiceExtractionResult): Set { + const connected = new Set(); + for (const group of result.toothSelectionGroups) { + if (group.kind !== 'connected') continue; + for (const tooth of group.teeth) connected.add(tooth); + } + return connected; +} + +/** + * Whether the sheet has anything worth showing. + * + * A recording that produced nothing usable should say so plainly rather than present an + * empty form of checkboxes. + */ +export function hasAnythingToApply(result: VoiceExtractionResult): boolean { + return Object.values(voiceRowAvailability(result)).some(Boolean); +} diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index 0d22bba..1f3faee 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -29,6 +29,14 @@ import { import { appointmentsApi } from '@/lib/api/appointments'; 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 { VoiceReviewSheet } from '@/components/ui/treatment/VoiceReviewSheet'; +import type { + VoiceApplySelection, + VoiceAvailability, + VoiceExtractionResult, +} from '@/types/voice'; import { treatmentsApi } from '@/lib/api/treatments'; import { notificationsApi } from '@/lib/api/notifications'; import { pickAutoAppointment } from '@/components/shared/treatmentSelection'; @@ -464,6 +472,11 @@ export function TreatmentWorkspace({ const [showWholeTreatmentPlan, setShowWholeTreatmentPlan] = useState(false); const [entryStep, setEntryStep] = useState('treatment'); + + const [voiceAvailability, setVoiceAvailability] = useState(null); + const [voiceResult, setVoiceResult] = useState(null); + + const isDetailLocked = useCallback( (detail: TreatmentDetailDraft) => labCaseDrafts.some((lc) => lc.sentAt && lc.detailClientId === detail.clientId), @@ -489,6 +502,97 @@ export function TreatmentWorkspace({ [appointments, selectedAppointmentId], ); + /** + * Voice entry. + * + * Confirm always appends a NEW detail — it never edits an existing one, and never + * touches onAddDetail. Nothing is created until this runs, so cancelling or a failed + * recording leaves the chip strip untouched. + */ + const applyVoiceResult = useCallback( + (result: VoiceExtractionResult, selection: VoiceApplySelection) => { + const detail = newDetail( + defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog), + ); + + // Ticked rows land on top of the seeded defaults, so unticking the type row leaves + // the appointment-purpose default rather than a blank. + if (selection.treatmentType && result.treatmentType) { + detail.treatmentType = result.treatmentType; + } + if (selection.teeth) { + detail.teeth = [...result.teeth]; + detail.toothSelectionGroups = result.toothSelectionGroups.map((group) => ({ + ...group, + teeth: [...group.teeth], + })); + } + if (selection.comment && result.comment) { + detail.comment = result.comment; + } + + setDetails((prev) => [...prev, detail]); + setActiveDetailId(detail.clientId); + setEntryStep('treatment'); + + // 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) || + (selection.lab && result.labId) || + (selection.dueDate && result.dueDate); + + if (wantsLabDraft) { + const draft = newLabCaseDraft(); + draft.detailClientId = detail.clientId; + if (selection.lab && result.labId) { + draft.destinationOrganizationId = result.labId; + } + 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(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), + })); + } + setLabCaseDrafts((prev) => [...prev, draft]); + } + + setVoiceResult(null); + }, + [selectedAppointment?.purpose, treatmentCatalog], + ); + + const voice = useVoiceCapture({ + // The locale the clinician is actually reading and speaking in. Sent explicitly so + // the server's ASR hint, catalog labels and week start match what the microphone was + // offered for — req.user.language can drift from the URL locale. + locale, + maxMs: voiceAvailability?.maxRecordingMs ?? null, + onExtracted: setVoiceResult, + onError: (error) => showError(getUserFacingError(error, tErrors, t('voiceFailed'))), + }); + + /** Absence is the unavailable state — the Add button then renders unsplit. */ + const voiceForEditor = + voiceAvailability?.enabled && voiceAvailability.locales.includes(locale) ? voice : undefined; + const selectedStandalone = useMemo( () => standaloneTreatments.find((t) => t.id === selectedStandaloneId) ?? null, [standaloneTreatments, selectedStandaloneId], @@ -927,12 +1031,18 @@ export function TreatmentWorkspace({ let cancelled = false; void (async () => { try { - const [orgsResponse, catalogResponse, prosthesisResponse] = await Promise.all([ - treatmentsApi.listLinkedOrganizations(), - treatmentCatalogApi.list(), - prosthesisCatalogApi.list(), - ]); + const [orgsResponse, catalogResponse, prosthesisResponse, voiceResponse] = + await Promise.all([ + treatmentsApi.listLinkedOrganizations(), + treatmentCatalogApi.list(), + prosthesisCatalogApi.list(), + // Voice availability comes from the API, not a NEXT_PUBLIC_* var: those are + // baked in at build time, so enabling a locale would need a frontend rebuild. + // A failure here must not take the whole treatment tab down with it. + voiceApi.availability().catch(() => null), + ]); if (cancelled) return; + setVoiceAvailability(voiceResponse?.data ?? null); setOrgs(orgsResponse.data); setTreatmentCatalog(catalogResponse.data); setProsthesisCatalog(prosthesisResponse.data); @@ -2478,6 +2588,7 @@ export function TreatmentWorkspace({ }} showChrome showFields={entryStep === 'treatment'} + voice={voiceForEditor} chartLocked={ entryStep === 'treatment' && !activeTypeSelected && !showWholeTreatmentPlan } @@ -2725,6 +2836,16 @@ export function TreatmentWorkspace({ )} + {voiceResult ? ( + applyVoiceResult(voiceResult, selection)} + onDiscard={() => setVoiceResult(null)} + /> + ) : null} ); } diff --git a/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx b/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx new file mode 100644 index 0000000..62371c6 --- /dev/null +++ b/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx @@ -0,0 +1,258 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { AlertTriangle } from 'lucide-react'; +import { Button } from '@/components/ui/shared/Button'; +import { Checkbox } from '@/components/ui/shared/Checkbox'; +import { + ResponsiveDialogOverlay, + ResponsiveDialogPanel, +} from '@/components/ui/shared/ResponsiveDialog'; +import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart'; +import { + connectedTeethFromResult, + countSelected, + hasAnythingToApply, + initialVoiceSelection, + voiceRowAvailability, +} 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 { LinkedOrganizationOption } from '@/types/treatment'; +import type { VoiceApplySelection, VoiceExtractionResult } from '@/types/voice'; + +interface VoiceReviewSheetProps { + result: VoiceExtractionResult; + treatmentCatalog: TreatmentCatalogEntry[]; + prosthesisCatalog: ProsthesisCatalogEntry[]; + labs: LinkedOrganizationOption[]; + onApply: (selection: VoiceApplySelection) => void; + onDiscard: () => void; +} + +/** + * Confirmation step between the model's output and the form. + * + * Modal on desktop, bottom sheet on mobile via ResponsiveDialog — deliberately an overlay + * and not a route, because navigating would unmount TreatmentWorkspace and destroy the + * in-progress draft. + */ +export function VoiceReviewSheet({ + result, + treatmentCatalog, + prosthesisCatalog, + labs, + onApply, + onDiscard, +}: VoiceReviewSheetProps) { + const t = useTranslations('treatment'); + const locale = useLocale(); + const { formatDate } = useAppFormatters(); + const [selection, setSelection] = useState(() => + initialVoiceSelection(result), + ); + + const available = useMemo(() => voiceRowAvailability(result), [result]); + const connectedTeeth = useMemo(() => connectedTeethFromResult(result), [result]); + const selectedTeeth = useMemo(() => new Set(result.teeth), [result.teeth]); + const nothingToApply = !hasAnythingToApply(result); + const selectedCount = countSelected(selection); + + const labelFor = (code: string | null, catalog: { code: string; label: string }[]) => + catalog.find((entry) => entry.code === code)?.label ?? code ?? ''; + + const toggle = (key: keyof VoiceApplySelection) => (checked: boolean) => + setSelection((prev) => ({ ...prev, [key]: checked })); + + return ( + + +

+ {t('voiceReviewTitle')} +

+ +

+ {result.transcript} +

+ + {nothingToApply ? ( +

{t('voiceNothingExtracted')}

+ ) : ( +
+ {available.treatmentType ? ( + + + {labelFor(result.treatmentType, treatmentCatalog)} + + + ) : null} + + {available.teeth ? ( + +
+ +
+
+ ) : null} + + {available.comment ? ( + + + {result.comment} + + + ) : null} + + {available.prosthesis && result.prosthesis ? ( + + + {Object.entries(result.prosthesis.byTooth) + .map( + ([tooth, code]) => `${tooth}: ${labelFor(code, prosthesisCatalog)}`, + ) + .join(' · ')} + + + ) : null} + + {available.lab ? ( + + + {labs.find((lab) => lab.id === result.labId)?.name ?? result.labId} + + + ) : null} + + {available.dueDate && result.dueDate ? ( + + + {formatDate(civilDateToLocalDate(result.dueDate))} + + + ) : null} +
+ )} + + {result.unresolved.length > 0 ? ( +
+

+ {t('voiceNotUnderstood')} +

+
    + {result.unresolved.map((item, index) => ( +
  • + {item.spoken ? `“${item.spoken}” — ` : ''} + {t(`voiceUnresolved.${item.reason}`)} +
  • + ))} +
+
+ ) : null} + +
+ + +
+
+
+ ); +} + +function Row({ + label, + checked, + onChange, + warning, + children, +}: { + label: string; + checked: boolean; + onChange: (checked: boolean) => void; + warning?: string; + children: React.ReactNode; +}) { + return ( +
+ +
{children}
+ {warning ? ( +

+ + {warning} +

+ ) : null} +
+ ); +} + +/** + * A bare `YYYY-MM-DD` is a *civil* date, but `new Date('2025-10-17')` parses it as UTC + * midnight — which renders as the 16th for any viewer west of Greenwich. Build the date + * from its parts so it means the same day everywhere. + */ +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(', '); + } +} diff --git a/frontend/src/lib/api/voice.ts b/frontend/src/lib/api/voice.ts index a9563c3..456f4c4 100644 --- a/frontend/src/lib/api/voice.ts +++ b/frontend/src/lib/api/voice.ts @@ -8,6 +8,8 @@ export interface ExtractVoicePayload { /** IANA zone — the server derives "today" from it for relative deadlines. */ timeZone: string; durationMs: number; + /** Locale the clinician is speaking; the server uses it rather than the stored one. */ + locale: string; } export const voiceApi = { diff --git a/frontend/src/lib/voice/useVoiceCapture.ts b/frontend/src/lib/voice/useVoiceCapture.ts index 40983e0..5008fbf 100644 --- a/frontend/src/lib/voice/useVoiceCapture.ts +++ b/frontend/src/lib/voice/useVoiceCapture.ts @@ -12,6 +12,8 @@ import { } from './audioFormat'; export interface UseVoiceCaptureOptions { + /** The locale the clinician is speaking, sent so the server does not have to guess. */ + locale: string; /** null means uncapped; otherwise the recorder auto-stops here. */ maxMs: number | null; onExtracted: (result: VoiceExtractionResult) => void; @@ -46,6 +48,7 @@ function clientError(code: string): ApiError { * and receives only a `voice` prop, so MediaRecorder and the API call never enter ui/. */ export function useVoiceCapture({ + locale, maxMs, onExtracted, onError, @@ -117,6 +120,7 @@ export function useVoiceCapture({ format: mimeTypeToFormat(mimeType), timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, durationMs, + locale, }, controller.signal, ); @@ -131,7 +135,7 @@ export function useVoiceCapture({ setElapsedMs(0); } }, - [onExtracted, onError], + [locale, onExtracted, onError], ); const stop = useCallback(() => { -- 2.53.0.windows.1 From e330572ad602998c59ffc5a32d1c0e29b1c6dfcd Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Thu, 20 Aug 2026 20:24:08 +0330 Subject: [PATCH 13/32] docs: mark the voice spec implemented Implemented across 12 commits on feat/voice-treatment-entry. Still blocked on the Persian ASR spike before it is trustworthy in front of patients: nothing in the implementation compensates for a bad transcript. Co-Authored-By: Claude Opus 5 (1M context) --- docs/specs/voice-treatment-entry/spec.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/specs/voice-treatment-entry/spec.md b/docs/specs/voice-treatment-entry/spec.md index a1b7059..6e76713 100644 --- a/docs/specs/voice-treatment-entry/spec.md +++ b/docs/specs/voice-treatment-entry/spec.md @@ -1,6 +1,7 @@ # Voice treatment entry -**Status:** Draft — not started +**Status:** Implemented on `feat/voice-treatment-entry` — unreviewed, and 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 -- 2.53.0.windows.1 From afb30691cfc5e79145310ab47e59ae7360fcf7df Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Thu, 20 Aug 2026 22:12:16 +0330 Subject: [PATCH 14/32] fix(backend): restore the large-body limit on the voice route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /voice/extract returned 500 for any real recording. The threshold was exactly 100 kb — Express's body-parser default — which is about 20 seconds of audio, so the endpoint was unusable at its own 2-minute cap. The scoped parser was registered as a path-mounted json() stacked in front of a default one, which relied on two implicit behaviours: Express stripping the mount path, and body-parser skipping a request another parser had already handled. That coupling broke when the surrounding middleware order shifted, and it broke silently — the parser was still registered, just no longer the one that ran. Bisected by dumping the Express layer stack and confirming the raw error was `entity.too.large` with `limit: 102400`. Replaced with a single middleware that picks a parser by path. No mount-path stripping, no dependence on parser ordering. Extracted to common/body-parsers.ts so it is covered by a unit test rather than only reachable through main.ts, which createTestingModule never executes. The test is mutation-checked: forcing the default parser fails 2 of its 5 cases. It also pins that the larger limit does not leak app-wide, and that a merely similar path (/api/voice/extract/extra) does not get it. Verified against the compiled server: 300 kb now reaches /api/voice/extract, /api/auth/login still rejects it, and ordinary requests are unaffected. Co-Authored-By: Claude Opus 5 (1M context) --- backend/src/common/body-parsers.spec.ts | 87 +++++++++++++++++++++++++ backend/src/common/body-parsers.ts | 35 ++++++++++ backend/src/main.ts | 11 ++-- 3 files changed, 126 insertions(+), 7 deletions(-) create mode 100644 backend/src/common/body-parsers.spec.ts create mode 100644 backend/src/common/body-parsers.ts diff --git a/backend/src/common/body-parsers.spec.ts b/backend/src/common/body-parsers.spec.ts new file mode 100644 index 0000000..ecde076 --- /dev/null +++ b/backend/src/common/body-parsers.spec.ts @@ -0,0 +1,87 @@ +import express, { + type NextFunction, + type Request, + type Response, +} from 'express'; +import request from 'supertest'; +import { createJsonBodyParser, VOICE_EXTRACT_PATH } from './body-parsers'; + +/** + * Guards a bug that made the voice endpoint completely unusable while surfacing as a + * generic 500: the large-body limit stopped applying, so every real recording — anything + * past roughly 20 seconds of audio — was rejected by Express's 100 kb default. + */ + +type ProbeBody = { keys?: number; type?: string }; + +function buildApp(): express.Express { + const app = express(); + app.use(createJsonBodyParser()); + app.post('*splat', (req: Request, res: Response) => { + res.json({ keys: Object.keys((req.body ?? {}) as object).length }); + }); + // Surface body-parser's own error instead of Express's HTML default page. + app.use( + ( + err: { status?: number; type?: string }, + _req: Request, + res: Response, + next: NextFunction, + ) => { + if (res.headersSent) { + next(err); + return; + } + res.status(err.status ?? 500).json({ type: err.type }); + }, + ); + return app; +} + +const bodyOfKb = (kb: number) => ({ audio: 'A'.repeat(kb * 1024) }); + +describe('createJsonBodyParser', () => { + it('accepts a body far past the default limit on the voice route', async () => { + const res = await request(buildApp()) + .post(VOICE_EXTRACT_PATH) + .send(bodyOfKb(300)); + expect(res.status).toBe(200); + expect((res.body as ProbeBody).keys).toBe(1); + }); + + it('accepts a realistic worst-case recording', async () => { + // Two minutes of opus is well under 1 MB, but wav is far larger; 4 MB must pass. + const res = await request(buildApp()) + .post(VOICE_EXTRACT_PATH) + .send(bodyOfKb(4096)); + expect(res.status).toBe(200); + }); + + it('keeps the default limit on every other route', async () => { + // The larger limit must not leak app-wide as a side effect. + const res = await request(buildApp()) + .post('/api/auth/login') + .send(bodyOfKb(300)); + expect(res.status).toBe(413); + expect((res.body as ProbeBody).type).toBe('entity.too.large'); + }); + + it('still parses ordinary bodies on ordinary routes', async () => { + const res = await request(buildApp()) + .post('/api/auth/login') + .send({ email: 'a@b.c' }); + expect(res.status).toBe(200); + expect((res.body as ProbeBody).keys).toBe(1); + }); + + it('does not widen the limit for a path that merely looks similar', async () => { + for (const path of [ + '/api/voice/extract/extra', + '/api/voice', + '/voice/extract', + ]) { + const res = await request(buildApp()).post(path).send(bodyOfKb(300)); + expect(res.status).toBe(413); + } + }); +}); diff --git a/backend/src/common/body-parsers.ts b/backend/src/common/body-parsers.ts new file mode 100644 index 0000000..c845d91 --- /dev/null +++ b/backend/src/common/body-parsers.ts @@ -0,0 +1,35 @@ +import { + json, + type NextFunction, + type Request, + type RequestHandler, + type Response, +} from 'express'; + +/** The one route that accepts a large body, and how large. */ +export const VOICE_EXTRACT_PATH = '/api/voice/extract'; +export const VOICE_BODY_LIMIT = '10mb'; + +/** + * JSON body parsing for the whole app. + * + * Voice recordings are base64 JSON and pass Express's 100 kb default at roughly 20 seconds + * of audio, so that one route needs a larger limit while every other endpoint keeps the + * default — a large body should not become acceptable everywhere. + * + * Deliberately a single middleware that *chooses* a parser, rather than a path-mounted + * parser stacked in front of a default one. That arrangement relied on Express's + * mount-path stripping plus body-parser skipping an already-parsed request, and it + * silently stopped applying when the surrounding middleware order shifted — at which point + * the endpoint rejected every real recording with a 500. One explicit branch has no such + * coupling, and is covered by body-parsers.spec.ts. + */ +export function createJsonBodyParser(): RequestHandler { + const voiceParser = json({ limit: VOICE_BODY_LIMIT }); + const defaultParser = json(); + + return (req: Request, res: Response, next: NextFunction) => + req.path === VOICE_EXTRACT_PATH + ? voiceParser(req, res, next) + : defaultParser(req, res, next); +} diff --git a/backend/src/main.ts b/backend/src/main.ts index 7509b3a..0749bbd 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,7 +1,8 @@ // backend/src/main.ts import { NestFactory } from '@nestjs/core'; -import { json, urlencoded } from 'express'; +import { urlencoded } from 'express'; import { AppModule } from './app.module'; +import { createJsonBodyParser } from './common/body-parsers'; import { ValidationPipe } from '@nestjs/common'; import cookieParser from 'cookie-parser'; // 👈 Change this line! import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; @@ -29,12 +30,8 @@ async function bootstrap() { // reject a voice recording at its 100 kb default before any later middleware ran. const app = await NestFactory.create(AppModule, { bodyParser: false }); - // Voice recordings are base64 JSON and pass 100 kb at roughly 20 seconds of audio. - // Registered first and scoped to the one route: body-parser marks the request handled, - // so the default-limit parser below skips it and every other endpoint keeps the - // standard limit. - app.use('/api/voice/extract', json({ limit: '10mb' })); - app.use(json()); + // Voice needs a larger JSON limit than everything else; see body-parsers.ts. + app.use(createJsonBodyParser()); app.use(urlencoded({ extended: true })); app.useGlobalFilters(new HttpExceptionFilter()); -- 2.53.0.windows.1 From 1be735a7ab391c49027c0cc6c18c18045f105644 Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Fri, 21 Aug 2026 04:02:39 +0800 Subject: [PATCH 15/32] fix(voice): say the quadrant is missing instead of "could not be read" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "ترمیم برای دندون دو" set the treatment type but reported the tooth as unreadable. Nothing was misheard: position 2 arrived intact, with no quadrant, because none was spoken — four teeth carry position 2 and the resolver correctly refused to pick one. Only the label was wrong, and it sent the clinician looking for a transcription fault. Adds a tooth_missing_quadrant reason that names what is missing and shows how to say it ("دو بالا راست"), and tells the model explicitly to report a quadrant-less number with arch and side null rather than guessing. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/modules/voice/extraction.prompt.ts | 4 ++ .../voice/tooth-intent.resolver.spec.ts | 51 +++++++++++++++++++ .../modules/voice/tooth-intent.resolver.ts | 9 +++- backend/src/modules/voice/voice.types.ts | 2 + frontend/messages/en.json | 1 + frontend/messages/fa.json | 1 + frontend/messages/nl.json | 1 + frontend/src/types/voice.ts | 1 + 8 files changed, 69 insertions(+), 1 deletion(-) diff --git a/backend/src/modules/voice/extraction.prompt.ts b/backend/src/modules/voice/extraction.prompt.ts index 6c07a23..86a415f 100644 --- a/backend/src/modules/voice/extraction.prompt.ts +++ b/backend/src/modules/voice/extraction.prompt.ts @@ -7,6 +7,7 @@ const LOCALE_NOTES: Record = { '"شش بالا راست" = upper right six -> arch "upper", side "patient_right", position 6.', 'Digits may appear in Persian or Latin script. Two-digit FDI notation ("یک چهار") does', 'occur — use the "fdi" field only for that.', + 'A bare "دندون دو" carries no quadrant: report position 2 with arch and side null.', ].join(' '), nl: [ 'The clinician is speaking Dutch and uses FDI notation, which is standard in the', @@ -50,6 +51,9 @@ export function buildExtractionPrompt( ' what was heard.', '6. If you are unsure about a value, use null. A missing field is recoverable; a wrong', ' one is not.', + '7. A tooth number spoken WITHOUT a quadrant ("دندون دو", "tooth two") does not identify', + ' a tooth — four teeth carry that position. Still report it: set "position" and leave', + ' "arch" and "side" null. Never pick a quadrant that was not said.', '', localeNote, '', diff --git a/backend/src/modules/voice/tooth-intent.resolver.spec.ts b/backend/src/modules/voice/tooth-intent.resolver.spec.ts index b9cd7cf..efaa894 100644 --- a/backend/src/modules/voice/tooth-intent.resolver.spec.ts +++ b/backend/src/modules/voice/tooth-intent.resolver.spec.ts @@ -150,6 +150,57 @@ describe('resolveToothIntents', () => { } }); + it('says the quadrant is missing rather than blaming the words', () => { + // Regression: "ترمیم برای دندون دو" reported "could not be read", sending the + // clinician to look for a transcription fault. Position 2 was understood fine — + // what is missing is the quadrant, and four teeth carry position 2. + const bare = { + kind: 'positional', + arch: null, + side: null, + position: 2, + spoken: 'دندون دو', + } as unknown as ToothIntent; + + const result = resolveToothIntents([bare]); + expect(result.teeth).toEqual([]); + expect(result.unresolved).toEqual([ + { spoken: 'دندون دو', reason: 'tooth_missing_quadrant' }, + ]); + }); + + it('reports a missing quadrant for a half-specified tooth too', () => { + // "دو بالا" narrows it to 12 or 22 — still not one tooth, and still not our guess. + for (const half of [ + { arch: 'upper', side: null }, + { arch: null, side: 'patient_right' }, + ]) { + const result = resolveToothIntents([ + { + kind: 'positional', + ...half, + position: 2, + spoken: 'دو', + } as unknown as ToothIntent, + ]); + expect(result.unresolved[0].reason).toBe('tooth_missing_quadrant'); + } + }); + + it('still calls an out-of-range position out of range when the quadrant is missing', () => { + // Position wins: "nine" is wrong however completely it was said. + const result = resolveToothIntents([ + { + kind: 'positional', + arch: null, + side: null, + position: 9, + spoken: 'نه', + } as unknown as ToothIntent, + ]); + expect(result.unresolved[0].reason).toBe('position_out_of_range'); + }); + it('keeps unresolved items separate when the model omits the spoken span', () => { // Without `spoken` these are indistinguishable; collapsing them would hide a lost tooth. const result = resolveToothIntents([ diff --git a/backend/src/modules/voice/tooth-intent.resolver.ts b/backend/src/modules/voice/tooth-intent.resolver.ts index a0f8225..4c3d48d 100644 --- a/backend/src/modules/voice/tooth-intent.resolver.ts +++ b/backend/src/modules/voice/tooth-intent.resolver.ts @@ -55,7 +55,14 @@ function unresolvedReason(intent: ToothIntent): UnresolvedItem['reason'] { !Number.isInteger(intent.position) || intent.position < 1 || intent.position > 8; - return positionBad ? 'position_out_of_range' : 'malformed'; + if (positionBad) return 'position_out_of_range'; + // The position was understood, so the words were not the problem: the speaker never + // said which quadrant. "دندون دو" names four teeth at once, and telling the + // clinician it "could not be read" would send them looking for the wrong fault. + const archMissing = intent.arch !== 'upper' && intent.arch !== 'lower'; + const sideMissing = + intent.side !== 'patient_right' && intent.side !== 'patient_left'; + return archMissing || sideMissing ? 'tooth_missing_quadrant' : 'malformed'; } return 'malformed'; diff --git a/backend/src/modules/voice/voice.types.ts b/backend/src/modules/voice/voice.types.ts index fc6ef4a..7d3b3f3 100644 --- a/backend/src/modules/voice/voice.types.ts +++ b/backend/src/modules/voice/voice.types.ts @@ -65,6 +65,8 @@ export type VoiceIntent = { export type UnresolvedReason = | 'not_permanent_tooth' | 'position_out_of_range' + /** A position was understood but no quadrant was spoken — four teeth match. */ + | 'tooth_missing_quadrant' | 'malformed' | 'span_not_same_arch' | 'unknown_catalog_code' diff --git a/frontend/messages/en.json b/frontend/messages/en.json index fea502a..80c2403 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -914,6 +914,7 @@ "voiceUnresolved": { "not_permanent_tooth": "not a permanent tooth", "position_out_of_range": "not a valid tooth position", + "tooth_missing_quadrant": "quadrant not said — e.g. “upper right two”", "malformed": "could not be read", "span_not_same_arch": "a bridge cannot span both jaws", "unknown_catalog_code": "not in this clinic’s list", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index bc80145..d5ff320 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -915,6 +915,7 @@ "voiceUnresolved": { "not_permanent_tooth": "دندان دائمی نیست", "position_out_of_range": "شماره دندان معتبر نیست", + "tooth_missing_quadrant": "بالا/پایین و چپ/راست گفته نشد — مثلاً «دو بالا راست»", "malformed": "قابل خواندن نبود", "span_not_same_arch": "بریج نمی‌تواند بین دو فک باشد", "unknown_catalog_code": "در فهرست این مطب نیست", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index d2e87e6..a5d055d 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -914,6 +914,7 @@ "voiceUnresolved": { "not_permanent_tooth": "geen blijvend element", "position_out_of_range": "geen geldige elementpositie", + "tooth_missing_quadrant": "kwadrant niet genoemd — bijv. “rechtsboven twee”", "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", diff --git a/frontend/src/types/voice.ts b/frontend/src/types/voice.ts index 3360839..8488066 100644 --- a/frontend/src/types/voice.ts +++ b/frontend/src/types/voice.ts @@ -5,6 +5,7 @@ import type { FdiToothId, ToothSelectionGroup } from '@/types/treatment'; export type VoiceUnresolvedReason = | 'not_permanent_tooth' | 'position_out_of_range' + | 'tooth_missing_quadrant' | 'malformed' | 'span_not_same_arch' | 'unknown_catalog_code' -- 2.53.0.windows.1 From efff258910410ee58f6e34b6ae2b622133e132ec Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Fri, 21 Aug 2026 04:02:49 +0800 Subject: [PATCH 16/32] fix(backend): apply the large-body limit to every spelling Express routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit req.path was compared to the canonical '/api/voice/extract' only, but Express routes case-insensitively and ignores a trailing slash by default. '/api/voice/extract/' therefore reached the controller with the 100 kb parser, and 413'd every recording past ~20 seconds — a failure that reads as a broken microphone rather than a routing detail. Co-Authored-By: Claude Opus 5 (1M context) --- backend/src/common/body-parsers.spec.ts | 14 ++++++++++++++ backend/src/common/body-parsers.ts | 12 +++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/backend/src/common/body-parsers.spec.ts b/backend/src/common/body-parsers.spec.ts index ecde076..d64b859 100644 --- a/backend/src/common/body-parsers.spec.ts +++ b/backend/src/common/body-parsers.spec.ts @@ -74,6 +74,20 @@ describe('createJsonBodyParser', () => { expect((res.body as ProbeBody).keys).toBe(1); }); + it('widens the limit for the spellings Express itself accepts', async () => { + // Express routes case-insensitively and ignores a trailing slash by default, so these + // all reach the voice controller. Any of them taking the 100 kb parser would 413 a + // real recording and read as a broken microphone. + for (const path of [ + '/api/voice/extract/', + '/API/Voice/Extract', + '/api/Voice/extract/', + ]) { + const res = await request(buildApp()).post(path).send(bodyOfKb(300)); + expect(res.status).toBe(200); + } + }); + it('does not widen the limit for a path that merely looks similar', async () => { for (const path of [ '/api/voice/extract/extra', diff --git a/backend/src/common/body-parsers.ts b/backend/src/common/body-parsers.ts index c845d91..97278da 100644 --- a/backend/src/common/body-parsers.ts +++ b/backend/src/common/body-parsers.ts @@ -24,12 +24,22 @@ export const VOICE_BODY_LIMIT = '10mb'; * the endpoint rejected every real recording with a 500. One explicit branch has no such * coupling, and is covered by body-parsers.spec.ts. */ +/** + * Express routes case-insensitively and ignores a trailing slash unless configured + * otherwise, so `/API/Voice/Extract/` reaches the same controller. Matching only the + * canonical spelling would hand those requests the 100 kb parser and 413 every real + * recording — a failure that looks like a broken microphone, not a routing detail. + */ +function isVoiceExtractPath(path: string): boolean { + return path.toLowerCase().replace(/\/+$/, '') === VOICE_EXTRACT_PATH; +} + export function createJsonBodyParser(): RequestHandler { const voiceParser = json({ limit: VOICE_BODY_LIMIT }); const defaultParser = json(); return (req: Request, res: Response, next: NextFunction) => - req.path === VOICE_EXTRACT_PATH + isVoiceExtractPath(req.path) ? voiceParser(req, res, next) : defaultParser(req, res, next); } -- 2.53.0.windows.1 From c09698aea219704d69425f874d27e4c76ec2cb8a Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Fri, 21 Aug 2026 04:42:09 +0800 Subject: [PATCH 17/32] fix(backend): read a tooth code whatever script its digits are in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extraction model transcribes Persian speech, so it can hand back "۲۶" in Persian digits or "2 6" from a digit-by-digit dictation. Both were compared literally against /^[1-8][1-8]$/, missed, and fell through to the positional branch with no quadrant — where the tooth was reported as "not understood". The clinician loses a tooth and is told the words were the problem. normalizeFdiCode() now runs at both the branch choice and the final validation, so the two cannot disagree. toLatinDigits moves out of jalali.ts into common/digits.ts: it was exported but unused in production, and a tooth module reaching into the calendar module would read as an accident. Co-Authored-By: Claude Opus 5 (1M context) --- backend/src/common/digits.spec.ts | 31 +++++++++++++++++++ backend/src/common/digits.ts | 24 ++++++++++++++ backend/src/common/fdi.ts | 15 +++++++++ backend/src/common/jalali.spec.ts | 26 ---------------- backend/src/common/jalali.ts | 22 ------------- .../src/modules/voice/extraction.wire.spec.ts | 27 ++++++++++++++++ backend/src/modules/voice/extraction.wire.ts | 6 +++- .../voice/tooth-intent.resolver.spec.ts | 8 +++++ .../modules/voice/tooth-intent.resolver.ts | 10 +++--- 9 files changed, 115 insertions(+), 54 deletions(-) create mode 100644 backend/src/common/digits.spec.ts create mode 100644 backend/src/common/digits.ts diff --git a/backend/src/common/digits.spec.ts b/backend/src/common/digits.spec.ts new file mode 100644 index 0000000..01a834a --- /dev/null +++ b/backend/src/common/digits.spec.ts @@ -0,0 +1,31 @@ +import { toLatinDigits } from './digits'; + +/** + * Exercised by the voice pipeline on two untrusted inputs: spoken dates, and the tooth + * code the extraction model echoes back — a Persian-digit "۲۶" that fails to normalise + * costs the clinician a tooth, silently. + */ +describe('toLatinDigits', () => { + it('normalises Persian digits and leaves everything else alone', () => { + expect( + toLatinDigits('\u06F1\u06F4\u06F0\u06F4/\u06F0\u06F7/\u06F2\u06F5'), + ).toBe('1404/07/25'); + expect(toLatinDigits('1404/07/25')).toBe('1404/07/25'); + expect(toLatinDigits('\u062F\u0646\u062F\u0627\u0646 \u06F1\u06F4')).toBe( + '\u062F\u0646\u062F\u0627\u0646 14', + ); + }); + + it('also normalises the Arabic-Indic block, which ASR output can carry', () => { + // U+0660..U+0669, distinct code points from the Persian U+06F0..U+06F9 block. + expect( + toLatinDigits('\u0661\u0664\u0660\u0664/\u0660\u0667/\u0662\u0665'), + ).toBe('1404/07/25'); + }); + + it('normalises a transcript that mixes both blocks with ASCII', () => { + expect(toLatinDigits('\u06F1\u06F4 and \u0661\u0665 and 16')).toBe( + '14 and 15 and 16', + ); + }); +}); diff --git a/backend/src/common/digits.ts b/backend/src/common/digits.ts new file mode 100644 index 0000000..cb8663a --- /dev/null +++ b/backend/src/common/digits.ts @@ -0,0 +1,24 @@ +/** + * Persian (Extended Arabic-Indic, U+06F0–U+06F9) zero, and Arabic-Indic (U+0660–U+0669) + * zero. ASR output can carry either block, sometimes mixed with ASCII in one transcript. + */ +const PERSIAN_ZERO = 0x06f0; +const ARABIC_INDIC_ZERO = 0x0660; + +/** + * Normalise Persian and Arabic-Indic digits to ASCII. Non-digits pass through. + * + * Deliberately wider than the frontend original, which only handles the Persian block: + * this parses model/ASR output rather than keystrokes, so both blocks must be accepted + * or a spoken date or tooth number silently degrades to "unresolved". + * + * Lives on its own rather than inside jalali.ts because tooth codes need it too, and a + * tooth module reaching into the calendar module would read as an accident. + */ +export function toLatinDigits(value: string): string { + return value.replace(/[۰-۹٠-٩]/g, (ch) => { + const code = ch.charCodeAt(0); + const base = code >= PERSIAN_ZERO ? PERSIAN_ZERO : ARABIC_INDIC_ZERO; + return String(code - base); + }); +} diff --git a/backend/src/common/fdi.ts b/backend/src/common/fdi.ts index e60ba3c..192967d 100644 --- a/backend/src/common/fdi.ts +++ b/backend/src/common/fdi.ts @@ -6,6 +6,8 @@ * midline pairs (11–21, 41–31) are neighbours, exactly as the chart treats them. */ +import { toLatinDigits } from './digits'; + export type Arch = 'upper' | 'lower'; /** Which side of the *patient*, not of the screen. Quadrant 1 is the patient's upper right. */ @@ -65,6 +67,19 @@ export function isFdiTooth(value: unknown): value is string { return typeof value === 'string' && FDI_TOOTH_IDS.has(value); } +/** + * Clean up a tooth code the extraction model echoed back, before it is matched. + * + * The model is transcribing Persian speech, so it can hand back "۲۶" in Persian digits or + * "2 6" from a digit-by-digit dictation. Neither matches an FDI code literally, and a + * near-miss here does not fail loudly — the tooth quietly turns into "not understood". + * Returns '' for anything that is not a string. + */ +export function normalizeFdiCode(value: unknown): string { + if (typeof value !== 'string') return ''; + return toLatinDigits(value).replace(/\s+/g, ''); +} + function archOrder(tooth: string): readonly string[] | null { if ((FDI_UPPER_ARCH_ORDER as readonly string[]).includes(tooth)) return FDI_UPPER_ARCH_ORDER; diff --git a/backend/src/common/jalali.spec.ts b/backend/src/common/jalali.spec.ts index aeb7ec9..df1bb3c 100644 --- a/backend/src/common/jalali.spec.ts +++ b/backend/src/common/jalali.spec.ts @@ -5,7 +5,6 @@ import { jalaliDaysInMonth, jalaliToGregorian, jalaliToIsoDate, - toLatinDigits, } from './jalali'; describe('jalali calendar', () => { @@ -87,29 +86,4 @@ describe('jalali calendar', () => { expect(jalaliDaysInMonth(1404, 13)).toBe(0); }); }); - - describe('toLatinDigits', () => { - it('normalises Persian digits and leaves everything else alone', () => { - expect( - toLatinDigits('\u06F1\u06F4\u06F0\u06F4/\u06F0\u06F7/\u06F2\u06F5'), - ).toBe('1404/07/25'); - expect(toLatinDigits('1404/07/25')).toBe('1404/07/25'); - expect(toLatinDigits('\u062F\u0646\u062F\u0627\u0646 \u06F1\u06F4')).toBe( - '\u062F\u0646\u062F\u0627\u0646 14', - ); - }); - - it('also normalises the Arabic-Indic block, which ASR output can carry', () => { - // U+0660..U+0669, distinct code points from the Persian U+06F0..U+06F9 block. - expect( - toLatinDigits('\u0661\u0664\u0660\u0664/\u0660\u0667/\u0662\u0665'), - ).toBe('1404/07/25'); - }); - - it('normalises a transcript that mixes both blocks with ASCII', () => { - expect(toLatinDigits('\u06F1\u06F4 and \u0661\u0665 and 16')).toBe( - '14 and 15 and 16', - ); - }); - }); }); diff --git a/backend/src/common/jalali.ts b/backend/src/common/jalali.ts index 4f03a28..989cb6e 100644 --- a/backend/src/common/jalali.ts +++ b/backend/src/common/jalali.ts @@ -158,28 +158,6 @@ export function jalaliDaysInMonth(jy: number, jm: number): number { return isJalaliLeapYear(jy) ? 30 : 29; } -/** - * Persian (Extended Arabic-Indic, U+06F0–U+06F9) zero, and Arabic-Indic (U+0660–U+0669) - * zero. ASR output can carry either block, sometimes mixed with ASCII in one transcript. - */ -const PERSIAN_ZERO = 0x06f0; -const ARABIC_INDIC_ZERO = 0x0660; - -/** - * Normalise Persian and Arabic-Indic digits to ASCII. Non-digits pass through. - * - * Deliberately wider than the frontend original, which only handles the Persian block: - * this parses model/ASR output rather than keystrokes, so both blocks must be accepted - * or a spoken date silently degrades to "unresolved". - */ -export function toLatinDigits(value: string): string { - return value.replace(/[\u06F0-\u06F9\u0660-\u0669]/g, (ch) => { - const code = ch.charCodeAt(0); - const base = code >= PERSIAN_ZERO ? PERSIAN_ZERO : ARABIC_INDIC_ZERO; - return String(code - base); - }); -} - /** True when the triple is a real Jalali date inside the supported year range. */ export function isValidJalaliDate(jy: number, jm: number, jd: number): boolean { if (!Number.isInteger(jy) || !Number.isInteger(jm) || !Number.isInteger(jd)) { diff --git a/backend/src/modules/voice/extraction.wire.spec.ts b/backend/src/modules/voice/extraction.wire.spec.ts index 6d9efb9..f40bcc7 100644 --- a/backend/src/modules/voice/extraction.wire.spec.ts +++ b/backend/src/modules/voice/extraction.wire.spec.ts @@ -56,6 +56,33 @@ describe('VOICE_INTENT_JSON_SCHEMA', () => { }); describe('toVoiceIntent', () => { + it('takes the explicit branch for a code the model wrote in Persian digits', () => { + // The model is reading Persian text back, so "۲۶" and a digit-by-digit "2 6" both + // reach us. Matching only ASCII drops the tooth into the positional branch with no + // quadrant, where it is reported as unresolved — the clinician loses a tooth and is + // told the words were the problem. + for (const raw of ['\u06F2\u06F6', '2 6', ' 26 ', '\u0662\u0666']) { + const [tooth] = toVoiceIntent( + wire({ + teeth: [ + { + spoken: '\u0628\u06CC\u0633\u062A \u0648 \u0634\u0634', + fdi: raw, + arch: null, + side: null, + position: null, + }, + ], + }), + ).teeth; + expect(tooth).toEqual({ + kind: 'explicit', + fdi: '26', + spoken: '\u0628\u06CC\u0633\u062A \u0648 \u0634\u0634', + }); + } + }); + it('narrows a positional tooth', () => { const result = toVoiceIntent(wire({ teeth: [positionalTooth] })); expect(result.teeth[0]).toEqual({ diff --git a/backend/src/modules/voice/extraction.wire.ts b/backend/src/modules/voice/extraction.wire.ts index af9ac1c..b91aed1 100644 --- a/backend/src/modules/voice/extraction.wire.ts +++ b/backend/src/modules/voice/extraction.wire.ts @@ -1,3 +1,4 @@ +import { normalizeFdiCode } from '../../common/fdi'; import type { ConnectedSpanIntent, DueIntent, @@ -180,7 +181,10 @@ const FDI_SHAPE = /^[1-8][1-8]$/; function toToothIntent(wire: WireToothIntent | undefined | null): ToothIntent { const spoken = typeof wire?.spoken === 'string' ? wire.spoken : ''; - const fdi = typeof wire?.fdi === 'string' ? wire.fdi.trim() : ''; + // Persian digits and digit-by-digit dictation ("۲۶", "2 6") are FDI codes that do not + // match literally; without normalising first they fall through to the positional branch + // with no quadrant and are reported as unresolved. + const fdi = normalizeFdiCode(wire?.fdi); // Only take the explicit branch for something actually FDI-shaped. A model that emits // fdi:"6" alongside correct arch/side/position would otherwise lose the tooth entirely. if (FDI_SHAPE.test(fdi)) { diff --git a/backend/src/modules/voice/tooth-intent.resolver.spec.ts b/backend/src/modules/voice/tooth-intent.resolver.spec.ts index efaa894..6b95091 100644 --- a/backend/src/modules/voice/tooth-intent.resolver.spec.ts +++ b/backend/src/modules/voice/tooth-intent.resolver.spec.ts @@ -134,6 +134,14 @@ describe('resolveToothIntents', () => { } }); + it('reads a spoken number as its FDI code, digits in any script', () => { + // The product rule: the number the clinician says IS the tooth. 26 = quadrant 2 + // (patient's upper left) + position 6 = first molar. + for (const raw of ['26', ' 26 ', '2 6', '\u06F2\u06F6', '\u0662\u0666']) { + expect(resolveToothIntents([explicit(raw, 'x')]).teeth).toEqual(['26']); + } + }); + it('trims an explicit code, matching normalizeTeeth', () => { expect(resolveToothIntents([explicit(' 14 ', 'x')]).teeth).toEqual(['14']); }); diff --git a/backend/src/modules/voice/tooth-intent.resolver.ts b/backend/src/modules/voice/tooth-intent.resolver.ts index 4c3d48d..7b5e151 100644 --- a/backend/src/modules/voice/tooth-intent.resolver.ts +++ b/backend/src/modules/voice/tooth-intent.resolver.ts @@ -1,4 +1,4 @@ -import { isFdiTooth, toFdi } from '../../common/fdi'; +import { isFdiTooth, normalizeFdiCode, toFdi } from '../../common/fdi'; import type { ToothIntent, UnresolvedItem } from './voice.types'; export type ToothResolution = { @@ -9,10 +9,10 @@ export type ToothResolution = { /** Everything here parses untrusted model output, so nothing may throw. */ function normalizedFdi(intent: ToothIntent): string { - const raw = (intent as { fdi?: unknown }).fdi; - // Trimmed for parity with normalizeTeeth — '14 ' is tooth 14 through the treatment API - // and must not be "malformed" here. - return typeof raw === 'string' ? raw.trim() : ''; + // Same normalisation the wire layer used to pick this branch, so the two cannot + // disagree: '14 ' is tooth 14 through the treatment API and '۲۶' is tooth 26, and + // neither may be reported as malformed here. + return normalizeFdiCode((intent as { fdi?: unknown }).fdi); } /** -- 2.53.0.windows.1 From 52a7359b9a397aed7f197e8c3b40efa2a522820d Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Fri, 21 Aug 2026 04:43:19 +0800 Subject: [PATCH 18/32] feat(backend): read a spoken tooth number as its FDI code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prompt had this backwards. "Never output an FDI tooth code unless the speaker used FDI notation. Prefer arch + side + position" pushed the model to decompose speech into "upper / patient_right / six", so the clinician effectively had to *describe* every tooth. Saying "دندون بیست و شش" — the way a dentist actually dictates — was the unsupported path. FDI is what clinicians speak, so the prompt now teaches the notation instead of forbidding it: first digit = quadrant from the patient's own point of view, second digit = position from the midline. arch/side/position stays as the reading of a *described* tooth, where a single digit is a position and the quadrant comes from words. Two guards come with it, because bare numbers are now teeth: a single digit alone still refuses to guess a quadrant, and dates, counts and quantities are explicitly not teeth. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/modules/voice/extraction.prompt.ts | 49 ++++++++++++------- backend/src/modules/voice/extraction.wire.ts | 8 +-- 2 files changed, 35 insertions(+), 22 deletions(-) diff --git a/backend/src/modules/voice/extraction.prompt.ts b/backend/src/modules/voice/extraction.prompt.ts index 86a415f..0df6c22 100644 --- a/backend/src/modules/voice/extraction.prompt.ts +++ b/backend/src/modules/voice/extraction.prompt.ts @@ -3,21 +3,19 @@ import type { ExtractionCatalog } from './voice.providers'; /** Locale-specific guidance. Only the tooth vocabulary and numbering habits differ. */ const LOCALE_NOTES: Record = { fa: [ - 'The clinician is speaking Persian. Tooth references are usually quadrant-relative:', + 'The clinician is speaking Persian. A tooth number can be said as a whole number', + '("بیست و شش" = 26), digit by digit ("دو شش" = 26), or with a lead-in', + '("دندون شماره ۲۶"). 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.', - 'Digits may appear in Persian or Latin script. Two-digit FDI notation ("یک چهار") does', - 'occur — use the "fdi" field only for that.', - 'A bare "دندون دو" carries no quadrant: report position 2 with arch and side null.', ].join(' '), nl: [ - 'The clinician is speaking Dutch and uses FDI notation, which is standard in the', - 'Netherlands. "rechtsboven zes" = upper right six. A bare two-digit number is FDI.', + 'The clinician is speaking Dutch, where FDI is standard. "zesentwintig" and "26" are', + 'tooth 26. The descriptive form is "rechtsboven zes" = upper right six.', ].join(' '), en: [ - 'The clinician is speaking English. IMPORTANT: a bare two-digit number is ambiguous,', - 'because Universal numbering and FDI disagree ("tooth 14" is a different tooth in each).', - 'Set "fdi" ONLY when the speaker made the notation explicit (e.g. "FDI one four").', - 'Otherwise describe the tooth with arch/side/position, or leave it unresolved.', + '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".', ].join(' '), }; @@ -41,19 +39,32 @@ export function buildExtractionPrompt( '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.', - '2. Never output an FDI tooth code unless the speaker used FDI notation. Prefer', - ' arch + side + position.', - '3. "side" is always the PATIENT\'s side. The patient\'s upper right is quadrant 1. Never', + '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.", - '4. Never do calendar arithmetic. Report the deadline as it was said, using due.kind.', + '3. Never do calendar arithmetic. Report the deadline as it was said, using due.kind.', ' If no deadline was mentioned, use due.kind = "none".', - '5. Copy the exact spoken words for each tooth into "spoken", so the clinician can see', + '4. Copy the exact spoken words for each tooth into "spoken", so the clinician can see', ' what was heard.', - '6. If you are unsure about a value, use null. A missing field is recoverable; a wrong', + '5. If you are unsure about a value, use null. A missing field is recoverable; a wrong', ' one is not.', - '7. A tooth number spoken WITHOUT a quadrant ("دندون دو", "tooth two") does not identify', - ' a tooth — four teeth carry that position. Still report it: set "position" and leave', - ' "arch" and "side" null. Never pick a quadrant that was not said.', + '', + 'TOOTH NUMBERS', + 'A number the clinician says for a tooth IS that tooth\'s FDI code. Put it in "fdi" as', + 'two digits. FDI is built from the two digits:', + " first digit = quadrant, from the PATIENT's own point of view —", + ' 1 upper right, 2 upper left, 3 lower left, 4 lower right.', + ' (5-8 are those same four quadrants in primary/deciduous teeth.)', + ' second digit = position from the midline — 1 central incisor ... 8 third molar.', + 'So 26 is the upper left first molar, and 47 is the lower right second molar.', + '', + '- Use "arch" + "side" + "position" only when the tooth is DESCRIBED rather than', + ' numbered ("upper right six" -> arch "upper", side "patient_right", position 6).', + '- A single digit is a position, never an FDI code. If a single digit is said with no', + ' quadrant words at all, set "position" and leave "arch" and "side" null. Never pick a', + ' quadrant that was not said.', + '- If a number is given AND the quadrant is spelled out as well, still use "fdi".', + '- Not every number is a tooth. Dates, counts and quantities ("two teeth", "the 26th")', + ' are not teeth, and must never appear in the teeth list.', '', localeNote, '', diff --git a/backend/src/modules/voice/extraction.wire.ts b/backend/src/modules/voice/extraction.wire.ts index b91aed1..554b99d 100644 --- a/backend/src/modules/voice/extraction.wire.ts +++ b/backend/src/modules/voice/extraction.wire.ts @@ -21,7 +21,7 @@ import { WEEKDAYS } from './voice.types'; export type WireToothIntent = { spoken: string; - /** Two-digit FDI code, only when the speaker genuinely used FDI notation. */ + /** The two-digit FDI code the clinician spoke; null when the tooth was described. */ fdi: string | null; arch: 'upper' | 'lower' | null; side: 'patient_right' | 'patient_left' | null; @@ -66,7 +66,8 @@ const TOOTH_SCHEMA = { fdi: { type: ['string', 'null'], description: - 'Two-digit FDI code ONLY if the speaker used FDI notation. Otherwise null.', + '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.', }, arch: { type: ['string', 'null'], enum: ['upper', 'lower', null] }, side: { @@ -76,7 +77,8 @@ const TOOTH_SCHEMA = { }, position: { type: ['integer', 'null'], - description: '1 = central incisor … 8 = third molar.', + description: + 'Position from the midline: 1 = central incisor … 8 = third molar. Never an FDI code.', }, }, } as const; -- 2.53.0.windows.1 From 82fad4ac02b0b86befaff649c2df6bc4b5f8a553 Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Fri, 21 Aug 2026 04:44:20 +0800 Subject: [PATCH 19/32] feat(backend): offer the candidate teeth for an unspecified quadrant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tooth the resolver refuses to guess at is currently a dead end: the sheet says the quadrant was missing and the clinician has to leave and find the tooth on the chart. But the readings are enumerable — "دو" is one of four teeth, "دو بالا" one of two — so unresolved items now carry them. Narrowed by whatever was actually said, so this stays a choice offered to the clinician rather than a guess made for them. Only tooth_missing_quadrant carries candidates; a wrong position or a deciduous tooth has nothing to choose between. Co-Authored-By: Claude Opus 5 (1M context) --- .../voice/tooth-intent.resolver.spec.ts | 39 +++++++++++++- .../modules/voice/tooth-intent.resolver.ts | 51 +++++++++++++++++-- backend/src/modules/voice/voice.types.ts | 7 +++ 3 files changed, 92 insertions(+), 5 deletions(-) diff --git a/backend/src/modules/voice/tooth-intent.resolver.spec.ts b/backend/src/modules/voice/tooth-intent.resolver.spec.ts index 6b95091..f145309 100644 --- a/backend/src/modules/voice/tooth-intent.resolver.spec.ts +++ b/backend/src/modules/voice/tooth-intent.resolver.spec.ts @@ -173,10 +173,47 @@ describe('resolveToothIntents', () => { const result = resolveToothIntents([bare]); expect(result.teeth).toEqual([]); expect(result.unresolved).toEqual([ - { spoken: 'دندون دو', reason: 'tooth_missing_quadrant' }, + { + spoken: 'دندون دو', + reason: 'tooth_missing_quadrant', + // Every reading of "position 2", for the clinician to pick from. + candidates: ['12', '22', '32', '42'], + }, ]); }); + it('narrows the candidates by whatever the clinician did say', () => { + const half = (arch: string | null, side: string | null) => + resolveToothIntents([ + { + kind: 'positional', + arch, + side, + position: 2, + spoken: 'دو', + } as unknown as ToothIntent, + ]).unresolved[0].candidates; + + expect(half('upper', null)).toEqual(['12', '22']); + expect(half('lower', null)).toEqual(['32', '42']); + // Quadrant 1 is the patient's upper right, 4 the lower right. + expect(half(null, 'patient_right')).toEqual(['12', '42']); + expect(half(null, 'patient_left')).toEqual(['22', '32']); + }); + + it('offers no candidates for a reason a choice cannot settle', () => { + // Nothing to choose between when the position itself was wrong, or the tooth is + // deciduous — offering chips there would invent options. + for (const intent of [ + positional('upper', 'patient_right', 9, 'نه'), + explicit('51', 'شیری'), + ]) { + expect( + resolveToothIntents([intent]).unresolved[0].candidates, + ).toBeUndefined(); + } + }); + it('reports a missing quadrant for a half-specified tooth too', () => { // "دو بالا" narrows it to 12 or 22 — still not one tooth, and still not our guess. for (const half of [ diff --git a/backend/src/modules/voice/tooth-intent.resolver.ts b/backend/src/modules/voice/tooth-intent.resolver.ts index 7b5e151..f021f16 100644 --- a/backend/src/modules/voice/tooth-intent.resolver.ts +++ b/backend/src/modules/voice/tooth-intent.resolver.ts @@ -1,6 +1,15 @@ -import { isFdiTooth, normalizeFdiCode, toFdi } from '../../common/fdi'; +import { + isFdiTooth, + normalizeFdiCode, + toFdi, + type Arch, + type PatientSide, +} from '../../common/fdi'; import type { ToothIntent, UnresolvedItem } from './voice.types'; +const ARCHES: readonly Arch[] = ['upper', 'lower']; +const SIDES: readonly PatientSide[] = ['patient_right', 'patient_left']; + export type ToothResolution = { /** Unique FDI codes, sorted (matching normalizeTeeth's ordering). */ teeth: string[]; @@ -68,6 +77,32 @@ function unresolvedReason(intent: ToothIntent): UnresolvedItem['reason'] { return 'malformed'; } +/** + * The teeth still consistent with what *was* heard. + * + * Narrowed by whatever the clinician did say, so "دو" offers four and "دو بالا" offers + * two. This is not a guess — it is the full set of readings, handed to the clinician to + * choose from rather than picked on their behalf. + */ +function quadrantCandidates(intent: ToothIntent): string[] { + if (intent.kind !== 'positional') return []; + const arches = + intent.arch === 'upper' || intent.arch === 'lower' ? [intent.arch] : ARCHES; + const sides = + intent.side === 'patient_right' || intent.side === 'patient_left' + ? [intent.side] + : SIDES; + + const codes: string[] = []; + for (const arch of arches) { + for (const side of sides) { + const fdi = toFdi(arch, side, intent.position); + if (fdi) codes.push(fdi); + } + } + return codes.sort(); +} + function spokenOf(intent: ToothIntent): string { const spoken = (intent as { spoken?: unknown })?.spoken; return typeof spoken === 'string' && spoken.trim() ? spoken.trim() : ''; @@ -101,14 +136,22 @@ export function resolveToothIntents( } const reason = unresolvedReason(intent); const spoken = spokenOf(intent); + const candidates = + reason === 'tooth_missing_quadrant' ? quadrantCandidates(intent) : []; // Only dedupe items we can actually tell apart. Without `spoken`, two distinct lost - // references would collapse into one blank review row and a tooth would vanish. + // references would collapse into one blank review row and a tooth would vanish. The + // candidates are part of the identity: the same word with a different arch heard + // offers a different choice. if (spoken) { - const key = `${spoken}::${reason}`; + const key = `${spoken}::${reason}::${candidates.join(',')}`; if (seenUnresolved.has(key)) continue; seenUnresolved.add(key); } - unresolved.push({ spoken, reason }); + unresolved.push( + candidates.length > 0 + ? { spoken, reason, candidates } + : { spoken, reason }, + ); } return { teeth: [...teeth].sort(), unresolved }; diff --git a/backend/src/modules/voice/voice.types.ts b/backend/src/modules/voice/voice.types.ts index 7d3b3f3..d200c40 100644 --- a/backend/src/modules/voice/voice.types.ts +++ b/backend/src/modules/voice/voice.types.ts @@ -77,4 +77,11 @@ export type UnresolvedItem = { /** The transcript span that could not be resolved, so the user can see what was heard. */ spoken: string; reason: UnresolvedReason; + /** + * FDI codes still consistent with what was heard, when a choice would settle it. + * Only `tooth_missing_quadrant` carries these: "دو" leaves four teeth on the table, + * "دو بالا" leaves two. The review sheet offers them so an under-specified tooth is one + * tap from resolved rather than a dead end. + */ + candidates?: string[]; }; -- 2.53.0.windows.1 From 754efdee09a69efd0772748f29df5e89b7725711 Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Fri, 21 Aug 2026 04:54:31 +0800 Subject: [PATCH 20/32] feat(frontend): let the clinician pick the tooth from the candidates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An under-specified tooth was a dead end: the sheet said what was missing and the clinician had to leave and hunt for it on the chart. The readings are enumerable, so the review sheet now renders them as chips — the one interactive part of an otherwise read-only confirmation step. A pick is folded into the result by withChosenTeeth() rather than tracked alongside it, so the rows, the mini chart, the prosthesis warning and applyVoiceResult all keep reading a single VoiceExtractionResult and none of them has to know the chips exist. It unions rather than toggles: a candidate can coincidentally be a tooth the recording already produced, and tapping it must not deselect that one. Two things that would otherwise make the chips look functional while applying nothing: the teeth row is ticked on the first pick (it starts unticked when the recording produced no teeth of its own), and the apply count is now intersected with row availability so it cannot promise to apply a row with nothing in it. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/messages/en.json | 3 +- frontend/messages/fa.json | 3 +- frontend/messages/nl.json | 3 +- .../components/treatment/voiceReviewRows.ts | 61 +++++++++++++- .../ui/treatment/TreatmentWorkspace.tsx | 2 +- .../ui/treatment/VoiceReviewSheet.tsx | 82 ++++++++++++++----- frontend/src/types/voice.ts | 5 ++ 7 files changed, 130 insertions(+), 29 deletions(-) diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 80c2403..0c76d3b 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -909,12 +909,13 @@ "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?", "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": "quadrant not said — e.g. “upper right two”", + "tooth_missing_quadrant": "not a whole tooth number — say e.g. “twenty-six”", "malformed": "could not be read", "span_not_same_arch": "a bridge cannot span both jaws", "unknown_catalog_code": "not in this clinic’s list", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index d5ff320..f3ec8dd 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -910,12 +910,13 @@ "voiceProsthesisIncomplete": "برای {teeth} نوع پروتز مشخص نشده — تا زمانی که همه دندان‌ها نوع داشته باشند، کیس ارسال نمی‌شود.", "voiceLabInexact": "نام گفته‌شده فقط تا حدی با این لابراتوار مطابقت داشت. پیش از ارسال تأیید کنید.", "voiceNotUnderstood": "شناسایی نشد", + "voicePickTooth": "کدام دندان؟", "voiceDiscard": "انصراف", "voiceApply": "{count, plural, one {اعمال # مورد} other {اعمال # مورد}}", "voiceUnresolved": { "not_permanent_tooth": "دندان دائمی نیست", "position_out_of_range": "شماره دندان معتبر نیست", - "tooth_missing_quadrant": "بالا/پایین و چپ/راست گفته نشد — مثلاً «دو بالا راست»", + "tooth_missing_quadrant": "شماره کامل دندان نیست — مثلاً «بیست و شش»", "malformed": "قابل خواندن نبود", "span_not_same_arch": "بریج نمی‌تواند بین دو فک باشد", "unknown_catalog_code": "در فهرست این مطب نیست", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index a5d055d..bad8d12 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -909,12 +909,13 @@ "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?", "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": "kwadrant niet genoemd — bijv. “rechtsboven twee”", + "tooth_missing_quadrant": "geen volledig elementnummer — bijv. “zesentwintig”", "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", diff --git a/frontend/src/components/treatment/voiceReviewRows.ts b/frontend/src/components/treatment/voiceReviewRows.ts index f67537c..eda78d6 100644 --- a/frontend/src/components/treatment/voiceReviewRows.ts +++ b/frontend/src/components/treatment/voiceReviewRows.ts @@ -1,5 +1,10 @@ +import { groupsFromFlatTeeth } from '@/components/treatment/toothSelectionGroups'; import type { FdiToothId } from '@/types/treatment'; -import type { VoiceApplySelection, VoiceExtractionResult } from '@/types/voice'; +import type { + VoiceApplySelection, + VoiceExtractionResult, + VoiceProsthesisResult, +} from '@/types/voice'; /** Which rows the review sheet renders at all — a row with nothing extracted is noise. */ export function voiceRowAvailability(result: VoiceExtractionResult) { @@ -35,9 +40,57 @@ export function initialVoiceSelection(result: VoiceExtractionResult): VoiceApply }; } -/** How many rows will actually be applied — drives the confirm button's label. */ -export function countSelected(selection: VoiceApplySelection): number { - return Object.values(selection).filter(Boolean).length; +/** + * How many rows will actually be applied — drives the confirm button's label. + * + * Intersected with availability rather than counting ticks: a row can be ticked and then + * lose its content (the last candidate tooth un-picked), and "Apply 1 item" that applies + * nothing is worse than a wrong number. + */ +export function countSelected( + selection: VoiceApplySelection, + available: Record, +): number { + return (Object.keys(selection) as (keyof VoiceApplySelection)[]).filter( + (key) => selection[key] && available[key], + ).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 }; +} + +/** + * Fold the clinician's candidate picks into the extracted result. + * + * Everything downstream reads a `VoiceExtractionResult` — row availability, the mini + * chart, the prosthesis warning, `applyVoiceResult` — so resolving the picks into one here + * means none of them has to know the chips exist. + * + * Union rather than toggle, for two reasons: a candidate can coincidentally be a tooth the + * recording already produced ("۱۲ و دو"), where tapping it must not deselect that tooth; + * and `groupsFromFlatTeeth` keeps the bridges intact while giving every remaining tooth a + * single group, so no tooth can be lost on the way through. + */ +export function withChosenTeeth( + result: VoiceExtractionResult, + chosen: readonly FdiToothId[], +): VoiceExtractionResult { + if (chosen.length === 0) return result; + + 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, + }; } /** Teeth that are part of a bridge, for the read-only chart's connection marks. */ diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index 1f3faee..233bc71 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -2842,7 +2842,7 @@ export function TreatmentWorkspace({ treatmentCatalog={treatmentCatalog} prosthesisCatalog={prosthesisCatalog} labs={orgs} - onApply={(selection) => applyVoiceResult(voiceResult, selection)} + onApply={(selection, applied) => applyVoiceResult(applied, selection)} onDiscard={() => setVoiceResult(null)} /> ) : null} diff --git a/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx b/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx index 62371c6..e55e041 100644 --- a/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx +++ b/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx @@ -16,12 +16,13 @@ import { hasAnythingToApply, initialVoiceSelection, voiceRowAvailability, + 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 { LinkedOrganizationOption } from '@/types/treatment'; +import type { FdiToothId, LinkedOrganizationOption } from '@/types/treatment'; import type { VoiceApplySelection, VoiceExtractionResult } from '@/types/voice'; interface VoiceReviewSheetProps { @@ -29,7 +30,8 @@ interface VoiceReviewSheetProps { treatmentCatalog: TreatmentCatalogEntry[]; prosthesisCatalog: ProsthesisCatalogEntry[]; labs: LinkedOrganizationOption[]; - onApply: (selection: VoiceApplySelection) => void; + /** The result is handed back because the sheet may have added teeth the model missed. */ + onApply: (selection: VoiceApplySelection, result: VoiceExtractionResult) => void; onDiscard: () => void; } @@ -54,12 +56,26 @@ export function VoiceReviewSheet({ const [selection, setSelection] = useState(() => initialVoiceSelection(result), ); + const [chosen, setChosen] = useState([]); - const available = useMemo(() => voiceRowAvailability(result), [result]); - const connectedTeeth = useMemo(() => connectedTeethFromResult(result), [result]); - const selectedTeeth = useMemo(() => new Set(result.teeth), [result.teeth]); - const nothingToApply = !hasAnythingToApply(result); - const selectedCount = countSelected(selection); + // 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]); + + const available = useMemo(() => voiceRowAvailability(effective), [effective]); + 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 pickCandidate = (tooth: FdiToothId) => { + setChosen((prev) => + prev.includes(tooth) ? prev.filter((t) => t !== tooth) : [...prev, tooth], + ); + // The teeth row starts unticked whenever the recording produced no teeth of its own, + // and a picked tooth that is not ticked applies nothing. + setSelection((prev) => (prev.teeth ? prev : { ...prev, teeth: true })); + }; const labelFor = (code: string | null, catalog: { code: string; label: string }[]) => catalog.find((entry) => entry.code === code)?.label ?? code ?? ''; @@ -80,7 +96,7 @@ export function VoiceReviewSheet({

- {result.transcript} + {effective.transcript}

{nothingToApply ? ( @@ -94,7 +110,7 @@ export function VoiceReviewSheet({ onChange={toggle('treatmentType')} > - {labelFor(result.treatmentType, treatmentCatalog)} + {labelFor(effective.treatmentType, treatmentCatalog)} ) : null} @@ -120,26 +136,26 @@ export function VoiceReviewSheet({ onChange={toggle('comment')} > - {result.comment} + {effective.comment} ) : null} - {available.prosthesis && result.prosthesis ? ( + {available.prosthesis && effective.prosthesis ? ( - {Object.entries(result.prosthesis.byTooth) + {Object.entries(effective.prosthesis.byTooth) .map( ([tooth, code]) => `${tooth}: ${labelFor(code, prosthesisCatalog)}`, ) @@ -153,38 +169,62 @@ export function VoiceReviewSheet({ label={t('entryStepLab')} checked={selection.lab} onChange={toggle('lab')} - warning={result.labMatchExact ? undefined : t('voiceLabInexact')} + warning={effective.labMatchExact ? undefined : t('voiceLabInexact')} > - {labs.find((lab) => lab.id === result.labId)?.name ?? result.labId} + {labs.find((lab) => lab.id === effective.labId)?.name ?? effective.labId} ) : null} - {available.dueDate && result.dueDate ? ( + {available.dueDate && effective.dueDate ? ( - {formatDate(civilDateToLocalDate(result.dueDate))} + {formatDate(civilDateToLocalDate(effective.dueDate))} ) : null} )} - {result.unresolved.length > 0 ? ( + {effective.unresolved.length > 0 ? (

{t('voiceNotUnderstood')}

    - {result.unresolved.map((item, index) => ( + {effective.unresolved.map((item, index) => (
  • {item.spoken ? `“${item.spoken}” — ` : ''} {t(`voiceUnresolved.${item.reason}`)} + {item.candidates && item.candidates.length > 0 ? ( + + {t('voicePickTooth')} + {item.candidates.map((tooth) => { + const picked = chosen.includes(tooth as FdiToothId); + return ( + + ); + })} + + ) : null}
  • ))}
@@ -199,7 +239,7 @@ export function VoiceReviewSheet({ type="button" variant="primary" disabled={selectedCount === 0} - onClick={() => onApply(selection)} + onClick={() => onApply(selection, effective)} fullWidth className="sm:w-auto" > diff --git a/frontend/src/types/voice.ts b/frontend/src/types/voice.ts index 8488066..b77665d 100644 --- a/frontend/src/types/voice.ts +++ b/frontend/src/types/voice.ts @@ -16,6 +16,11 @@ export interface VoiceUnresolvedItem { /** The transcript span that could not be resolved, so the clinician sees what was heard. */ 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. + */ + candidates?: string[]; } export interface VoiceProsthesisResult { -- 2.53.0.windows.1 From b7ee61433ecd43d62cc4223e426c9af116db9048 Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Fri, 21 Aug 2026 04:54:31 +0800 Subject: [PATCH 21/32] docs: record the FDI-first tooth rule in the voice spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec's tooth section described the design that the first live test disproved — descriptive phrasing primary, bare numerals refused in en. Rewrites §6 around the rule the product actually wants, records the chip affordance in §7, and closes open item §11.5: a two-digit number is FDI in all three locales, with the Universal-numbering trade-off written down rather than left implied. Co-Authored-By: Claude Opus 5 (1M context) --- docs/specs/voice-treatment-entry/spec.md | 44 +++++++++++++++--------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/docs/specs/voice-treatment-entry/spec.md b/docs/specs/voice-treatment-entry/spec.md index 6e76713..70fd5e0 100644 --- a/docs/specs/voice-treatment-entry/spec.md +++ b/docs/specs/voice-treatment-entry/spec.md @@ -1,7 +1,8 @@ # Voice treatment entry -**Status:** Implemented on `feat/voice-treatment-entry` — unreviewed, and blocked on the -ASR spike (§11 item 1) before it is trustworthy in front of patients +**Status:** Implemented on `feat/voice-treatment-entry`. 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). +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 @@ -415,12 +416,20 @@ that justified this whole design. words, so the resolver needs no per-locale branches. The locale-specific part is the *prompt*: each enabled locale needs its own spoken tooth vocabulary (`شش بالا راست`, `upper right six`, `rechtsboven zes`). -- **English carries a numbering hazard the other locales do not.** A clinician trained - under Universal numbering says "tooth number 14" and means a different tooth than FDI - 14. `nl` is safe — the Netherlands uses FDI — but `en` is not. The `en` prompt must - therefore not accept a bare two-digit number as `explicitFdi` without the speaker - having made the notation explicit; ambiguous English numerals resolve to - **unresolved**. See §11. +- **A spoken tooth number is an FDI code, in every locale.** This is how clinicians + actually dictate — "بیست و شش" is tooth 26 — so the prompt *teaches* the notation + (first digit = quadrant from the patient's own point of view, second = position from + the midline) rather than refusing it. `arch`/`side`/`position` is the reading of a + tooth that was **described** instead of numbered, where a single digit is a position + and the quadrant comes from words. Revised after the first live test; the original + design had this backwards and made the descriptive form the only supported path. +- **A single digit alone is never resolved.** "دندون دو" names four teeth. It is reported + as `tooth_missing_quadrant` **with the candidate codes attached** — narrowed by whatever + *was* said, so "دو بالا" offers two — and the review sheet turns them into chips. The + clinician chooses; the resolver still never guesses. +- **Digits arrive in three scripts.** `normalizeFdiCode` (`common/fdi.ts`) folds Persian + and Arabic-Indic digits to ASCII and strips the spaces of a digit-by-digit dictation + before anything is matched, at both the wire branch choice and the final validation. ### `resolveDueDate()` @@ -472,6 +481,10 @@ that justified this whole design. - 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. +- 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`. @@ -607,12 +620,11 @@ enabling this for real clinics. per-request one: re-verify it if the API key or the OpenRouter account changes, and remember `whisper-1` is forwarded to OpenAI, so the effective policy is OpenRouter's plus that provider's. -5. **English tooth numbering is unresolved as a product question.** Enabling `en` means - deciding what "tooth number 14" means when the speaker's notation is unknown — - Universal or FDI. The spec's current answer is to refuse ambiguous bare numerals in - `en`, which is safe but will feel broken to a US-trained clinician. Options are: refuse - (current), an org-level notation preference, or restricting `en` to quadrant-relative - phrasing. Decide before `en` ships to a real clinic; `fa` and `nl` are unaffected. +5. ~~**English tooth numbering**~~ — **resolved:** a bare two-digit number is read as + **FDI in all three locales**. FDI is what the product is built on and what clinicians + dictate. Known trade-off, accepted: a clinician trained under Universal numbering says + "tooth 14" and means a different tooth, so an `en` clinic needs either training or a + later per-org notation setting. Revisit if a US clinic is onboarded. 6. **`nl` and `en` have no spike data.** The Persian spike (item 1) should be repeated per locale before that locale's mic is enabled for real users — same protocol, same scoring, different speaker. @@ -688,7 +700,7 @@ Settled in a grilling session on 2026-08-20. | 1 | Scope | Everything including lab dispatch | | 2 | AI supply chain | Domestic provider originally; OpenRouter for v1, registry keeps both open | | 3 | Apply model | Review sheet, then apply | -| 4 | Speech → FDI | LLM emits intent, code resolves | +| 4 | Speech → FDI | LLM emits intent, code resolves. A spoken number **is** the FDI code (revised 2026-08-21, §6) | | 5 | Cardinality | One detail per recording | | 6 | Lab destination | Closed list of linked labs, explicit confirm, unticked when inexact | | 7 | Due date | Intent + deterministic resolver | @@ -704,7 +716,7 @@ Settled in a grilling session on 2026-08-20. | 25 | Salvage target | Creates a new detail with only `comment` set — voice never writes into an existing detail | | 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 | +| 28 | Review sheet | Modal on desktop, full-screen overlay (not a route) on mobile; candidate chips are its only interactive part | | 29 | Cancel | Aborts the in-flight vendor call | | 30 | v1 gating | Open to everyone; `Plan.features` gate deferred, not dropped | | 15 | Gating | `Plan.features` flag — its first consumer | -- 2.53.0.windows.1 From 62deff0523d68935697dd813dc3690909b6de6ee Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Fri, 21 Aug 2026 05:04:15 +0800 Subject: [PATCH 22/32] fix(frontend): untick prosthesis when a picked tooth breaks its map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit initialVoiceSelection deliberately never auto-ticks an incomplete prosthesis map, because a detail with an untyped tooth cannot ship — it fails at dispatch instead. Picking a candidate tooth walked straight through that rule: the tick was seeded once, so a map that was complete at extraction stayed ticked after a tooth with no prosthesis type joined it, and Apply attached a map assertCompleteToothProsthesisMap rejects. Recomputed on each pick, and only ever downwards — re-ticking is the clinician's call, not a side effect of un-picking. Co-Authored-By: Claude Opus 5 (1M context) --- .../ui/treatment/VoiceReviewSheet.tsx | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx b/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx index e55e041..25f22ac 100644 --- a/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx +++ b/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx @@ -69,12 +69,23 @@ export function VoiceReviewSheet({ const selectedCount = countSelected(selection, available); const pickCandidate = (tooth: FdiToothId) => { - setChosen((prev) => - prev.includes(tooth) ? prev.filter((t) => t !== tooth) : [...prev, tooth], - ); - // The teeth row starts unticked whenever the recording produced no teeth of its own, - // and a picked tooth that is not ticked applies nothing. - setSelection((prev) => (prev.teeth ? prev : { ...prev, teeth: true })); + 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, + // The picked tooth has no prosthesis type, which makes the map unshippable. Leaving + // the row ticked would apply a map that `assertCompleteToothProsthesisMap` rejects + // at dispatch — the exact failure the never-auto-tick-incomplete rule exists to + // prevent. Only ever unticks: re-ticking is the clinician's call. + prosthesis: + prev.prosthesis && + withChosenTeeth(result, nextChosen).prosthesis?.complete !== false, + })); }; const labelFor = (code: string | null, catalog: { code: string; label: string }[]) => -- 2.53.0.windows.1 From 3911477e42f489621e7b89a2d3387d8163d51c65 Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Fri, 21 Aug 2026 05:04:15 +0800 Subject: [PATCH 23/32] fix(frontend): persist the lab case a voice result creates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applyVoiceResult put the lab draft in state and stopped there. Every other path that creates a LabCaseDraft — handleContinueToLab, handleLabCasesChange — immediately runs persistDraft + persistLabCases, and the autosave effect only watches `details`. So applying a voice result carrying a lab, a due date and a prosthesis map, then reloading, kept the detail and silently dropped all three: the surviving detail made it look like the save worked. applyVoiceResult moves below persistDraft/persistLabCases so it can call them, and writes detailsRef itself before persisting — persistDraft reads that ref, and setDetails has not rendered by the time the save runs. The ref is already written imperatively elsewhere for the same reason. Co-Authored-By: Claude Opus 5 (1M context) --- .../ui/treatment/TreatmentWorkspace.tsx | 180 ++++++++++-------- 1 file changed, 104 insertions(+), 76 deletions(-) diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index 233bc71..9f652ba 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -502,82 +502,6 @@ export function TreatmentWorkspace({ [appointments, selectedAppointmentId], ); - /** - * Voice entry. - * - * Confirm always appends a NEW detail — it never edits an existing one, and never - * touches onAddDetail. Nothing is created until this runs, so cancelling or a failed - * recording leaves the chip strip untouched. - */ - const applyVoiceResult = useCallback( - (result: VoiceExtractionResult, selection: VoiceApplySelection) => { - const detail = newDetail( - defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog), - ); - - // Ticked rows land on top of the seeded defaults, so unticking the type row leaves - // the appointment-purpose default rather than a blank. - if (selection.treatmentType && result.treatmentType) { - detail.treatmentType = result.treatmentType; - } - if (selection.teeth) { - detail.teeth = [...result.teeth]; - detail.toothSelectionGroups = result.toothSelectionGroups.map((group) => ({ - ...group, - teeth: [...group.teeth], - })); - } - if (selection.comment && result.comment) { - detail.comment = result.comment; - } - - setDetails((prev) => [...prev, detail]); - setActiveDetailId(detail.clientId); - setEntryStep('treatment'); - - // 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) || - (selection.lab && result.labId) || - (selection.dueDate && result.dueDate); - - if (wantsLabDraft) { - const draft = newLabCaseDraft(); - draft.detailClientId = detail.clientId; - if (selection.lab && result.labId) { - draft.destinationOrganizationId = result.labId; - } - 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(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), - })); - } - setLabCaseDrafts((prev) => [...prev, draft]); - } - - setVoiceResult(null); - }, - [selectedAppointment?.purpose, treatmentCatalog], - ); const voice = useVoiceCapture({ // The locale the clinician is actually reading and speaking in. Sent explicitly so @@ -2045,6 +1969,110 @@ export function TreatmentWorkspace({ ], ); + /** + * Voice entry. + * + * Confirm always appends a NEW detail — it never edits an existing one, and never + * touches onAddDetail. Nothing is created until this runs, so cancelling or a failed + * recording leaves the chip strip untouched. + */ + const applyVoiceResult = useCallback( + (result: VoiceExtractionResult, selection: VoiceApplySelection) => { + const detail = newDetail( + defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog), + ); + + // Ticked rows land on top of the seeded defaults, so unticking the type row leaves + // the appointment-purpose default rather than a blank. + if (selection.treatmentType && result.treatmentType) { + detail.treatmentType = result.treatmentType; + } + if (selection.teeth) { + detail.teeth = [...result.teeth]; + detail.toothSelectionGroups = result.toothSelectionGroups.map((group) => ({ + ...group, + teeth: [...group.teeth], + })); + } + if (selection.comment && result.comment) { + detail.comment = result.comment; + } + + const nextDetails = [...detailsRef.current, detail]; + setDetails(nextDetails); + // persistDraft reads detailsRef, and setDetails has not rendered yet. The codebase + // already writes this ref imperatively after a save for the same reason. + detailsRef.current = nextDetails; + setActiveDetailId(detail.clientId); + setEntryStep('treatment'); + + // 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) || + (selection.lab && result.labId) || + (selection.dueDate && result.dueDate); + + if (wantsLabDraft) { + const draft = newLabCaseDraft(); + draft.detailClientId = detail.clientId; + if (selection.lab && result.labId) { + draft.destinationOrganizationId = result.labId; + } + 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(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), + })); + } + const updatedLabCases = [...labCaseDrafts, draft]; + setLabCaseDrafts(updatedLabCases); + + // Every other path that creates a lab draft persists it immediately, and the + // autosave effect only watches `details`. Left in state alone, the destination + // lab, the due date and the whole prosthesis map vanish on the next reload — + // silently, because the detail itself does survive. + void (async () => { + try { + const saved = await persistDraft({ force: true }); + await persistLabCases(saved, updatedLabCases); + } catch (error: unknown) { + showError(getUserFacingError(error, tErrors, t('errorSaveLabShipments'))); + } + })(); + } + + setVoiceResult(null); + }, + [ + labCaseDrafts, + persistDraft, + persistLabCases, + selectedAppointment?.purpose, + showError, + t, + tErrors, + treatmentCatalog, + ], + ); + const handleRemoveDetail = useCallback( (detailClientId: string) => { if (!canEditTreatmentForDay) return; -- 2.53.0.windows.1 From 6a4c0cb1bbca7c4301528b9d7ac074e168857df5 Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Fri, 21 Aug 2026 05:04:30 +0800 Subject: [PATCH 24/32] fix(frontend): stop the level meter re-rendering the whole workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The meter wrote React state from a requestAnimationFrame loop, and the hook lives in TreatmentWorkspace — so every frame re-rendered the details editor, the FDI chart, the lab panel and the history rail. About 7,200 whole-tree renders across a two-minute recording, while the user is dictating. Now samples every frame but publishes at LEVEL_POLL_MS, the rate the elapsed timer already used. Peaks between publishes are carried forward, so the meter stays responsive to transients rather than sampling at 10 Hz. Also adds the catch the start path never had: new MediaRecorder() and recorder.start() both throw on some browsers, and by then the stream is live. The rejection went unhandled, the UI sat at 'idle' showing nothing, and the browser's recording indicator stayed lit until unmount. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/lib/voice/useVoiceCapture.ts | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/voice/useVoiceCapture.ts b/frontend/src/lib/voice/useVoiceCapture.ts index 5008fbf..f17a74e 100644 --- a/frontend/src/lib/voice/useVoiceCapture.ts +++ b/frontend/src/lib/voice/useVoiceCapture.ts @@ -219,6 +219,14 @@ export function useVoiceCapture({ // minutes of dictation because a timer expired would be the worst failure. if (maxMs != null && elapsed >= maxMs) stop(); }, LEVEL_POLL_MS); + } catch { + // `new MediaRecorder(...)` and `recorder.start()` both throw on some browsers, + // and by then the stream is already live. Without this the promise rejects + // unhandled, the UI sits at 'idle' with nothing shown, and the browser's + // recording indicator stays lit until the workspace unmounts. + teardown(); + setPhase('idle'); + onError(clientError('VOICE_MIC_DENIED')); } finally { startingRef.current = false; } @@ -262,12 +270,23 @@ function attachLevelMeter( source.connect(analyser); const data = new Uint8Array(analyser.frequencyBinCount); - const tick = () => { + // Sample every frame so a transient is not missed, but publish at LEVEL_POLL_MS. + // This hook lives in TreatmentWorkspace, so an unthrottled setLevel re-renders the + // details editor, the FDI chart and the lab panel on every animation frame — about + // 7,200 whole-tree renders across a two-minute recording. + let peakSinceEmit = 0; + let lastEmit = 0; + const tick = (now: number) => { if (contextRef.current !== context || context.state === 'closed') return; analyser.getByteTimeDomainData(data); let peak = 0; for (const sample of data) peak = Math.max(peak, Math.abs(sample - 128)); - setLevel(Math.min(1, peak / 128)); + peakSinceEmit = Math.max(peakSinceEmit, peak); + if (now - lastEmit >= LEVEL_POLL_MS) { + lastEmit = now; + setLevel(Math.min(1, peakSinceEmit / 128)); + peakSinceEmit = 0; + } requestAnimationFrame(tick); }; requestAnimationFrame(tick); -- 2.53.0.windows.1 From 3d64414dd30568813a3df8f8031cda2830914b25 Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Fri, 21 Aug 2026 05:04:30 +0800 Subject: [PATCH 25/32] fix(backend): stop showing the clinician null, NaN and the wrong failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways a voice failure described itself wrongly. describe() built the quoted-back text from fields that are all nullable on the wire, and toVoiceIntent casts rather than checks — so a half-classified deadline rendered as “null null” — not a usable date, and an offset with no amount as “+NaN day”. Blank is already handled by the sheet; it now falls back to that. The DTO's constraints resolved to unrelated codes: maxLength fell through to VALIDATION_FIELD_REQUIRED, so an oversized recording said a field was missing, and isIn maps to VALIDATION_LANGUAGE_INVALID, so an unsupported container said the language was invalid. Both now name their own code — the validation factory already returns a message verbatim when it is itself a known ErrorCode, so this needs no change to the shared mapping. Co-Authored-By: Claude Opus 5 (1M context) --- backend/src/common/errors/error-codes.ts | 1 + backend/src/modules/voice/dto/voice.dto.ts | 9 ++++- .../modules/voice/due-date.resolver.spec.ts | 37 +++++++++++++++++++ .../src/modules/voice/due-date.resolver.ts | 28 ++++++++++++-- frontend/messages/en.json | 1 + frontend/messages/fa.json | 1 + frontend/messages/nl.json | 1 + 7 files changed, 72 insertions(+), 6 deletions(-) diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index 73d7385..79dfe99 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -187,6 +187,7 @@ export const ErrorCode = { // Voice treatment entry VOICE_NOT_AVAILABLE: 'VOICE_NOT_AVAILABLE', VOICE_CLIP_TOO_LONG: 'VOICE_CLIP_TOO_LONG', + VOICE_UNSUPPORTED_FORMAT: 'VOICE_UNSUPPORTED_FORMAT', VOICE_ASR_FAILED: 'VOICE_ASR_FAILED', VOICE_EXTRACT_FAILED: 'VOICE_EXTRACT_FAILED', VOICE_NOTHING_RECOGNIZED: 'VOICE_NOTHING_RECOGNIZED', diff --git a/backend/src/modules/voice/dto/voice.dto.ts b/backend/src/modules/voice/dto/voice.dto.ts index 41b3b40..18a7ca9 100644 --- a/backend/src/modules/voice/dto/voice.dto.ts +++ b/backend/src/modules/voice/dto/voice.dto.ts @@ -6,6 +6,7 @@ import { MaxLength, Min, } from 'class-validator'; +import { ErrorCode } from '../../../common/errors/error-codes'; /** Containers OpenRouter's transcription endpoint accepts, and MediaRecorder can produce. */ export const VOICE_AUDIO_FORMATS = [ @@ -32,10 +33,14 @@ export class ExtractVoiceDto { */ @IsString() @IsBase64() - @MaxLength(8_000_000) + // Both constraints name their own code. Left to the default mapping, `maxLength` falls + // through to VALIDATION_FIELD_REQUIRED and `isIn` resolves to + // VALIDATION_LANGUAGE_INVALID — so an oversized recording told the clinician a field + // was missing, and an unsupported container told them their language was invalid. + @MaxLength(8_000_000, { message: ErrorCode.VOICE_CLIP_TOO_LONG }) audio: string; - @IsIn(VOICE_AUDIO_FORMATS) + @IsIn(VOICE_AUDIO_FORMATS, { message: ErrorCode.VOICE_UNSUPPORTED_FORMAT }) format: VoiceAudioFormat; /** diff --git a/backend/src/modules/voice/due-date.resolver.spec.ts b/backend/src/modules/voice/due-date.resolver.spec.ts index 5eb5029..74b7167 100644 --- a/backend/src/modules/voice/due-date.resolver.spec.ts +++ b/backend/src/modules/voice/due-date.resolver.spec.ts @@ -346,3 +346,40 @@ describe('resolveDueDate', () => { }); }); }); + +describe('what an unresolvable deadline quotes back', () => { + // The wire shape allows nulls in every field and toVoiceIntent casts rather than + // checks, so these reach the resolver intact. The sheet renders `spoken` verbatim. + it('never puts "null" or "NaN" in front of the clinician', () => { + const bad = [ + { kind: 'weekday', weekday: null, which: null }, + { kind: 'offset', unit: null, amount: null }, + { kind: 'offset', unit: 'day', amount: Number.NaN }, + { kind: 'jalali', jy: null, jm: 7, jd: 25 }, + { kind: 'gregorian', y: 2026, m: null, d: null }, + ]; + for (const intent of bad) { + const result = resolveDueDate( + intent as unknown as DueIntent, + SATURDAY, + FA_WEEK, + ); + expect(result.dueDate).toBeNull(); + expect(result.unresolved?.spoken ?? '').not.toMatch(/null|NaN/); + } + }); + + it('still quotes a deadline it did understand the words of', () => { + const result = resolveDueDate( + { + kind: 'weekday', + weekday: 'thursday', + which: null, + } as unknown as DueIntent, + SATURDAY, + FA_WEEK, + ); + // A weekday with no "this/next" resolves, so nothing is quoted back at all. + expect(result.dueDate).not.toBeNull(); + }); +}); diff --git a/backend/src/modules/voice/due-date.resolver.ts b/backend/src/modules/voice/due-date.resolver.ts index 3411efd..47b8017 100644 --- a/backend/src/modules/voice/due-date.resolver.ts +++ b/backend/src/modules/voice/due-date.resolver.ts @@ -77,16 +77,36 @@ function unresolved(spoken: string): DueResolution { return { dueDate: null, unresolved: { spoken, reason: 'invalid_date' } }; } +/** + * What to quote back when a deadline could not be resolved. + * + * Every field here is nullable on the wire and `toVoiceIntent` casts rather than checks, + * so a half-classified deadline arrives with nulls in it. The review sheet renders this + * verbatim — `"null null" — not a usable date` in front of a clinician is worse than the + * reason on its own, which the sheet already handles for a blank string. + */ function describe(intent: DueIntent): string { + const usable = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + switch (intent?.kind) { case 'weekday': - return `${intent.which} ${intent.weekday}`; + // `which` is legitimately null (it means "this"), the weekday is not. + return [intent.which, intent.weekday] + .filter((part) => typeof part === 'string') + .join(' '); case 'offset': - return `+${intent.amount} ${intent.unit}`; + return usable(intent.amount) + ? `+${intent.amount} ${intent.unit ?? ''}`.trim() + : ''; case 'jalali': - return `${intent.jy}/${intent.jm}/${intent.jd}`; + return [intent.jy, intent.jm, intent.jd].every(usable) + ? `${intent.jy}/${intent.jm}/${intent.jd}` + : ''; case 'gregorian': - return `${intent.y}-${intent.m}-${intent.d}`; + return [intent.y, intent.m, intent.d].every(usable) + ? `${intent.y}-${intent.m}-${intent.d}` + : ''; default: { // Reaching here means an unrecognised `kind`, which resolveDueDate has already // established is a string — echo it so the review row names what was heard. diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 0c76d3b..f2681c3 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1267,6 +1267,7 @@ "VOICE_MIC_DENIED": "Microphone access was blocked. Allow it in your browser settings and try again.", "VOICE_NOT_AVAILABLE": "Voice entry is not available for this language yet.", "VOICE_CLIP_TOO_LONG": "That recording is too long. Please keep it under two minutes.", + "VOICE_UNSUPPORTED_FORMAT": "That recording format is not supported on this device.", "VOICE_ASR_FAILED": "Could not turn the recording into text. Please try again.", "VOICE_EXTRACT_FAILED": "Could not read the treatment details from the recording.", "VOICE_NOTHING_RECOGNIZED": "No speech was recognised. Check the microphone and try again.", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index f3ec8dd..eb3357a 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -1268,6 +1268,7 @@ "VOICE_MIC_DENIED": "دسترسی به میکروفون مسدود شده است. در تنظیمات مرورگر اجازه دهید و دوباره تلاش کنید.", "VOICE_NOT_AVAILABLE": "ثبت گفتاری هنوز برای این زبان در دسترس نیست.", "VOICE_CLIP_TOO_LONG": "مدت ضبط بیش از حد است. لطفاً کمتر از دو دقیقه صحبت کنید.", + "VOICE_UNSUPPORTED_FORMAT": "قالب این ضبط پشتیبانی نمی‌شود.", "VOICE_ASR_FAILED": "تبدیل گفتار به متن انجام نشد. لطفاً دوباره تلاش کنید.", "VOICE_EXTRACT_FAILED": "اطلاعات درمان از روی گفتار استخراج نشد.", "VOICE_NOTHING_RECOGNIZED": "گفتاری شناسایی نشد. میکروفون را بررسی کنید و دوباره تلاش کنید.", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index bad8d12..f5f81b8 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -1267,6 +1267,7 @@ "VOICE_MIC_DENIED": "Microfoontoegang is geblokkeerd. Sta dit toe in uw browserinstellingen en probeer opnieuw.", "VOICE_NOT_AVAILABLE": "Spraakinvoer is nog niet beschikbaar voor deze taal.", "VOICE_CLIP_TOO_LONG": "Die opname is te lang. Houd het onder twee minuten.", + "VOICE_UNSUPPORTED_FORMAT": "Dit opnameformaat wordt niet ondersteund.", "VOICE_ASR_FAILED": "De opname kon niet naar tekst worden omgezet. Probeer het opnieuw.", "VOICE_EXTRACT_FAILED": "De behandelgegevens konden niet uit de opname worden gelezen.", "VOICE_NOTHING_RECOGNIZED": "Er is geen spraak herkend. Controleer de microfoon en probeer opnieuw.", -- 2.53.0.windows.1 From f46ecbd7714481a2fd29a5f9fdfe8a5d0f60b931 Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Fri, 21 Aug 2026 05:05:04 +0800 Subject: [PATCH 26/32] docs: mark transcript salvage as specified but not built The backend returns the transcript on VOICE_EXTRACT_FAILED and the client never reads it, so dictation the clinic paid for is shipped in an error body and dropped. The spec claimed the whole feature was implemented; it now names the gap and the two ways out. Co-Authored-By: Claude Opus 5 (1M context) --- docs/specs/voice-treatment-entry/spec.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/specs/voice-treatment-entry/spec.md b/docs/specs/voice-treatment-entry/spec.md index 70fd5e0..0b83fcd 100644 --- a/docs/specs/voice-treatment-entry/spec.md +++ b/docs/specs/voice-treatment-entry/spec.md @@ -1,7 +1,8 @@ # Voice treatment entry -**Status:** Implemented on `feat/voice-treatment-entry`. 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:** 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). 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 @@ -537,7 +538,14 @@ English Nest exception for a user-facing failure. | `VOICE_NOT_AVAILABLE` | no profile for locale (v1); plan flag off, once enforced | | `VOICE_RATE_LIMITED` | throttle | -**Transcript salvage:** when ASR succeeded and only extraction failed, the response still +**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. + +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 -- 2.53.0.windows.1 From 3fb7f02f432c2053c9cfed911031530451130c99 Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Fri, 21 Aug 2026 00:52:00 +0330 Subject: [PATCH 27/32] docs: add CLAUDE.md for project guidance and conventions --- CLAUDE.md | 108 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..898a35c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,108 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Read first + +Project conventions already live in **`AGENTS.md`** (project map + per-feature quick-reference), **`.cursor/rules/*.mdc`** (short always-on / file-scoped rules), and **`.cursor/skills/*/SKILL.md`** (multi-step workflow playbooks). They are plain markdown — read the ones covering the area you touch **before** editing. This file covers only what those do not: commands and cross-cutting architecture. + +Per `.cursor/rules/maintain-agent-docs.mdc`: when the user establishes a durable convention, update the matching `.mdc` rule or `SKILL.md` — not this file. + +## Commands + +There is **no root `package.json`**. Every npm command runs inside `backend/` or `frontend/`. + +### Backend (`cd backend`) + +| Command | Purpose | +|---|---| +| `npm run start:dev` | API on `http://localhost:3000/api`; Swagger `/api/docs`; AdminJS `/admin` | +| `npm run build` | **Verification gate for cross-cutting backend changes** | +| `npm test` | Jest (`src/**/*.spec.ts`) | +| `npm test -- lab-case-task.generator` | Single suite by path fragment | +| `npm test -- -t "merges teeth"` | Single test by name | +| `npm run test:e2e` | Jest with `test/jest-e2e.json` | +| `npm run lint` | ESLint with `--fix` | +| `docker compose -f docker-compose.postgres.yml up -d` | Dev Postgres (host port from `POSTGRES_PORT` in `.env`) | +| `npm run prisma:generate` / `prisma:migrate` / `prisma:seed` | Client, dev migration, reference-data upsert (seed never wipes) | +| `npx prisma migrate reset` | Dev clean slate — drop, re-migrate, re-seed. Never against staging/prod | +| `npm run prisma:wipe-app-data` / `prisma:reset-treatment` / `prisma:regenerate-tasks` | Targeted dev data scripts | + +`DATABASE_URL` must use `localhost` when Nest runs on the host and Postgres in Docker. + +### Frontend (`cd frontend`) + +| Command | Purpose | +|---|---| +| `npm run dev` | Dev server on **3001** (3000 is the API) | +| `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 | + +`NEXT_PUBLIC_*` values are baked in at build time — restart `npm run dev` after changing `.env.local`. + +### Git + +Do not commit, push, amend, force-push, or skip hooks unless the user explicitly asks. + +## Architecture + +Dental **clinic ↔ lab** platform. Every user acts inside one `Organization` whose `type` is `CLINIC` (patients, appointments, treatment) or `LAB` (cases, tasks). Most features exist only for one side. + +### Request identity: cookie JWT carrying the selected org + +There is no `Authorization` header. `JwtStrategy` reads the httpOnly **`accessToken` cookie**, and the JWT payload carries `organizationId` — the org the user currently acts as. `POST /auth/select-organization` re-issues the token with a different org, so **switching orgs means a new token**, and every service scopes queries by `req.user.organizationId`. + +On 401 the axios interceptor (`frontend/src/lib/api/client.ts`) refreshes, **re-selects** the org from `localStorage.currentOrganizationId`, then retries the original request — skipping that dance for auth endpoints and public invitation routes. `frontend/src/proxy.ts` (the Next middleware, exported as `proxy`) is a separate, cookie-only route gate that redirects unauthenticated users to `/{locale}/login?from=…`. + +### Permissions + +`TAB_*_READ` / `TAB_*_EDIT` codes in `backend/src/common/permissions.ts`; **EDIT implies READ**. Owners get org-type defaults merged with stored grants — always resolve via `hasEffectivePermission` / `getEffectivePermissionNames` in `common/membership-permissions.ts`, never by reading `membership.permissions` directly. Controllers stack `JwtAuthGuard` + `ClinicOrgGuard`/`LabOrgGuard`; feature-specific checks belong in the **service**. + +### Error contract (spans 3 layers — change all of them) + +`AppException(ErrorCode.X)` → `HttpExceptionFilter` → `{ success: false, error: { code } }` → axios normalizes to `ApiError` → `getUserFacingError(err, tErrors, fallback)` resolves `errors.X` from the message files. Adding a user-facing failure means: a code in `common/errors/error-codes.ts`, the throw site, and an `errors.X` key in **all three** of `frontend/messages/{en,fa,nl}.json`. Never throw raw English Nest exceptions for user-facing failures. + +### The core domain pipeline + +``` +Appointment ─┐ + ├→ Treatment (patient + day) → TreatmentDetail (treatment type + selected teeth) +Walk-in ─────┘ │ + │ "send to lab" (clinic side) + ▼ + LabCase + LabCaseToothProsthesis (per tooth, grouped by sourceKey) + │ generateLabCaseTasks() + ▼ + ProsthesisType → ProsthesisTypeStep → LabWorkflowStep ⇒ LabCaseTask rows + │ + ▼ + LAB org: Cases tab + Tasks tab +``` + +`backend/src/modules/cases/lab-case-task.generator.ts` is the expansion point: it is **idempotent** (returns early if tasks exist) and drives the entire lab-side task list from catalog data. Teeth carry `selectionGroupId` so bridges/connected units survive into task grouping. A `LabCase` can also be lab-origin (`LabCaseOrigin`), created without any clinic treatment. + +Clinics may only dispatch to labs they are linked to: `OrganizationLink` (A↔B, `LinkStatus`), plus `OrganizationInvitation` for counterparts not yet on the platform — the invite flow writes both rows in one transaction and stores only the token hash. + +### Catalog is code-based and DB-translated + +`TreatmentType`, `ProsthesisType`, and `LabWorkflowStep` store a stable `code` and **no label**. Labels come from `CatalogTranslation(entityKind, entityCode, locale)` resolved by `CatalogLabelService` (falls back locale → `en` → humanized code). So: never hardcode a catalog label in backend code, and pass the actor's locale into anything that materializes labels (task generation does). Frontend colors/labels for these codes live in `components/shared/treatmentTypeDisplay.ts` and `components/treatment/prosthesisTypeDisplay.ts`. + +### Realtime and unread state + +`modules/notifications/user-notification.service.ts` writes `UserNotification` rows and pushes them through the Socket.IO transport in `backend/src/realtime/` (`emitToUserOrg` → `notification.created`). On the frontend a single `notification.created` event drives three things: the header bell inbox, sidebar **tab badges**, and a *soft* refresh of whatever list is currently open — soft meaning it must not remount components or clear an in-progress treatment draft. Unread is per-user cursor state (`LabCaseUserReadState`, `LabCaseUserTabReadState`) plus the `LabCaseActivity` log — badges clear on opening a case, not on visiting a tab. + +### Layout conventions worth knowing before you create a file + +- **Prisma lives outside `src/`**: `backend/prisma/` holds `schema.prisma`, migrations, seeds *and* `prisma.module.ts` / `prisma.service.ts` — hence imports like `../../../prisma/prisma.service`. Register new Nest modules in `app.module.ts`. +- **Frontend layering** (`.cursor/rules/frontend-components.mdc`): `app/**/page.tsx` is a thin wrapper only → route logic in `components/ui/{feature}/{Feature}Page.tsx` → JSX in `components/ui/**` → pure helpers in `components/{feature}/` or `components/shared/`. No JSX outside `ui/`, no pure helpers inside it. +- **i18n is mandatory, not a follow-up**: every user-visible string goes into `en.json`, `fa.json`, **and** `nl.json`. `fa` is RTL, so use logical `text-start`/`text-end`, never `text-left`/`text-right`. Dates/times/numbers go through `lib/i18n/format.ts`; form dates use `AppDateInput`, never a native date input. +- Treatment attachments are written to disk at `backend/uploads/treatments` relative to `process.cwd()`. + +### 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. + +## Deployment + +Images are built on a dev machine and pulled by the server; Compose files and scripts are in `infrastructure/` (`docker-compose.{prod,staging,registry}.yml`). Full guide: `infrastructure/DEPLOY.md`. Root `README.md` covers the Docker Hub + Let's Encrypt path and the Gitea registry path. Frontend `NEXT_PUBLIC_*` are **build args** — changing the public domain requires rebuilding the frontend image. -- 2.53.0.windows.1 From bdf6da135bf732f712fac366078882e7c4d53f8e Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Fri, 21 Aug 2026 17:16:46 +0800 Subject: [PATCH 28/32] fix(frontend): stop the remembered prosthesis default rewriting the map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dictating "12 روکش PFM, 13 روکش PFZ" previewed correctly and then landed in the form as PFM on both teeth. The stored data was never wrong — the dev database holds 12 → pfm_crown and 13 → pfz_crown with selectionGroupIds matching the detail's groups exactly, and a page reload renders it correctly. The damage was live client state: the dispatch panel's "last type used for this lab" default rebuilt the *entire* map from one code, so a single row reading as unfilled destroyed every type already set. Two changes: - It fills blanks now, and leaves every entry that already carries a type alone. The bulk "apply to all" select only pre-sets itself when the fill really did cover every tooth, instead of claiming one type while the rows below disagree. - A lab case created by confirming a voice result is exempt from the default entirely. The review sheet is a contract: topping the case up with a type for a tooth the preview never showed makes the confirmation step a lie about what it was going to fill. The exemption is tracked in workspace state rather than on LabCaseDraft because a draft field is dropped by mapLabCaseDraftFromApi on the first server round-trip — exactly the window this failure lives in. isProsthesisMapComplete is deliberately untouched: its strict selectionGroupId match succeeds on the real data, so loosening it would have been a blind change to a working path. Co-Authored-By: Claude Opus 5 (1M context) --- docs/specs/voice-treatment-entry/spec.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/specs/voice-treatment-entry/spec.md b/docs/specs/voice-treatment-entry/spec.md index 0b83fcd..8c37d76 100644 --- a/docs/specs/voice-treatment-entry/spec.md +++ b/docs/specs/voice-treatment-entry/spec.md @@ -482,6 +482,12 @@ that justified this whole design. - 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.** Per-detail + conveniences that would top the case up afterwards are suppressed for a voice-created + case; concretely, the dispatch panel's remembered-prosthesis default + (`previewConfirmedCaseIds`). 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. - 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 -- 2.53.0.windows.1 From 8f2d3f97bae44e32a87facabff99548729ccbbf0 Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Fri, 21 Aug 2026 17:44:51 +0800 Subject: [PATCH 29/32] fix: stop voice entry posting an unsaved detail id, and name failures right MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on this branch. The lab-case save could be posted against a detail the server has never seen. persistDraft returns a *preview* treatment instead of saving when any detail lacks a treatment type — the blank one the workspace opens with is enough — and a preview's detail id falls back to the client id. Recording straight after opening a visit and confirming a result with a lab or due date would send that id and fail the whole save. It now checks what came back rather than the precondition, so it holds for every early return persistDraft has. stop() optional-chained into a no-op when the recorder was already gone, leaving the bar recording forever with a live timer and only Cancel as a way out. Three "this browser cannot record" paths reported VOICE_MIC_DENIED — no MediaRecorder at all, no container the API accepts, and a recorder that throws after permission was already granted. Telling clinicians their microphone was denied sends them hunting for a permission nothing asked for; they now report VOICE_UNSUPPORTED_FORMAT. The voice route's large-body match stripped every trailing slash while Express ignores exactly one, so '/api/voice/extract//' bought a 10 MB buffer for a request that then 404s. Co-Authored-By: Claude Opus 5 (1M context) --- backend/src/common/body-parsers.spec.ts | 3 +++ backend/src/common/body-parsers.ts | 5 ++++- .../ui/treatment/TreatmentWorkspace.tsx | 8 +++++++ frontend/src/lib/voice/useVoiceCapture.ts | 21 +++++++++++++++---- 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/backend/src/common/body-parsers.spec.ts b/backend/src/common/body-parsers.spec.ts index d64b859..e54019b 100644 --- a/backend/src/common/body-parsers.spec.ts +++ b/backend/src/common/body-parsers.spec.ts @@ -93,6 +93,9 @@ describe('createJsonBodyParser', () => { '/api/voice/extract/extra', '/api/voice', '/voice/extract', + // Express ignores one trailing slash, not two — this one never routes, so it must + // not get the large parser either. + '/api/voice/extract//', ]) { const res = await request(buildApp()).post(path).send(bodyOfKb(300)); expect(res.status).toBe(413); diff --git a/backend/src/common/body-parsers.ts b/backend/src/common/body-parsers.ts index 97278da..9f40162 100644 --- a/backend/src/common/body-parsers.ts +++ b/backend/src/common/body-parsers.ts @@ -31,7 +31,10 @@ export const VOICE_BODY_LIMIT = '10mb'; * recording — a failure that looks like a broken microphone, not a routing detail. */ function isVoiceExtractPath(path: string): boolean { - return path.toLowerCase().replace(/\/+$/, '') === VOICE_EXTRACT_PATH; + // Exactly one trailing slash, because that is exactly what Express ignores. Stripping + // every trailing slash would hand the 10 MB parser to `/api/voice/extract//`, which + // buffers the body and then 404s — memory spent on a request that never routes. + return path.toLowerCase().replace(/\/$/, '') === VOICE_EXTRACT_PATH; } export function createJsonBodyParser(): RequestHandler { diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index 9f652ba..76915c4 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -2052,6 +2052,14 @@ export function TreatmentWorkspace({ void (async () => { try { const saved = await persistDraft({ force: true }); + // persistDraft returns a *preview* treatment rather than saving when the + // details are not persistable — one blank detail, the kind the workspace opens + // with, is enough. A preview's detail id falls back to the client id, so + // posting lab cases against it would send the server an id it has never seen + // and fail the whole save. Check what came back, not the precondition, so this + // holds for every early return persistDraft has. + const savedDetail = saved.details.find((d) => d.clientId === detail.clientId); + if (!savedDetail?.id || savedDetail.id === detail.clientId) return; await persistLabCases(saved, updatedLabCases); } catch (error: unknown) { showError(getUserFacingError(error, tErrors, t('errorSaveLabShipments'))); diff --git a/frontend/src/lib/voice/useVoiceCapture.ts b/frontend/src/lib/voice/useVoiceCapture.ts index f17a74e..7882db3 100644 --- a/frontend/src/lib/voice/useVoiceCapture.ts +++ b/frontend/src/lib/voice/useVoiceCapture.ts @@ -139,8 +139,16 @@ export function useVoiceCapture({ ); const stop = useCallback(() => { + // No recorder means nothing will fire `onstop`, so nothing else will move the phase. + // Optional-chaining into a no-op here left the bar recording forever with a running + // timer, and only Cancel could get out of it. + if (!recorderRef.current) { + teardown(); + setPhase('idle'); + return; + } try { - recorderRef.current?.stop(); + recorderRef.current.stop(); } catch { teardown(); setPhase('idle'); @@ -150,7 +158,9 @@ export function useVoiceCapture({ const onStart = useCallback(() => { if (phase !== 'idle' || startingRef.current) return; if (!isMediaRecorderSupported()) { - onError(clientError('VOICE_MIC_DENIED')); + // Not a permission problem: this browser cannot record at all. Saying "microphone + // denied" sends the clinician to hunt for a permission nothing ever asked for. + onError(clientError('VOICE_UNSUPPORTED_FORMAT')); return; } @@ -177,8 +187,9 @@ export function useVoiceCapture({ const mimeType = pickRecordingMimeType(); if (mimeType === null) { + // The browser records, but in no container the transcription API accepts. stream.getTracks().forEach((track) => track.stop()); - onError(clientError('VOICE_MIC_DENIED')); + onError(clientError('VOICE_UNSUPPORTED_FORMAT')); return; } @@ -226,7 +237,9 @@ export function useVoiceCapture({ // recording indicator stays lit until the workspace unmounts. teardown(); setPhase('idle'); - onError(clientError('VOICE_MIC_DENIED')); + // The permission was already granted by this point — what failed is the recorder + // itself, so this is "this browser cannot record", not "you denied the mic". + onError(clientError('VOICE_UNSUPPORTED_FORMAT')); } finally { startingRef.current = false; } -- 2.53.0.windows.1 From 562ef2ae6ea8245f4ccb72bc205c1565de3c1516 Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Fri, 21 Aug 2026 18:54:40 +0800 Subject: [PATCH 30/32] docs: bring the voice spec in line with the flow as built MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec still described the flow as designed, not as it works after the first live recordings. §2 gains what confirm actually writes: the detail and its lab case are persisted on confirm, because the autosave effect watches `details` only and a lab draft left in component state loses the lab, the due date and the prosthesis map on reload — while the detail survives, which is what makes that loss look like a save. Plus the guard: a preview treatment comes back instead when any detail is still untyped, and confirm skips the lab-case save rather than posting an id the server has never seen. §9 corrects three codes: VOICE_MIC_DENIED is now only a real permission failure, VOICE_UNSUPPORTED_FORMAT covers every "this browser cannot record" path, and both it and VOICE_CLIP_TOO_LONG are named on their DTO constraints rather than falling through the shared map to an unrelated message. §8 no longer claims there is no duration cap — there is, 2 minutes, decided before implementation. §12 gains the checks these changes need, including the reload that catches an unsaved lab case, and decisions 31-33 record the three rules the live testing settled. Co-Authored-By: Claude Opus 5 (1M context) --- docs/specs/voice-treatment-entry/spec.md | 63 +++++++++++++++++++++--- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/docs/specs/voice-treatment-entry/spec.md b/docs/specs/voice-treatment-entry/spec.md index 8c37d76..396b261 100644 --- a/docs/specs/voice-treatment-entry/spec.md +++ b/docs/specs/voice-treatment-entry/spec.md @@ -145,9 +145,25 @@ path**. `onAddDetail` is not called and not changed. `setEntryStep('treatment')` matters: `showChrome` is always on, so the control is visible during the **Lab** wizard step too. Confirming there returns to the treatment step. +**Confirm also saves.** The new detail is persisted immediately (`persistDraft({force:true})`), +and when the result carries a lab, a due date or a prosthesis map the lab case is saved with it +(`persistLabCases`). Not politeness — the autosave effect watches `details` only, so a lab draft +left in component state alone loses the destination lab, the due date and the whole prosthesis +map on the next reload. The detail survives, which is what makes that loss look like a +successful save. + +One guard on it: `persistDraft` returns a **preview** treatment instead of saving when any +detail still lacks a treatment type — the blank chip the workspace opens with is enough — and a +preview's detail id falls back to the client id. Confirm therefore checks *what came back*, not +the precondition, and skips the lab-case save when it did not get a real id; posting a lab case +against an id the server has never seen fails the whole save. Checking the result rather than +the condition keeps this true for every early return `persistDraft` has. + > Accepted consequences: > - Tapping Add and then 🎤 leaves behind the blank chip that Add created. It carries the > usual trash affordance. +> - That same blank chip blocks confirm's immediate lab-case save until it is given a type +> or removed; the lab rows stay in local state until the ordinary Lab-step save. > - Dictating into an existing detail is not supported in v1 — voice always makes a new > one. @@ -522,8 +538,8 @@ who can edit treatments, in every configured locale. `Plan.features.voiceTreatme and the availability API stay documented here as the intended gate, deferred rather than dropped, so turning them on later is additive. -Consequence to accept deliberately: with no plan gate and no duration cap (§2), the -per-user throttle is the **only** control on metered vendor spend. See open item 14. +Consequence to accept deliberately: with no plan gate, the per-user throttle and the 2-minute +recording cap are the **only** controls on metered vendor spend. See open item 14. --- @@ -535,15 +551,31 @@ English Nest exception for a user-facing failure. | Code | When | |---|---| -| `VOICE_MIC_DENIED` | browser permission refused — **client-side only**: needs the `errors.X` key in all three message files, but no `ErrorCode` entry and no throw site | -| `VOICE_CLIP_TOO_LONG` | over `maxMs` (server-side re-check), or over vendor limits | -| `VOICE_UNSUPPORTED_FORMAT` | recorder produced a container the profile rejects | +| `VOICE_MIC_DENIED` | microphone permission actually refused, or no input device — **client-side only**: needs the `errors.X` key in all three message files, but no `ErrorCode` entry and no throw site. Reserved for a real permission failure: see the note below | +| `VOICE_CLIP_TOO_LONG` | over `maxMs` (server-side re-check), over vendor limits, or a request body past the DTO's size cap | +| `VOICE_UNSUPPORTED_FORMAT` | **the browser cannot record at all** — no `MediaRecorder`, no container both it and the API accept, or a recorder that throws after permission was granted; and server-side, a `format` outside `VOICE_AUDIO_FORMATS` | | `VOICE_ASR_FAILED` | transcription stage failed | | `VOICE_EXTRACT_FAILED` | transcript obtained, structuring failed | | `VOICE_NOTHING_RECOGNIZED` | empty or unusable transcript | | `VOICE_NOT_AVAILABLE` | no profile for locale (v1); plan flag off, once enforced | | `VOICE_RATE_LIMITED` | throttle | +**Two of these are raised by DTO validation, not by a throw site.** +`validationExceptionFactory` returns a constraint's `message` verbatim when the message is +itself a known `ErrorCode`, so the voice DTO names its own failures: +`@MaxLength(…, { message: ErrorCode.VOICE_CLIP_TOO_LONG })` and +`@IsIn(…, { message: ErrorCode.VOICE_UNSUPPORTED_FORMAT })`. Left to the shared constraint map +they fall through to `VALIDATION_FIELD_REQUIRED` and `VALIDATION_LANGUAGE_INVALID` — an +oversized recording telling the clinician a field is missing, and an unsupported container +telling them their language is invalid. Any new voice constraint should name its code the same +way. + +**`VOICE_MIC_DENIED` is only for a real permission failure.** Three client paths used to report +it for something else entirely — no `MediaRecorder`, no acceptable container, and a recorder +that throws after permission was already granted. All three are "this browser cannot record" +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 @@ -701,7 +733,18 @@ enabling this for real clinics. - 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. + - **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 dispatch panel's remembered "last type for this lab" does **not** overwrite + either. Also check the bulk «اعمال برای همه دندان‌ها» select stays on its placeholder; + - **a hand-made prosthesis detail with an empty map** → still gets the remembered default + pre-filled. The exemption is for previewed cases only, not a removal of the convenience. --- @@ -733,6 +776,14 @@ Settled in a grilling session on 2026-08-20. | 28 | Review sheet | Modal on desktop, full-screen overlay (not a route) on mobile; candidate chips are its only interactive part | | 29 | Cancel | Aborts the in-flight vendor call | | 30 | v1 gating | Open to everyone; `Plan.features` gate deferred, not dropped | + +Added while getting the first live recordings working (2026-08-21): + +| # | Question | Decision | +|---|---|---| +| 31 | Tooth numbering | A spoken number **is** its FDI code, in all three locales. A lone digit stays unresolved and offers its candidate teeth as chips (§6, §7) | +| 32 | What confirm writes | Confirm persists the detail *and* its lab case, because autosave watches `details` only — but skips the lab-case save when it got a preview treatment back (§2) | +| 33 | Preview as contract | Applying a voice result fills exactly what the sheet showed. Per-detail conveniences that would add more are suppressed for that case (§7) | | 15 | Gating | `Plan.features` flag — its first consumer | UI placement settled in a second grilling session on 2026-08-20. -- 2.53.0.windows.1 From dc10d8dbe3dbf23f086f9ae4468f77802fa013aa Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Fri, 21 Aug 2026 23:46:56 +0800 Subject: [PATCH 31/32] docs: cut the comments that were not earning their place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I wrote 731 comment lines on this branch against 4,530 lines of code — 14%, where the rest of the repo runs at 1.8%. CLAUDE.md asks for code that reads like its surroundings, and this did not. Removed by genre rather than by taste: - restating the code, e.g. "JS getUTCDay() numbering: Sunday = 0" above the map that literally shows it, and a docblock on startOfWeek explaining that it returns the start of the week; - narrating history — "this used to rebuild the whole map", "left the bar recording forever" — which the commit message and git blame already carry; - saying the same thing in several places: the "cannot record is not a denied microphone" reason appeared three times in one file, and the "aborting stops a per-minute metered call" reason across three files. Each now lives once, where the behaviour it explains lives; - defending decisions nobody would question, like why toLatinDigits is its own module; - over-explaining defensive branches, three separate comments to distinguish null from missing-kind from unrecognised-kind. What stays is what the code cannot say: the patient-right convention in toFdi, whose failure mode is a valid code for the wrong tooth; the "this"-vs-"next" week anchoring; StrictMode re-arming mountedRef; Safari accepting no mimeType hint; and the invariants whose violation already cost a bug — the body parser's middleware ordering and the dispatch panel's auto-fill rules. Comments only. The diff contains no non-comment line. Co-Authored-By: Claude Opus 5 (1M context) --- backend/src/common/body-parsers.ts | 21 ++++----- backend/src/common/digits.ts | 12 +---- backend/src/common/fdi.ts | 25 ++++------ backend/src/common/jalali.ts | 23 ++++------ backend/src/common/zoned-civil-time.ts | 11 ++--- backend/src/configs/configurations.ts | 7 ++- backend/src/main.ts | 5 +- backend/src/modules/voice/dto/voice.dto.ts | 34 ++++---------- .../src/modules/voice/due-date.resolver.ts | 46 ++++++------------- .../src/modules/voice/extraction.resolver.ts | 25 ++++------ backend/src/modules/voice/extraction.wire.ts | 14 ++---- .../src/modules/voice/openrouter.provider.ts | 5 +- .../modules/voice/tooth-intent.resolver.ts | 37 +++++---------- .../modules/voice/voice-throttler.guard.ts | 12 ++--- backend/src/modules/voice/voice.controller.ts | 5 +- backend/src/modules/voice/voice.providers.ts | 6 +-- backend/src/modules/voice/voice.service.ts | 28 ++++------- backend/src/modules/voice/voice.types.ts | 6 +-- .../components/treatment/voiceReviewRows.ts | 37 ++++----------- .../ui/treatment/TreatmentDetailsEditor.tsx | 15 ++---- .../ui/treatment/TreatmentWorkspace.tsx | 20 ++++---- .../ui/treatment/VoiceRecordingBar.tsx | 6 +-- .../ui/treatment/VoiceReviewSheet.tsx | 17 +++---- frontend/src/lib/api/voice.ts | 3 +- frontend/src/lib/voice/audioFormat.ts | 8 ++-- frontend/src/lib/voice/useVoiceCapture.ts | 33 +++++-------- 26 files changed, 155 insertions(+), 306 deletions(-) diff --git a/backend/src/common/body-parsers.ts b/backend/src/common/body-parsers.ts index 9f40162..554c75e 100644 --- a/backend/src/common/body-parsers.ts +++ b/backend/src/common/body-parsers.ts @@ -17,23 +17,18 @@ export const VOICE_BODY_LIMIT = '10mb'; * of audio, so that one route needs a larger limit while every other endpoint keeps the * default — a large body should not become acceptable everywhere. * - * Deliberately a single middleware that *chooses* a parser, rather than a path-mounted - * parser stacked in front of a default one. That arrangement relied on Express's - * mount-path stripping plus body-parser skipping an already-parsed request, and it - * silently stopped applying when the surrounding middleware order shifted — at which point - * the endpoint rejected every real recording with a 500. One explicit branch has no such - * coupling, and is covered by body-parsers.spec.ts. + * Deliberately one middleware that *chooses* a parser, not a path-mounted parser stacked in + * front of a default one: that arrangement depended on Express's mount-path stripping and on + * body-parser skipping an already-parsed request, and silently stopped applying whenever the + * middleware order shifted. One explicit branch has no such coupling. */ /** - * Express routes case-insensitively and ignores a trailing slash unless configured - * otherwise, so `/API/Voice/Extract/` reaches the same controller. Matching only the - * canonical spelling would hand those requests the 100 kb parser and 413 every real - * recording — a failure that looks like a broken microphone, not a routing detail. + * Express routes case-insensitively and ignores exactly one trailing slash, so + * `/API/Voice/Extract/` reaches the same controller and must get the same limit — otherwise + * it 413s every real recording, which reads as a broken microphone rather than a route. + * Two slashes never route, so they must not buy a 10 MB buffer either. */ function isVoiceExtractPath(path: string): boolean { - // Exactly one trailing slash, because that is exactly what Express ignores. Stripping - // every trailing slash would hand the 10 MB parser to `/api/voice/extract//`, which - // buffers the body and then 404s — memory spent on a request that never routes. return path.toLowerCase().replace(/\/$/, '') === VOICE_EXTRACT_PATH; } diff --git a/backend/src/common/digits.ts b/backend/src/common/digits.ts index cb8663a..d78d538 100644 --- a/backend/src/common/digits.ts +++ b/backend/src/common/digits.ts @@ -1,19 +1,11 @@ -/** - * Persian (Extended Arabic-Indic, U+06F0–U+06F9) zero, and Arabic-Indic (U+0660–U+0669) - * zero. ASR output can carry either block, sometimes mixed with ASCII in one transcript. - */ const PERSIAN_ZERO = 0x06f0; const ARABIC_INDIC_ZERO = 0x0660; /** * Normalise Persian and Arabic-Indic digits to ASCII. Non-digits pass through. * - * Deliberately wider than the frontend original, which only handles the Persian block: - * this parses model/ASR output rather than keystrokes, so both blocks must be accepted - * or a spoken date or tooth number silently degrades to "unresolved". - * - * Lives on its own rather than inside jalali.ts because tooth codes need it too, and a - * tooth module reaching into the calendar module would read as an accident. + * Both blocks, not just the Persian one the frontend handles: ASR output can carry either, + * sometimes mixed with ASCII in a single transcript. */ export function toLatinDigits(value: string): string { return value.replace(/[۰-۹٠-٩]/g, (ch) => { diff --git a/backend/src/common/fdi.ts b/backend/src/common/fdi.ts index 192967d..ae37fbe 100644 --- a/backend/src/common/fdi.ts +++ b/backend/src/common/fdi.ts @@ -14,10 +14,9 @@ export type Arch = 'upper' | 'lower'; export type PatientSide = 'patient_right' | 'patient_left'; /** - * Upper arch in chart order: patient's RIGHT (18) → midline → patient's LEFT (28). - * That is the drawn left-to-right layout, which is the mirror of the patient's own sides. - * Do not read a tooth position off this array by index — use `toFdi()`, which owns the - * side convention. + * Upper arch in chart order: patient's RIGHT (18) → midline → patient's LEFT (28) — the drawn + * layout, which mirrors the patient's own sides. Never read a position off this array by + * index; use `toFdi()`, which owns the side convention. */ export const FDI_UPPER_ARCH_ORDER = [ '18', @@ -68,12 +67,9 @@ export function isFdiTooth(value: unknown): value is string { } /** - * Clean up a tooth code the extraction model echoed back, before it is matched. - * - * The model is transcribing Persian speech, so it can hand back "۲۶" in Persian digits or - * "2 6" from a digit-by-digit dictation. Neither matches an FDI code literally, and a - * near-miss here does not fail loudly — the tooth quietly turns into "not understood". - * Returns '' for anything that is not a string. + * Clean up a tooth code the model echoed back. It is reading Persian speech, so it can hand + * back "۲۶" or "2 6" from digit-by-digit dictation; neither matches literally, and the + * near-miss does not fail loudly — the tooth just turns into "not understood". */ export function normalizeFdiCode(value: unknown): string { if (typeof value !== 'string') return ''; @@ -114,9 +110,8 @@ export function teethBetweenInclusive(a: string, b: string): string[] | null { /** * Arch + patient side + position (1 = central incisor … 8 = third molar) → FDI code. * - * This function is the single place the patient-right convention lives. Getting it - * backwards mirrors every quadrant and produces a valid-looking code for the wrong tooth, - * which no schema check can catch — hence the exhaustive test coverage. + * The single place the patient-right convention lives. Getting it backwards mirrors every + * quadrant into a valid-looking code for the wrong tooth, which no schema check can catch. */ export function toFdi( arch: Arch, @@ -135,8 +130,8 @@ export function toFdi( } /** - * Sort teeth along the arch, not lexically — a bridge reads 16-15-14, and 11 sits beside - * 21 across the midline. Teeth from another arch (or unknown) sort to the end, stably. + * Along the arch, not lexically — a bridge reads 16-15-14, and 11 sits beside 21 across the + * midline. Teeth from another arch sort to the end, stably. */ export function sortInArchOrder(teeth: readonly string[]): string[] { if (teeth.length === 0) return []; diff --git a/backend/src/common/jalali.ts b/backend/src/common/jalali.ts index 989cb6e..b061e65 100644 --- a/backend/src/common/jalali.ts +++ b/backend/src/common/jalali.ts @@ -1,10 +1,8 @@ /** - * Jalali (Persian) calendar arithmetic. - * - * Ported from `frontend/src/lib/i18n/persianCalendar.ts` (itself from jalaali-js, MIT). - * The backend needs this because voice extraction resolves spoken Jalali dates into ISO - * dates server-side, where the resolvers are unit-tested — the frontend has no test - * runner. Keep the two copies in step; the underlying calendar does not change. + * Jalali (Persian) calendar arithmetic, ported from + * `frontend/src/lib/i18n/persianCalendar.ts` (itself jalaali-js, MIT). The backend needs it + * because voice resolves spoken Jalali dates server-side, where the resolvers are tested. + * Keep the two copies in step; the underlying calendar does not change. */ const BREAKS = [ @@ -144,11 +142,8 @@ export function isJalaliLeapYear(jy: number): boolean { } /** - * Days in a Jalali month, or 0 when the year or month is not real. - * - * Zero rather than a throw: every export here is reachable from model-supplied values, so - * the whole module degrades instead of raising. Zero also makes `isValidJalaliDate`'s - * `jd <= jalaliDaysInMonth(...)` naturally false. + * Days in a Jalali month, or 0 when the year or month is not real. Zero rather than a throw: + * every export here is reachable from model-supplied values, so the module degrades. */ export function jalaliDaysInMonth(jy: number, jm: number): number { if (!isSupportedJalaliYear(jy)) return 0; @@ -170,10 +165,8 @@ export function isValidJalaliDate(jy: number, jm: number, jd: number): boolean { } /** - * Jalali triple → `YYYY-MM-DD`, or null when the date is not real. - * - * Returns null rather than throwing: callers resolve model-supplied values, which may be - * nonsense, and an invalid date must degrade to "unresolved" rather than a 500. + * Jalali triple → `YYYY-MM-DD`, or null when the date is not real. Null rather than a throw, + * for the same reason: callers resolve model-supplied values, which may be nonsense. */ export function jalaliToIsoDate( jy: number, diff --git a/backend/src/common/zoned-civil-time.ts b/backend/src/common/zoned-civil-time.ts index 269d224..e61f161 100644 --- a/backend/src/common/zoned-civil-time.ts +++ b/backend/src/common/zoned-civil-time.ts @@ -55,15 +55,12 @@ export function civilDateJsWeekday(isoDate: string): number { } /** - * Today's civil date (`YYYY-MM-DD`) in an IANA zone. - * - * Lets the server derive "today" from a client-supplied time zone instead of trusting a - * client-supplied date, which matters for relative deadlines like "by Thursday". + * Today's civil date (`YYYY-MM-DD`) in an IANA zone, so the server derives "today" from a + * client-supplied *zone* rather than trusting a client-supplied date. */ export function civilDateInZone(date: Date, timeZone: string): string { - // Intl throws RangeError on an unknown zone, before any fallback below could help, and - // this receives a client-supplied string. Callers validate first; this is the backstop - // so a bad zone degrades to a date that is at most a day out rather than a 500. + // Intl throws RangeError on an unknown zone and this takes a client-supplied string; + // callers validate first, this is the backstop. const zone = isValidIanaTimeZone(timeZone) ? timeZone : 'UTC'; const parts = new Intl.DateTimeFormat('en-CA', { timeZone: zone, diff --git a/backend/src/configs/configurations.ts b/backend/src/configs/configurations.ts index 48a00ab..cc4c5eb 100644 --- a/backend/src/configs/configurations.ts +++ b/backend/src/configs/configurations.ts @@ -186,10 +186,9 @@ function parseProviderId( } /** - * Every enabled locale gets its own ASR and LLM provider+model, each overridable - * independently. They all point at the same OpenRouter models today; the per-locale - * indirection is kept because Persian ASR is the weakest link and swapping only `fa` must - * not be a code change. + * Every enabled locale gets its own ASR and LLM provider+model, each independently + * overridable. They all point at the same OpenRouter models today; the per-locale + * indirection stays so a locale can diverge by configuration rather than by code. */ function buildVoiceConfig( getEnvVarWithDefault: (key: string, defaultValue: string) => string, diff --git a/backend/src/main.ts b/backend/src/main.ts index 0749bbd..b2ec46b 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -25,9 +25,8 @@ console.log = (...args) => { }; async function bootstrap() { - // bodyParser is disabled here so the JSON parsers can be registered in an explicit - // order below; Nest's built-in one is installed during create() and would otherwise - // reject a voice recording at its 100 kb default before any later middleware ran. + // bodyParser is disabled so the JSON parsers can be registered in an explicit order below; + // Nest's built-in one would otherwise reject a voice recording at 100 kb. const app = await NestFactory.create(AppModule, { bodyParser: false }); // Voice needs a larger JSON limit than everything else; see body-parsers.ts. diff --git a/backend/src/modules/voice/dto/voice.dto.ts b/backend/src/modules/voice/dto/voice.dto.ts index 18a7ca9..7a8492b 100644 --- a/backend/src/modules/voice/dto/voice.dto.ts +++ b/backend/src/modules/voice/dto/voice.dto.ts @@ -26,48 +26,32 @@ export type VoiceAudioFormat = (typeof VOICE_AUDIO_FORMATS)[number]; export const VOICE_LOCALES = ['en', 'fa', 'nl'] as const; export class ExtractVoiceDto { - /** - * Base64 audio, no data: prefix. Capped well above a 2-minute opus clip (~400 KB) but - * far below OpenRouter's 25 MB ceiling, so an oversized upload is rejected before it - * costs a vendor call. - */ + /** Base64 audio, no data: prefix. Well above a 2-minute opus clip (~400 KB). */ @IsString() @IsBase64() - // Both constraints name their own code. Left to the default mapping, `maxLength` falls - // through to VALIDATION_FIELD_REQUIRED and `isIn` resolves to - // VALIDATION_LANGUAGE_INVALID — so an oversized recording told the clinician a field - // was missing, and an unsupported container told them their language was invalid. + // Both name their own code: the shared map sends `maxLength` to + // VALIDATION_FIELD_REQUIRED and `isIn` to VALIDATION_LANGUAGE_INVALID, neither of which + // is true here. @MaxLength(8_000_000, { message: ErrorCode.VOICE_CLIP_TOO_LONG }) audio: string; @IsIn(VOICE_AUDIO_FORMATS, { message: ErrorCode.VOICE_UNSUPPORTED_FORMAT }) format: VoiceAudioFormat; - /** - * The clinician's IANA zone. The server derives "today" from it rather than trusting a - * client-supplied date, which is what relative deadlines resolve against. - */ + /** The clinician's IANA zone; "today" is derived from it, never sent by the client. */ @IsString() @MaxLength(64) timeZone: string; - /** - * Recording length as measured by the client. - * - * Required, not optional: an optional value means omitting it bypasses - * VOICE_MAX_RECORDING_MS entirely, which would make the cap advisory. - */ + /** Required, not optional — omitting it would bypass VOICE_MAX_RECORDING_MS entirely. */ @IsInt() @Min(0) durationMs: number; /** - * The locale the clinician is actually speaking, as the UI offered the microphone. - * - * Sent explicitly rather than read from `user.language`: the two can diverge (a - * bookmarked /fa/ URL, a language toggle whose save failed), and a mismatch would - * transcribe Persian with an English hint and anchor "next Thursday" to the wrong - * week start. Gating the button and resolving the request must agree by construction. + * The locale the UI offered the microphone in. Sent explicitly because `user.language` can + * diverge from the URL locale, and a mismatch transcribes Persian with an English hint and + * anchors "next Thursday" to the wrong week start. */ @IsIn(VOICE_LOCALES) locale: string; diff --git a/backend/src/modules/voice/due-date.resolver.ts b/backend/src/modules/voice/due-date.resolver.ts index 47b8017..d65d2bf 100644 --- a/backend/src/modules/voice/due-date.resolver.ts +++ b/backend/src/modules/voice/due-date.resolver.ts @@ -15,7 +15,6 @@ import type { DueIntent, UnresolvedItem, Weekday } from './voice.types'; * to reason about instants. */ -/** JS `getUTCDay()` numbering: Sunday = 0. */ const WEEKDAY_TO_JS: Record = { saturday: 6, sunday: 0, @@ -26,7 +25,6 @@ const WEEKDAY_TO_JS: Record = { friday: 5, }; -/** Refuse absurd deadlines however they were arrived at. */ const MAX_DAYS_AHEAD = 365 * 5; export type DueResolution = { @@ -78,12 +76,9 @@ function unresolved(spoken: string): DueResolution { } /** - * What to quote back when a deadline could not be resolved. - * - * Every field here is nullable on the wire and `toVoiceIntent` casts rather than checks, - * so a half-classified deadline arrives with nulls in it. The review sheet renders this - * verbatim — `"null null" — not a usable date` in front of a clinician is worse than the - * reason on its own, which the sheet already handles for a blank string. + * What to quote back when a deadline could not be resolved. Every field is nullable on the + * wire and `toVoiceIntent` casts rather than checks, and the sheet renders this verbatim — + * so a half-classified deadline must fall back to '', not to `"null null"`. */ function describe(intent: DueIntent): string { const usable = (value: unknown): value is number => @@ -108,8 +103,7 @@ function describe(intent: DueIntent): string { ? `${intent.y}-${intent.m}-${intent.d}` : ''; default: { - // Reaching here means an unrecognised `kind`, which resolveDueDate has already - // established is a string — echo it so the review row names what was heard. + // An unrecognised `kind`, already established as a string — echo what was heard. const kind = (intent as { kind?: unknown })?.kind; return typeof kind === 'string' ? kind : ''; } @@ -117,11 +111,9 @@ function describe(intent: DueIntent): string { } /** - * Which weekday starts the week, per locale. - * * "Next Thursday" is week-relative, so this changes the answer: the Iranian week starts - * Saturday, the Dutch and (European) English week starts Monday. Hardcoding Saturday - * would put an en/nl clinician's deadline a week out. + * Saturday, the Dutch and English week Monday. Hardcoding either puts the other locale's + * deadline a week out. */ const WEEK_START_BY_LOCALE: Record = { fa: WEEKDAY_TO_JS.saturday, @@ -135,22 +127,18 @@ export function weekStartForLocale(locale: string): number { return WEEK_START_BY_LOCALE[locale] ?? DEFAULT_WEEK_START; } -/** Most recent week-start day, counting today if today is that day. */ function startOfWeek(iso: string, weekStartJs: number): string { const back = (civilDateJsWeekday(iso) - weekStartJs + 7) % 7; return addDays(iso, -back); } /** - * `'this'` is occurrence-anchored: the soonest occurrence strictly after today, so "by - * Thursday" said on a Thursday means the next one — a deadline of today is almost never - * what was meant, and this can never resolve into the past. + * `'this'` is occurrence-anchored: the soonest occurrence strictly after today, so it can + * never resolve into the past. * - * `'next'` is *week*-anchored, not "this plus seven". "Thursday next week" means the - * Thursday of the Saturday-start week after this one; adding a week to `'this'` would - * overshoot by seven days whenever `'this'` had already rolled into next week. The two - * can legitimately coincide — said on a Thursday, "the coming Saturday" and "Saturday - * next week" are the same day. + * `'next'` is *week*-anchored, not "this plus seven" — adding a week to `'this'` overshoots + * by seven days whenever `'this'` has already rolled into next week. The two legitimately + * coincide: said on a Thursday, "the coming Saturday" and "Saturday next week" are one day. */ function resolveWeekday( intent: Extract, @@ -199,18 +187,15 @@ export function resolveDueDate( todayIso: string, weekStartJs: number = DEFAULT_WEEK_START, ): DueResolution { - // Absent is not an error — most utterances carry no deadline. Anything else that is not - // an intent object is a deadline we failed to understand, and must be flagged rather - // than silently dropped. + // Absent is not an error — most utterances carry no deadline. if (intent === null || intent === undefined) { return { dueDate: null, unresolved: null }; } if (typeof intent !== 'object') { return unresolved(String(intent).slice(0, 120)); } - // An object carrying no `kind` at all says nothing about a deadline; flagging it would - // put a blank "heard but lost" row in front of a clinician who never mentioned one. An - // object with an *unrecognised* kind did try to say something, and is flagged below. + // No `kind` at all says nothing about a deadline, so it is not "heard but lost". An + // *unrecognised* kind did try to say something, and is flagged below. if (typeof (intent as { kind?: unknown }).kind !== 'string') { return { dueDate: null, unresolved: null }; } @@ -240,8 +225,7 @@ export function resolveDueDate( if (!resolved) return unresolved(describe(intent)); - // An absolute date the model invented can land anywhere; a deadline in the past or - // decades away is not a deadline. + // A date the model invented can land anywhere; past or decades away is not a deadline. const daysAhead = (utcMsOf(resolved) - utcMsOf(todayIso)) / 86_400_000; if (daysAhead < 0 || daysAhead > MAX_DAYS_AHEAD) return unresolved(describe(intent)); diff --git a/backend/src/modules/voice/extraction.resolver.ts b/backend/src/modules/voice/extraction.resolver.ts index 2d92f27..e7f6714 100644 --- a/backend/src/modules/voice/extraction.resolver.ts +++ b/backend/src/modules/voice/extraction.resolver.ts @@ -91,12 +91,9 @@ function mergeOverlapping(sets: string[][]): string[][] { } /** - * Turn spoken bridge spans plus loose teeth into selection groups. - * - * Span teeth are added to the selection: saying "a bridge from 14 to 16" selects 15 even - * though it was never named. A span whose endpoints are in different arches is impossible - * and is reported rather than guessed at. A span that collapses to one tooth degrades to a - * single — there is no such thing as a one-tooth bridge. + * Span teeth join the selection: "a bridge from 14 to 16" selects 15 though it was never + * named. A cross-arch span is reported rather than guessed at, and a span collapsing to one + * tooth degrades to a single — there is no one-tooth bridge. */ export function resolveConnectedSpans( spans: readonly ConnectedSpanIntent[], @@ -169,10 +166,8 @@ export function resolveConnectedSpans( } /** - * Expand a default prosthesis type across the selection, then apply per-tooth overrides. - * - * "همه زیرکونیا، ۲۶ پی‌اف‌ام" is how clinicians actually speak, so the model names the type - * once and overrides the exceptions. + * A default across the selection, then per-tooth overrides — "همه زیرکونیا، ۲۶ پی‌اف‌ام" is + * how clinicians actually speak. */ export function resolveProsthesis( intent: ProsthesisIntent | null | undefined, @@ -282,14 +277,12 @@ export function resolveVoiceIntent( ? intent.comment.trim() : null; - // A lab id the model invented is worse than none — it would ship a case to a lab the - // clinic never named. Only ids from the list we supplied survive, and a rejected one is - // reported: a hallucinated lab must not look identical to "no lab was spoken". + // An invented lab id would ship a case to a lab the clinic never named. A rejected one is + // reported, so it cannot look identical to "no lab was spoken". const labId = resolveCatalogCode(intent?.labId, ctx.linkedLabIds); if (intent?.labId != null && !labId) { - // `spoken` means "what the clinician said". A rejected lab id is an opaque - // identifier the model invented, so quoting it back would put a raw UUID in front - // of the user; the reason alone carries the meaning. + // `spoken` is what the clinician said — quoting an invented id back would put a raw + // UUID in front of the user. unresolved.push({ spoken: '', reason: 'unknown_catalog_code' }); } diff --git a/backend/src/modules/voice/extraction.wire.ts b/backend/src/modules/voice/extraction.wire.ts index 554b99d..fecf194 100644 --- a/backend/src/modules/voice/extraction.wire.ts +++ b/backend/src/modules/voice/extraction.wire.ts @@ -10,13 +10,10 @@ import type { import { WEEKDAYS } from './voice.types'; /** - * The shape the model actually emits, and its JSON schema. - * * Deliberately flat: strict `json_schema` mode has poor support for discriminated unions, - * so every variant field is present and nullable on the wire. `toVoiceIntent` narrows the - * flat shape into the internal union the resolvers consume, and is total — anything it - * cannot classify becomes a shape the resolvers will report as unresolved rather than - * something that throws here. + * so every variant field is present and nullable. `toVoiceIntent` narrows it into the + * internal union and is total — anything it cannot classify becomes a shape the resolvers + * report as unresolved rather than something that throws here. */ export type WireToothIntent = { @@ -183,9 +180,8 @@ const FDI_SHAPE = /^[1-8][1-8]$/; function toToothIntent(wire: WireToothIntent | undefined | null): ToothIntent { const spoken = typeof wire?.spoken === 'string' ? wire.spoken : ''; - // Persian digits and digit-by-digit dictation ("۲۶", "2 6") are FDI codes that do not - // match literally; without normalising first they fall through to the positional branch - // with no quadrant and are reported as unresolved. + // "۲۶" and "2 6" are FDI codes that do not match literally; unnormalised they fall + // through to the positional branch with no quadrant and read as unresolved. const fdi = normalizeFdiCode(wire?.fdi); // Only take the explicit branch for something actually FDI-shaped. A model that emits // fdi:"6" alongside correct arch/side/position would otherwise lose the tooth entirely. diff --git a/backend/src/modules/voice/openrouter.provider.ts b/backend/src/modules/voice/openrouter.provider.ts index 422c56a..7753fa2 100644 --- a/backend/src/modules/voice/openrouter.provider.ts +++ b/backend/src/modules/voice/openrouter.provider.ts @@ -124,9 +124,8 @@ export class OpenRouterExtractionProvider implements ExtractionProvider { body: JSON.stringify({ model: this.config.model, temperature: 0, - // Only route to endpoints that actually honour the JSON schema. Without this, - // OpenRouter may pick a provider that treats it as a hint and returns prose, - // which fails parsing intermittently and unreproducibly. + // Only route to endpoints that actually honour the JSON schema — otherwise OpenRouter may + // pick a provider that treats it as a hint and returns prose, failing intermittently. provider: { require_parameters: true }, messages: buildExtractionPrompt(transcript, catalog, localeHint), response_format: { diff --git a/backend/src/modules/voice/tooth-intent.resolver.ts b/backend/src/modules/voice/tooth-intent.resolver.ts index f021f16..fd1e719 100644 --- a/backend/src/modules/voice/tooth-intent.resolver.ts +++ b/backend/src/modules/voice/tooth-intent.resolver.ts @@ -18,18 +18,14 @@ export type ToothResolution = { /** Everything here parses untrusted model output, so nothing may throw. */ function normalizedFdi(intent: ToothIntent): string { - // Same normalisation the wire layer used to pick this branch, so the two cannot - // disagree: '14 ' is tooth 14 through the treatment API and '۲۶' is tooth 26, and - // neither may be reported as malformed here. + // The same normalisation the wire layer used to pick this branch, so the two agree. return normalizeFdiCode((intent as { fdi?: unknown }).fdi); } /** - * Resolve one spoken tooth reference to an FDI code, or null. - * - * Never guesses and never clamps: a position of 9, a deciduous tooth, or a malformed - * intent resolves to null so the caller can surface it as "not understood" rather than - * silently selecting a neighbouring tooth. + * Never guesses and never clamps: position 9, a deciduous tooth or a malformed intent all + * resolve to null, so the caller surfaces "not understood" rather than silently selecting a + * neighbouring tooth. */ export function resolveToothIntent(intent: ToothIntent): string | null { if (!intent || typeof intent !== 'object') return null; @@ -65,9 +61,8 @@ function unresolvedReason(intent: ToothIntent): UnresolvedItem['reason'] { intent.position < 1 || intent.position > 8; if (positionBad) return 'position_out_of_range'; - // The position was understood, so the words were not the problem: the speaker never - // said which quadrant. "دندون دو" names four teeth at once, and telling the - // clinician it "could not be read" would send them looking for the wrong fault. + // The position was understood; the quadrant was never said. "دندون دو" names four + // teeth, so "could not be read" would send the clinician after the wrong fault. const archMissing = intent.arch !== 'upper' && intent.arch !== 'lower'; const sideMissing = intent.side !== 'patient_right' && intent.side !== 'patient_left'; @@ -78,11 +73,8 @@ function unresolvedReason(intent: ToothIntent): UnresolvedItem['reason'] { } /** - * The teeth still consistent with what *was* heard. - * - * Narrowed by whatever the clinician did say, so "دو" offers four and "دو بالا" offers - * two. This is not a guess — it is the full set of readings, handed to the clinician to - * choose from rather than picked on their behalf. + * The teeth still consistent with what *was* heard — "دو" leaves four, "دو بالا" two. Not + * a guess: the full set of readings, for the clinician to choose from. */ function quadrantCandidates(intent: ToothIntent): string[] { if (intent.kind !== 'positional') return []; @@ -109,11 +101,8 @@ function spokenOf(intent: ToothIntent): string { } /** - * Resolve a list of spoken tooth references. - * - * Duplicates collapse — a clinician may name the same tooth twice in one sentence — and - * anything unresolvable is reported rather than dropped, so the review sheet can show the - * user exactly which words were not understood. + * Duplicates collapse; anything unresolvable is reported rather than dropped, so the sheet + * can show which words were not understood. */ export function resolveToothIntents( intents: readonly ToothIntent[], @@ -138,10 +127,8 @@ export function resolveToothIntents( const spoken = spokenOf(intent); const candidates = reason === 'tooth_missing_quadrant' ? quadrantCandidates(intent) : []; - // Only dedupe items we can actually tell apart. Without `spoken`, two distinct lost - // references would collapse into one blank review row and a tooth would vanish. The - // candidates are part of the identity: the same word with a different arch heard - // offers a different choice. + // Only dedupe what we can tell apart: without `spoken`, two lost references collapse + // into one blank row and a tooth vanishes. Candidates are part of the identity. if (spoken) { const key = `${spoken}::${reason}::${candidates.join(',')}`; if (seenUnresolved.has(key)) continue; diff --git a/backend/src/modules/voice/voice-throttler.guard.ts b/backend/src/modules/voice/voice-throttler.guard.ts index 0a42949..f28bdad 100644 --- a/backend/src/modules/voice/voice-throttler.guard.ts +++ b/backend/src/modules/voice/voice-throttler.guard.ts @@ -3,15 +3,9 @@ import { ThrottlerGuard } from '@nestjs/throttler'; import { AppException, ErrorCode } from '../../common/errors'; /** - * Rate limits voice extraction per user rather than per IP. - * - * The default tracker keys on `req.ip`, which behind nginx means the whole deployment - * shares one bucket unless `trust proxy` is set — and an abuser rotating IPs would bypass - * it entirely. Since v1 ships with no plan gate, this is the only control on metered - * vendor spend, so it has to key on something the client cannot change. - * - * Guard order matters: the controller's JwtAuthGuard runs before this method-level guard, - * so `req.user` is populated by the time `getTracker` is called. + * Rate limits voice extraction per user, not per IP: the default tracker keys on `req.ip`, + * which behind nginx means the whole deployment shares one bucket unless `trust proxy` is + * set, and one clinic could then lock out every other. */ @Injectable() export class VoiceThrottlerGuard extends ThrottlerGuard { diff --git a/backend/src/modules/voice/voice.controller.ts b/backend/src/modules/voice/voice.controller.ts index ae15a44..0b93657 100644 --- a/backend/src/modules/voice/voice.controller.ts +++ b/backend/src/modules/voice/voice.controller.ts @@ -48,9 +48,8 @@ export class VoiceController { @Res({ passthrough: true }) res: Response, @Body() dto: ExtractVoiceDto, ) { - // Cancelling in the browser closes the connection; propagate that as an abort so the - // in-flight vendor call stops rather than settling and being discarded. It is metered - // per minute, so letting it run costs real money for a result nobody will see. + // Cancelling in the browser closes the connection; propagate it as an abort so the vendor + // call stops rather than settling unseen. It is metered per minute. const aborter = new AbortController(); res.on('close', () => { if (!res.writableFinished) aborter.abort(); diff --git a/backend/src/modules/voice/voice.providers.ts b/backend/src/modules/voice/voice.providers.ts index d44976e..7511963 100644 --- a/backend/src/modules/voice/voice.providers.ts +++ b/backend/src/modules/voice/voice.providers.ts @@ -1,10 +1,8 @@ import type { VoiceIntent } from './voice.types'; /** - * ASR and extraction are separate, independently swappable roles — they will not come - * from the same vendor for every locale. Both are resolved per locale from - * `config.voice.profiles`, so pointing `fa` at a Persian-specialist vendor while `en` - * and `nl` keep OpenRouter is configuration, not code. + * ASR and extraction are separate, independently swappable roles — they will not come from + * the same vendor for every locale. Both resolve per locale from `config.voice.profiles`. */ export type AudioInput = { diff --git a/backend/src/modules/voice/voice.service.ts b/backend/src/modules/voice/voice.service.ts index bfeb7e6..f36666d 100644 --- a/backend/src/modules/voice/voice.service.ts +++ b/backend/src/modules/voice/voice.service.ts @@ -55,10 +55,8 @@ export class VoiceService { } /** - * What the frontend needs to decide whether to render the microphone at all. - * - * v1 ships ungated beyond a configured locale profile — no plan check. The - * Plan.features design is deferred, not dropped. + * What the frontend needs to decide whether to render the microphone. v1 is ungated beyond + * a configured locale profile; the Plan.features design is deferred, not dropped. */ getAvailability(): VoiceAvailability { const voice = this.voiceConfig; @@ -107,10 +105,9 @@ export class VoiceService { throw this.toAppException(error, 'asr'); } - // durationMs is client-reported and therefore not enforcement. usage.seconds is the - // vendor's own measurement of the audio it decoded, so a client under-reporting length - // to slip past the cap is caught here — after the ASR spend, but before the extraction - // call, and visibly in telemetry. + // durationMs is client-reported, so not enforcement. usage.seconds is the vendor's own + // measurement — a client under-reporting to slip past the cap is caught here, after the + // ASR spend but before the more expensive extraction call. if (asrSeconds != null) { this.assertWithinCap(asrSeconds * 1000); } @@ -211,13 +208,9 @@ export class VoiceService { } /** - * Grace above the configured cap. - * - * The client auto-stops when elapsed >= maxMs, then measures the final length after the - * recorder has actually stopped — so a recording that runs to the cap always reports - * slightly over it. Without this tolerance the auto-stop would guarantee a rejection, - * discarding exactly the recording it was meant to save. The client still reports the - * true length, so telemetry stays honest. + * The client auto-stops at maxMs and only then measures, so a capped recording always + * reports slightly over. Without this tolerance every auto-stopped recording — the exact + * case the cap exists for — would be rejected as too long. */ private static readonly CAP_TOLERANCE_MS = 2_000; @@ -319,10 +312,7 @@ export class VoiceService { ); } - /** - * Structured, patient-free. Never the transcript, never audio, never a patient id. - * Log lines are the interim sink until this repo has metrics infrastructure. - */ + /** Structured and patient-free: never the transcript, never audio, never a patient id. */ private logTelemetry(input: { locale: string; durationMs: number; diff --git a/backend/src/modules/voice/voice.types.ts b/backend/src/modules/voice/voice.types.ts index d200c40..f557110 100644 --- a/backend/src/modules/voice/voice.types.ts +++ b/backend/src/modules/voice/voice.types.ts @@ -78,10 +78,8 @@ export type UnresolvedItem = { spoken: string; reason: UnresolvedReason; /** - * FDI codes still consistent with what was heard, when a choice would settle it. - * Only `tooth_missing_quadrant` carries these: "دو" leaves four teeth on the table, - * "دو بالا" leaves two. The review sheet offers them so an under-specified tooth is one - * tap from resolved rather than a dead end. + * FDI codes still consistent with what was heard — "دو" leaves four, "دو بالا" two. Only + * `tooth_missing_quadrant` carries them; the sheet offers them as chips. */ candidates?: string[]; }; diff --git a/frontend/src/components/treatment/voiceReviewRows.ts b/frontend/src/components/treatment/voiceReviewRows.ts index eda78d6..9d0516c 100644 --- a/frontend/src/components/treatment/voiceReviewRows.ts +++ b/frontend/src/components/treatment/voiceReviewRows.ts @@ -19,14 +19,9 @@ export function voiceRowAvailability(result: VoiceExtractionResult) { } /** - * Which rows start ticked. - * - * Everything available ticks itself, with two deliberate exceptions: - * - * - **lab, when the name only approximately matched.** Shipping a case to a lab is the one - * extracted value whose error leaves the building, so it always requires a deliberate tick. - * - **prosthesis, when the map is incomplete.** A prosthesis detail with an untyped tooth - * cannot ship at all, so applying it would just move the failure to dispatch. + * 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. */ export function initialVoiceSelection(result: VoiceExtractionResult): VoiceApplySelection { const available = voiceRowAvailability(result); @@ -41,11 +36,8 @@ export function initialVoiceSelection(result: VoiceExtractionResult): VoiceApply } /** - * How many rows will actually be applied — drives the confirm button's label. - * - * Intersected with availability rather than counting ticks: a row can be ticked and then - * lose its content (the last candidate tooth un-picked), and "Apply 1 item" that applies - * nothing is worse than a wrong number. + * Intersected with availability rather than counting ticks: a row can be ticked and then lose + * its content, and "Apply 1 item" that applies nothing is worse than a wrong number. */ export function countSelected( selection: VoiceApplySelection, @@ -66,16 +58,10 @@ function recheckProsthesis( } /** - * Fold the clinician's candidate picks into the extracted result. + * Fold the candidate picks into the result, so nothing downstream has to know chips exist. * - * Everything downstream reads a `VoiceExtractionResult` — row availability, the mini - * chart, the prosthesis warning, `applyVoiceResult` — so resolving the picks into one here - * means none of them has to know the chips exist. - * - * Union rather than toggle, for two reasons: a candidate can coincidentally be a tooth the - * recording already produced ("۱۲ و دو"), where tapping it must not deselect that tooth; - * and `groupsFromFlatTeeth` keeps the bridges intact while giving every remaining tooth a - * single group, so no tooth can be lost on the way through. + * Union rather than toggle: a candidate can coincidentally be a tooth the recording already + * produced ("۱۲ و دو"), and tapping it must not deselect that one. */ export function withChosenTeeth( result: VoiceExtractionResult, @@ -103,12 +89,7 @@ export function connectedTeethFromResult(result: VoiceExtractionResult): Set`s - * divided by `border-s` — rather than two shared `Button`s, which each hardcode their own - * rounding and would fight a segmented control. + * Built like the detail chip's trash affordance in this same file — a wrapper holding two + * raw `