Files
dyolink/backend/src/modules/voice/extraction.wire.ts

307 lines
9.3 KiB
TypeScript
Raw Normal View History

import { normalizeFdiCode } from '../../common/fdi';
feat(backend): OpenRouter voice providers and per-locale registry ASR and extraction are separate, independently swappable roles resolved per locale from config. All three locales point at the same OpenRouter models today (whisper-1, gemini-3.7-flash); the indirection stays because Persian ASR is the weakest link and repointing only `fa` must not be a code change. The model emits a deliberately flat wire shape rather than the internal discriminated unions — strict json_schema mode has poor union support — and toVoiceIntent narrows it. That normalizer is total: a missing or malformed payload yields a shape the resolvers report as unresolved rather than one that throws. The prompt supplies catalog codes with labels in the actor's locale, so the model matches spoken words rather than translating, and carries per-locale tooth vocabulary. English gets an explicit warning that a bare two-digit number is ambiguous under Universal numbering, and must not be treated as FDI unless the speaker said so. From review of this commit: - only an actually FDI-shaped code takes the explicit branch; fdi:"6" alongside valid arch/side/position used to lose the tooth entirely - an unrecognised due kind passes through to be flagged, instead of collapsing to null and looking like no deadline was ever spoken - vendor error bodies stay out of the thrown message and the default log level; a 4xx can echo the request back, transcript included - the chat call sets provider.require_parameters so OpenRouter only routes to endpoints that honour the JSON schema, rather than ones treating it as a hint - an unknown locale in VOICE_ENABLED_LOCALES now fails at boot like an unknown provider id, instead of silently disabling the microphone everywhere Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:00:53 +03:30
import type {
ConnectedSpanIntent,
DueIntent,
feat(voice): adapt voice entry to the stacked-jobs prosthesis model Authored by the /orchestrate builder agent, committed unrepaired so the fixes that follow are reviewable against it. Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode / resolveProsthesisAssignment for leaf-vs-category classification, region validity with mixed-region deferral, and assignmentIndex on unresolved items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to CatalogEntityKind with a migration and seeded fa/en/nl translations; and rewrites the extraction prompt to render the catalog as a tree. Frontend: merged "teeth and prosthesis" row, stack preview through the existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult and voiceForEditor, and the two carried-forward recording fixes — the container fallback that refused Safari and the render gate that never checked isMediaRecorderSupported(). Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md. Gate was green: backend 16 suites / 209 tests, nest build, prisma validate; frontend 37 Vitest tests, tsc --noEmit, next build. KNOWN DEFECTS, fixed in the commits that follow: - VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply - VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's type-row lock is missing, so unticking it saves prosthesis lab rows on a non-prosthesis detail Reviewed on the correctness lens only; regression-risk never ran. The migration was validated but never applied. Spec: docs/specs/voice-treatment-entry/spec.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
ProsthesisAssignment,
feat(backend): OpenRouter voice providers and per-locale registry ASR and extraction are separate, independently swappable roles resolved per locale from config. All three locales point at the same OpenRouter models today (whisper-1, gemini-3.7-flash); the indirection stays because Persian ASR is the weakest link and repointing only `fa` must not be a code change. The model emits a deliberately flat wire shape rather than the internal discriminated unions — strict json_schema mode has poor union support — and toVoiceIntent narrows it. That normalizer is total: a missing or malformed payload yields a shape the resolvers report as unresolved rather than one that throws. The prompt supplies catalog codes with labels in the actor's locale, so the model matches spoken words rather than translating, and carries per-locale tooth vocabulary. English gets an explicit warning that a bare two-digit number is ambiguous under Universal numbering, and must not be treated as FDI unless the speaker said so. From review of this commit: - only an actually FDI-shaped code takes the explicit branch; fdi:"6" alongside valid arch/side/position used to lose the tooth entirely - an unrecognised due kind passes through to be flagged, instead of collapsing to null and looking like no deadline was ever spoken - vendor error bodies stay out of the thrown message and the default log level; a 4xx can echo the request back, transcript included - the chat call sets provider.require_parameters so OpenRouter only routes to endpoints that honour the JSON schema, rather than ones treating it as a hint - an unknown locale in VOICE_ENABLED_LOCALES now fails at boot like an unknown provider id, instead of silently disabling the microphone everywhere Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:00:53 +03:30
ToothIntent,
VoiceIntent,
Weekday,
} from './voice.types';
import { WEEKDAYS } from './voice.types';
/**
* Deliberately flat: strict `json_schema` mode has poor support for discriminated unions,
docs: cut the comments that were not earning their place 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) <noreply@anthropic.com>
2026-08-21 23:46:56 +08:00
* 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.
feat(backend): OpenRouter voice providers and per-locale registry ASR and extraction are separate, independently swappable roles resolved per locale from config. All three locales point at the same OpenRouter models today (whisper-1, gemini-3.7-flash); the indirection stays because Persian ASR is the weakest link and repointing only `fa` must not be a code change. The model emits a deliberately flat wire shape rather than the internal discriminated unions — strict json_schema mode has poor union support — and toVoiceIntent narrows it. That normalizer is total: a missing or malformed payload yields a shape the resolvers report as unresolved rather than one that throws. The prompt supplies catalog codes with labels in the actor's locale, so the model matches spoken words rather than translating, and carries per-locale tooth vocabulary. English gets an explicit warning that a bare two-digit number is ambiguous under Universal numbering, and must not be treated as FDI unless the speaker said so. From review of this commit: - only an actually FDI-shaped code takes the explicit branch; fdi:"6" alongside valid arch/side/position used to lose the tooth entirely - an unrecognised due kind passes through to be flagged, instead of collapsing to null and looking like no deadline was ever spoken - vendor error bodies stay out of the thrown message and the default log level; a 4xx can echo the request back, transcript included - the chat call sets provider.require_parameters so OpenRouter only routes to endpoints that honour the JSON schema, rather than ones treating it as a hint - an unknown locale in VOICE_ENABLED_LOCALES now fails at boot like an unknown provider id, instead of silently disabling the microphone everywhere Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:00:53 +03:30
*/
export type WireToothIntent = {
spoken: string;
/** The two-digit FDI code the clinician spoke; null when the tooth was described. */
feat(backend): OpenRouter voice providers and per-locale registry ASR and extraction are separate, independently swappable roles resolved per locale from config. All three locales point at the same OpenRouter models today (whisper-1, gemini-3.7-flash); the indirection stays because Persian ASR is the weakest link and repointing only `fa` must not be a code change. The model emits a deliberately flat wire shape rather than the internal discriminated unions — strict json_schema mode has poor union support — and toVoiceIntent narrows it. That normalizer is total: a missing or malformed payload yields a shape the resolvers report as unresolved rather than one that throws. The prompt supplies catalog codes with labels in the actor's locale, so the model matches spoken words rather than translating, and carries per-locale tooth vocabulary. English gets an explicit warning that a bare two-digit number is ambiguous under Universal numbering, and must not be treated as FDI unless the speaker said so. From review of this commit: - only an actually FDI-shaped code takes the explicit branch; fdi:"6" alongside valid arch/side/position used to lose the tooth entirely - an unrecognised due kind passes through to be flagged, instead of collapsing to null and looking like no deadline was ever spoken - vendor error bodies stay out of the thrown message and the default log level; a 4xx can echo the request back, transcript included - the chat call sets provider.require_parameters so OpenRouter only routes to endpoints that honour the JSON schema, rather than ones treating it as a hint - an unknown locale in VOICE_ENABLED_LOCALES now fails at boot like an unknown provider id, instead of silently disabling the microphone everywhere Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:00:53 +03:30
fdi: string | null;
feat(voice): adapt voice entry to the stacked-jobs prosthesis model Authored by the /orchestrate builder agent, committed unrepaired so the fixes that follow are reviewable against it. Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode / resolveProsthesisAssignment for leaf-vs-category classification, region validity with mixed-region deferral, and assignmentIndex on unresolved items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to CatalogEntityKind with a migration and seeded fa/en/nl translations; and rewrites the extraction prompt to render the catalog as a tree. Frontend: merged "teeth and prosthesis" row, stack preview through the existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult and voiceForEditor, and the two carried-forward recording fixes — the container fallback that refused Safari and the render gate that never checked isMediaRecorderSupported(). Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md. Gate was green: backend 16 suites / 209 tests, nest build, prisma validate; frontend 37 Vitest tests, tsc --noEmit, next build. KNOWN DEFECTS, fixed in the commits that follow: - VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply - VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's type-row lock is missing, so unticking it saves prosthesis lab rows on a non-prosthesis detail Reviewed on the correctness lens only; regression-risk never ran. The migration was validated but never applied. Spec: docs/specs/voice-treatment-entry/spec.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
arch: 'upper' | 'lower' | 'both' | null;
feat(backend): OpenRouter voice providers and per-locale registry ASR and extraction are separate, independently swappable roles resolved per locale from config. All three locales point at the same OpenRouter models today (whisper-1, gemini-3.7-flash); the indirection stays because Persian ASR is the weakest link and repointing only `fa` must not be a code change. The model emits a deliberately flat wire shape rather than the internal discriminated unions — strict json_schema mode has poor union support — and toVoiceIntent narrows it. That normalizer is total: a missing or malformed payload yields a shape the resolvers report as unresolved rather than one that throws. The prompt supplies catalog codes with labels in the actor's locale, so the model matches spoken words rather than translating, and carries per-locale tooth vocabulary. English gets an explicit warning that a bare two-digit number is ambiguous under Universal numbering, and must not be treated as FDI unless the speaker said so. From review of this commit: - only an actually FDI-shaped code takes the explicit branch; fdi:"6" alongside valid arch/side/position used to lose the tooth entirely - an unrecognised due kind passes through to be flagged, instead of collapsing to null and looking like no deadline was ever spoken - vendor error bodies stay out of the thrown message and the default log level; a 4xx can echo the request back, transcript included - the chat call sets provider.require_parameters so OpenRouter only routes to endpoints that honour the JSON schema, rather than ones treating it as a hint - an unknown locale in VOICE_ENABLED_LOCALES now fails at boot like an unknown provider id, instead of silently disabling the microphone everywhere Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:00:53 +03:30
side: 'patient_right' | 'patient_left' | null;
position: number | null;
};
export type WireDue = {
kind: 'weekday' | 'offset' | 'jalali' | 'gregorian' | 'none';
weekday: Weekday | null;
which: 'this' | 'next' | null;
unit: 'day' | 'week' | 'month' | null;
amount: number | null;
jy: number | null;
jm: number | null;
jd: number | null;
y: number | null;
m: number | null;
d: number | null;
};
feat(voice): adapt voice entry to the stacked-jobs prosthesis model Authored by the /orchestrate builder agent, committed unrepaired so the fixes that follow are reviewable against it. Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode / resolveProsthesisAssignment for leaf-vs-category classification, region validity with mixed-region deferral, and assignmentIndex on unresolved items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to CatalogEntityKind with a migration and seeded fa/en/nl translations; and rewrites the extraction prompt to render the catalog as a tree. Frontend: merged "teeth and prosthesis" row, stack preview through the existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult and voiceForEditor, and the two carried-forward recording fixes — the container fallback that refused Safari and the render gate that never checked isMediaRecorderSupported(). Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md. Gate was green: backend 16 suites / 209 tests, nest build, prisma validate; frontend 37 Vitest tests, tsc --noEmit, next build. KNOWN DEFECTS, fixed in the commits that follow: - VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply - VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's type-row lock is missing, so unticking it saves prosthesis lab rows on a non-prosthesis detail Reviewed on the correctness lens only; regression-risk never ran. The migration was validated but never applied. Spec: docs/specs/voice-treatment-entry/spec.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
export type WireProsthesisAssignment = {
targets: WireToothIntent[];
types: string[];
spoken: string;
};
feat(backend): OpenRouter voice providers and per-locale registry ASR and extraction are separate, independently swappable roles resolved per locale from config. All three locales point at the same OpenRouter models today (whisper-1, gemini-3.7-flash); the indirection stays because Persian ASR is the weakest link and repointing only `fa` must not be a code change. The model emits a deliberately flat wire shape rather than the internal discriminated unions — strict json_schema mode has poor union support — and toVoiceIntent narrows it. That normalizer is total: a missing or malformed payload yields a shape the resolvers report as unresolved rather than one that throws. The prompt supplies catalog codes with labels in the actor's locale, so the model matches spoken words rather than translating, and carries per-locale tooth vocabulary. English gets an explicit warning that a bare two-digit number is ambiguous under Universal numbering, and must not be treated as FDI unless the speaker said so. From review of this commit: - only an actually FDI-shaped code takes the explicit branch; fdi:"6" alongside valid arch/side/position used to lose the tooth entirely - an unrecognised due kind passes through to be flagged, instead of collapsing to null and looking like no deadline was ever spoken - vendor error bodies stay out of the thrown message and the default log level; a 4xx can echo the request back, transcript included - the chat call sets provider.require_parameters so OpenRouter only routes to endpoints that honour the JSON schema, rather than ones treating it as a hint - an unknown locale in VOICE_ENABLED_LOCALES now fails at boot like an unknown provider id, instead of silently disabling the microphone everywhere Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:00:53 +03:30
export type WireVoiceIntent = {
treatmentType: string | null;
teeth: WireToothIntent[];
connectedSpans: { from: WireToothIntent; to: WireToothIntent }[];
comment: string | null;
feat(voice): adapt voice entry to the stacked-jobs prosthesis model Authored by the /orchestrate builder agent, committed unrepaired so the fixes that follow are reviewable against it. Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode / resolveProsthesisAssignment for leaf-vs-category classification, region validity with mixed-region deferral, and assignmentIndex on unresolved items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to CatalogEntityKind with a migration and seeded fa/en/nl translations; and rewrites the extraction prompt to render the catalog as a tree. Frontend: merged "teeth and prosthesis" row, stack preview through the existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult and voiceForEditor, and the two carried-forward recording fixes — the container fallback that refused Safari and the render gate that never checked isMediaRecorderSupported(). Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md. Gate was green: backend 16 suites / 209 tests, nest build, prisma validate; frontend 37 Vitest tests, tsc --noEmit, next build. KNOWN DEFECTS, fixed in the commits that follow: - VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply - VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's type-row lock is missing, so unticking it saves prosthesis lab rows on a non-prosthesis detail Reviewed on the correctness lens only; regression-risk never ran. The migration was validated but never applied. Spec: docs/specs/voice-treatment-entry/spec.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
prosthesis: WireProsthesisAssignment[];
feat(backend): OpenRouter voice providers and per-locale registry ASR and extraction are separate, independently swappable roles resolved per locale from config. All three locales point at the same OpenRouter models today (whisper-1, gemini-3.7-flash); the indirection stays because Persian ASR is the weakest link and repointing only `fa` must not be a code change. The model emits a deliberately flat wire shape rather than the internal discriminated unions — strict json_schema mode has poor union support — and toVoiceIntent narrows it. That normalizer is total: a missing or malformed payload yields a shape the resolvers report as unresolved rather than one that throws. The prompt supplies catalog codes with labels in the actor's locale, so the model matches spoken words rather than translating, and carries per-locale tooth vocabulary. English gets an explicit warning that a bare two-digit number is ambiguous under Universal numbering, and must not be treated as FDI unless the speaker said so. From review of this commit: - only an actually FDI-shaped code takes the explicit branch; fdi:"6" alongside valid arch/side/position used to lose the tooth entirely - an unrecognised due kind passes through to be flagged, instead of collapsing to null and looking like no deadline was ever spoken - vendor error bodies stay out of the thrown message and the default log level; a 4xx can echo the request back, transcript included - the chat call sets provider.require_parameters so OpenRouter only routes to endpoints that honour the JSON schema, rather than ones treating it as a hint - an unknown locale in VOICE_ENABLED_LOCALES now fails at boot like an unknown provider id, instead of silently disabling the microphone everywhere Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:00:53 +03:30
labId: string | null;
labMatchExact: boolean;
due: WireDue;
};
const TOOTH_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['spoken', 'fdi', 'arch', 'side', 'position'],
properties: {
spoken: {
type: 'string',
feat(voice): adapt voice entry to the stacked-jobs prosthesis model Authored by the /orchestrate builder agent, committed unrepaired so the fixes that follow are reviewable against it. Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode / resolveProsthesisAssignment for leaf-vs-category classification, region validity with mixed-region deferral, and assignmentIndex on unresolved items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to CatalogEntityKind with a migration and seeded fa/en/nl translations; and rewrites the extraction prompt to render the catalog as a tree. Frontend: merged "teeth and prosthesis" row, stack preview through the existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult and voiceForEditor, and the two carried-forward recording fixes — the container fallback that refused Safari and the render gate that never checked isMediaRecorderSupported(). Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md. Gate was green: backend 16 suites / 209 tests, nest build, prisma validate; frontend 37 Vitest tests, tsc --noEmit, next build. KNOWN DEFECTS, fixed in the commits that follow: - VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply - VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's type-row lock is missing, so unticking it saves prosthesis lab rows on a non-prosthesis detail Reviewed on the correctness lens only; regression-risk never ran. The migration was validated but never applied. Spec: docs/specs/voice-treatment-entry/spec.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
description: 'The exact transcript words for this tooth (or jaw).',
feat(backend): OpenRouter voice providers and per-locale registry ASR and extraction are separate, independently swappable roles resolved per locale from config. All three locales point at the same OpenRouter models today (whisper-1, gemini-3.7-flash); the indirection stays because Persian ASR is the weakest link and repointing only `fa` must not be a code change. The model emits a deliberately flat wire shape rather than the internal discriminated unions — strict json_schema mode has poor union support — and toVoiceIntent narrows it. That normalizer is total: a missing or malformed payload yields a shape the resolvers report as unresolved rather than one that throws. The prompt supplies catalog codes with labels in the actor's locale, so the model matches spoken words rather than translating, and carries per-locale tooth vocabulary. English gets an explicit warning that a bare two-digit number is ambiguous under Universal numbering, and must not be treated as FDI unless the speaker said so. From review of this commit: - only an actually FDI-shaped code takes the explicit branch; fdi:"6" alongside valid arch/side/position used to lose the tooth entirely - an unrecognised due kind passes through to be flagged, instead of collapsing to null and looking like no deadline was ever spoken - vendor error bodies stay out of the thrown message and the default log level; a 4xx can echo the request back, transcript included - the chat call sets provider.require_parameters so OpenRouter only routes to endpoints that honour the JSON schema, rather than ones treating it as a hint - an unknown locale in VOICE_ENABLED_LOCALES now fails at boot like an unknown provider id, instead of silently disabling the microphone everywhere Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:00:53 +03:30
},
fdi: {
type: ['string', 'null'],
description:
'The two-digit FDI code the clinician said for this tooth, e.g. "26". Null only ' +
feat(voice): adapt voice entry to the stacked-jobs prosthesis model Authored by the /orchestrate builder agent, committed unrepaired so the fixes that follow are reviewable against it. Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode / resolveProsthesisAssignment for leaf-vs-category classification, region validity with mixed-region deferral, and assignmentIndex on unresolved items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to CatalogEntityKind with a migration and seeded fa/en/nl translations; and rewrites the extraction prompt to render the catalog as a tree. Frontend: merged "teeth and prosthesis" row, stack preview through the existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult and voiceForEditor, and the two carried-forward recording fixes — the container fallback that refused Safari and the render gate that never checked isMediaRecorderSupported(). Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md. Gate was green: backend 16 suites / 209 tests, nest build, prisma validate; frontend 37 Vitest tests, tsc --noEmit, next build. KNOWN DEFECTS, fixed in the commits that follow: - VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply - VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's type-row lock is missing, so unticking it saves prosthesis lab rows on a non-prosthesis detail Reviewed on the correctness lens only; regression-risk never ran. The migration was validated but never applied. Spec: docs/specs/voice-treatment-entry/spec.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
'when the tooth was described in words instead of numbered, or the target is a jaw.',
},
arch: {
type: ['string', 'null'],
enum: ['upper', 'lower', 'both', null],
description:
'"both" is only ever used for a jaw-level prosthesis target (e.g. an appliance for ' +
'both jaws), never for a single tooth.',
feat(backend): OpenRouter voice providers and per-locale registry ASR and extraction are separate, independently swappable roles resolved per locale from config. All three locales point at the same OpenRouter models today (whisper-1, gemini-3.7-flash); the indirection stays because Persian ASR is the weakest link and repointing only `fa` must not be a code change. The model emits a deliberately flat wire shape rather than the internal discriminated unions — strict json_schema mode has poor union support — and toVoiceIntent narrows it. That normalizer is total: a missing or malformed payload yields a shape the resolvers report as unresolved rather than one that throws. The prompt supplies catalog codes with labels in the actor's locale, so the model matches spoken words rather than translating, and carries per-locale tooth vocabulary. English gets an explicit warning that a bare two-digit number is ambiguous under Universal numbering, and must not be treated as FDI unless the speaker said so. From review of this commit: - only an actually FDI-shaped code takes the explicit branch; fdi:"6" alongside valid arch/side/position used to lose the tooth entirely - an unrecognised due kind passes through to be flagged, instead of collapsing to null and looking like no deadline was ever spoken - vendor error bodies stay out of the thrown message and the default log level; a 4xx can echo the request back, transcript included - the chat call sets provider.require_parameters so OpenRouter only routes to endpoints that honour the JSON schema, rather than ones treating it as a hint - an unknown locale in VOICE_ENABLED_LOCALES now fails at boot like an unknown provider id, instead of silently disabling the microphone everywhere Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:00:53 +03:30
},
side: {
type: ['string', 'null'],
enum: ['patient_right', 'patient_left', null],
description: "The PATIENT's side, never the viewer's.",
},
position: {
type: ['integer', 'null'],
description:
feat(voice): adapt voice entry to the stacked-jobs prosthesis model Authored by the /orchestrate builder agent, committed unrepaired so the fixes that follow are reviewable against it. Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode / resolveProsthesisAssignment for leaf-vs-category classification, region validity with mixed-region deferral, and assignmentIndex on unresolved items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to CatalogEntityKind with a migration and seeded fa/en/nl translations; and rewrites the extraction prompt to render the catalog as a tree. Frontend: merged "teeth and prosthesis" row, stack preview through the existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult and voiceForEditor, and the two carried-forward recording fixes — the container fallback that refused Safari and the render gate that never checked isMediaRecorderSupported(). Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md. Gate was green: backend 16 suites / 209 tests, nest build, prisma validate; frontend 37 Vitest tests, tsc --noEmit, next build. KNOWN DEFECTS, fixed in the commits that follow: - VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply - VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's type-row lock is missing, so unticking it saves prosthesis lab rows on a non-prosthesis detail Reviewed on the correctness lens only; regression-risk never ran. The migration was validated but never applied. Spec: docs/specs/voice-treatment-entry/spec.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
'Position from the midline: 1 = central incisor … 8 = third molar. Never an FDI ' +
'code. Null when this target is a jaw rather than a tooth.',
},
},
} as const;
const PROSTHESIS_ASSIGNMENT_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['targets', 'types', 'spoken'],
properties: {
targets: {
type: 'array',
description: 'The teeth or jaws this instruction applies to.',
items: TOOTH_SCHEMA,
},
types: {
type: 'array',
description:
'Prosthesis type codes to apply to every target above — a stack, e.g. an abutment ' +
'plus a crown on the same tooth. A code from the CATEGORY or SUBCATEGORY lists is ' +
'fine when only the general term was said.',
items: { type: 'string' },
},
spoken: {
type: 'string',
description: 'The exact transcript words for this instruction.',
feat(backend): OpenRouter voice providers and per-locale registry ASR and extraction are separate, independently swappable roles resolved per locale from config. All three locales point at the same OpenRouter models today (whisper-1, gemini-3.7-flash); the indirection stays because Persian ASR is the weakest link and repointing only `fa` must not be a code change. The model emits a deliberately flat wire shape rather than the internal discriminated unions — strict json_schema mode has poor union support — and toVoiceIntent narrows it. That normalizer is total: a missing or malformed payload yields a shape the resolvers report as unresolved rather than one that throws. The prompt supplies catalog codes with labels in the actor's locale, so the model matches spoken words rather than translating, and carries per-locale tooth vocabulary. English gets an explicit warning that a bare two-digit number is ambiguous under Universal numbering, and must not be treated as FDI unless the speaker said so. From review of this commit: - only an actually FDI-shaped code takes the explicit branch; fdi:"6" alongside valid arch/side/position used to lose the tooth entirely - an unrecognised due kind passes through to be flagged, instead of collapsing to null and looking like no deadline was ever spoken - vendor error bodies stay out of the thrown message and the default log level; a 4xx can echo the request back, transcript included - the chat call sets provider.require_parameters so OpenRouter only routes to endpoints that honour the JSON schema, rather than ones treating it as a hint - an unknown locale in VOICE_ENABLED_LOCALES now fails at boot like an unknown provider id, instead of silently disabling the microphone everywhere Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:00:53 +03:30
},
},
} as const;
export const VOICE_INTENT_JSON_SCHEMA = {
type: 'object',
additionalProperties: false,
required: [
'treatmentType',
'teeth',
'connectedSpans',
'comment',
feat(voice): adapt voice entry to the stacked-jobs prosthesis model Authored by the /orchestrate builder agent, committed unrepaired so the fixes that follow are reviewable against it. Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode / resolveProsthesisAssignment for leaf-vs-category classification, region validity with mixed-region deferral, and assignmentIndex on unresolved items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to CatalogEntityKind with a migration and seeded fa/en/nl translations; and rewrites the extraction prompt to render the catalog as a tree. Frontend: merged "teeth and prosthesis" row, stack preview through the existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult and voiceForEditor, and the two carried-forward recording fixes — the container fallback that refused Safari and the render gate that never checked isMediaRecorderSupported(). Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md. Gate was green: backend 16 suites / 209 tests, nest build, prisma validate; frontend 37 Vitest tests, tsc --noEmit, next build. KNOWN DEFECTS, fixed in the commits that follow: - VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply - VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's type-row lock is missing, so unticking it saves prosthesis lab rows on a non-prosthesis detail Reviewed on the correctness lens only; regression-risk never ran. The migration was validated but never applied. Spec: docs/specs/voice-treatment-entry/spec.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
'prosthesis',
feat(backend): OpenRouter voice providers and per-locale registry ASR and extraction are separate, independently swappable roles resolved per locale from config. All three locales point at the same OpenRouter models today (whisper-1, gemini-3.7-flash); the indirection stays because Persian ASR is the weakest link and repointing only `fa` must not be a code change. The model emits a deliberately flat wire shape rather than the internal discriminated unions — strict json_schema mode has poor union support — and toVoiceIntent narrows it. That normalizer is total: a missing or malformed payload yields a shape the resolvers report as unresolved rather than one that throws. The prompt supplies catalog codes with labels in the actor's locale, so the model matches spoken words rather than translating, and carries per-locale tooth vocabulary. English gets an explicit warning that a bare two-digit number is ambiguous under Universal numbering, and must not be treated as FDI unless the speaker said so. From review of this commit: - only an actually FDI-shaped code takes the explicit branch; fdi:"6" alongside valid arch/side/position used to lose the tooth entirely - an unrecognised due kind passes through to be flagged, instead of collapsing to null and looking like no deadline was ever spoken - vendor error bodies stay out of the thrown message and the default log level; a 4xx can echo the request back, transcript included - the chat call sets provider.require_parameters so OpenRouter only routes to endpoints that honour the JSON schema, rather than ones treating it as a hint - an unknown locale in VOICE_ENABLED_LOCALES now fails at boot like an unknown provider id, instead of silently disabling the microphone everywhere Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:00:53 +03:30
'labId',
'labMatchExact',
'due',
],
properties: {
treatmentType: {
type: ['string', 'null'],
description: 'A treatment type CODE from the supplied list, or null.',
},
fix(voice): stop reading a dictated jaw as a broken tooth "یه کامپلیت دنچر برای فک بالا میخوام" resolved the upper arch and the complete denture correctly, but the review sheet also showed position_out_of_range against «فک بالا» and asked which tooth was meant. No tooth was said. The model names the jaw in the top-level `teeth` array as well as in the prosthesis target it belongs to. resolveVoiceIntent passed that array straight to resolveToothIntents, extraction.wire.ts turns `position: null` into NaN, and unresolvedReason tests positionBad first — so it reported a range fault for a value that was never a number, before ever reaching the quadrant branch. resolveVoiceIntent now drops jaw-shaped entries — an arch with no usable position — before the tooth resolver sees them. The jaw already reaches the form through its assignment, so the duplicate carries no information worth reporting. An entry that DOES give a position survives: arch plus position without a side is a real tooth described without its quadrant, and must keep offering its candidate chips. Two invitations removed as well, both ours: the `teeth` property in VOICE_INTENT_JSON_SCHEMA had no description at all, and no prompt rule said a jaw must stay out of it, while TOOTH_SCHEMA — shared with prosthesis[].targets — describes jaws as acceptable. Adds the description and HARD RULE 6. Four tests. The two jaw cases fail without the filter; the out-of-range and missing-quadrant cases pass either way and exist to prove the filter does not over-reach. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 16:56:54 +08:00
teeth: {
type: 'array',
description:
'Individual teeth only. A whole jaw NEVER belongs here — put it in ' +
'prosthesis[].targets with "arch" set and "position" null.',
items: TOOTH_SCHEMA,
},
feat(backend): OpenRouter voice providers and per-locale registry ASR and extraction are separate, independently swappable roles resolved per locale from config. All three locales point at the same OpenRouter models today (whisper-1, gemini-3.7-flash); the indirection stays because Persian ASR is the weakest link and repointing only `fa` must not be a code change. The model emits a deliberately flat wire shape rather than the internal discriminated unions — strict json_schema mode has poor union support — and toVoiceIntent narrows it. That normalizer is total: a missing or malformed payload yields a shape the resolvers report as unresolved rather than one that throws. The prompt supplies catalog codes with labels in the actor's locale, so the model matches spoken words rather than translating, and carries per-locale tooth vocabulary. English gets an explicit warning that a bare two-digit number is ambiguous under Universal numbering, and must not be treated as FDI unless the speaker said so. From review of this commit: - only an actually FDI-shaped code takes the explicit branch; fdi:"6" alongside valid arch/side/position used to lose the tooth entirely - an unrecognised due kind passes through to be flagged, instead of collapsing to null and looking like no deadline was ever spoken - vendor error bodies stay out of the thrown message and the default log level; a 4xx can echo the request back, transcript included - the chat call sets provider.require_parameters so OpenRouter only routes to endpoints that honour the JSON schema, rather than ones treating it as a hint - an unknown locale in VOICE_ENABLED_LOCALES now fails at boot like an unknown provider id, instead of silently disabling the microphone everywhere Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:00:53 +03:30
connectedSpans: {
type: 'array',
description: 'Bridges / splinted units. Endpoints inclusive.',
items: {
type: 'object',
additionalProperties: false,
required: ['from', 'to'],
properties: { from: TOOTH_SCHEMA, to: TOOTH_SCHEMA },
},
},
comment: {
type: ['string', 'null'],
description: 'Clinical notes, in the spoken language.',
},
feat(voice): adapt voice entry to the stacked-jobs prosthesis model Authored by the /orchestrate builder agent, committed unrepaired so the fixes that follow are reviewable against it. Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode / resolveProsthesisAssignment for leaf-vs-category classification, region validity with mixed-region deferral, and assignmentIndex on unresolved items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to CatalogEntityKind with a migration and seeded fa/en/nl translations; and rewrites the extraction prompt to render the catalog as a tree. Frontend: merged "teeth and prosthesis" row, stack preview through the existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult and voiceForEditor, and the two carried-forward recording fixes — the container fallback that refused Safari and the render gate that never checked isMediaRecorderSupported(). Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md. Gate was green: backend 16 suites / 209 tests, nest build, prisma validate; frontend 37 Vitest tests, tsc --noEmit, next build. KNOWN DEFECTS, fixed in the commits that follow: - VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply - VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's type-row lock is missing, so unticking it saves prosthesis lab rows on a non-prosthesis detail Reviewed on the correctness lens only; regression-risk never ran. The migration was validated but never applied. Spec: docs/specs/voice-treatment-entry/spec.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
prosthesis: {
feat(backend): OpenRouter voice providers and per-locale registry ASR and extraction are separate, independently swappable roles resolved per locale from config. All three locales point at the same OpenRouter models today (whisper-1, gemini-3.7-flash); the indirection stays because Persian ASR is the weakest link and repointing only `fa` must not be a code change. The model emits a deliberately flat wire shape rather than the internal discriminated unions — strict json_schema mode has poor union support — and toVoiceIntent narrows it. That normalizer is total: a missing or malformed payload yields a shape the resolvers report as unresolved rather than one that throws. The prompt supplies catalog codes with labels in the actor's locale, so the model matches spoken words rather than translating, and carries per-locale tooth vocabulary. English gets an explicit warning that a bare two-digit number is ambiguous under Universal numbering, and must not be treated as FDI unless the speaker said so. From review of this commit: - only an actually FDI-shaped code takes the explicit branch; fdi:"6" alongside valid arch/side/position used to lose the tooth entirely - an unrecognised due kind passes through to be flagged, instead of collapsing to null and looking like no deadline was ever spoken - vendor error bodies stay out of the thrown message and the default log level; a 4xx can echo the request back, transcript included - the chat call sets provider.require_parameters so OpenRouter only routes to endpoints that honour the JSON schema, rather than ones treating it as a hint - an unknown locale in VOICE_ENABLED_LOCALES now fails at boot like an unknown provider id, instead of silently disabling the microphone everywhere Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:00:53 +03:30
type: 'array',
feat(voice): adapt voice entry to the stacked-jobs prosthesis model Authored by the /orchestrate builder agent, committed unrepaired so the fixes that follow are reviewable against it. Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode / resolveProsthesisAssignment for leaf-vs-category classification, region validity with mixed-region deferral, and assignmentIndex on unresolved items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to CatalogEntityKind with a migration and seeded fa/en/nl translations; and rewrites the extraction prompt to render the catalog as a tree. Frontend: merged "teeth and prosthesis" row, stack preview through the existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult and voiceForEditor, and the two carried-forward recording fixes — the container fallback that refused Safari and the render gate that never checked isMediaRecorderSupported(). Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md. Gate was green: backend 16 suites / 209 tests, nest build, prisma validate; frontend 37 Vitest tests, tsc --noEmit, next build. KNOWN DEFECTS, fixed in the commits that follow: - VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply - VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's type-row lock is missing, so unticking it saves prosthesis lab rows on a non-prosthesis detail Reviewed on the correctness lens only; regression-risk never ran. The migration was validated but never applied. Spec: docs/specs/voice-treatment-entry/spec.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
description:
'One entry per spoken instruction: these targets get these jobs. No default and no ' +
'overrides — every entry names its own targets.',
items: PROSTHESIS_ASSIGNMENT_SCHEMA,
feat(backend): OpenRouter voice providers and per-locale registry ASR and extraction are separate, independently swappable roles resolved per locale from config. All three locales point at the same OpenRouter models today (whisper-1, gemini-3.7-flash); the indirection stays because Persian ASR is the weakest link and repointing only `fa` must not be a code change. The model emits a deliberately flat wire shape rather than the internal discriminated unions — strict json_schema mode has poor union support — and toVoiceIntent narrows it. That normalizer is total: a missing or malformed payload yields a shape the resolvers report as unresolved rather than one that throws. The prompt supplies catalog codes with labels in the actor's locale, so the model matches spoken words rather than translating, and carries per-locale tooth vocabulary. English gets an explicit warning that a bare two-digit number is ambiguous under Universal numbering, and must not be treated as FDI unless the speaker said so. From review of this commit: - only an actually FDI-shaped code takes the explicit branch; fdi:"6" alongside valid arch/side/position used to lose the tooth entirely - an unrecognised due kind passes through to be flagged, instead of collapsing to null and looking like no deadline was ever spoken - vendor error bodies stay out of the thrown message and the default log level; a 4xx can echo the request back, transcript included - the chat call sets provider.require_parameters so OpenRouter only routes to endpoints that honour the JSON schema, rather than ones treating it as a hint - an unknown locale in VOICE_ENABLED_LOCALES now fails at boot like an unknown provider id, instead of silently disabling the microphone everywhere Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:00:53 +03:30
},
labId: {
type: ['string', 'null'],
description: 'An id from the supplied lab list. Never invent one.',
},
labMatchExact: {
type: 'boolean',
description: 'True only when the spoken name matched a lab name exactly.',
},
due: {
type: 'object',
additionalProperties: false,
required: [
'kind',
'weekday',
'which',
'unit',
'amount',
'jy',
'jm',
'jd',
'y',
'm',
'd',
],
properties: {
kind: {
type: 'string',
enum: ['weekday', 'offset', 'jalali', 'gregorian', 'none'],
},
weekday: { type: ['string', 'null'], enum: [...WEEKDAYS, null] },
which: { type: ['string', 'null'], enum: ['this', 'next', null] },
unit: {
type: ['string', 'null'],
enum: ['day', 'week', 'month', null],
},
amount: { type: ['integer', 'null'] },
jy: { type: ['integer', 'null'] },
jm: { type: ['integer', 'null'] },
jd: { type: ['integer', 'null'] },
y: { type: ['integer', 'null'] },
m: { type: ['integer', 'null'] },
d: { type: ['integer', 'null'] },
},
},
},
} as const;
/** Two digits, quadrant 1-8, position 1-8 — the only thing that can be an FDI code. */
const FDI_SHAPE = /^[1-8][1-8]$/;
function toToothIntent(wire: WireToothIntent | undefined | null): ToothIntent {
const spoken = typeof wire?.spoken === 'string' ? wire.spoken : '';
docs: cut the comments that were not earning their place 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) <noreply@anthropic.com>
2026-08-21 23:46:56 +08:00
// "۲۶" 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);
feat(backend): OpenRouter voice providers and per-locale registry ASR and extraction are separate, independently swappable roles resolved per locale from config. All three locales point at the same OpenRouter models today (whisper-1, gemini-3.7-flash); the indirection stays because Persian ASR is the weakest link and repointing only `fa` must not be a code change. The model emits a deliberately flat wire shape rather than the internal discriminated unions — strict json_schema mode has poor union support — and toVoiceIntent narrows it. That normalizer is total: a missing or malformed payload yields a shape the resolvers report as unresolved rather than one that throws. The prompt supplies catalog codes with labels in the actor's locale, so the model matches spoken words rather than translating, and carries per-locale tooth vocabulary. English gets an explicit warning that a bare two-digit number is ambiguous under Universal numbering, and must not be treated as FDI unless the speaker said so. From review of this commit: - only an actually FDI-shaped code takes the explicit branch; fdi:"6" alongside valid arch/side/position used to lose the tooth entirely - an unrecognised due kind passes through to be flagged, instead of collapsing to null and looking like no deadline was ever spoken - vendor error bodies stay out of the thrown message and the default log level; a 4xx can echo the request back, transcript included - the chat call sets provider.require_parameters so OpenRouter only routes to endpoints that honour the JSON schema, rather than ones treating it as a hint - an unknown locale in VOICE_ENABLED_LOCALES now fails at boot like an unknown provider id, instead of silently disabling the microphone everywhere Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:00:53 +03:30
// 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)) {
return { kind: 'explicit', fdi, spoken };
}
return {
kind: 'positional',
feat(voice): adapt voice entry to the stacked-jobs prosthesis model Authored by the /orchestrate builder agent, committed unrepaired so the fixes that follow are reviewable against it. Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode / resolveProsthesisAssignment for leaf-vs-category classification, region validity with mixed-region deferral, and assignmentIndex on unresolved items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to CatalogEntityKind with a migration and seeded fa/en/nl translations; and rewrites the extraction prompt to render the catalog as a tree. Frontend: merged "teeth and prosthesis" row, stack preview through the existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult and voiceForEditor, and the two carried-forward recording fixes — the container fallback that refused Safari and the render gate that never checked isMediaRecorderSupported(). Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md. Gate was green: backend 16 suites / 209 tests, nest build, prisma validate; frontend 37 Vitest tests, tsc --noEmit, next build. KNOWN DEFECTS, fixed in the commits that follow: - VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply - VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's type-row lock is missing, so unticking it saves prosthesis lab rows on a non-prosthesis detail Reviewed on the correctness lens only; regression-risk never ran. The migration was validated but never applied. Spec: docs/specs/voice-treatment-entry/spec.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
arch: wire?.arch as 'upper' | 'lower' | 'both',
feat(backend): OpenRouter voice providers and per-locale registry ASR and extraction are separate, independently swappable roles resolved per locale from config. All three locales point at the same OpenRouter models today (whisper-1, gemini-3.7-flash); the indirection stays because Persian ASR is the weakest link and repointing only `fa` must not be a code change. The model emits a deliberately flat wire shape rather than the internal discriminated unions — strict json_schema mode has poor union support — and toVoiceIntent narrows it. That normalizer is total: a missing or malformed payload yields a shape the resolvers report as unresolved rather than one that throws. The prompt supplies catalog codes with labels in the actor's locale, so the model matches spoken words rather than translating, and carries per-locale tooth vocabulary. English gets an explicit warning that a bare two-digit number is ambiguous under Universal numbering, and must not be treated as FDI unless the speaker said so. From review of this commit: - only an actually FDI-shaped code takes the explicit branch; fdi:"6" alongside valid arch/side/position used to lose the tooth entirely - an unrecognised due kind passes through to be flagged, instead of collapsing to null and looking like no deadline was ever spoken - vendor error bodies stay out of the thrown message and the default log level; a 4xx can echo the request back, transcript included - the chat call sets provider.require_parameters so OpenRouter only routes to endpoints that honour the JSON schema, rather than ones treating it as a hint - an unknown locale in VOICE_ENABLED_LOCALES now fails at boot like an unknown provider id, instead of silently disabling the microphone everywhere Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:00:53 +03:30
side: wire?.side as 'patient_right' | 'patient_left',
position: typeof wire?.position === 'number' ? wire.position : Number.NaN,
spoken,
};
}
function toDueIntent(wire: WireDue | undefined | null): DueIntent | null {
switch (wire?.kind) {
case 'weekday':
return {
kind: 'weekday',
weekday: wire.weekday as Weekday,
which: wire.which as 'this',
};
case 'offset':
return {
kind: 'offset',
unit: wire.unit as 'day',
amount: typeof wire.amount === 'number' ? wire.amount : Number.NaN,
};
case 'jalali':
return {
kind: 'jalali',
jy: wire.jy as number,
jm: wire.jm as number,
jd: wire.jd as number,
};
case 'gregorian':
return {
kind: 'gregorian',
y: wire.y as number,
m: wire.m as number,
d: wire.d as number,
};
case 'none':
case undefined:
return null;
default:
// An unrecognised kind means a deadline WAS spoken and we failed to classify it.
// Passing it through lets the resolver flag it; collapsing it to null would make a
// misunderstood deadline indistinguishable from no deadline at all.
return { kind: wire?.kind } as unknown as DueIntent;
}
}
feat(voice): adapt voice entry to the stacked-jobs prosthesis model Authored by the /orchestrate builder agent, committed unrepaired so the fixes that follow are reviewable against it. Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode / resolveProsthesisAssignment for leaf-vs-category classification, region validity with mixed-region deferral, and assignmentIndex on unresolved items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to CatalogEntityKind with a migration and seeded fa/en/nl translations; and rewrites the extraction prompt to render the catalog as a tree. Frontend: merged "teeth and prosthesis" row, stack preview through the existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult and voiceForEditor, and the two carried-forward recording fixes — the container fallback that refused Safari and the render gate that never checked isMediaRecorderSupported(). Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md. Gate was green: backend 16 suites / 209 tests, nest build, prisma validate; frontend 37 Vitest tests, tsc --noEmit, next build. KNOWN DEFECTS, fixed in the commits that follow: - VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply - VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's type-row lock is missing, so unticking it saves prosthesis lab rows on a non-prosthesis detail Reviewed on the correctness lens only; regression-risk never ran. The migration was validated but never applied. Spec: docs/specs/voice-treatment-entry/spec.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
function toProsthesisAssignment(
wire: WireProsthesisAssignment | undefined | null,
): ProsthesisAssignment {
const targets = Array.isArray(wire?.targets) ? wire.targets : [];
const types = Array.isArray(wire?.types) ? wire.types : [];
return {
targets: targets.map(toToothIntent),
types: types.filter((code): code is string => typeof code === 'string'),
spoken: typeof wire?.spoken === 'string' ? wire.spoken : '',
};
}
feat(backend): OpenRouter voice providers and per-locale registry ASR and extraction are separate, independently swappable roles resolved per locale from config. All three locales point at the same OpenRouter models today (whisper-1, gemini-3.7-flash); the indirection stays because Persian ASR is the weakest link and repointing only `fa` must not be a code change. The model emits a deliberately flat wire shape rather than the internal discriminated unions — strict json_schema mode has poor union support — and toVoiceIntent narrows it. That normalizer is total: a missing or malformed payload yields a shape the resolvers report as unresolved rather than one that throws. The prompt supplies catalog codes with labels in the actor's locale, so the model matches spoken words rather than translating, and carries per-locale tooth vocabulary. English gets an explicit warning that a bare two-digit number is ambiguous under Universal numbering, and must not be treated as FDI unless the speaker said so. From review of this commit: - only an actually FDI-shaped code takes the explicit branch; fdi:"6" alongside valid arch/side/position used to lose the tooth entirely - an unrecognised due kind passes through to be flagged, instead of collapsing to null and looking like no deadline was ever spoken - vendor error bodies stay out of the thrown message and the default log level; a 4xx can echo the request back, transcript included - the chat call sets provider.require_parameters so OpenRouter only routes to endpoints that honour the JSON schema, rather than ones treating it as a hint - an unknown locale in VOICE_ENABLED_LOCALES now fails at boot like an unknown provider id, instead of silently disabling the microphone everywhere Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:00:53 +03:30
export function toVoiceIntent(wire: WireVoiceIntent): VoiceIntent {
const teeth = Array.isArray(wire?.teeth) ? wire.teeth : [];
const spans = Array.isArray(wire?.connectedSpans) ? wire.connectedSpans : [];
feat(voice): adapt voice entry to the stacked-jobs prosthesis model Authored by the /orchestrate builder agent, committed unrepaired so the fixes that follow are reviewable against it. Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode / resolveProsthesisAssignment for leaf-vs-category classification, region validity with mixed-region deferral, and assignmentIndex on unresolved items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to CatalogEntityKind with a migration and seeded fa/en/nl translations; and rewrites the extraction prompt to render the catalog as a tree. Frontend: merged "teeth and prosthesis" row, stack preview through the existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult and voiceForEditor, and the two carried-forward recording fixes — the container fallback that refused Safari and the render gate that never checked isMediaRecorderSupported(). Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md. Gate was green: backend 16 suites / 209 tests, nest build, prisma validate; frontend 37 Vitest tests, tsc --noEmit, next build. KNOWN DEFECTS, fixed in the commits that follow: - VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply - VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's type-row lock is missing, so unticking it saves prosthesis lab rows on a non-prosthesis detail Reviewed on the correctness lens only; regression-risk never ran. The migration was validated but never applied. Spec: docs/specs/voice-treatment-entry/spec.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
const assignments = Array.isArray(wire?.prosthesis) ? wire.prosthesis : [];
feat(backend): OpenRouter voice providers and per-locale registry ASR and extraction are separate, independently swappable roles resolved per locale from config. All three locales point at the same OpenRouter models today (whisper-1, gemini-3.7-flash); the indirection stays because Persian ASR is the weakest link and repointing only `fa` must not be a code change. The model emits a deliberately flat wire shape rather than the internal discriminated unions — strict json_schema mode has poor union support — and toVoiceIntent narrows it. That normalizer is total: a missing or malformed payload yields a shape the resolvers report as unresolved rather than one that throws. The prompt supplies catalog codes with labels in the actor's locale, so the model matches spoken words rather than translating, and carries per-locale tooth vocabulary. English gets an explicit warning that a bare two-digit number is ambiguous under Universal numbering, and must not be treated as FDI unless the speaker said so. From review of this commit: - only an actually FDI-shaped code takes the explicit branch; fdi:"6" alongside valid arch/side/position used to lose the tooth entirely - an unrecognised due kind passes through to be flagged, instead of collapsing to null and looking like no deadline was ever spoken - vendor error bodies stay out of the thrown message and the default log level; a 4xx can echo the request back, transcript included - the chat call sets provider.require_parameters so OpenRouter only routes to endpoints that honour the JSON schema, rather than ones treating it as a hint - an unknown locale in VOICE_ENABLED_LOCALES now fails at boot like an unknown provider id, instead of silently disabling the microphone everywhere Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:00:53 +03:30
const connectedSpans: ConnectedSpanIntent[] = spans.map((span) => ({
from: toToothIntent(span?.from),
to: toToothIntent(span?.to),
}));
return {
treatmentType: wire?.treatmentType ?? null,
teeth: teeth.map(toToothIntent),
connectedSpans,
comment: wire?.comment ?? null,
feat(voice): adapt voice entry to the stacked-jobs prosthesis model Authored by the /orchestrate builder agent, committed unrepaired so the fixes that follow are reviewable against it. Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode / resolveProsthesisAssignment for leaf-vs-category classification, region validity with mixed-region deferral, and assignmentIndex on unresolved items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to CatalogEntityKind with a migration and seeded fa/en/nl translations; and rewrites the extraction prompt to render the catalog as a tree. Frontend: merged "teeth and prosthesis" row, stack preview through the existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult and voiceForEditor, and the two carried-forward recording fixes — the container fallback that refused Safari and the render gate that never checked isMediaRecorderSupported(). Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md. Gate was green: backend 16 suites / 209 tests, nest build, prisma validate; frontend 37 Vitest tests, tsc --noEmit, next build. KNOWN DEFECTS, fixed in the commits that follow: - VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply - VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's type-row lock is missing, so unticking it saves prosthesis lab rows on a non-prosthesis detail Reviewed on the correctness lens only; regression-risk never ran. The migration was validated but never applied. Spec: docs/specs/voice-treatment-entry/spec.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
prosthesis: assignments.map(toProsthesisAssignment),
feat(backend): OpenRouter voice providers and per-locale registry ASR and extraction are separate, independently swappable roles resolved per locale from config. All three locales point at the same OpenRouter models today (whisper-1, gemini-3.7-flash); the indirection stays because Persian ASR is the weakest link and repointing only `fa` must not be a code change. The model emits a deliberately flat wire shape rather than the internal discriminated unions — strict json_schema mode has poor union support — and toVoiceIntent narrows it. That normalizer is total: a missing or malformed payload yields a shape the resolvers report as unresolved rather than one that throws. The prompt supplies catalog codes with labels in the actor's locale, so the model matches spoken words rather than translating, and carries per-locale tooth vocabulary. English gets an explicit warning that a bare two-digit number is ambiguous under Universal numbering, and must not be treated as FDI unless the speaker said so. From review of this commit: - only an actually FDI-shaped code takes the explicit branch; fdi:"6" alongside valid arch/side/position used to lose the tooth entirely - an unrecognised due kind passes through to be flagged, instead of collapsing to null and looking like no deadline was ever spoken - vendor error bodies stay out of the thrown message and the default log level; a 4xx can echo the request back, transcript included - the chat call sets provider.require_parameters so OpenRouter only routes to endpoints that honour the JSON schema, rather than ones treating it as a hint - an unknown locale in VOICE_ENABLED_LOCALES now fails at boot like an unknown provider id, instead of silently disabling the microphone everywhere Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:00:53 +03:30
labId: wire?.labId ?? null,
labMatchExact: wire?.labMatchExact === true,
due: toDueIntent(wire?.due),
};
}