Files
dyolink/backend/src/modules/voice/openrouter.provider.spec.ts
Amin Mousavi 15ddb9aac2 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

222 lines
6.4 KiB
TypeScript

import {
OpenRouterAsrProvider,
OpenRouterExtractionProvider,
} from './openrouter.provider';
import { VoiceProviderError } from './voice.providers';
const CONFIG = {
apiKey: 'test-key',
baseUrl: 'https://openrouter.test/api/v1',
model: 'm',
};
const CATALOG = {
treatmentTypes: [{ code: 'prosthesis', label: 'پروتز' }],
prosthesisTypes: [
{
code: 'pfm_crown',
label: 'روکش پی‌اف‌ام',
category: 'crown',
subcategory: '',
chartRegion: 'crown',
stackGroup: 'restoration',
},
],
prosthesisCategories: [{ code: 'crown', label: 'روکش‌ها' }],
prosthesisSubcategories: [],
labs: [{ id: 'lab-1', name: 'لابراتوار سینا' }],
};
type ChatBody = {
temperature: number;
provider: { require_parameters: boolean };
response_format: { type: string; json_schema: { strict: boolean } };
messages: { role: string; content: string }[];
};
function parseBody(spy: jest.Mock): ChatBody {
const init = (spy.mock.calls[0] as [string, RequestInit])[1];
return JSON.parse(init.body as string) as ChatBody;
}
function mockFetch(response: {
ok: boolean;
status?: number;
body?: unknown;
text?: string;
}) {
const spy = jest.fn().mockResolvedValue({
ok: response.ok,
status: response.status ?? (response.ok ? 200 : 500),
json: () => Promise.resolve(response.body),
text: () => Promise.resolve(response.text ?? ''),
});
global.fetch = spy;
return spy;
}
afterEach(() => {
jest.restoreAllMocks();
});
describe('OpenRouterAsrProvider', () => {
it('posts base64 JSON with the language hint, per the documented STT contract', async () => {
const spy = mockFetch({
ok: true,
body: { text: ' سلام ', usage: { seconds: 12, cost: 0.002 } },
});
const result = await new OpenRouterAsrProvider(CONFIG).transcribe(
{ data: 'BASE64', format: 'webm' },
'fa',
);
const [url, init] = spy.mock.calls[0] as [string, RequestInit];
expect(url).toBe('https://openrouter.test/api/v1/audio/transcriptions');
expect(init.headers).toMatchObject({ Authorization: 'Bearer test-key' });
expect(JSON.parse(init.body as string)).toEqual({
model: 'm',
input_audio: { data: 'BASE64', format: 'webm' },
language: 'fa',
});
expect(result.text).toBe('سلام');
expect(result.usage).toEqual({ seconds: 12, costUsd: 0.002 });
});
it('reports missing usage as null rather than zero', async () => {
mockFetch({ ok: true, body: { text: 'x' } });
const result = await new OpenRouterAsrProvider(CONFIG).transcribe(
{ data: 'B', format: 'webm' },
'en',
);
expect(result.usage).toEqual({ seconds: null, costUsd: null });
});
it('raises a staged error without leaking the vendor body', async () => {
// A 4xx can echo the request back, transcript included.
mockFetch({
ok: false,
status: 400,
text: 'transcript: patient name here',
});
const provider = new OpenRouterAsrProvider(CONFIG);
await expect(
provider.transcribe({ data: 'B', format: 'webm' }, 'fa'),
).rejects.toMatchObject({
name: 'VoiceProviderError',
stage: 'asr',
status: 400,
});
await expect(
provider.transcribe({ data: 'B', format: 'webm' }, 'fa'),
).rejects.not.toThrow(/patient name/);
});
});
describe('OpenRouterExtractionProvider', () => {
const wireContent = JSON.stringify({
treatmentType: 'prosthesis',
teeth: [
{ spoken: 'یک چهار', fdi: '14', arch: null, side: null, position: null },
],
connectedSpans: [],
comment: null,
prosthesis: [
{
targets: [
{
spoken: 'یک چهار',
fdi: '14',
arch: null,
side: null,
position: null,
},
],
types: ['pfm_crown'],
spoken: 'روکش پی‌اف‌ام روی ۱۴',
},
],
labId: 'lab-1',
labMatchExact: true,
due: {
kind: 'none',
weekday: null,
which: null,
unit: null,
amount: null,
jy: null,
jm: null,
jd: null,
y: null,
m: null,
d: null,
},
});
it('constrains output with a strict JSON schema and a schema-honouring provider', async () => {
const spy = mockFetch({
ok: true,
body: {
choices: [{ message: { content: wireContent } }],
usage: { cost: 0.0008 },
},
});
const result = await new OpenRouterExtractionProvider(CONFIG).extract(
'روی دندان ۱۴ روکش',
CATALOG,
'fa',
);
const body = parseBody(spy);
expect(body.response_format.type).toBe('json_schema');
expect(body.response_format.json_schema.strict).toBe(true);
// Without require_parameters OpenRouter may route to a provider that treats the
// schema as a hint and returns prose, failing parsing intermittently.
expect(body.provider).toEqual({ require_parameters: true });
expect(body.temperature).toBe(0);
expect(result.intent.treatmentType).toBe('prosthesis');
expect(result.intent.teeth[0]).toEqual({
kind: 'explicit',
fdi: '14',
spoken: 'یک چهار',
});
expect(result.intent.due).toBeNull();
expect(result.costUsd).toBe(0.0008);
});
it('sends the catalog codes and lab ids the model is allowed to choose from', async () => {
const spy = mockFetch({
ok: true,
body: { choices: [{ message: { content: wireContent } }] },
});
await new OpenRouterExtractionProvider(CONFIG).extract('x', CATALOG, 'fa');
const body = parseBody(spy);
const system = body.messages[0].content;
expect(system).toContain('prosthesis');
expect(system).toContain('pfm_crown');
expect(system).toContain('lab-1');
expect(body.messages[1]).toEqual({ role: 'user', content: 'x' });
});
it('fails loudly on unparseable content rather than passing rubbish downstream', async () => {
mockFetch({
ok: true,
body: { choices: [{ message: { content: 'I think tooth 14?' } }] },
});
await expect(
new OpenRouterExtractionProvider(CONFIG).extract('x', CATALOG, 'fa'),
).rejects.toBeInstanceOf(VoiceProviderError);
});
it('fails when the model returns no content at all', async () => {
mockFetch({ ok: true, body: { choices: [] } });
await expect(
new OpenRouterExtractionProvider(CONFIG).extract('x', CATALOG, 'fa'),
).rejects.toMatchObject({ stage: 'extraction' });
});
});