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.',
'A jaw is spoken as "فک بالا" (upper jaw) or "فک پایین" (lower jaw), sometimes just',
'"بالا"/"پایین" in context, or "هر دو فک" (both jaws).',
'A note is asked for with "بنویس", "یادداشت کن", "این را یادداشت کن", "بنویس که",',
'"در توضیحات بنویس". Anything else the clinician says is not a note.',
].join(' '),
nl: [
'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',
'"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(' '),
en: [
'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',
'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(' '),
};
@@ -110,6 +116,9 @@ export function buildExtractionPrompt(
' one is not.',
'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.',
'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',
'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',
'- connectedSpans: only for bridges or splinted units. Endpoints inclusive.',
'- comment: clinical notes, in the language spoken. Omit the parts already captured as',
' treatment type, teeth, prosthesis work or deadline.',
'- comment + commentTrigger: a note is written ONLY when the clinician asked for one.',
' "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.',
].join('\n');

View File

@@ -505,6 +505,7 @@ describe('resolveVoiceIntent', () => {
teeth: [tooth('14'), tooth('15')],
connectedSpans: [],
comment: ' حساسیت به سرما ',
commentTrigger: 'بنویس که',
prosthesis: [],
labId: null,
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', () => {
const result = resolveVoiceIntent(
{ ...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);
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 =
typeof intent?.comment === 'string' && intent.comment.trim()
commentAsked && typeof intent?.comment === 'string' && intent.comment.trim()
? intent.comment.trim()
: null;

View File

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

View File

@@ -50,6 +50,7 @@ export type WireVoiceIntent = {
teeth: WireToothIntent[];
connectedSpans: { from: WireToothIntent; to: WireToothIntent }[];
comment: string | null;
commentTrigger: string | null;
prosthesis: WireProsthesisAssignment[];
labId: string | null;
labMatchExact: boolean;
@@ -125,6 +126,7 @@ export const VOICE_INTENT_JSON_SCHEMA = {
'teeth',
'connectedSpans',
'comment',
'commentTrigger',
'prosthesis',
'labId',
'labMatchExact',
@@ -154,7 +156,17 @@ export const VOICE_INTENT_JSON_SCHEMA = {
},
comment: {
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: {
type: 'array',
@@ -298,6 +310,7 @@ export function toVoiceIntent(wire: WireVoiceIntent): VoiceIntent {
teeth: teeth.map(toToothIntent),
connectedSpans,
comment: wire?.comment ?? null,
commentTrigger: wire?.commentTrigger ?? null,
prosthesis: assignments.map(toProsthesisAssignment),
labId: wire?.labId ?? null,
labMatchExact: wire?.labMatchExact === true,

View File

@@ -66,6 +66,12 @@ export type VoiceIntent = {
teeth: ToothIntent[];
connectedSpans: ConnectedSpanIntent[];
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. */
prosthesis: ProsthesisAssignment[];
/** Must be one of the linked-lab ids supplied in the prompt, or null. */