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

@@ -10,16 +10,22 @@ const LOCALE_NOTES: Record<string, string> = {
'"شش بالا راست" = upper right six -> arch "upper", side "patient_right", position 6.', '"شش بالا راست" = upper right six -> arch "upper", side "patient_right", position 6.',
'A jaw is spoken as "فک بالا" (upper jaw) or "فک پایین" (lower jaw), sometimes just', 'A jaw is spoken as "فک بالا" (upper jaw) or "فک پایین" (lower jaw), sometimes just',
'"بالا"/"پایین" in context, or "هر دو فک" (both jaws).', '"بالا"/"پایین" in context, or "هر دو فک" (both jaws).',
'A note is asked for with "بنویس", "یادداشت کن", "این را یادداشت کن", "بنویس که",',
'"در توضیحات بنویس". Anything else the clinician says is not a note.',
].join(' '), ].join(' '),
nl: [ nl: [
'The clinician is speaking Dutch, where FDI is standard. "zesentwintig" and "26" are', 'The clinician is speaking Dutch, where FDI is standard. "zesentwintig" and "26" are',
'tooth 26. The descriptive form is "rechtsboven zes" = upper right six. A jaw is', 'tooth 26. The descriptive form is "rechtsboven zes" = upper right six. A jaw is',
'"bovenkaak" (upper) or "onderkaak" (lower), or "beide kaken" (both).', '"bovenkaak" (upper) or "onderkaak" (lower), or "beide kaken" (both).',
'A note is asked for with "schrijf op", "noteer", "zet in de notities". Anything else',
'the clinician says is not a note.',
].join(' '), ].join(' '),
en: [ en: [
'The clinician is speaking English and uses FDI. "twenty-six", "two six" and "26" are', 'The clinician is speaking English and uses FDI. "twenty-six", "two six" and "26" are',
'all tooth 26. The descriptive form is "upper right six". A jaw is "upper jaw"/"lower', 'all tooth 26. The descriptive form is "upper right six". A jaw is "upper jaw"/"lower',
'jaw", or "both jaws".', 'jaw", or "both jaws".',
'A note is asked for with "write this in the notes", "note that", "add a note",',
'"put in the comments". Anything else the clinician says is not a note.',
].join(' '), ].join(' '),
}; };
@@ -110,6 +116,9 @@ export function buildExtractionPrompt(
' one is not.', ' one is not.',
'6. A whole jaw is not a tooth. Never put a jaw in the "teeth" list. A jaw appears only', '6. A whole jaw is not a tooth. Never put a jaw in the "teeth" list. A jaw appears only',
' as a prosthesis[].targets entry with "arch" set and "position" null.', ' as a prosthesis[].targets entry with "arch" set and "position" null.',
'7. Never decide something is a note. Fill "comment" only when the clinician asked for',
' one, and report the words they used in "commentTrigger". Speech you cannot place in',
' a field is simply not reported — it is never a note.',
'', '',
'TOOTH NUMBERS', 'TOOTH NUMBERS',
'A number the clinician says for a tooth IS that tooth\'s FDI code. Put it in "fdi" as', 'A number the clinician says for a tooth IS that tooth\'s FDI code. Put it in "fdi" as',
@@ -160,8 +169,13 @@ export function buildExtractionPrompt(
'', '',
'OTHER FIELDS', 'OTHER FIELDS',
'- connectedSpans: only for bridges or splinted units. Endpoints inclusive.', '- connectedSpans: only for bridges or splinted units. Endpoints inclusive.',
'- comment: clinical notes, in the language spoken. Omit the parts already captured as', '- comment + commentTrigger: a note is written ONLY when the clinician asked for one.',
' treatment type, teeth, prosthesis work or deadline.', ' "commentTrigger" is the exact words that asked, copied from the transcript. "comment"',
' is what they then dictated, WITHOUT those words: for "بنویس که بیمار حساسیت به سرما',
' دارد", commentTrigger is "بنویس که" and comment is "بیمار حساسیت به سرما دارد".',
' If nobody asked, BOTH are null. Never sweep up leftover speech, filler, small talk or',
' a diagnosis nobody asked you to record. A comment without a trigger is discarded, so',
' guessing costs the clinician the note.',
'- labMatchExact: true only when the spoken name matched a lab name exactly.', '- labMatchExact: true only when the spoken name matched a lab name exactly.',
].join('\n'); ].join('\n');

