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

210 lines
5.9 KiB
TypeScript
Raw Normal View History

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 {
toVoiceIntent,
VOICE_INTENT_JSON_SCHEMA,
type WireVoiceIntent,
} from './extraction.wire';
const emptyDue = {
kind: 'none' as const,
weekday: null,
which: null,
unit: null,
amount: null,
jy: null,
jm: null,
jd: null,
y: null,
m: null,
d: null,
};
const wire = (overrides: Partial<WireVoiceIntent> = {}): WireVoiceIntent => ({
treatmentType: null,
teeth: [],
connectedSpans: [],
comment: null,
prosthesisDefaultType: null,
prosthesisOverrides: [],
labId: null,
labMatchExact: false,
due: emptyDue,
...overrides,
});
const positionalTooth = {
spoken: 'شش بالا راست',
fdi: null,
arch: 'upper' as const,
side: 'patient_right' as const,
position: 6,
};
describe('VOICE_INTENT_JSON_SCHEMA', () => {
it('is strict — every property required, no extras', () => {
expect(VOICE_INTENT_JSON_SCHEMA.additionalProperties).toBe(false);
expect([...VOICE_INTENT_JSON_SCHEMA.required].sort()).toEqual(
Object.keys(VOICE_INTENT_JSON_SCHEMA.properties).sort(),
);
});
it('requires every field of the due object, since strict mode allows no optionals', () => {
const due = VOICE_INTENT_JSON_SCHEMA.properties.due;
expect([...due.required].sort()).toEqual(
Object.keys(due.properties).sort(),
);
});
});
describe('toVoiceIntent', () => {
it('narrows a positional tooth', () => {
const result = toVoiceIntent(wire({ teeth: [positionalTooth] }));
expect(result.teeth[0]).toEqual({
kind: 'positional',
arch: 'upper',
side: 'patient_right',
position: 6,
spoken: 'شش بالا راست',
});
});
it('narrows an explicit FDI tooth, which wins over positional fields', () => {
const result = toVoiceIntent(
wire({ teeth: [{ ...positionalTooth, fdi: '14', spoken: 'یک چهار' }] }),
);
expect(result.teeth[0]).toEqual({
kind: 'explicit',
fdi: '14',
spoken: 'یک چهار',
});
});
it('maps due.kind "none" to no deadline', () => {
expect(toVoiceIntent(wire()).due).toBeNull();
});
it('narrows each due kind', () => {
expect(
toVoiceIntent(
wire({
due: {
...emptyDue,
kind: 'weekday',
weekday: 'thursday',
which: 'next',
},
}),
).due,
).toEqual({ kind: 'weekday', weekday: 'thursday', which: 'next' });
expect(
toVoiceIntent(
wire({ due: { ...emptyDue, kind: 'offset', unit: 'week', amount: 2 } }),
).due,
).toEqual({ kind: 'offset', unit: 'week', amount: 2 });
expect(
toVoiceIntent(
wire({ due: { ...emptyDue, kind: 'jalali', jy: 1404, jm: 7, jd: 25 } }),
).due,
).toEqual({ kind: 'jalali', jy: 1404, jm: 7, jd: 25 });
expect(
toVoiceIntent(
wire({
due: { ...emptyDue, kind: 'gregorian', y: 2025, m: 10, d: 17 },
}),
).due,
).toEqual({ kind: 'gregorian', y: 2025, m: 10, d: 17 });
});
it('ignores a non-FDI-shaped fdi and keeps the positional fields', () => {
// A model emitting fdi:"6" alongside correct arch/side/position must still yield 16,
// not lose the tooth to the explicit branch.
const result = toVoiceIntent(
wire({ teeth: [{ ...positionalTooth, fdi: '6' }] }),
);
expect(result.teeth[0]).toEqual({
kind: 'positional',
arch: 'upper',
side: 'patient_right',
position: 6,
spoken: 'شش بالا راست',
});
});
it('rejects impossible FDI shapes from the explicit branch', () => {
for (const fdi of ['99', '0', '140', '9', 'ab']) {
expect(
toVoiceIntent(wire({ teeth: [{ ...positionalTooth, fdi }] })).teeth[0]
.kind,
).toBe('positional');
}
});
it('passes an unrecognised due kind through so it can be flagged', () => {
// Collapsing it to null would make a misunderstood deadline indistinguishable from
// no deadline at all, and the resolver's flagging path unreachable.
const result = toVoiceIntent(
wire({ due: { ...emptyDue, kind: 'lunar_month' as never } }),
);
expect(result.due).toEqual({ kind: 'lunar_month' });
});
it('reports no prosthesis when neither a default nor an override was given', () => {
expect(toVoiceIntent(wire()).prosthesis).toBeNull();
});
it('builds a prosthesis intent from a default alone', () => {
const result = toVoiceIntent(wire({ prosthesisDefaultType: 'pfm_crown' }));
expect(result.prosthesis).toEqual({
defaultType: 'pfm_crown',
overrides: [],
});
});
it('builds a prosthesis intent from overrides alone', () => {
const result = toVoiceIntent(
wire({
prosthesisOverrides: [{ tooth: positionalTooth, type: 'pfm_crown' }],
}),
);
expect(result.prosthesis?.defaultType).toBeNull();
expect(result.prosthesis?.overrides).toHaveLength(1);
});
it('narrows connected spans', () => {
const result = toVoiceIntent(
wire({
connectedSpans: [
{
from: { ...positionalTooth, fdi: '14' },
to: { ...positionalTooth, fdi: '16' },
},
],
}),
);
expect(result.connectedSpans[0].from).toEqual({
kind: 'explicit',
fdi: '14',
spoken: 'شش بالا راست',
});
});
it('is total — a missing or malformed payload yields a resolvable shape, not a throw', () => {
// Whatever survives here is reported as unresolved downstream rather than crashing.
for (const bad of [undefined, null, {}, { teeth: 'nope', due: 5 }]) {
expect(() => toVoiceIntent(bad as never)).not.toThrow();
const result = toVoiceIntent(bad as never);
expect(result.teeth).toEqual([]);
expect(result.due).toBeNull();
expect(result.labMatchExact).toBe(false);
}
});
it('coerces a non-boolean labMatchExact to false', () => {
expect(
toVoiceIntent(wire({ labMatchExact: 'yes' as never })).labMatchExact,
).toBe(false);
});
});