diff --git a/backend/src/modules/voice/voice.service.ts b/backend/src/modules/voice/voice.service.ts index 9bea739..7c554cf 100644 --- a/backend/src/modules/voice/voice.service.ts +++ b/backend/src/modules/voice/voice.service.ts @@ -35,9 +35,12 @@ export type VoiceAvailability = { maxRecordingMs: number | null; }; -export type VoiceExtractionResponse = ResolvedExtraction & { - transcript: string; -}; +/** + * The transcript is deliberately absent. A raw dictation can carry the patient's spoken name, so + * it never leaves the server — it is logged there instead (§10). Nothing the client renders needs + * it, and what is not sent cannot leak through the network tab or an error reporter. + */ +export type VoiceExtractionResponse = ResolvedExtraction; @Injectable() export class VoiceService { @@ -119,13 +122,20 @@ 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). + // The transcript's only destination. Logged before extraction so it survives an extraction + // failure too, and kept out of logTelemetry so that method's patient-free guarantee stays + // true. This line DOES carry what the clinician said, which may include a patient's name. + this.logger.log( + `voice transcript [${catalogLocale}]: ${transcript.trim()}`, + ); + + // Stage 2 — structure it. A failure here returns a code only; the transcript stays in the + // server log above, never in the response. 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. + // Inside the try: a catalog/DB failure here must surface as VOICE_EXTRACT_FAILED, which + // the clinician can act on, rather than a generic 500. const catalog = await this.buildCatalog(organizationId, catalogLocale); const result = await extraction.extract( transcript, @@ -148,7 +158,7 @@ export class VoiceService { linkedLabIds: new Set(catalog.labs.map((l) => l.id)), }); } catch (error) { - throw this.toAppException(error, 'extraction', transcript); + throw this.toAppException(error, 'extraction'); } this.logTelemetry({ @@ -160,7 +170,7 @@ export class VoiceService { resolved, }); - return { ...resolved, transcript }; + return resolved; } private assertOrganization(user: { organizationId?: string }): string { @@ -303,7 +313,6 @@ export class VoiceService { private toAppException( error: unknown, stage: 'asr' | 'extraction', - transcript?: string, ): AppException { if (error instanceof Error && error.name === 'AbortError') { // The clinician cancelled; not a failure worth a translated message. @@ -318,11 +327,9 @@ export class VoiceService { stage === 'asr' ? ErrorCode.VOICE_ASR_FAILED : ErrorCode.VOICE_EXTRACT_FAILED; - return new AppException( - code, - HttpStatus.BAD_GATEWAY, - transcript ? { transcript } : undefined, - ); + // No details: the transcript used to ride along here for a salvage dialog that was never + // built, so it was serialized onto the wire and dropped. It stays on the server now. + return new AppException(code, HttpStatus.BAD_GATEWAY); } /** Structured and patient-free: never the transcript, never audio, never a patient id. */ diff --git a/docs/specs/voice-treatment-entry/spec.md b/docs/specs/voice-treatment-entry/spec.md index f34c713..3d485ec 100644 --- a/docs/specs/voice-treatment-entry/spec.md +++ b/docs/specs/voice-treatment-entry/spec.md @@ -664,9 +664,9 @@ assignment's existing targets. An item raised outside any assignment (a tooth sp > same constraint the realtime soft-refresh already lives under: never remount the > treatment form, never clear a draft. -- Renders the transcript, then one row per extracted field in the app's own vocabulary: - translated catalog labels, and a mini FDI chart for the teeth rather than a list of - numbers. +- Renders one row per extracted field in the app's own vocabulary: translated catalog labels, + and a mini FDI chart for the teeth rather than a list of numbers. **The transcript is not + shown** — it never reaches the browser at all (§10). - Each row has a checkbox. Ticked rows apply; nothing else is touched. Confirm is also what creates the new detail — see §2. - Rows default to ticked **except** the lab row when `labMatchExact` is false — shipping to a @@ -822,26 +822,16 @@ function already had for Safari versions that shipped no `isTypeSupported`; `ons the real container from `recorder.mimeType`, as it already did. `VOICE_UNSUPPORTED_FORMAT` is left for a browser that genuinely cannot record. -**Transcript salvage — specified, NOT built.** The backend half exists: `VOICE_EXTRACT_FAILED` -carries `details.transcript` and `HttpExceptionFilter` forwards it. The client half was -never written — `onError` only resolves a message through `getUserFacingError`, which never -reads `details`, so the transcript is shipped in an error body and dropped. Either build the -dialog below or stop returning the transcript; shipping dictation to the client and -discarding it is the worst of both. +**Transcript salvage — dropped, not deferred.** `VOICE_EXTRACT_FAILED` used to carry +`details.transcript` so a failure dialog could offer the words back as a note. It was never +built, and the transcript no longer reaches the client at all (§10), so the dialog as specified +cannot be built either. `toAppException` now returns a code and no `details`. -When ASR succeeded and only extraction failed, the response still -carries the transcript and the failure dialog offers *"افزودن به یادداشت"*. That action -**creates a new detail with only `comment` set to the transcript** — everything else left -at `newDetail()` defaults. The words were captured and paid for; only the structure was -lost. +The trade, stated plainly: when ASR succeeded and only extraction failed, the words were +captured and paid for, and the clinician cannot be offered them. They are in the server log, +readable by an operator, not by the person who spoke them. The clinician re-dictates. -This keeps the feature's one invariant intact: **voice never writes into an existing -detail.** Dictating into an already-filled detail is a separate, later feature with its -own voice-to-text control scoped to that field (§1, out of scope). - -It also does not bypass the confirmation rule — the dialog shows the transcript, and the -dentist taps to accept it. That review matters, because a raw transcript carries ASR -errors and may contain the patient's spoken name, and `comment` is persisted (§10). +Reversing this needs a decision about the transcript leaving the server, not just client code. --- @@ -850,7 +840,20 @@ errors and may contain the patient's spoken name, and `comment` is persisted (§ - Audio is held **in memory for the request only**. Never written to disk, never a Prisma row. Note this is deliberately unlike treatment attachments, which do persist to `backend/uploads/treatments`. -- The transcript goes to the browser for the review sheet and dies with it. +- **The transcript never leaves the server.** It is not in the success response and not in any + error body. A raw dictation can carry the patient's spoken name, and the review sheet has no + need of it — the resolved rows are what the clinician confirms. +- The transcript **is** written to the server log, once per recording, at **info** level + (`voice.service.ts`, immediately after the emptiness check so an extraction failure still + records it). This is a deliberate exception to the rule below, and the only durable trace + besides `comment`. It is logged before extraction, not inside `logTelemetry`, so that + method's patient-free guarantee stays literally true. + + > ⚠ Consequence to accept: patient words persist in production server logs at default level. + > Whatever retention and access control applies to those logs now applies to dictation. The + > repo's other sensitive-text path — a vendor error body that can echo the request back — + > uses `debug` level and truncates to 500 characters + > (`openrouter.provider.ts`). Moving this line to `debug` is a one-word change. - The `comment` field persists a cleaned version of what was said — that is legitimate clinical record-keeping and is the only durable trace. - Telemetry is **structured and patient-free**: clip duration, which fields resolved, @@ -969,11 +972,10 @@ enabling this for real clinics. pre-authentication allocation and nobody has decided whether that is acceptable. Not changed in this revision. -16. **Transcript salvage is still specified and not built.** `VOICE_EXTRACT_FAILED` carries - `details.transcript`, `HttpExceptionFilter` forwards it, and nothing on the client reads - it — `getUserFacingError` resolves a message only. The field also does not match - `ApiError['details']`, which is an array. Either build the dialog in §9 or stop returning - the transcript. Carried forward unchanged; it is orthogonal to the prosthesis model. +16. ~~**Transcript salvage is still specified and not built.**~~ — **resolved 2026-09-10:** of + its two options, "stop returning the transcript" was taken. The transcript is no longer in + the success response or in `details`, the review sheet no longer shows it, and it is logged + on the server instead (§9, §10). The `ApiError['details']` shape mismatch goes away with it. --- @@ -1073,14 +1075,14 @@ Settled in a grilling session on 2026-08-20. | 7 | Due date | Intent + deterministic resolver | | 8 | Resolver location | Backend, Jalali math ported | | 9 | Prosthesis | Default type + overrides, all-or-nothing | -| 10 | Retention | Discard audio and transcript, non-PHI telemetry only | +| 10 | Retention | Discard audio and transcript, non-PHI telemetry only — **partly superseded by 52**: the transcript is now logged on the server at info level | | 11 | Capture | Tap to start/stop, hard cap (see 27) | | 12 | Failure UX | Stage-aware codes, transcript salvage | | 13 | Locales | Provider registry per locale; all three locales enabled | | 14 | Reachability | Registry now, slots filled per deployment | | 23 | ASR model | `openai/whisper-1` for **every** locale; registry kept so `fa` can diverge | | 24 | Extraction model | **`google/gemini-3.7-flash`**; escalation path documented in §4 | -| 25 | Salvage target | Creates a new detail with only `comment` set — voice never writes into an existing detail | +| 25 | Salvage target | Creates a new detail with only `comment` set — voice never writes into an existing detail. **Superseded by 53**: salvage is dropped | | 26 | Throttle | Configurable; v1 default 6 requests / 60s per user | | 27 | Duration cap | **2 minutes**, configurable via `maxMs` | | 28 | Review sheet | Modal on desktop, full-screen overlay (not a route) on mobile; candidate chips are its only interactive part | @@ -1139,3 +1141,11 @@ Corrections to the v1 text found in the same pass: the endpoint is `POST /voice/ `/treatments/voice-extract` (§3); `GET /voice/availability` does exist and only the plan check is deferred (§11 item 13); the catalog has 5 subcategories, not 4, and the disjointness test asserts against the live catalog rather than a written count (§5). + +Transcript handling revised on 2026-09-10. + +| # | Question | Decision | +|---|---|---| +| 51 | Who sees the transcript | Nobody outside the server. It is absent from the success response and from every error body, and the review sheet does not render it — a raw dictation can carry the patient's spoken name, and what is not sent cannot leak through the network tab or an error reporter (§7, §10) | +| 52 | Where it goes instead | One **info**-level server log line per recording, written before extraction so a failed extraction still records it, and outside `logTelemetry` so that method stays patient-free. Accepted consequence: patient words persist in production logs at default level; `debug` is a one-word change (§10) | +| 53 | Transcript salvage | Dropped, not deferred. Reversing it needs a decision about the transcript leaving the server, not just client code. This supersedes decision 25 (§9) | diff --git a/frontend/src/components/treatment/voiceReviewRows.spec.ts b/frontend/src/components/treatment/voiceReviewRows.spec.ts index 1e2f706..e6929d3 100644 --- a/frontend/src/components/treatment/voiceReviewRows.spec.ts +++ b/frontend/src/components/treatment/voiceReviewRows.spec.ts @@ -59,7 +59,6 @@ const LAB_DEPENDENT = new Set(['prosthesis']); function baseResult(overrides: Partial = {}): VoiceExtractionResult { return { - transcript: '', treatmentType: 'restoration', teeth: [], toothSelectionGroups: [], diff --git a/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx b/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx index 768f020..ccb978d 100644 --- a/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx +++ b/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx @@ -219,10 +219,6 @@ export function VoiceReviewSheet({ {t('voiceReviewTitle')} -

- {effective.transcript} -

- {nothingToApply ? (

{t('voiceNothingExtracted')}

) : ( diff --git a/frontend/src/types/voice.ts b/frontend/src/types/voice.ts index 2410386..8aa6ae6 100644 --- a/frontend/src/types/voice.ts +++ b/frontend/src/types/voice.ts @@ -46,7 +46,10 @@ export interface VoiceProsthesisAssignment { } export interface VoiceExtractionResult { - transcript: string; + /** + * No transcript. A raw dictation can carry the patient's spoken name, so the server never + * sends it — it is logged there instead (spec §10). + */ treatmentType: string | null; teeth: FdiToothId[]; toothSelectionGroups: ToothSelectionGroup[];