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:
@@ -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');
|
||||
|
||||
|
||||
@@ -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' } },
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ const wire = (overrides: Partial<WireVoiceIntent> = {}): WireVoiceIntent => ({
|
||||
teeth: [],
|
||||
connectedSpans: [],
|
||||
comment: null,
|
||||
commentTrigger: null,
|
||||
prosthesis: [],
|
||||
labId: null,
|
||||
labMatchExact: false,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user