The extraction model emits intents, never resolved values — no FDI codes, no
ISO dates. This adds the contract it must satisfy and the resolver that turns
spoken tooth references into FDI, so quadrant mirroring is a unit test rather
than a hope.
resolveToothIntent never guesses and never clamps: position 9, a deciduous
tooth, or a malformed shape resolve to null and are reported as unresolved with
the transcript span that produced them, so the review sheet can show the
clinician exactly which words were not understood.
Everything here parses untrusted model output, so nothing may throw:
- a non-array where a list was expected degrades like any other malformed shape
- explicit codes are trimmed, for parity with normalizeTeeth
- '51' reports as not_permanent_tooth (a real primary tooth the chart cannot
show) while '99' reports as malformed — the clinician should not be told a
deciduous tooth was heard when nothing tooth-shaped was
- unresolved items only dedupe when they carry a spoken span; without one,
collapsing them would hide a lost tooth behind a single blank review row
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Voice extraction needs quadrant mapping and adjacency server-side, and
treatment.utils.ts already held a private copy of the tooth set. Lift it into
common/fdi.ts rather than create a second source of truth; treatment.utils now
imports it, behaviour unchanged (existing suites still pass).
toFdi() is the single place the patient-right convention lives: quadrant 1 is
the patient's upper right, so upper+patient_right -> 1x, upper+patient_left ->
2x, lower+patient_left -> 3x, lower+patient_right -> 4x. Getting this backwards
mirrors every quadrant and yields a valid-looking code for the wrong tooth,
which no schema check can catch — so all four quadrants are pinned by tests,
along with out-of-range positions never being clamped and deciduous teeth being
rejected outright (the chart is permanent dentition only).
Adjacency mirrors the frontend's arch-order rule, so the midline pairs 11-21
and 41-31 count as neighbours exactly as the chart treats them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Voice extraction resolves spoken Jalali dates into ISO dates server-side, so
the backend needs the conversion the frontend already had. The resolvers live
here rather than in the frontend precisely because this half of the repo has a
test runner.
Ported from frontend/src/lib/i18n/persianCalendar.ts and verified faithful by
differential test: every day from 1900-2100 (73,414 days), zero mismatches on
conversion, leap years and month lengths.
Two deliberate divergences from the original:
- jalaliToIsoDate() returns null instead of throwing. It is fed model-supplied
values, which may be nonsense, and an invalid date must degrade to
"unresolved" rather than a 500. The year guard runs before jalaliDaysInMonth
so the throwing jalCal is unreachable from it.
- toLatinDigits() also handles the Arabic-Indic block (U+0660-U+0669), not just
Persian (U+06F0-U+06F9). ASR output can carry either, sometimes mixed with
ASCII in one transcript; the frontend version only parses keystrokes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Logical API errors throw stable codes so users see translated messages instead of a generic bad request.
Co-authored-by: Cursor <cursoragent@cursor.com>