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. */
|
||||
|
||||
Reference in New Issue
Block a user