diff --git a/backend/src/modules/voice/due-date.resolver.spec.ts b/backend/src/modules/voice/due-date.resolver.spec.ts index 6b7b5cf..5eb5029 100644 --- a/backend/src/modules/voice/due-date.resolver.spec.ts +++ b/backend/src/modules/voice/due-date.resolver.spec.ts @@ -1,86 +1,121 @@ -import { resolveDueDate } from './due-date.resolver'; +import { resolveDueDate, weekStartForLocale } from './due-date.resolver'; import type { DueIntent } from './voice.types'; // 2025-10-11 is a Saturday — the first day of the Iranian week. +const FA_WEEK = 6; // Saturday +const EU_WEEK = 1; // Monday const SATURDAY = '2025-10-11'; const THURSDAY = '2025-10-16'; describe('resolveDueDate', () => { describe('weekday intents', () => { - it('resolves "this " to the coming occurrence in the same week', () => { + it('resolves "this " to the coming occurrence', () => { const result = resolveDueDate( { kind: 'weekday', weekday: 'thursday', which: 'this' }, SATURDAY, + FA_WEEK, ); - expect(result.dueDate).toBe(THURSDAY); // Sat -> Thu is 5 days in a Saturday-start week + expect(result.dueDate).toBe(THURSDAY); // Sat -> Thu is 5 days }); - it('resolves "next " to the following week', () => { + it('resolves "next " to that weekday in the following week', () => { const result = resolveDueDate( { kind: 'weekday', weekday: 'thursday', which: 'next' }, SATURDAY, + FA_WEEK, ); expect(result.dueDate).toBe('2025-10-23'); }); it('anchors "next" to the week, not to "this" plus seven', () => { - // Said on Thursday 2025-10-16: next week runs Sat 10-18 .. Fri 10-24, so its + // Said on Thursday 2025-10-16, the Iranian week runs Sat 10-18 .. Fri 10-24, so its // Thursday is 10-23. Adding a week to "this Thursday" (already 10-23) would // overshoot to 10-30 — a lab case a week late. expect( resolveDueDate( { kind: 'weekday', weekday: 'thursday', which: 'next' }, THURSDAY, + FA_WEEK, ).dueDate, ).toBe('2025-10-23'); }); it('lets "this" and "next" coincide when they name the same day', () => { // On a Thursday, "the coming Saturday" and "Saturday next week" are both 10-18. + for (const which of ['this', 'next'] as const) { + expect( + resolveDueDate( + { kind: 'weekday', weekday: 'saturday', which }, + THURSDAY, + FA_WEEK, + ).dueDate, + ).toBe('2025-10-18'); + } + }); + + it('reads "by Thursday" said on a Thursday as the next one, not today', () => { + // A deadline of today is almost never what was meant. expect( resolveDueDate( - { kind: 'weekday', weekday: 'saturday', which: 'this' }, + { kind: 'weekday', weekday: 'thursday', which: 'this' }, THURSDAY, + FA_WEEK, ).dueDate, - ).toBe('2025-10-18'); - expect( - resolveDueDate( - { kind: 'weekday', weekday: 'saturday', which: 'next' }, - THURSDAY, - ).dueDate, - ).toBe('2025-10-18'); + ).toBe('2025-10-23'); }); it('never resolves a weekday into the past', () => { - // Sunday already passed in the week containing Thursday 10-16. for (const which of ['this', 'next'] as const) { const result = resolveDueDate( { kind: 'weekday', weekday: 'sunday', which }, THURSDAY, + FA_WEEK, ); expect(result.dueDate).not.toBeNull(); expect(result.dueDate! > THURSDAY).toBe(true); } }); - it('reads "by Thursday" said on a Thursday as the next one, not today', () => { - // A deadline of today is almost never what was meant. - const result = resolveDueDate( - { kind: 'weekday', weekday: 'thursday', which: 'this' }, - THURSDAY, + it('anchors "next" to a Monday week for en and nl', () => { + // The same sentence means a different day depending on where the week starts. + // Said on Saturday 10-11: the Monday-start week is 10-13..10-19, Thursday = 10-16. + // The Saturday-start week is 10-18..10-24, Thursday = 10-23. + const intent = { + kind: 'weekday', + weekday: 'thursday', + which: 'next', + } as const; + expect(resolveDueDate(intent, SATURDAY, EU_WEEK).dueDate).toBe( + '2025-10-16', + ); + expect(resolveDueDate(intent, SATURDAY, FA_WEEK).dueDate).toBe( + '2025-10-23', ); - expect(result.dueDate).toBe('2025-10-23'); }); - it('resolves the Saturday that starts the next week', () => { - const result = resolveDueDate( - { kind: 'weekday', weekday: 'saturday', which: 'this' }, - SATURDAY, - ); - expect(result.dueDate).toBe('2025-10-18'); + it('maps each locale to its week start', () => { + expect(weekStartForLocale('fa')).toBe(FA_WEEK); + expect(weekStartForLocale('en')).toBe(EU_WEEK); + expect(weekStartForLocale('nl')).toBe(EU_WEEK); + expect(weekStartForLocale('unknown')).toBe(EU_WEEK); }); - it('rejects an unknown weekday or qualifier', () => { + it('treats a missing qualifier as "this" rather than failing', () => { + // A bare weekday carries no qualifier; failing would discard a real spoken deadline. + expect( + resolveDueDate( + { + kind: 'weekday', + weekday: 'thursday', + which: null, + } as unknown as DueIntent, + SATURDAY, + FA_WEEK, + ).dueDate, + ).toBe(THURSDAY); + }); + + it('rejects an unknown weekday', () => { expect( resolveDueDate( { @@ -89,16 +124,7 @@ describe('resolveDueDate', () => { which: 'this', } as unknown as DueIntent, SATURDAY, - ).dueDate, - ).toBeNull(); - expect( - resolveDueDate( - { - kind: 'weekday', - weekday: 'thursday', - which: 'soon', - } as unknown as DueIntent, - SATURDAY, + FA_WEEK, ).dueDate, ).toBeNull(); }); @@ -107,16 +133,25 @@ describe('resolveDueDate', () => { describe('offset intents', () => { it('adds days, weeks and months', () => { expect( - resolveDueDate({ kind: 'offset', unit: 'day', amount: 1 }, SATURDAY) - .dueDate, + resolveDueDate( + { kind: 'offset', unit: 'day', amount: 1 }, + SATURDAY, + FA_WEEK, + ).dueDate, ).toBe('2025-10-12'); expect( - resolveDueDate({ kind: 'offset', unit: 'week', amount: 1 }, SATURDAY) - .dueDate, + resolveDueDate( + { kind: 'offset', unit: 'week', amount: 1 }, + SATURDAY, + FA_WEEK, + ).dueDate, ).toBe('2025-10-18'); expect( - resolveDueDate({ kind: 'offset', unit: 'month', amount: 1 }, SATURDAY) - .dueDate, + resolveDueDate( + { kind: 'offset', unit: 'month', amount: 1 }, + SATURDAY, + FA_WEEK, + ).dueDate, ).toBe('2025-11-11'); }); @@ -153,8 +188,11 @@ describe('resolveDueDate', () => { it('rejects negative, fractional and absurd amounts', () => { for (const amount of [-1, 1.5, 10_000, Number.NaN]) { expect( - resolveDueDate({ kind: 'offset', unit: 'day', amount }, SATURDAY) - .dueDate, + resolveDueDate( + { kind: 'offset', unit: 'day', amount }, + SATURDAY, + FA_WEEK, + ).dueDate, ).toBeNull(); } }); @@ -163,8 +201,11 @@ describe('resolveDueDate', () => { describe('jalali intents', () => { it('converts by arithmetic, not inference', () => { expect( - resolveDueDate({ kind: 'jalali', jy: 1404, jm: 7, jd: 25 }, SATURDAY) - .dueDate, + resolveDueDate( + { kind: 'jalali', jy: 1404, jm: 7, jd: 25 }, + SATURDAY, + FA_WEEK, + ).dueDate, ).toBe('2025-10-17'); }); @@ -190,16 +231,25 @@ describe('resolveDueDate', () => { describe('gregorian intents', () => { it('accepts a real date and rejects an impossible one', () => { expect( - resolveDueDate({ kind: 'gregorian', y: 2025, m: 10, d: 17 }, SATURDAY) - .dueDate, + resolveDueDate( + { kind: 'gregorian', y: 2025, m: 10, d: 17 }, + SATURDAY, + FA_WEEK, + ).dueDate, ).toBe('2025-10-17'); expect( - resolveDueDate({ kind: 'gregorian', y: 2025, m: 2, d: 30 }, SATURDAY) - .dueDate, + resolveDueDate( + { kind: 'gregorian', y: 2025, m: 2, d: 30 }, + SATURDAY, + FA_WEEK, + ).dueDate, ).toBeNull(); expect( - resolveDueDate({ kind: 'gregorian', y: 2025, m: 13, d: 1 }, SATURDAY) - .dueDate, + resolveDueDate( + { kind: 'gregorian', y: 2025, m: 13, d: 1 }, + SATURDAY, + FA_WEEK, + ).dueDate, ).toBeNull(); }); }); @@ -216,15 +266,21 @@ describe('resolveDueDate', () => { it('treats a date decades away as unresolved', () => { expect( - resolveDueDate({ kind: 'gregorian', y: 2099, m: 1, d: 1 }, SATURDAY) - .dueDate, + resolveDueDate( + { kind: 'gregorian', y: 2099, m: 1, d: 1 }, + SATURDAY, + FA_WEEK, + ).dueDate, ).toBeNull(); }); it('accepts today itself via a zero-day offset', () => { expect( - resolveDueDate({ kind: 'offset', unit: 'day', amount: 0 }, SATURDAY) - .dueDate, + resolveDueDate( + { kind: 'offset', unit: 'day', amount: 0 }, + SATURDAY, + FA_WEEK, + ).dueDate, ).toBe(SATURDAY); }); diff --git a/backend/src/modules/voice/due-date.resolver.ts b/backend/src/modules/voice/due-date.resolver.ts index 7019610..3411efd 100644 --- a/backend/src/modules/voice/due-date.resolver.ts +++ b/backend/src/modules/voice/due-date.resolver.ts @@ -96,12 +96,28 @@ function describe(intent: DueIntent): string { } } -/** The Iranian week starts Saturday. */ -const WEEK_START_JS = WEEKDAY_TO_JS.saturday; +/** + * Which weekday starts the week, per locale. + * + * "Next Thursday" is week-relative, so this changes the answer: the Iranian week starts + * Saturday, the Dutch and (European) English week starts Monday. Hardcoding Saturday + * would put an en/nl clinician's deadline a week out. + */ +const WEEK_START_BY_LOCALE: Record = { + fa: WEEKDAY_TO_JS.saturday, + en: WEEKDAY_TO_JS.monday, + nl: WEEKDAY_TO_JS.monday, +}; -/** Most recent Saturday, counting today if today is Saturday. */ -function startOfWeek(iso: string): string { - const back = (civilDateJsWeekday(iso) - WEEK_START_JS + 7) % 7; +const DEFAULT_WEEK_START = WEEKDAY_TO_JS.monday; + +export function weekStartForLocale(locale: string): number { + return WEEK_START_BY_LOCALE[locale] ?? DEFAULT_WEEK_START; +} + +/** Most recent week-start day, counting today if today is that day. */ +function startOfWeek(iso: string, weekStartJs: number): string { + const back = (civilDateJsWeekday(iso) - weekStartJs + 7) % 7; return addDays(iso, -back); } @@ -119,20 +135,25 @@ function startOfWeek(iso: string): string { function resolveWeekday( intent: Extract, todayIso: string, + weekStartJs: number, ) { const targetJs = WEEKDAY_TO_JS[intent.weekday]; if (targetJs === undefined) return null; - if (intent.which === 'this') { + // A bare weekday ("پنجشنبه") carries no qualifier, and the model may leave `which` + // null. Treat that as 'this' rather than failing an utterance that named a real day. + const which = intent.which === 'next' ? 'next' : 'this'; + + if (which === 'this') { const todayJs = civilDateJsWeekday(todayIso); let delta = (targetJs - todayJs + 7) % 7; if (delta === 0) delta = 7; return addDays(todayIso, delta); } - if (intent.which === 'next') { - const offsetInWeek = (targetJs - WEEK_START_JS + 7) % 7; - return addDays(startOfWeek(todayIso), 7 + offsetInWeek); + if (which === 'next') { + const offsetInWeek = (targetJs - weekStartJs + 7) % 7; + return addDays(startOfWeek(todayIso, weekStartJs), 7 + offsetInWeek); } return null; @@ -156,6 +177,7 @@ function resolveOffset( export function resolveDueDate( intent: DueIntent | null | undefined, todayIso: string, + weekStartJs: number = DEFAULT_WEEK_START, ): DueResolution { // Absent is not an error — most utterances carry no deadline. Anything else that is not // an intent object is a deadline we failed to understand, and must be flagged rather @@ -179,7 +201,7 @@ export function resolveDueDate( let resolved: string | null = null; switch (intent.kind) { case 'weekday': - resolved = resolveWeekday(intent, todayIso); + resolved = resolveWeekday(intent, todayIso, weekStartJs); break; case 'offset': resolved = resolveOffset(intent, todayIso); diff --git a/backend/src/modules/voice/extraction.resolver.spec.ts b/backend/src/modules/voice/extraction.resolver.spec.ts index aaecb36..dc8f67c 100644 --- a/backend/src/modules/voice/extraction.resolver.spec.ts +++ b/backend/src/modules/voice/extraction.resolver.spec.ts @@ -14,6 +14,8 @@ const tooth = (fdi: string, spoken = fdi): ToothIntent => ({ const CTX: ResolveContext = { todayIso: '2025-10-11', + weekStartJs: 6, // Saturday — the fa week + treatmentTypeCodes: new Set(['restoration', 'prosthesis', 'extraction']), prosthesisTypeCodes: new Set(['monolithic_zirconia', 'pfm_crown']), linkedLabIds: new Set(['lab-sina', 'lab-mehr']), diff --git a/backend/src/modules/voice/extraction.resolver.ts b/backend/src/modules/voice/extraction.resolver.ts index e920475..96bf616 100644 --- a/backend/src/modules/voice/extraction.resolver.ts +++ b/backend/src/modules/voice/extraction.resolver.ts @@ -48,6 +48,8 @@ export type ResolvedExtraction = { export type ResolveContext = { todayIso: string; + /** JS weekday index the clinician's week starts on — see weekStartForLocale. */ + weekStartJs: number; treatmentTypeCodes: ReadonlySet; prosthesisTypeCodes: ReadonlySet; linkedLabIds: ReadonlySet; @@ -272,7 +274,7 @@ export function resolveVoiceIntent( ); unresolved.push(...prosthesisResult.unresolved); - const due = resolveDueDate(intent?.due, ctx.todayIso); + const due = resolveDueDate(intent?.due, ctx.todayIso, ctx.weekStartJs); if (due.unresolved) unresolved.push(due.unresolved); const comment = diff --git a/backend/src/modules/voice/openrouter.provider.spec.ts b/backend/src/modules/voice/openrouter.provider.spec.ts new file mode 100644 index 0000000..a18f0e6 --- /dev/null +++ b/backend/src/modules/voice/openrouter.provider.spec.ts @@ -0,0 +1,197 @@ +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: 'روکش پی‌اف‌ام' }], + 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, + prosthesisDefaultType: 'pfm_crown', + prosthesisOverrides: [], + 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' }); + }); +}); diff --git a/backend/src/modules/voice/voice.service.ts b/backend/src/modules/voice/voice.service.ts index a5f9ee3..79d1819 100644 --- a/backend/src/modules/voice/voice.service.ts +++ b/backend/src/modules/voice/voice.service.ts @@ -12,6 +12,7 @@ import { hasEffectivePermission } from '../../common/membership-permissions'; import { normalizeCatalogLocale } from '../catalog/catalog-label.service'; import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service'; import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service'; +import { weekStartForLocale } from './due-date.resolver'; import { resolveVoiceIntent, type ResolvedExtraction, @@ -92,6 +93,7 @@ export class VoiceService { // Stage 1 — audio never touches disk and is not retained beyond this call. let transcript: string; let asrCost: number | null = null; + let asrSeconds: number | null = null; try { const result = await asr.transcribe( { data: dto.audio, format: dto.format }, @@ -100,10 +102,19 @@ export class VoiceService { ); transcript = result.text; asrCost = result.usage.costUsd; + asrSeconds = result.usage.seconds; } catch (error) { throw this.toAppException(error, 'asr'); } + // durationMs is client-reported and therefore not enforcement. usage.seconds is the + // vendor's own measurement of the audio it decoded, so a client under-reporting length + // to slip past the cap is caught here — after the ASR spend, but before the extraction + // call, and visibly in telemetry. + if (asrSeconds != null) { + this.assertWithinCap(asrSeconds * 1000); + } + if (!transcript.trim()) { throw new AppException( ErrorCode.VOICE_NOTHING_RECOGNIZED, @@ -126,6 +137,7 @@ export class VoiceService { llmCost = result.costUsd; resolved = resolveVoiceIntent(result.intent, { todayIso, + weekStartJs: weekStartForLocale(catalogLocale), treatmentTypeCodes: new Set(catalog.treatmentTypes.map((t) => t.code)), prosthesisTypeCodes: new Set( catalog.prosthesisTypes.map((t) => t.code), diff --git a/docs/specs/voice-treatment-entry/spec.md b/docs/specs/voice-treatment-entry/spec.md index 8bbca52..a1b7059 100644 --- a/docs/specs/voice-treatment-entry/spec.md +++ b/docs/specs/voice-treatment-entry/spec.md @@ -309,6 +309,13 @@ Both roles, one API key. **no transcode dependency is required**. - Limits: 25 MB; 60s upstream *processing* timeout. The 2-minute recording cap (§2) sits comfortably inside both. +- Request shape **verified** against OpenRouter's STT docs: base64 JSON `input_audio` is + the documented primary path (multipart `file` is the OpenAI-compatible alternative), and + both default model slugs exist on the live models API. +- The cap is enforced twice: the client's reported `durationMs`, and again against the + vendor's own `usage.seconds` — the client's figure is a claim, not enforcement. The + server allows a 2s tolerance, because the client measures length *after* the recorder + stops and a recording that runs to the cap always reports slightly over it. - Response: `{ text, usage: { seconds, total_tokens, cost } }`. - Price: `openai/whisper-1` is **$0.006/minute, billed to the nearest second** → $0.002 for a typical 20s utterance, **$0.012 at the 2-minute cap**. OpenRouter forwards this model @@ -417,7 +424,12 @@ that justified this whole design. ### `resolveDueDate()` - Takes `clientTodayIso` + IANA `timeZone`; reuses `common/zoned-civil-time.ts`. -- Week starts **Saturday** (Iranian week) — one place, tested. +- Week start is **per locale**, because "next Thursday" is week-relative: `fa` starts + Saturday, `en` and `nl` start Monday. Hardcoding Saturday put an en/nl clinician's + deadline a week out. One place (`weekStartForLocale`), tested in both directions. +- `'this'` is occurrence-anchored (soonest strictly-future, never resolves into the past); + `'next'` is week-anchored. A missing qualifier is read as `'this'` — a bare weekday + carries none, and failing would discard a real spoken deadline. - Jalali conversion is arithmetic, not inference: port `jalaliToGregorian` and `toLatinDigits` from `frontend/src/lib/i18n/persianCalendar.ts` into `backend/src/common/jalali.ts` with a spec. It is dependency-free integer math @@ -645,7 +657,7 @@ enabling this for real clinics. - `cd backend && npm test` — new suites for `resolveToothIntent` (quadrant mapping in all four quadrants, out-of-range rejection, deciduous → unresolved), `resolveDueDate` - (Saturday week start, "this" vs "next" weekday, Jalali leap year, month-end), the + (per-locale week start, "this" vs "next" weekday, Jalali leap year, month-end), the Jalali port, prosthesis expansion + completeness, and connected-span validation. - `cd backend && npm run build` — cross-cutting backend gate. - `cd frontend && npx tsc --noEmit` — frontend gate. diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 05cee8c..7465c24 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -899,7 +899,11 @@ "toothAria": "FDI tooth {fdi}", "toothSelectedSuffix": ", selected", "sentToAt": "Sent to {orgName} at {datetime}", - "fallbackOrgName": "organization" + "fallbackOrgName": "organization", + "voiceStart": "Record treatment", + "voiceStop": "Stop recording", + "voiceCancel": "Cancel", + "voiceProcessing": "Reading the recording…" }, "organizations": { "loadingOrganization": "Loading organization...", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index e301954..bac7e91 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -900,7 +900,11 @@ "toothAria": "دندان FDI {fdi}", "toothSelectedSuffix": "، انتخاب شده", "sentToAt": "ارسال به {orgName} در {datetime}", - "fallbackOrgName": "سازمان" + "fallbackOrgName": "سازمان", + "voiceStart": "ثبت گفتاری درمان", + "voiceStop": "توقف ضبط", + "voiceCancel": "لغو", + "voiceProcessing": "در حال پردازش گفتار…" }, "organizations": { "loadingOrganization": "در حال بارگذاری سازمان...", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index c4ef4f4..1839a3f 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -899,7 +899,11 @@ "toothAria": "FDI-tand {fdi}", "toothSelectedSuffix": ", geselecteerd", "sentToAt": "Verzonden naar {orgName} op {datetime}", - "fallbackOrgName": "organisatie" + "fallbackOrgName": "organisatie", + "voiceStart": "Behandeling inspreken", + "voiceStop": "Opname stoppen", + "voiceCancel": "Annuleren", + "voiceProcessing": "Opname wordt gelezen…" }, "organizations": { "loadingOrganization": "Organisatie laden...", diff --git a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx index 89bcae0..c88a80e 100644 --- a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx +++ b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx @@ -2,12 +2,14 @@ import { useEffect, useRef, type ReactNode, type RefObject } from 'react'; import { useTranslations } from 'next-intl'; -import { Trash2 } from 'lucide-react'; +import { Mic, Square, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/shared/Button'; import { Dropdown } from '@/components/ui/shared/Dropdown'; import { formatDetailChipLabel } from '@/components/treatment/detailChipLabel'; import { autosaveStatusClass, labBlockedBannerClass } from '@/components/treatment/treatmentStatusStyles'; import { TreatmentDetailAttachmentsStrip } from '@/components/ui/treatment/TreatmentDetailAttachmentsStrip'; +import { VoiceRecordingBar } from '@/components/ui/treatment/VoiceRecordingBar'; +import type { VoiceCaptureState } from '@/lib/voice/useVoiceCapture'; import type { TreatmentDetailDraft } from '@/types/treatment'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import { treatmentTypeColor, treatmentTypeOptionStyle } from '@/components/shared/treatmentTypeDisplay'; @@ -42,6 +44,12 @@ interface TreatmentDetailsEditorProps { stepper?: ReactNode; /** Shown below type + chart + notes (e.g. Continue to lab). */ footer?: ReactNode; + /** + * Voice entry. Omit when unavailable — the Add button then renders unsplit, exactly as + * before this feature existed. Presence *is* the availability flag, so the two cannot + * disagree. + */ + voice?: VoiceCaptureState; /** Dim the chart until a treatment type is chosen. */ chartLocked?: boolean; chartLockMessage?: string; @@ -65,6 +73,7 @@ export function TreatmentDetailsEditor({ onRemoveAttachment, showChrome = true, showFields = true, + voice, chart, stepper, footer, @@ -109,18 +118,31 @@ export function TreatmentDetailsEditor({

