fix(voice): hear "یادداشت" as a note request, and stop the note at the next instruction

Two reports from live dictation in fa.

The locale trigger vocabulary listed only verbs that ask for a note ("بنویس",
"یادداشت کن"). Naming the field itself — "توی یادداشت‌ها بنویس", "در توضیحات" —
reported no commentTrigger, and the resolver discards a comment without one, so
the note was silently dropped. Added the field-name shape to fa, en and nl.

Where the dictated note ends was never stated, so the model answered it
inconsistently: "توی توضیحات بنویس سلام چطوری و بفرست برای لابراتوآر سانشاین"
sometimes put the lab dispatch inside the comment. Added a no-overlap rule — a
clause reported as a lab, tooth, prosthesis or deadline is an instruction and
must not appear in comment as well — with that sentence as a worked example.

Both rules go in the prompt and in the JSON-schema field descriptions, because
the schema description is what the model reads while filling the field. No code
enforcement for the second: stripping an extracted clause out of free-speech
note text by string matching is guesswork, and the review sheet shows the note
before it is saved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-11 19:33:49 +08:00
parent b71b4e9ca9
commit 78fd3eb092
4 changed files with 61 additions and 8 deletions

View File

@@ -10,14 +10,17 @@ 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 "بنویس", "یادداشت کن", "این را یادداشت کن", "بنویس که",', 'A note is asked for with "بنویس", "بنویس که", "یادداشت کن", "این را یادداشت کن",',
'"در توضیحات بنویس". Anything else the clinician says is not a note.', 'or by naming the field the words go into: "یادداشت", "یادداشت‌ها", "توضیحات",',
'"کامنت", "کامنت‌ها" — as in "توی یادداشت‌ها بنویس", "در توضیحات بنویس",',
'"یادداشت: ...". 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', 'A note is asked for with "schrijf op", "noteer", "zet in de notities", or by naming',
'the field the words go into ("notitie", "notities", "opmerkingen"). Anything else',
'the clinician says is not a note.', 'the clinician says is not a note.',
].join(' '), ].join(' '),
en: [ en: [
@@ -25,7 +28,8 @@ const LOCALE_NOTES: Record<string, string> = {
'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",', '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.', '"put in the comments", or by naming the field the words go into ("note", "notes",',
'"comments", "remarks"). Anything else the clinician says is not a note.',
].join(' '), ].join(' '),
}; };
@@ -176,6 +180,12 @@ export function buildExtractionPrompt(
' If nobody asked, BOTH are null. Never sweep up leftover speech, filler, small talk or', ' 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', ' a diagnosis nobody asked you to record. A comment without a trigger is discarded, so',
' guessing costs the clinician the note.', ' guessing costs the clinician the note.',
' WHERE THE NOTE ENDS: the dictated words end where the next instruction begins. Any',
' clause you also reported in another field — a lab, a tooth, a prosthesis, a deadline —',
' is that instruction, so it never belongs in "comment" as well. Nothing may appear in',
' two fields. Example: "توی توضیحات بنویس سلام چطوری و بفرست برای لابراتوآر سانشاین" ->',
' commentTrigger "توی توضیحات بنویس", comment "سلام چطوری", and the lab goes to labId.',
' "و بفرست برای لابراتوآر سانشاین" is an instruction, not part of 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

@@ -159,14 +159,17 @@ export const VOICE_INTENT_JSON_SCHEMA = {
description: description:
'The note the clinician explicitly dictated, in the spoken language, WITHOUT the ' + '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 ' + 'words that asked for it. Null unless they actually asked for a note. Never put ' +
'leftover speech here.', 'leftover speech here. It ends where the next instruction begins: a clause you ' +
'also reported as a lab, a tooth, a prosthesis or a deadline is that instruction, ' +
'and must not appear here too.',
}, },
commentTrigger: { commentTrigger: {
type: ['string', 'null'], type: ['string', 'null'],
description: description:
'The exact words that asked for a note, copied from the transcript (e.g. ' + '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 ' + '"بنویس که", "توی یادداشت‌ها بنویس", "write this in the notes"). Naming the field ' +
'no trigger is discarded.', 'itself counts as asking ("یادداشت", "توضیحات", "notes", "comments"). Null when ' +
'nobody asked. A comment with no trigger is discarded.',
}, },
prosthesis: { prosthesis: {
type: 'array', type: 'array',

View File

@@ -524,6 +524,32 @@ step — only build and deploy. Combined with `nest build` excluding `**/*spec.t
running transpile-only under `isolatedModules`, nothing in this repo would ever catch a type error running transpile-only under `isolatedModules`, nothing in this repo would ever catch a type error
in a spec file. That is how two of them reached this branch unnoticed. in a spec file. That is how two of them reached this branch unnoticed.
## 2026-09-11 — note triggers and where a note ends (dyolink)
Two reports from live dictation in `fa`, both prompt-side, no resolver change.
1. «یادداشت» / «یادداشت‌ها» produced no trigger, so the note was discarded. The locale notes
listed only verbs that ask («بنویس», «یادداشت کن»); naming the field itself was not in the
vocabulary. Added the field-name shape for all three locales — fa «یادداشت/یادداشت‌ها/توضیحات/
کامنت», en "note/notes/comments/remarks", nl "notitie/notities/opmerkingen".
2. The note swallowed the next instruction. For «توی توضیحات بنویس سلام چطوری و بفرست برای
لابراتوآر سانشاین» the model sometimes put the whole tail in `comment`, sometimes only
«سلام چطوری». Added a no-overlap invariant: a clause reported as a lab, tooth, prosthesis or
deadline is an instruction and must not also appear in `comment`, plus that exact sentence as
a worked example.
Both rules are stated twice on purpose — in `extraction.prompt.ts` and in the `comment` /
`commentTrigger` descriptions in `extraction.wire.ts`, since the schema description is what the
model reads while filling the field.
No code enforcement for (2). Stripping an extracted clause out of free-speech note text by string
matching is guesswork, and the review sheet shows the note before it is saved.
Files: `backend/src/modules/voice/extraction.prompt.ts`,
`backend/src/modules/voice/extraction.wire.ts`, `docs/specs/voice-treatment-entry/spec.md`.
Verified: `npx eslint src/modules/voice/**/*.ts` clean, `npm test -- extraction` 74/74.
No unit test — both are prompt text, which the extraction specs deliberately do not assert on.
## Remaining before this is shippable ## Remaining before this is shippable
- The **manual pass in §12** — none of it has been run. Safari and iPad especially, since that is - The **manual pass in §12** — none of it has been run. Safari and iPad especially, since that is

View File

@@ -535,7 +535,21 @@ 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 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 vocabulary. The resolver only checks that a trigger was reported, so it needs no per-locale
knowledge and stays locale-neutral by construction (§6). knowledge and stays locale-neutral by construction (§6). The vocabulary covers two shapes: a
verb that asks («بنویس که», "noteer") and the *name of the field* the words go into («یادداشت»,
«یادداشت‌ها», «توضیحات», "notes", "comments"). Naming the field is how a clinician usually asks,
and the first version listed only the verbs, so «توی یادداشت‌ها بنویس» produced no trigger and
the note was discarded.
Where the dictated note ends is a second question, and the model answered it inconsistently: for
«توی توضیحات بنویس سلام چطوری و بفرست برای لابراتوآر سانشاین» the note sometimes ran to the end
of the sentence and swallowed the lab dispatch. The rule is that the note ends where the next
instruction begins, stated as a no-overlap invariant: a clause reported as a lab, a tooth, a
prosthesis or a deadline is that instruction and must not also appear in `comment`. It lives in
both the prompt and the JSON-schema field description, because the schema description is what the
model reads while it fills the field. This is a prompt-side rule with no code enforcement —
stripping an already-extracted clause out of the note text by string matching would be guesswork
on free speech, and the review sheet shows the note before it is saved.
### Prosthesis work implies the treatment type ### Prosthesis work implies the treatment type