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

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

View File

@@ -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>
) : (

View File

@@ -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[];