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>
79 lines
3.4 KiB
TypeScript
79 lines
3.4 KiB
TypeScript
import type { ExtractionCatalog } from './voice.providers';
|
|
|
|
/** Locale-specific guidance. Only the tooth vocabulary and numbering habits differ. */
|
|
const LOCALE_NOTES: Record<string, string> = {
|
|
fa: [
|
|
'The clinician is speaking Persian. Tooth references are usually 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.',
|
|
].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.',
|
|
].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.',
|
|
].join(' '),
|
|
};
|
|
|
|
function codeList(entries: { code: string; label: string }[]): string {
|
|
if (entries.length === 0) return '(none available)';
|
|
return entries.map((e) => `- ${e.code} = ${e.label}`).join('\n');
|
|
}
|
|
|
|
export function buildExtractionPrompt(
|
|
transcript: string,
|
|
catalog: ExtractionCatalog,
|
|
localeHint: string,
|
|
) {
|
|
const localeNote = LOCALE_NOTES[localeHint] ?? LOCALE_NOTES.en;
|
|
|
|
const system = [
|
|
'You extract structured dental treatment data from a transcript of a clinician speaking.',
|
|
'You are a parser, not an assistant: report only what was said.',
|
|
'',
|
|
'HARD RULES',
|
|
'1. Never invent a code. treatmentType, prosthesisDefaultType and prosthesisOverrides[].type',
|
|
' must be codes from the lists below. labId must be an id from the lab list. If what you',
|
|
' heard is not in a list, use null.',
|
|
'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',
|
|
" flip to the viewer's point of view.",
|
|
'4. 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',
|
|
' what was heard.',
|
|
'6. If you are unsure about a value, use null. A missing field is recoverable; a wrong',
|
|
' one is not.',
|
|
'',
|
|
localeNote,
|
|
'',
|
|
'TREATMENT TYPE CODES',
|
|
codeList(catalog.treatmentTypes),
|
|
'',
|
|
'PROSTHESIS TYPE CODES',
|
|
codeList(catalog.prosthesisTypes),
|
|
'',
|
|
'LABS THIS CLINIC CAN SEND TO',
|
|
catalog.labs.length > 0
|
|
? catalog.labs.map((l) => `- ${l.id} = ${l.name}`).join('\n')
|
|
: '(none linked — labId must be null)',
|
|
'',
|
|
'OTHER FIELDS',
|
|
'- connectedSpans: only for bridges or splinted units. Endpoints inclusive.',
|
|
'- comment: clinical notes, in the language spoken. Omit the parts already captured as',
|
|
' treatment type, teeth or deadline.',
|
|
'- labMatchExact: true only when the spoken name matched a lab name exactly.',
|
|
].join('\n');
|
|
|
|
return [
|
|
{ role: 'system' as const, content: system },
|
|
{ role: 'user' as const, content: transcript },
|
|
];
|
|
}
|