feat: wire voice entry into the treatment workspace

Makes the feature reachable end to end: availability is fetched alongside the
catalogs, the capture hook drives the segmented control, and confirming the
review sheet appends a new detail.

Confirm always appends — it never edits an existing detail and never calls
onAddDetail. Ticked rows land on top of the seeded defaults, so unticking the
type row leaves the appointment-purpose default rather than a blank. Lab-side
rows ride on a lab case draft keyed by the detail's *client* id, so a brand-new
unsaved detail can carry a lab, due date and per-tooth prosthesis map.

Availability comes from the API rather than a NEXT_PUBLIC_* var, since those are
baked in at build time; a failure fetching it degrades to no microphone rather
than taking the treatment tab down.

From review of this commit:

- Unticking "teeth" while leaving "prosthesis" ticked attached prosthesis rows
  for teeth the detail does not contain. Nothing downstream filters them —
  assertCompleteToothProsthesisMap only checks detail-teeth ⊆ map, never the
  reverse — so they would have reached task generation as lab work for teeth
  nobody is treating. The map is now filtered to the detail's own teeth.
- The microphone was gated on the URL locale while the server resolved
  everything from req.user.language. Those diverge (a bookmarked /fa/ URL, a
  language toggle whose save failed), which would transcribe Persian with an
  English hint and anchor "next Thursday" to a Monday week instead of a Saturday
  one — or 403 from a visibly-enabled button. The client now sends the locale the
  microphone was offered in, so the gate and the request agree by construction.

Also fixed from the previous review: a civil YYYY-MM-DD date rendered a day
early west of Greenwich (parsed as UTC midnight); the missing-teeth list
hardcoded the Arabic comma for all locales; and voiceApply had no ICU plural, so
the common single-field case read "Apply 1 fields".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-20 20:22:42 +03:30
parent c07f550e00
commit 3f97940a16
13 changed files with 533 additions and 21 deletions

View File

@@ -21,6 +21,9 @@ export const VOICE_AUDIO_FORMATS = [
export type VoiceAudioFormat = (typeof VOICE_AUDIO_FORMATS)[number];
/** Locales the app ships; a profile still has to be configured for one to be usable. */
export const VOICE_LOCALES = ['en', 'fa', 'nl'] as const;
export class ExtractVoiceDto {
/**
* Base64 audio, no data: prefix. Capped well above a 2-minute opus clip (~400 KB) but
@@ -52,4 +55,15 @@ export class ExtractVoiceDto {
@IsInt()
@Min(0)
durationMs: number;
/**
* The locale the clinician is actually speaking, as the UI offered the microphone.
*
* Sent explicitly rather than read from `user.language`: the two can diverge (a
* bookmarked /fa/ URL, a language toggle whose save failed), and a mismatch would
* transcribe Persian with an English hint and anchor "next Thursday" to the wrong
* week start. Gating the button and resolving the request must agree by construction.
*/
@IsIn(VOICE_LOCALES)
locale: string;
}

View File

@@ -284,8 +284,9 @@ describe('resolveVoiceIntent', () => {
it('reports a hallucinated lab rather than dropping it silently', () => {
// A near-miss lab id must not look identical to "no lab was spoken".
const result = resolveVoiceIntent({ ...base, labId: 'lab-elsewhere' }, CTX);
// The id is not what the clinician said — quoting it back shows them a raw UUID.
expect(result.unresolved).toContainEqual({
spoken: 'lab-elsewhere',
spoken: '',
reason: 'unknown_catalog_code',
});
});

View File

@@ -287,10 +287,10 @@ export function resolveVoiceIntent(
// reported: a hallucinated lab must not look identical to "no lab was spoken".
const labId = resolveCatalogCode(intent?.labId, ctx.linkedLabIds);
if (intent?.labId != null && !labId) {
unresolved.push({
spoken: String(intent.labId),
reason: 'unknown_catalog_code',
});
// `spoken` means "what the clinician said". A rejected lab id is an opaque
// identifier the model invented, so quoting it back would put a raw UUID in front
// of the user; the reason alone carries the meaning.
unresolved.push({ spoken: '', reason: 'unknown_catalog_code' });
}
return {

View File

@@ -15,11 +15,7 @@ import { ExtractVoiceDto } from './dto/voice.dto';
import { VoiceThrottlerGuard } from './voice-throttler.guard';
import { VoiceService } from './voice.service';
type VoiceRequestUser = {
id: string;
organizationId?: string;
language?: string | null;
};
type VoiceRequestUser = { id: string; organizationId?: string };
@ApiTags('voice')
@ApiBearerAuth('JWT-auth')
@@ -60,10 +56,12 @@ export class VoiceController {
if (!res.writableFinished) aborter.abort();
});
// dto.locale, not req.user.language: the client sends the locale the microphone was
// actually offered in, so the ASR hint, catalog labels and week start all match it.
const data = await this.voiceService.extract(
req.user,
dto,
req.user?.language ?? 'en',
dto.locale,
aborter.signal,
);
return { success: true, data };

View File

@@ -124,10 +124,12 @@ export class VoiceService {
// Stage 2 — structure it. On failure the transcript still goes back to the client so
// the words the clinician already paid for are not lost (transcript salvage).
const catalog = await this.buildCatalog(organizationId, catalogLocale);
let resolved: ResolvedExtraction;
let llmCost: number | null = null;
try {
// Inside the try: the transcript is already paid for, so a catalog/DB failure here
// must still salvage it rather than becoming a generic 500 that throws it away.
const catalog = await this.buildCatalog(organizationId, catalogLocale);
const result = await extraction.extract(
transcript,
catalog,