feat(frontend): split Add detail into a segmented control with voice
The microphone becomes the second segment of the Add detail button, built like
the detail chip's trash affordance in the same file — an overflow-hidden rounded
wrapper holding two raw <button>s divided by border-s — rather than two shared
Buttons, which each hardcode their own rounding and would fight a segmented
control. border-s puts the mic at the logical end: visually right in en/nl,
visually left in fa, on the same side as the chip's trash in both directions.
The two halves share a wrapper and nothing else. Add keeps its exact behaviour.
The control never changes size while recording; the timer and level meter live
in a bar between the header row and the chip strip, because the header is
sm:justify-between and growing the button would shove the row on every start and
stop. The meter exists to prove the microphone is actually hearing something —
silence and a dead mic look identical otherwise.
Voice reaches the editor as one optional `voice` prop, so its absence *is* the
unavailable state and the two cannot disagree.
Fixes from review of this commit:
- mountedRef was set false on unmount and never re-armed, so under StrictMode
the hook was permanently "unmounted" in dev and recording silently never
started.
- onStart guarded only on `phase`, which does not change until getUserMedia
resolves; a second click during the permission prompt orphaned the first
MediaStream, leaving the mic indicator lit.
- Week start is now per locale. "Next Thursday" is week-relative, and hardcoding
Saturday put an en/nl clinician's deadline a week out.
- A missing `which` on a weekday intent is read as "this" rather than failing —
a bare weekday carries no qualifier, and rejecting it discarded a real
deadline.
- durationMs is client-reported and so is a claim, not enforcement; the cap is
now also checked against the vendor's own usage.seconds.
- Blob type falls back to the recorder's actual mimeType before webm, so old
Safari's mp4/aac clips are not mislabelled.
Two review findings were rejected as incorrect, both re-verified against live
sources: google/gemini-3.7-flash does exist on OpenRouter (1M context,
$0.375/$1.875 per M), and base64 JSON input_audio is the documented primary
path for /audio/transcriptions, with multipart as the OpenAI-compatible
alternative. The spec's stale "unverified" note is corrected, and the provider
now has unit tests covering the request shape, usage parsing, and that a vendor
error body never reaches the thrown message.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 19:47:10 +03:30
|
|
|
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: 'پروتز' }],
|
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
|
|
|
prosthesisTypes: [
|
|
|
|
|
{
|
|
|
|
|
code: 'pfm_crown',
|
|
|
|
|
label: 'روکش پیافام',
|
|
|
|
|
category: 'crown',
|
|
|
|
|
subcategory: '',
|
|
|
|
|
chartRegion: 'crown',
|
|
|
|
|
stackGroup: 'restoration',
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
prosthesisCategories: [{ code: 'crown', label: 'روکشها' }],
|
|
|
|
|
prosthesisSubcategories: [],
|
feat(frontend): split Add detail into a segmented control with voice
The microphone becomes the second segment of the Add detail button, built like
the detail chip's trash affordance in the same file — an overflow-hidden rounded
wrapper holding two raw <button>s divided by border-s — rather than two shared
Buttons, which each hardcode their own rounding and would fight a segmented
control. border-s puts the mic at the logical end: visually right in en/nl,
visually left in fa, on the same side as the chip's trash in both directions.
The two halves share a wrapper and nothing else. Add keeps its exact behaviour.
The control never changes size while recording; the timer and level meter live
in a bar between the header row and the chip strip, because the header is
sm:justify-between and growing the button would shove the row on every start and
stop. The meter exists to prove the microphone is actually hearing something —
silence and a dead mic look identical otherwise.
Voice reaches the editor as one optional `voice` prop, so its absence *is* the
unavailable state and the two cannot disagree.
Fixes from review of this commit:
- mountedRef was set false on unmount and never re-armed, so under StrictMode
the hook was permanently "unmounted" in dev and recording silently never
started.
- onStart guarded only on `phase`, which does not change until getUserMedia
resolves; a second click during the permission prompt orphaned the first
MediaStream, leaving the mic indicator lit.
- Week start is now per locale. "Next Thursday" is week-relative, and hardcoding
Saturday put an en/nl clinician's deadline a week out.
- A missing `which` on a weekday intent is read as "this" rather than failing —
a bare weekday carries no qualifier, and rejecting it discarded a real
deadline.
- durationMs is client-reported and so is a claim, not enforcement; the cap is
now also checked against the vendor's own usage.seconds.
- Blob type falls back to the recorder's actual mimeType before webm, so old
Safari's mp4/aac clips are not mislabelled.
Two review findings were rejected as incorrect, both re-verified against live
sources: google/gemini-3.7-flash does exist on OpenRouter (1M context,
$0.375/$1.875 per M), and base64 JSON input_audio is the documented primary
path for /audio/transcriptions, with multipart as the OpenAI-compatible
alternative. The spec's stale "unverified" note is corrected, and the provider
now has unit tests covering the request shape, usage parsing, and that a vendor
error body never reaches the thrown message.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 19:47:10 +03:30
|
|
|
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,
|
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: [
|
|
|
|
|
{
|
|
|
|
|
targets: [
|
|
|
|
|
{
|
|
|
|
|
spoken: 'یک چهار',
|
|
|
|
|
fdi: '14',
|
|
|
|
|
arch: null,
|
|
|
|
|
side: null,
|
|
|
|
|
position: null,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
types: ['pfm_crown'],
|
|
|
|
|
spoken: 'روکش پیافام روی ۱۴',
|
|
|
|
|
},
|
|
|
|
|
],
|
feat(frontend): split Add detail into a segmented control with voice
The microphone becomes the second segment of the Add detail button, built like
the detail chip's trash affordance in the same file — an overflow-hidden rounded
wrapper holding two raw <button>s divided by border-s — rather than two shared
Buttons, which each hardcode their own rounding and would fight a segmented
control. border-s puts the mic at the logical end: visually right in en/nl,
visually left in fa, on the same side as the chip's trash in both directions.
The two halves share a wrapper and nothing else. Add keeps its exact behaviour.
The control never changes size while recording; the timer and level meter live
in a bar between the header row and the chip strip, because the header is
sm:justify-between and growing the button would shove the row on every start and
stop. The meter exists to prove the microphone is actually hearing something —
silence and a dead mic look identical otherwise.
Voice reaches the editor as one optional `voice` prop, so its absence *is* the
unavailable state and the two cannot disagree.
Fixes from review of this commit:
- mountedRef was set false on unmount and never re-armed, so under StrictMode
the hook was permanently "unmounted" in dev and recording silently never
started.
- onStart guarded only on `phase`, which does not change until getUserMedia
resolves; a second click during the permission prompt orphaned the first
MediaStream, leaving the mic indicator lit.
- Week start is now per locale. "Next Thursday" is week-relative, and hardcoding
Saturday put an en/nl clinician's deadline a week out.
- A missing `which` on a weekday intent is read as "this" rather than failing —
a bare weekday carries no qualifier, and rejecting it discarded a real
deadline.
- durationMs is client-reported and so is a claim, not enforcement; the cap is
now also checked against the vendor's own usage.seconds.
- Blob type falls back to the recorder's actual mimeType before webm, so old
Safari's mp4/aac clips are not mislabelled.
Two review findings were rejected as incorrect, both re-verified against live
sources: google/gemini-3.7-flash does exist on OpenRouter (1M context,
$0.375/$1.875 per M), and base64 JSON input_audio is the documented primary
path for /audio/transcriptions, with multipart as the OpenAI-compatible
alternative. The spec's stale "unverified" note is corrected, and the provider
now has unit tests covering the request shape, usage parsing, and that a vendor
error body never reaches the thrown message.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 19:47:10 +03:30
|
|
|
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' });
|
|
|
|
|
});
|
|
|
|
|
});
|