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;
|
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. */
|
||||||
|
|||||||
@@ -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) |
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
Reference in New Issue
Block a user