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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user