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.',
|
'"شش بالا راست" = 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');
|
||||||
|
|
||||||
|
|||||||
@@ -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' } },
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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) |
|
||||||
|
|||||||
Reference in New Issue
Block a user