feat(voice): keep the transcript on the server
The review sheet rendered the full dictation at the top of the modal, unconditionally. A raw transcript can carry the patient's spoken name — the spec said so itself in §9, while §10 said telemetry must never contain it. The transcript no longer reaches the browser by any route: - removed from the success response (VoiceExtractionResponse is now plain ResolvedExtraction, which never had the field) - removed from the error body. VOICE_EXTRACT_FAILED carried details.transcript for a salvage dialog that was never built, and ApiError['details'] is an array, so the shape never even matched — it was serialized onto the wire and dropped - removed from the sheet, and from VoiceExtractionResult. tsc proves that <p> was the only reader in the whole frontend It is logged instead: one info line per recording, written immediately after the emptiness check so a failed extraction still records it, and deliberately outside logTelemetry so that method's patient-free guarantee stays literally true. Accepted consequence, recorded in §10: patient words now persist in production server logs at default level, so whatever retention and access control applies to those logs applies to dictation. The repo's other sensitive-text path (openrouter.provider.ts) uses debug level with truncation; moving this line to debug is a one-word change. Transcript salvage is dropped rather than deferred, which settles §11 open item 16 by taking its second option. When extraction fails the clinician re-dictates; an operator can read the words in the log, the person who spoke them cannot. Spec: §7, §9 and §10 rewritten, item 16 resolved, decisions 51-53 added, and decisions 10 and 25 marked superseded so the log stops contradicting itself. No automated coverage for the response shape or the log line: there is no voice.service.spec.ts — the service is I/O orchestration and has never been unit tested. Removing the type field is what proves no reader survives. Gates: backend 216 tests, nest build, ESLint clean on the voice module; frontend tsc --noEmit clean, 52 Vitest tests, next build clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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. */
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -59,7 +59,6 @@ const LAB_DEPENDENT = new Set(['prosthesis']);
|
||||
|
||||
function baseResult(overrides: Partial<VoiceExtractionResult> = {}): VoiceExtractionResult {
|
||||
return {
|
||||
transcript: '',
|
||||
treatmentType: 'restoration',
|
||||
teeth: [],
|
||||
toothSelectionGroups: [],
|
||||
|
||||
@@ -219,10 +219,6 @@ export function VoiceReviewSheet({
|
||||
{t('voiceReviewTitle')}
|
||||
</h2>
|
||||
|
||||
<p className="mt-2 rounded-[var(--radius-md)] bg-background-card/60 px-3 py-2 text-sm text-text-secondary">
|
||||
{effective.transcript}
|
||||
</p>
|
||||
|
||||
{nothingToApply ? (
|
||||
<p className="mt-4 text-sm text-text-secondary">{t('voiceNothingExtracted')}</p>
|
||||
) : (
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user