View File

@@ -505,6 +505,7 @@ describe('resolveVoiceIntent', () => {
teeth: [tooth('14'), tooth('15')], teeth: [tooth('14'), tooth('15')],
connectedSpans: [], connectedSpans: [],
comment: ' حساسیت به سرما ', comment: ' حساسیت به سرما ',
commentTrigger: 'بنویس که',
prosthesis: [], prosthesis: [],
labId: null, labId: null,
labMatchExact: false, labMatchExact: false,
@@ -679,6 +680,40 @@ describe('resolveVoiceIntent', () => {
); );
}); });
it('drops a note the clinician never asked for', () => {
// The whole point: leftover speech the model decided was a note is discarded. Before this
// rule the prompt told it to sweep up "the parts already captured" as a comment.
const result = resolveVoiceIntent(
{ ...base, comment: 'بیمار عصبی بود', commentTrigger: null },
CTX,
);
expect(result.comment).toBeNull();
});
it('drops a note whose trigger is only whitespace', () => {
const result = resolveVoiceIntent(
{ ...base, comment: 'بیمار عصبی بود', commentTrigger: ' ' },
CTX,
);
expect(result.comment).toBeNull();
});
it('keeps a note that was asked for, trimmed', () => {
const result = resolveVoiceIntent(
{ ...base, comment: ' حساسیت به سرما ', commentTrigger: 'بنویس که' },
CTX,
);
expect(result.comment).toBe('حساسیت به سرما');
});
it('reports no note when the trigger was heard but nothing followed it', () => {
const result = resolveVoiceIntent(
{ ...base, comment: ' ', commentTrigger: 'بنویس که' },
CTX,
);
expect(result.comment).toBeNull();
});
it('resolves a due date through the same context', () => { it('resolves a due date through the same context', () => {
const result = resolveVoiceIntent( const result = resolveVoiceIntent(
{ ...base, due: { kind: 'weekday', weekday: 'thursday', which: 'this' } }, { ...base, due: { kind: 'weekday', weekday: 'thursday', which: 'this' } },

View File

@@ -545,8 +545,15 @@ export function resolveVoiceIntent(
const due = resolveDueDate(intent?.due, ctx.todayIso, ctx.weekStartJs); const due = resolveDueDate(intent?.due, ctx.todayIso, ctx.weekStartJs);
if (due.unresolved) unresolved.push(due.unresolved); if (due.unresolved) unresolved.push(due.unresolved);
// A note is written only when the clinician asked for one. The model reports the words that
// asked ("بنویس که", "write this in the notes"); without them, whatever it put in `comment`
// is leftover speech it decided was a note, and it is dropped. Matching the phrase itself
// stays in the prompt, so this check needs no per-locale vocabulary.
const commentAsked =
typeof intent?.commentTrigger === 'string' &&
intent.commentTrigger.trim().length > 0;
const comment = const comment =
typeof intent?.comment === 'string' && intent.comment.trim() commentAsked && typeof intent?.comment === 'string' && intent.comment.trim()
? intent.comment.trim() ? intent.comment.trim()
: null; : null;

View File

@@ -23,6 +23,7 @@ const wire = (overrides: Partial<WireVoiceIntent> = {}): WireVoiceIntent => ({
teeth: [], teeth: [],
connectedSpans: [], connectedSpans: [],
comment: null, comment: null,
commentTrigger: null,
prosthesis: [], prosthesis: [],
labId: null, labId: null,
labMatchExact: false, labMatchExact: false,

View File

@@ -50,6 +50,7 @@ export type WireVoiceIntent = {
teeth: WireToothIntent[]; teeth: WireToothIntent[];
connectedSpans: { from: WireToothIntent; to: WireToothIntent }[]; connectedSpans: { from: WireToothIntent; to: WireToothIntent }[];
comment: string | null; comment: string | null;
commentTrigger: string | null;
prosthesis: WireProsthesisAssignment[]; prosthesis: WireProsthesisAssignment[];
labId: string | null; labId: string | null;
labMatchExact: boolean; labMatchExact: boolean;
@@ -125,6 +126,7 @@ export const VOICE_INTENT_JSON_SCHEMA = {
'teeth', 'teeth',
'connectedSpans', 'connectedSpans',
'comment', 'comment',
'commentTrigger',
'prosthesis', 'prosthesis',
'labId', 'labId',
'labMatchExact', 'labMatchExact',
@@ -154,7 +156,17 @@ export const VOICE_INTENT_JSON_SCHEMA = {
}, },
comment: { comment: {
type: ['string', 'null'], type: ['string', 'null'],
description: 'Clinical notes, in the spoken language.', description:
'The note the clinician explicitly dictated, in the spoken language, WITHOUT the ' +
'words that asked for it. Null unless they actually asked for a note. Never put ' +
'leftover speech here.',
},
commentTrigger: {
type: ['string', 'null'],
description:
'The exact words that asked for a note, copied from the transcript (e.g. ' +
'"بنویس که", "write this in the notes"). Null when nobody asked. A comment with ' +
'no trigger is discarded.',
}, },
prosthesis: { prosthesis: {
type: 'array', type: 'array',
@@ -298,6 +310,7 @@ export function toVoiceIntent(wire: WireVoiceIntent): VoiceIntent {
teeth: teeth.map(toToothIntent), teeth: teeth.map(toToothIntent),
connectedSpans, connectedSpans,
comment: wire?.comment ?? null, comment: wire?.comment ?? null,
commentTrigger: wire?.commentTrigger ?? null,
prosthesis: assignments.map(toProsthesisAssignment), prosthesis: assignments.map(toProsthesisAssignment),
labId: wire?.labId ?? null, labId: wire?.labId ?? null,
labMatchExact: wire?.labMatchExact === true, labMatchExact: wire?.labMatchExact === true,

View File

@@ -66,6 +66,12 @@ export type VoiceIntent = {
teeth: ToothIntent[]; teeth: ToothIntent[];
connectedSpans: ConnectedSpanIntent[]; connectedSpans: ConnectedSpanIntent[];
comment: string | null; comment: string | null;
/**
* The exact spoken words that asked for a note — "بنویس که", "write this in the notes".
* Null when nothing asked. The resolver keeps `comment` only when this is present, so the
* model cannot decide on its own that leftover speech was a note.
*/
commentTrigger: string | null;
/** Empty array, never null. */ /** Empty array, never null. */
prosthesis: ProsthesisAssignment[]; prosthesis: ProsthesisAssignment[];
/** Must be one of the linked-lab ids supplied in the prompt, or null. */ /** Must be one of the linked-lab ids supplied in the prompt, or null. */

View File

@@ -411,7 +411,8 @@ type VoiceIntent = {
treatmentType: string | null; // catalog code, from the supplied closed list treatmentType: string | null; // catalog code, from the supplied closed list
teeth: ToothIntent[]; teeth: ToothIntent[];
connectedSpans: { from: ToothIntent; to: 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 prosthesis: ProsthesisAssignment[]; // empty array, never null
labId: string | null; // must be one of the supplied linked-lab ids labId: string | null; // must be one of the supplied linked-lab ids
labMatchExact: boolean; 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 > 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. > 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 work implies the treatment type
`prosthesis` is the only `labDependent` treatment type. Any resolved assignment therefore `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) | | 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) | | 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) | | 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) |