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;
};
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. */