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:
2026-09-10 17:39:27 +08:00
parent 46c8a25139
commit 10408058c4
5 changed files with 65 additions and 50 deletions

View File

@@ -35,9 +35,12 @@ export type VoiceAvailability = {
maxRecordingMs: number | null; 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() @Injectable()
export class VoiceService { 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 transcript's only destination. Logged before extraction so it survives an extraction
// the words the clinician already paid for are not lost (transcript salvage). // 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 resolved: ResolvedExtraction;
let llmCost: number | null = null; let llmCost: number | null = null;
try { try {
// Inside the try: the transcript is already paid for, so a catalog/DB failure here // Inside the try: a catalog/DB failure here must surface as VOICE_EXTRACT_FAILED, which
// must still salvage it rather than becoming a generic 500 that throws it away. // the clinician can act on, rather than a generic 500.
const catalog = await this.buildCatalog(organizationId, catalogLocale); const catalog = await this.buildCatalog(organizationId, catalogLocale);
const result = await extraction.extract( const result = await extraction.extract(
transcript, transcript,
@@ -148,7 +158,7 @@ export class VoiceService {
linkedLabIds: new Set(catalog.labs.map((l) => l.id)), linkedLabIds: new Set(catalog.labs.map((l) => l.id)),
}); });
} catch (error) { } catch (error) {
throw this.toAppException(error, 'extraction', transcript); throw this.toAppException(error, 'extraction');
} }
this.logTelemetry({ this.logTelemetry({
@@ -160,7 +170,7 @@ export class VoiceService {
resolved, resolved,
}); });
return { ...resolved, transcript }; return resolved;
} }
private assertOrganization(user: { organizationId?: string }): string { private assertOrganization(user: { organizationId?: string }): string {
@@ -303,7 +313,6 @@ export class VoiceService {
private toAppException( private toAppException(
error: unknown, error: unknown,
stage: 'asr' | 'extraction', stage: 'asr' | 'extraction',
transcript?: string,
): AppException { ): AppException {
if (error instanceof Error && error.name === 'AbortError') { if (error instanceof Error && error.name === 'AbortError') {
// The clinician cancelled; not a failure worth a translated message. // The clinician cancelled; not a failure worth a translated message.
@@ -318,11 +327,9 @@ export class VoiceService {
stage === 'asr' stage === 'asr'
? ErrorCode.VOICE_ASR_FAILED ? ErrorCode.VOICE_ASR_FAILED
: ErrorCode.VOICE_EXTRACT_FAILED; : ErrorCode.VOICE_EXTRACT_FAILED;
return new AppException( // No details: the transcript used to ride along here for a salvage dialog that was never
code, // built, so it was serialized onto the wire and dropped. It stays on the server now.
HttpStatus.BAD_GATEWAY, return new AppException(code, HttpStatus.BAD_GATEWAY);
transcript ? { transcript } : undefined,
);
} }
/** Structured and patient-free: never the transcript, never audio, never a patient id. */ /** Structured and patient-free: never the transcript, never audio, never a patient id. */

View File

@@ -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 > same constraint the realtime soft-refresh already lives under: never remount the
> treatment form, never clear a draft. > treatment form, never clear a draft.
- Renders the transcript, then one row per extracted field in the app's own vocabulary: - Renders one row per extracted field in the app's own vocabulary: translated catalog labels,
translated catalog labels, and a mini FDI chart for the teeth rather than a list of and a mini FDI chart for the teeth rather than a list of numbers. **The transcript is not
numbers. shown** — it never reaches the browser at all (§10).
- Each row has a checkbox. Ticked rows apply; nothing else is touched. Confirm is also - Each row has a checkbox. Ticked rows apply; nothing else is touched. Confirm is also
what creates the new detail — see §2. what creates the new detail — see §2.
- Rows default to ticked **except** the lab row when `labMatchExact` is false — shipping to a - 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 the real container from `recorder.mimeType`, as it already did. `VOICE_UNSUPPORTED_FORMAT` is
left for a browser that genuinely cannot record. left for a browser that genuinely cannot record.
**Transcript salvage — specified, NOT built.** The backend half exists: `VOICE_EXTRACT_FAILED` **Transcript salvage — dropped, not deferred.** `VOICE_EXTRACT_FAILED` used to carry
carries `details.transcript` and `HttpExceptionFilter` forwards it. The client half was `details.transcript` so a failure dialog could offer the words back as a note. It was never
never written — `onError` only resolves a message through `getUserFacingError`, which never built, and the transcript no longer reaches the client at all (§10), so the dialog as specified
reads `details`, so the transcript is shipped in an error body and dropped. Either build the cannot be built either. `toAppException` now returns a code and no `details`.
dialog below or stop returning the transcript; shipping dictation to the client and
discarding it is the worst of both.
When ASR succeeded and only extraction failed, the response still The trade, stated plainly: when ASR succeeded and only extraction failed, the words were
carries the transcript and the failure dialog offers *"افزودن به یادداشت"*. That action captured and paid for, and the clinician cannot be offered them. They are in the server log,
**creates a new detail with only `comment` set to the transcript** — everything else left readable by an operator, not by the person who spoke them. The clinician re-dictates.
at `newDetail()` defaults. The words were captured and paid for; only the structure was
lost.
This keeps the feature's one invariant intact: **voice never writes into an existing Reversing this needs a decision about the transcript leaving the server, not just client code.
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).
--- ---
@@ -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 - 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 row. Note this is deliberately unlike treatment attachments, which do persist to
`backend/uploads/treatments`. `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 - The `comment` field persists a cleaned version of what was said — that is legitimate
clinical record-keeping and is the only durable trace. clinical record-keeping and is the only durable trace.
- Telemetry is **structured and patient-free**: clip duration, which fields resolved, - 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 pre-authentication allocation and nobody has decided whether that is acceptable. Not
changed in this revision. changed in this revision.
16. **Transcript salvage is still specified and not built.** `VOICE_EXTRACT_FAILED` carries 16. ~~**Transcript salvage is still specified and not built.**~~**resolved 2026-09-10:** of
`details.transcript`, `HttpExceptionFilter` forwards it, and nothing on the client reads its two options, "stop returning the transcript" was taken. The transcript is no longer in
it — `getUserFacingError` resolves a message only. The field also does not match the success response or in `details`, the review sheet no longer shows it, and it is logged
`ApiError['details']`, which is an array. Either build the dialog in §9 or stop returning on the server instead (§9, §10). The `ApiError['details']` shape mismatch goes away with it.
the transcript. Carried forward unchanged; it is orthogonal to the prosthesis model.
--- ---
@@ -1073,14 +1075,14 @@ Settled in a grilling session on 2026-08-20.
| 7 | Due date | Intent + deterministic resolver | | 7 | Due date | Intent + deterministic resolver |
| 8 | Resolver location | Backend, Jalali math ported | | 8 | Resolver location | Backend, Jalali math ported |
| 9 | Prosthesis | Default type + overrides, all-or-nothing | | 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) | | 11 | Capture | Tap to start/stop, hard cap (see 27) |
| 12 | Failure UX | Stage-aware codes, transcript salvage | | 12 | Failure UX | Stage-aware codes, transcript salvage |
| 13 | Locales | Provider registry per locale; all three locales enabled | | 13 | Locales | Provider registry per locale; all three locales enabled |
| 14 | Reachability | Registry now, slots filled per deployment | | 14 | Reachability | Registry now, slots filled per deployment |
| 23 | ASR model | `openai/whisper-1` for **every** locale; registry kept so `fa` can diverge | | 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 | | 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 | | 26 | Throttle | Configurable; v1 default 6 requests / 60s per user |
| 27 | Duration cap | **2 minutes**, configurable via `maxMs` | | 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 | | 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 `/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 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). 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) |

View File

@@ -59,7 +59,6 @@ const LAB_DEPENDENT = new Set(['prosthesis']);
function baseResult(overrides: Partial<VoiceExtractionResult> = {}): VoiceExtractionResult { function baseResult(overrides: Partial<VoiceExtractionResult> = {}): VoiceExtractionResult {
return { return {
transcript: '',
treatmentType: 'restoration', treatmentType: 'restoration',
teeth: [], teeth: [],
toothSelectionGroups: [], toothSelectionGroups: [],

View File

@@ -219,10 +219,6 @@ export function VoiceReviewSheet({
{t('voiceReviewTitle')} {t('voiceReviewTitle')}
</h2> </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 ? ( {nothingToApply ? (
<p className="mt-4 text-sm text-text-secondary">{t('voiceNothingExtracted')}</p> <p className="mt-4 text-sm text-text-secondary">{t('voiceNothingExtracted')}</p>
) : ( ) : (

View File

@@ -46,7 +46,10 @@ export interface VoiceProsthesisAssignment {
} }
export interface VoiceExtractionResult { 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; treatmentType: string | null;
teeth: FdiToothId[]; teeth: FdiToothId[];
toothSelectionGroups: ToothSelectionGroup[]; toothSelectionGroups: ToothSelectionGroup[];