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

@@ -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
> treatment form, never clear a draft.
- Renders the transcript, then one row per extracted field in the app's own vocabulary:
translated catalog labels, and a mini FDI chart for the teeth rather than a list of
numbers.
- Renders one row per extracted field in the app's own vocabulary: translated catalog labels,
and a mini FDI chart for the teeth rather than a list of numbers. **The transcript is not
shown** — it never reaches the browser at all (§10).
- Each row has a checkbox. Ticked rows apply; nothing else is touched. Confirm is also
what creates the new detail — see §2.
- 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
left for a browser that genuinely cannot record.
**Transcript salvage — specified, NOT built.** The backend half exists: `VOICE_EXTRACT_FAILED`
carries `details.transcript` and `HttpExceptionFilter` forwards it. The client half was
never written — `onError` only resolves a message through `getUserFacingError`, which never
reads `details`, so the transcript is shipped in an error body and dropped. Either build the
dialog below or stop returning the transcript; shipping dictation to the client and
discarding it is the worst of both.
**Transcript salvage — dropped, not deferred.** `VOICE_EXTRACT_FAILED` used to carry
`details.transcript` so a failure dialog could offer the words back as a note. It was never
built, and the transcript no longer reaches the client at all (§10), so the dialog as specified
cannot be built either. `toAppException` now returns a code and no `details`.
When ASR succeeded and only extraction failed, the response still
carries the transcript and the failure dialog offers *"افزودن به یادداشت"*. That action
**creates a new detail with only `comment` set to the transcript** — everything else left
at `newDetail()` defaults. The words were captured and paid for; only the structure was
lost.
The trade, stated plainly: when ASR succeeded and only extraction failed, the words were
captured and paid for, and the clinician cannot be offered them. They are in the server log,
readable by an operator, not by the person who spoke them. The clinician re-dictates.
This keeps the feature's one invariant intact: **voice never writes into an existing
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).
Reversing this needs a decision about the transcript leaving the server, not just client code.
---
@@ -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
row. Note this is deliberately unlike treatment attachments, which do persist to
`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
clinical record-keeping and is the only durable trace.
- 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
changed in this revision.
16. **Transcript salvage is still specified and not built.** `VOICE_EXTRACT_FAILED` carries
`details.transcript`, `HttpExceptionFilter` forwards it, and nothing on the client reads
it — `getUserFacingError` resolves a message only. The field also does not match
`ApiError['details']`, which is an array. Either build the dialog in §9 or stop returning
the transcript. Carried forward unchanged; it is orthogonal to the prosthesis model.
16. ~~**Transcript salvage is still specified and not built.**~~**resolved 2026-09-10:** of
its two options, "stop returning the transcript" was taken. The transcript is no longer in
the success response or in `details`, the review sheet no longer shows it, and it is logged
on the server instead (§9, §10). The `ApiError['details']` shape mismatch goes away with it.
---
@@ -1073,14 +1075,14 @@ Settled in a grilling session on 2026-08-20.
| 7 | Due date | Intent + deterministic resolver |
| 8 | Resolver location | Backend, Jalali math ported |
| 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) |
| 12 | Failure UX | Stage-aware codes, transcript salvage |
| 13 | Locales | Provider registry per locale; all three locales enabled |
| 14 | Reachability | Registry now, slots filled per deployment |
| 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 |
| 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 |
| 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 |
@@ -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
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).
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) |