feat(voice): write a note only when the clinician asks for one

The comment instruction told the model to sweep up leftovers: "clinical
notes, in the language spoken. Omit the parts already captured as treatment
type, teeth, prosthesis work or deadline." So filler, small talk and an
unrequested diagnosis all became a persisted clinical note — and comment is
the one durable trace a recording leaves (§10).

The model now reports the exact words that asked for the note in a new
commentTrigger field, and comment holds only what was dictated after them.
resolveVoiceIntent keeps comment only when a trigger is present, so the rule
is enforced by code instead of trusted to the prompt: a model that decides
leftover speech was a note loses that note.

Per-locale trigger vocabulary goes in the prompt's locale notes, beside the
tooth vocabulary — «بنویس که», "write this in the notes", "noteer". The
resolver only checks that a trigger was reported, so it needs no per-locale
knowledge and stays locale-neutral, as §6 requires.

Also adds HARD RULE 7: never decide something is a note; speech that fits no
field is simply not reported.

Four resolver tests. The two that drop an unasked note fail without the
guard; the trigger-only and trigger-with-note cases pass either way and exist
to prove the guard does not swallow a real note.

Frontend untouched — it only ever saw the resolved comment, never the
trigger.

Spec: §5 gains the rule and the field, decision 54 added.

Gates: backend 220 tests, nest build, ESLint clean on the voice module;
frontend tsc --noEmit clean, 52 Vitest tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-10 17:47:13 +08:00
parent 10408058c4
commit d8c9fa8995
7 changed files with 107 additions and 5 deletions

View File

@@ -411,7 +411,8 @@ type VoiceIntent = {
treatmentType: string | null; // catalog code, from the supplied closed list
teeth: ToothIntent[];
connectedSpans: { from: ToothIntent; to: ToothIntent }[];
comment: string | null;
comment: string | null; // only when asked for — see below
commentTrigger: string | null; // the words that asked; null discards the comment
prosthesis: ProsthesisAssignment[]; // empty array, never null
labId: string | null; // must be one of the supplied linked-lab ids
labMatchExact: boolean;
@@ -517,6 +518,25 @@ The `sub_*` key set is wider than the subcategory set — it also carries techni
> the print layout, none of which this task otherwise opens. The seed is written from the message
> files precisely so the two agree at the point they diverge.
### A note is written only when the clinician asks for one
The model does not decide that something was a note. It reports the exact words that asked —
«بنویس که», "write this in the notes" — in `commentTrigger`, and `comment` holds what was
dictated after them, without the asking words. The resolver keeps `comment` only when a trigger
is present, so the rule is enforced by code rather than trusted to the prompt.
The instruction this replaces said "clinical notes, in the language spoken. Omit the parts
already captured as treatment type, teeth, prosthesis work or deadline" — which told the model to
sweep up whatever was left over. Filler, small talk and an unrequested diagnosis all became a
persisted clinical note, and `comment` is the one durable trace of a recording (§10).
Speech that fits no field and was not asked to be a note is simply not reported. The sheet shows
what was captured, so nothing is hidden by leaving it out.
Per-locale trigger vocabulary lives in the prompt's locale notes, exactly like the tooth
vocabulary. The resolver only checks that a trigger was reported, so it needs no per-locale
knowledge and stays locale-neutral by construction (§6).
### Prosthesis work implies the treatment type
`prosthesis` is the only `labDependent` treatment type. Any resolved assignment therefore
@@ -1149,3 +1169,9 @@ Transcript handling revised on 2026-09-10.
| 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) |
Notes made explicit on 2026-09-10.
| # | Question | Decision |
|---|---|---|
| 54 | What becomes a note | Only what the clinician explicitly asked to be written. The model reports the asking words in `commentTrigger`; the resolver discards `comment` without one, so a prompt drift cannot quietly persist unrequested speech as a clinical note (§5) |