diff --git a/backend/src/modules/voice/extraction.prompt.ts b/backend/src/modules/voice/extraction.prompt.ts index ee05893..66af5fb 100644 --- a/backend/src/modules/voice/extraction.prompt.ts +++ b/backend/src/modules/voice/extraction.prompt.ts @@ -10,16 +10,22 @@ const LOCALE_NOTES: Record = { '"شش بالا راست" = 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'); diff --git a/backend/src/modules/voice/extraction.resolver.spec.ts b/backend/src/modules/voice/extraction.resolver.spec.ts index 2900a8e..56a4b08 100644 --- a/backend/src/modules/voice/extraction.resolver.spec.ts +++ b/backend/src/modules/voice/extraction.resolver.spec.ts @@ -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' } }, diff --git a/backend/src/modules/voice/extraction.resolver.ts b/backend/src/modules/voice/extraction.resolver.ts index 3be229b..c386cde 100644 --- a/backend/src/modules/voice/extraction.resolver.ts +++ b/backend/src/modules/voice/extraction.resolver.ts @@ -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; diff --git a/backend/src/modules/voice/extraction.wire.spec.ts b/backend/src/modules/voice/extraction.wire.spec.ts index 468ecb3..b033979 100644 --- a/backend/src/modules/voice/extraction.wire.spec.ts +++ b/backend/src/modules/voice/extraction.wire.spec.ts @@ -23,6 +23,7 @@ const wire = (overrides: Partial = {}): WireVoiceIntent => ({ teeth: [], connectedSpans: [], comment: null, + commentTrigger: null, prosthesis: [], labId: null, labMatchExact: false, diff --git a/backend/src/modules/voice/extraction.wire.ts b/backend/src/modules/voice/extraction.wire.ts index 9ed850b..284d247 100644 --- a/backend/src/modules/voice/extraction.wire.ts +++ b/backend/src/modules/voice/extraction.wire.ts @@ -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, diff --git a/backend/src/modules/voice/voice.types.ts b/backend/src/modules/voice/voice.types.ts index bc359b8..774be87 100644 --- a/backend/src/modules/voice/voice.types.ts +++ b/backend/src/modules/voice/voice.types.ts @@ -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. */ diff --git a/docs/specs/voice-treatment-entry/spec.md b/docs/specs/voice-treatment-entry/spec.md index 3d485ec..7825c9d 100644 --- a/docs/specs/voice-treatment-entry/spec.md +++ b/docs/specs/voice-treatment-entry/spec.md @@ -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) |