{t('detailsTitle')}

- + {voice ? ( + + ) : ( + + )} + {voice ? : null} +
{details.map((d, idx) => { const detailLocked = isDetailLocked(d); @@ -312,3 +334,82 @@ function NotesField({ ); } + +/** + * "Add detail", split into two segments with the microphone at the logical end. + * + * Built like the detail chip's trash affordance in this same file — an + * `inline-flex items-stretch overflow-hidden rounded` wrapper holding two raw ` + +
+ ); +} diff --git a/frontend/src/components/ui/treatment/VoiceRecordingBar.tsx b/frontend/src/components/ui/treatment/VoiceRecordingBar.tsx new file mode 100644 index 0000000..30ad007 --- /dev/null +++ b/frontend/src/components/ui/treatment/VoiceRecordingBar.tsx @@ -0,0 +1,90 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { Loader2, X } from 'lucide-react'; +import type { VoiceCaptureState } from '@/lib/voice/useVoiceCapture'; + +const METER_BARS = 9; + +function formatElapsed(ms: number): string { + const totalSeconds = Math.floor(Math.max(0, ms) / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${String(seconds).padStart(2, '0')}`; +} + +/** + * Live recording / processing strip. + * + * Sits between the header row and the chip strip rather than inside the segmented + * control: the header is `sm:justify-between`, so growing the button mid-recording would + * shove the row on every start and every stop. + */ +export function VoiceRecordingBar({ voice }: { voice: VoiceCaptureState }) { + const t = useTranslations('treatment'); + + if (voice.phase === 'idle') return null; + + const isRecording = voice.phase === 'recording'; + + return ( +
+ {isRecording ? ( + <> + + + {formatElapsed(voice.elapsedMs)} + {voice.maxMs != null ? ( + / {formatElapsed(voice.maxMs)} + ) : null} + + + + ) : ( + <> + + {t('voiceProcessing')} + + )} + + +
+ ); +} + +/** Proves the microphone is actually hearing something — silence looks identical otherwise. */ +function LevelMeter({ level }: { level: number }) { + return ( + + {Array.from({ length: METER_BARS }, (_, index) => { + // Bars light up left to right as the level rises, with a floor so the meter never + // looks dead while a quiet voice is still being captured. + const threshold = (index + 1) / METER_BARS; + const active = level >= threshold * 0.9; + const height = active ? 30 + threshold * 70 : 20; + return ( + + ); + })} + + ); +} diff --git a/frontend/src/lib/voice/useVoiceCapture.ts b/frontend/src/lib/voice/useVoiceCapture.ts index 37574d7..40983e0 100644 --- a/frontend/src/lib/voice/useVoiceCapture.ts +++ b/frontend/src/lib/voice/useVoiceCapture.ts @@ -65,6 +65,12 @@ export function useVoiceCapture({ const cancelledRef = useRef(false); /** getUserMedia is async; without this a permission granted after unmount leaks the mic. */ const mountedRef = useRef(true); + /** + * Set synchronously on click. `phase` does not become 'recording' until getUserMedia + * resolves, so without this a second click during the permission prompt would start a + * second stream and orphan the first — mic indicator lit, interval leaked. + */ + const startingRef = useRef(false); const teardown = useCallback(() => { if (timerRef.current) { @@ -81,16 +87,21 @@ export function useVoiceCapture({ // Releasing the microphone on unmount matters: the browser shows a recording indicator // for as long as the track is live, and an orphaned one looks like the app is listening. - useEffect(() => () => { - mountedRef.current = false; - cancelledRef.current = true; - abortRef.current?.abort(); - try { - recorderRef.current?.stop(); - } catch { - // already stopped - } - teardown(); + useEffect(() => { + // Re-armed on every mount: React StrictMode runs mount → unmount → mount in dev, and + // a ref that is only ever set false would leave the hook permanently "unmounted". + mountedRef.current = true; + return () => { + mountedRef.current = false; + cancelledRef.current = true; + abortRef.current?.abort(); + try { + recorderRef.current?.stop(); + } catch { + // already stopped + } + teardown(); + }; }, [teardown]); const send = useCallback( @@ -133,71 +144,80 @@ export function useVoiceCapture({ }, [teardown]); const onStart = useCallback(() => { - if (phase !== 'idle') return; + if (phase !== 'idle' || startingRef.current) return; if (!isMediaRecorderSupported()) { onError(clientError('VOICE_MIC_DENIED')); return; } cancelledRef.current = false; + startingRef.current = true; + void (async () => { - let stream: MediaStream; try { - stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - } catch { - // Permission refused, or no input device. Never a server round-trip. - onError(clientError('VOICE_MIC_DENIED')); - return; - } - - if (!mountedRef.current) { - // Permission resolved after the component went away — release it immediately - // rather than leaving the browser's recording indicator lit. - stream.getTracks().forEach((track) => track.stop()); - return; - } - - const mimeType = pickRecordingMimeType(); - if (mimeType === null) { - stream.getTracks().forEach((track) => track.stop()); - onError(clientError('VOICE_MIC_DENIED')); - return; - } - - streamRef.current = stream; - chunksRef.current = []; - const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined); - recorderRef.current = recorder; - - recorder.ondataavailable = (event) => { - if (event.data.size > 0) chunksRef.current.push(event.data); - }; - recorder.onstop = () => { - const durationMs = Date.now() - startedAtRef.current; - const blob = new Blob(chunksRef.current, { type: recorder.mimeType || mimeType }); - teardown(); - if (cancelledRef.current || blob.size === 0) { - setPhase('idle'); - setElapsedMs(0); + let stream: MediaStream; + try { + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + } catch { + // Permission refused, or no input device. Never a server round-trip. + onError(clientError('VOICE_MIC_DENIED')); return; } - void send(blob, recorder.mimeType || mimeType || 'audio/webm', durationMs); - }; - attachLevelMeter(stream, audioContextRef, setLevel); + if (!mountedRef.current) { + // Permission resolved after the component went away — release it immediately + // rather than leaving the browser's recording indicator lit. + stream.getTracks().forEach((track) => track.stop()); + return; + } - startedAtRef.current = Date.now(); - recorder.start(); - setPhase('recording'); - setElapsedMs(0); + const mimeType = pickRecordingMimeType(); + if (mimeType === null) { + stream.getTracks().forEach((track) => track.stop()); + onError(clientError('VOICE_MIC_DENIED')); + return; + } - timerRef.current = setInterval(() => { - const elapsed = Date.now() - startedAtRef.current; - setElapsedMs(elapsed); - // Auto-stop proceeds to processing with what was captured; discarding two minutes - // of dictation because a timer expired would be the worst possible failure. - if (maxMs != null && elapsed >= maxMs) stop(); - }, LEVEL_POLL_MS); + streamRef.current = stream; + chunksRef.current = []; + const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined); + recorderRef.current = recorder; + + recorder.ondataavailable = (event) => { + if (event.data.size > 0) chunksRef.current.push(event.data); + }; + recorder.onstop = () => { + const durationMs = Date.now() - startedAtRef.current; + const blob = new Blob(chunksRef.current, { type: recorder.mimeType || mimeType }); + teardown(); + if (cancelledRef.current || blob.size === 0) { + setPhase('idle'); + setElapsedMs(0); + return; + } + // Prefer what the recorder actually produced, then the blob's own type. Old + // Safari accepts no mimeType hint, and defaulting to webm would mislabel its + // mp4/aac clips as something they are not. + void send(blob, recorder.mimeType || blob.type || mimeType || 'audio/webm', durationMs); + }; + + attachLevelMeter(stream, audioContextRef, setLevel); + + startedAtRef.current = Date.now(); + recorder.start(); + setPhase('recording'); + setElapsedMs(0); + + timerRef.current = setInterval(() => { + const elapsed = Date.now() - startedAtRef.current; + setElapsedMs(elapsed); + // Auto-stop proceeds to processing with what was captured; discarding two + // minutes of dictation because a timer expired would be the worst failure. + if (maxMs != null && elapsed >= maxMs) stop(); + }, LEVEL_POLL_MS); + } finally { + startingRef.current = false; + } })(); }, [maxMs, onError, phase, send, stop, teardown]);