mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
feat(meds): natural-language extraction overlay for the create wizard
Adds an optional Coach-backed overlay that extracts a structured medication payload from a free-text description so the wizard can pre-fill its steps. The route is rate-limited and budget-gated; the prompt carries a citation-coverage guard so the extraction cannot hallucinate a name + dose pair the user did not write. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
53695f9434
commit
1bdbc2106f
12 files changed
+1944
No files matched your search
@@ -0,0 +1,459 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`buildMedicationExtractionPrompt — cadence snapshots > snapshot — daily NSAID, three times a day 1`] = `
|
||||
{
|
||||
"systemPrompt": "You are HealthLog's medication-schedule extractor.
|
||||
|
||||
TASK
|
||||
The user describes a medication course in free text (any locale).
|
||||
Return a single JSON object that fills the wizard's structured
|
||||
fields. Do not invent details the user did not write.
|
||||
|
||||
OUTPUT SHAPE
|
||||
Reply with ONE JSON object (no prose, no Markdown, no code fences).
|
||||
Every field is optional — omit a field rather than guess. Keys:
|
||||
|
||||
name — medication brand or active substance, verbatim
|
||||
dose — numeric portion only, as a string: "5", "7.5"
|
||||
doseUnit — one of: mg | ml | iu | tablets | drops | puffs | sprays
|
||||
cadenceKind — one of: daily | weekdays | everyNWeeks |
|
||||
monthly | everyNMonths | yearly | rolling |
|
||||
oneShot
|
||||
weekdays — array of RFC-5545 BYDAY tokens (MO,TU,WE,TH,
|
||||
FR,SA,SU), used with weekdays / everyNWeeks
|
||||
intervalWeeks — 1..52, used with everyNWeeks
|
||||
intervalMonths — 1..12, used with everyNMonths
|
||||
dayOfMonth — 1..31, used with monthly / everyNMonths
|
||||
rollingIntervalDays — 1..365, used with rolling ("every N days
|
||||
from the last intake")
|
||||
timesOfDay — array of "HH:MM" wall-clock strings (24h)
|
||||
startsOn — ISO YYYY-MM-DD, course start date
|
||||
endsOn — ISO YYYY-MM-DD, course end date
|
||||
oneShot — true when the user describes a single-time
|
||||
dose (one flu shot, a single follow-up dose)
|
||||
confidence — optional { fieldName: number in [0,1] }
|
||||
so the wizard can render uncertainty
|
||||
|
||||
GROUND RULES
|
||||
|
||||
1. Do NOT invent a name or dose the user did not write. If the
|
||||
text does not name a medication, omit \`name\`. If the text does
|
||||
not state a numeric dose, omit \`dose\` and \`doseUnit\`.
|
||||
|
||||
2. Map vague time-of-day words to canonical 24h slots:
|
||||
morning → "08:00", noon / midday → "12:00",
|
||||
afternoon → "14:00", evening → "18:00", night → "22:00".
|
||||
Multiple times in one description → emit each as its own entry.
|
||||
|
||||
3. "Every day" / "daily" / "once a day" → \`cadenceKind:
|
||||
"daily"\`. "Twice a day" / "3 times a day" → still
|
||||
\`daily\` with the right \`timesOfDay[]\` count; never invent a
|
||||
weekly cadence.
|
||||
|
||||
4. "Every N days from my last injection / dose" /
|
||||
"7 days after the previous one" → \`rolling\` with
|
||||
\`rollingIntervalDays = N\`. This is distinct from
|
||||
\`everyNWeeks\` (calendar-anchored).
|
||||
|
||||
5. Single-shot wording ("single flu shot", "one-off dose",
|
||||
"on October 15 only") → \`oneShot: true\` and a \`startsOn\`
|
||||
date. Do not also emit \`cadenceKind\`.
|
||||
|
||||
6. Resolve relative dates against today's date supplied in the
|
||||
CONTEXT block. "Next Monday", "tomorrow", "in 2 weeks"
|
||||
become absolute ISO dates.
|
||||
|
||||
7. If a field is ambiguous, leave it empty. The wizard prefers
|
||||
a missing slot over a confidently wrong one.
|
||||
|
||||
8. Output JSON only. No prose. No code fences. No comments.",
|
||||
"userPrompt": "CONTEXT
|
||||
today=2026-05-28
|
||||
locale=en
|
||||
|
||||
DESCRIPTION
|
||||
Ibuprofen 200mg every day, 3 times a day
|
||||
|
||||
Reply now with the JSON object only.",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`buildMedicationExtractionPrompt — cadence snapshots > snapshot — flexible rolling dose, every 7 days from last intake 1`] = `
|
||||
{
|
||||
"systemPrompt": "You are HealthLog's medication-schedule extractor.
|
||||
|
||||
TASK
|
||||
The user describes a medication course in free text (any locale).
|
||||
Return a single JSON object that fills the wizard's structured
|
||||
fields. Do not invent details the user did not write.
|
||||
|
||||
OUTPUT SHAPE
|
||||
Reply with ONE JSON object (no prose, no Markdown, no code fences).
|
||||
Every field is optional — omit a field rather than guess. Keys:
|
||||
|
||||
name — medication brand or active substance, verbatim
|
||||
dose — numeric portion only, as a string: "5", "7.5"
|
||||
doseUnit — one of: mg | ml | iu | tablets | drops | puffs | sprays
|
||||
cadenceKind — one of: daily | weekdays | everyNWeeks |
|
||||
monthly | everyNMonths | yearly | rolling |
|
||||
oneShot
|
||||
weekdays — array of RFC-5545 BYDAY tokens (MO,TU,WE,TH,
|
||||
FR,SA,SU), used with weekdays / everyNWeeks
|
||||
intervalWeeks — 1..52, used with everyNWeeks
|
||||
intervalMonths — 1..12, used with everyNMonths
|
||||
dayOfMonth — 1..31, used with monthly / everyNMonths
|
||||
rollingIntervalDays — 1..365, used with rolling ("every N days
|
||||
from the last intake")
|
||||
timesOfDay — array of "HH:MM" wall-clock strings (24h)
|
||||
startsOn — ISO YYYY-MM-DD, course start date
|
||||
endsOn — ISO YYYY-MM-DD, course end date
|
||||
oneShot — true when the user describes a single-time
|
||||
dose (one flu shot, a single follow-up dose)
|
||||
confidence — optional { fieldName: number in [0,1] }
|
||||
so the wizard can render uncertainty
|
||||
|
||||
GROUND RULES
|
||||
|
||||
1. Do NOT invent a name or dose the user did not write. If the
|
||||
text does not name a medication, omit \`name\`. If the text does
|
||||
not state a numeric dose, omit \`dose\` and \`doseUnit\`.
|
||||
|
||||
2. Map vague time-of-day words to canonical 24h slots:
|
||||
morning → "08:00", noon / midday → "12:00",
|
||||
afternoon → "14:00", evening → "18:00", night → "22:00".
|
||||
Multiple times in one description → emit each as its own entry.
|
||||
|
||||
3. "Every day" / "daily" / "once a day" → \`cadenceKind:
|
||||
"daily"\`. "Twice a day" / "3 times a day" → still
|
||||
\`daily\` with the right \`timesOfDay[]\` count; never invent a
|
||||
weekly cadence.
|
||||
|
||||
4. "Every N days from my last injection / dose" /
|
||||
"7 days after the previous one" → \`rolling\` with
|
||||
\`rollingIntervalDays = N\`. This is distinct from
|
||||
\`everyNWeeks\` (calendar-anchored).
|
||||
|
||||
5. Single-shot wording ("single flu shot", "one-off dose",
|
||||
"on October 15 only") → \`oneShot: true\` and a \`startsOn\`
|
||||
date. Do not also emit \`cadenceKind\`.
|
||||
|
||||
6. Resolve relative dates against today's date supplied in the
|
||||
CONTEXT block. "Next Monday", "tomorrow", "in 2 weeks"
|
||||
become absolute ISO dates.
|
||||
|
||||
7. If a field is ambiguous, leave it empty. The wizard prefers
|
||||
a missing slot over a confidently wrong one.
|
||||
|
||||
8. Output JSON only. No prose. No code fences. No comments.",
|
||||
"userPrompt": "CONTEXT
|
||||
today=2026-05-28
|
||||
locale=en
|
||||
|
||||
DESCRIPTION
|
||||
Methotrexate 7.5mg every 7 days from last injection
|
||||
|
||||
Reply now with the JSON object only.",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`buildMedicationExtractionPrompt — cadence snapshots > snapshot — single flu shot on a specific date 1`] = `
|
||||
{
|
||||
"systemPrompt": "You are HealthLog's medication-schedule extractor.
|
||||
|
||||
TASK
|
||||
The user describes a medication course in free text (any locale).
|
||||
Return a single JSON object that fills the wizard's structured
|
||||
fields. Do not invent details the user did not write.
|
||||
|
||||
OUTPUT SHAPE
|
||||
Reply with ONE JSON object (no prose, no Markdown, no code fences).
|
||||
Every field is optional — omit a field rather than guess. Keys:
|
||||
|
||||
name — medication brand or active substance, verbatim
|
||||
dose — numeric portion only, as a string: "5", "7.5"
|
||||
doseUnit — one of: mg | ml | iu | tablets | drops | puffs | sprays
|
||||
cadenceKind — one of: daily | weekdays | everyNWeeks |
|
||||
monthly | everyNMonths | yearly | rolling |
|
||||
oneShot
|
||||
weekdays — array of RFC-5545 BYDAY tokens (MO,TU,WE,TH,
|
||||
FR,SA,SU), used with weekdays / everyNWeeks
|
||||
intervalWeeks — 1..52, used with everyNWeeks
|
||||
intervalMonths — 1..12, used with everyNMonths
|
||||
dayOfMonth — 1..31, used with monthly / everyNMonths
|
||||
rollingIntervalDays — 1..365, used with rolling ("every N days
|
||||
from the last intake")
|
||||
timesOfDay — array of "HH:MM" wall-clock strings (24h)
|
||||
startsOn — ISO YYYY-MM-DD, course start date
|
||||
endsOn — ISO YYYY-MM-DD, course end date
|
||||
oneShot — true when the user describes a single-time
|
||||
dose (one flu shot, a single follow-up dose)
|
||||
confidence — optional { fieldName: number in [0,1] }
|
||||
so the wizard can render uncertainty
|
||||
|
||||
GROUND RULES
|
||||
|
||||
1. Do NOT invent a name or dose the user did not write. If the
|
||||
text does not name a medication, omit \`name\`. If the text does
|
||||
not state a numeric dose, omit \`dose\` and \`doseUnit\`.
|
||||
|
||||
2. Map vague time-of-day words to canonical 24h slots:
|
||||
morning → "08:00", noon / midday → "12:00",
|
||||
afternoon → "14:00", evening → "18:00", night → "22:00".
|
||||
Multiple times in one description → emit each as its own entry.
|
||||
|
||||
3. "Every day" / "daily" / "once a day" → \`cadenceKind:
|
||||
"daily"\`. "Twice a day" / "3 times a day" → still
|
||||
\`daily\` with the right \`timesOfDay[]\` count; never invent a
|
||||
weekly cadence.
|
||||
|
||||
4. "Every N days from my last injection / dose" /
|
||||
"7 days after the previous one" → \`rolling\` with
|
||||
\`rollingIntervalDays = N\`. This is distinct from
|
||||
\`everyNWeeks\` (calendar-anchored).
|
||||
|
||||
5. Single-shot wording ("single flu shot", "one-off dose",
|
||||
"on October 15 only") → \`oneShot: true\` and a \`startsOn\`
|
||||
date. Do not also emit \`cadenceKind\`.
|
||||
|
||||
6. Resolve relative dates against today's date supplied in the
|
||||
CONTEXT block. "Next Monday", "tomorrow", "in 2 weeks"
|
||||
become absolute ISO dates.
|
||||
|
||||
7. If a field is ambiguous, leave it empty. The wizard prefers
|
||||
a missing slot over a confidently wrong one.
|
||||
|
||||
8. Output JSON only. No prose. No code fences. No comments.",
|
||||
"userPrompt": "CONTEXT
|
||||
today=2026-05-28
|
||||
locale=en
|
||||
|
||||
DESCRIPTION
|
||||
Single flu shot on October 15
|
||||
|
||||
Reply now with the JSON object only.",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`buildMedicationExtractionPrompt — cadence snapshots > snapshot — weekly GLP-1 starting next Monday 1`] = `
|
||||
{
|
||||
"systemPrompt": "You are HealthLog's medication-schedule extractor.
|
||||
|
||||
TASK
|
||||
The user describes a medication course in free text (any locale).
|
||||
Return a single JSON object that fills the wizard's structured
|
||||
fields. Do not invent details the user did not write.
|
||||
|
||||
OUTPUT SHAPE
|
||||
Reply with ONE JSON object (no prose, no Markdown, no code fences).
|
||||
Every field is optional — omit a field rather than guess. Keys:
|
||||
|
||||
name — medication brand or active substance, verbatim
|
||||
dose — numeric portion only, as a string: "5", "7.5"
|
||||
doseUnit — one of: mg | ml | iu | tablets | drops | puffs | sprays
|
||||
cadenceKind — one of: daily | weekdays | everyNWeeks |
|
||||
monthly | everyNMonths | yearly | rolling |
|
||||
oneShot
|
||||
weekdays — array of RFC-5545 BYDAY tokens (MO,TU,WE,TH,
|
||||
FR,SA,SU), used with weekdays / everyNWeeks
|
||||
intervalWeeks — 1..52, used with everyNWeeks
|
||||
intervalMonths — 1..12, used with everyNMonths
|
||||
dayOfMonth — 1..31, used with monthly / everyNMonths
|
||||
rollingIntervalDays — 1..365, used with rolling ("every N days
|
||||
from the last intake")
|
||||
timesOfDay — array of "HH:MM" wall-clock strings (24h)
|
||||
startsOn — ISO YYYY-MM-DD, course start date
|
||||
endsOn — ISO YYYY-MM-DD, course end date
|
||||
oneShot — true when the user describes a single-time
|
||||
dose (one flu shot, a single follow-up dose)
|
||||
confidence — optional { fieldName: number in [0,1] }
|
||||
so the wizard can render uncertainty
|
||||
|
||||
GROUND RULES
|
||||
|
||||
1. Do NOT invent a name or dose the user did not write. If the
|
||||
text does not name a medication, omit \`name\`. If the text does
|
||||
not state a numeric dose, omit \`dose\` and \`doseUnit\`.
|
||||
|
||||
2. Map vague time-of-day words to canonical 24h slots:
|
||||
morning → "08:00", noon / midday → "12:00",
|
||||
afternoon → "14:00", evening → "18:00", night → "22:00".
|
||||
Multiple times in one description → emit each as its own entry.
|
||||
|
||||
3. "Every day" / "daily" / "once a day" → \`cadenceKind:
|
||||
"daily"\`. "Twice a day" / "3 times a day" → still
|
||||
\`daily\` with the right \`timesOfDay[]\` count; never invent a
|
||||
weekly cadence.
|
||||
|
||||
4. "Every N days from my last injection / dose" /
|
||||
"7 days after the previous one" → \`rolling\` with
|
||||
\`rollingIntervalDays = N\`. This is distinct from
|
||||
\`everyNWeeks\` (calendar-anchored).
|
||||
|
||||
5. Single-shot wording ("single flu shot", "one-off dose",
|
||||
"on October 15 only") → \`oneShot: true\` and a \`startsOn\`
|
||||
date. Do not also emit \`cadenceKind\`.
|
||||
|
||||
6. Resolve relative dates against today's date supplied in the
|
||||
CONTEXT block. "Next Monday", "tomorrow", "in 2 weeks"
|
||||
become absolute ISO dates.
|
||||
|
||||
7. If a field is ambiguous, leave it empty. The wizard prefers
|
||||
a missing slot over a confidently wrong one.
|
||||
|
||||
8. Output JSON only. No prose. No code fences. No comments.",
|
||||
"userPrompt": "CONTEXT
|
||||
today=2026-05-28
|
||||
locale=en
|
||||
|
||||
DESCRIPTION
|
||||
Mounjaro 5mg weekly Wednesday morning starting next Monday
|
||||
|
||||
Reply now with the JSON object only.",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`buildMedicationExtractionPrompt — cadence snapshots > snapshot — weekly vitamin D, Sunday morning 1`] = `
|
||||
{
|
||||
"systemPrompt": "You are HealthLog's medication-schedule extractor.
|
||||
|
||||
TASK
|
||||
The user describes a medication course in free text (any locale).
|
||||
Return a single JSON object that fills the wizard's structured
|
||||
fields. Do not invent details the user did not write.
|
||||
|
||||
OUTPUT SHAPE
|
||||
Reply with ONE JSON object (no prose, no Markdown, no code fences).
|
||||
Every field is optional — omit a field rather than guess. Keys:
|
||||
|
||||
name — medication brand or active substance, verbatim
|
||||
dose — numeric portion only, as a string: "5", "7.5"
|
||||
doseUnit — one of: mg | ml | iu | tablets | drops | puffs | sprays
|
||||
cadenceKind — one of: daily | weekdays | everyNWeeks |
|
||||
monthly | everyNMonths | yearly | rolling |
|
||||
oneShot
|
||||
weekdays — array of RFC-5545 BYDAY tokens (MO,TU,WE,TH,
|
||||
FR,SA,SU), used with weekdays / everyNWeeks
|
||||
intervalWeeks — 1..52, used with everyNWeeks
|
||||
intervalMonths — 1..12, used with everyNMonths
|
||||
dayOfMonth — 1..31, used with monthly / everyNMonths
|
||||
rollingIntervalDays — 1..365, used with rolling ("every N days
|
||||
from the last intake")
|
||||
timesOfDay — array of "HH:MM" wall-clock strings (24h)
|
||||
startsOn — ISO YYYY-MM-DD, course start date
|
||||
endsOn — ISO YYYY-MM-DD, course end date
|
||||
oneShot — true when the user describes a single-time
|
||||
dose (one flu shot, a single follow-up dose)
|
||||
confidence — optional { fieldName: number in [0,1] }
|
||||
so the wizard can render uncertainty
|
||||
|
||||
GROUND RULES
|
||||
|
||||
1. Do NOT invent a name or dose the user did not write. If the
|
||||
text does not name a medication, omit \`name\`. If the text does
|
||||
not state a numeric dose, omit \`dose\` and \`doseUnit\`.
|
||||
|
||||
2. Map vague time-of-day words to canonical 24h slots:
|
||||
morning → "08:00", noon / midday → "12:00",
|
||||
afternoon → "14:00", evening → "18:00", night → "22:00".
|
||||
Multiple times in one description → emit each as its own entry.
|
||||
|
||||
3. "Every day" / "daily" / "once a day" → \`cadenceKind:
|
||||
"daily"\`. "Twice a day" / "3 times a day" → still
|
||||
\`daily\` with the right \`timesOfDay[]\` count; never invent a
|
||||
weekly cadence.
|
||||
|
||||
4. "Every N days from my last injection / dose" /
|
||||
"7 days after the previous one" → \`rolling\` with
|
||||
\`rollingIntervalDays = N\`. This is distinct from
|
||||
\`everyNWeeks\` (calendar-anchored).
|
||||
|
||||
5. Single-shot wording ("single flu shot", "one-off dose",
|
||||
"on October 15 only") → \`oneShot: true\` and a \`startsOn\`
|
||||
date. Do not also emit \`cadenceKind\`.
|
||||
|
||||
6. Resolve relative dates against today's date supplied in the
|
||||
CONTEXT block. "Next Monday", "tomorrow", "in 2 weeks"
|
||||
become absolute ISO dates.
|
||||
|
||||
7. If a field is ambiguous, leave it empty. The wizard prefers
|
||||
a missing slot over a confidently wrong one.
|
||||
|
||||
8. Output JSON only. No prose. No code fences. No comments.",
|
||||
"userPrompt": "CONTEXT
|
||||
today=2026-05-28
|
||||
locale=en
|
||||
|
||||
DESCRIPTION
|
||||
Vitamin D 1000IU once per week on Sunday morning
|
||||
|
||||
Reply now with the JSON object only.",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`buildMedicationExtractionSystemPrompt > pins the system prompt body 1`] = `
|
||||
"You are HealthLog's medication-schedule extractor.
|
||||
|
||||
TASK
|
||||
The user describes a medication course in free text (any locale).
|
||||
Return a single JSON object that fills the wizard's structured
|
||||
fields. Do not invent details the user did not write.
|
||||
|
||||
OUTPUT SHAPE
|
||||
Reply with ONE JSON object (no prose, no Markdown, no code fences).
|
||||
Every field is optional — omit a field rather than guess. Keys:
|
||||
|
||||
name — medication brand or active substance, verbatim
|
||||
dose — numeric portion only, as a string: "5", "7.5"
|
||||
doseUnit — one of: mg | ml | iu | tablets | drops | puffs | sprays
|
||||
cadenceKind — one of: daily | weekdays | everyNWeeks |
|
||||
monthly | everyNMonths | yearly | rolling |
|
||||
oneShot
|
||||
weekdays — array of RFC-5545 BYDAY tokens (MO,TU,WE,TH,
|
||||
FR,SA,SU), used with weekdays / everyNWeeks
|
||||
intervalWeeks — 1..52, used with everyNWeeks
|
||||
intervalMonths — 1..12, used with everyNMonths
|
||||
dayOfMonth — 1..31, used with monthly / everyNMonths
|
||||
rollingIntervalDays — 1..365, used with rolling ("every N days
|
||||
from the last intake")
|
||||
timesOfDay — array of "HH:MM" wall-clock strings (24h)
|
||||
startsOn — ISO YYYY-MM-DD, course start date
|
||||
endsOn — ISO YYYY-MM-DD, course end date
|
||||
oneShot — true when the user describes a single-time
|
||||
dose (one flu shot, a single follow-up dose)
|
||||
confidence — optional { fieldName: number in [0,1] }
|
||||
so the wizard can render uncertainty
|
||||
|
||||
GROUND RULES
|
||||
|
||||
1. Do NOT invent a name or dose the user did not write. If the
|
||||
text does not name a medication, omit \`name\`. If the text does
|
||||
not state a numeric dose, omit \`dose\` and \`doseUnit\`.
|
||||
|
||||
2. Map vague time-of-day words to canonical 24h slots:
|
||||
morning → "08:00", noon / midday → "12:00",
|
||||
afternoon → "14:00", evening → "18:00", night → "22:00".
|
||||
Multiple times in one description → emit each as its own entry.
|
||||
|
||||
3. "Every day" / "daily" / "once a day" → \`cadenceKind:
|
||||
"daily"\`. "Twice a day" / "3 times a day" → still
|
||||
\`daily\` with the right \`timesOfDay[]\` count; never invent a
|
||||
weekly cadence.
|
||||
|
||||
4. "Every N days from my last injection / dose" /
|
||||
"7 days after the previous one" → \`rolling\` with
|
||||
\`rollingIntervalDays = N\`. This is distinct from
|
||||
\`everyNWeeks\` (calendar-anchored).
|
||||
|
||||
5. Single-shot wording ("single flu shot", "one-off dose",
|
||||
"on October 15 only") → \`oneShot: true\` and a \`startsOn\`
|
||||
date. Do not also emit \`cadenceKind\`.
|
||||
|
||||
6. Resolve relative dates against today's date supplied in the
|
||||
CONTEXT block. "Next Monday", "tomorrow", "in 2 weeks"
|
||||
become absolute ISO dates.
|
||||
|
||||
7. If a field is ambiguous, leave it empty. The wizard prefers
|
||||
a missing slot over a confidently wrong one.
|
||||
|
||||
8. Output JSON only. No prose. No code fences. No comments."
|
||||
`;
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* v1.5.0 — pins the assembled medication-extraction prompt across the
|
||||
* five canonical cadence shapes the design synthesis calls out.
|
||||
*
|
||||
* The tests run pure prompt assembly — no provider client touched, no
|
||||
* network, no Prisma. A drift in either the system prompt body or the
|
||||
* per-input formatter trips the snapshot.
|
||||
*
|
||||
* The citation-coverage guard has its own pinned test below: an
|
||||
* extraction that names a medication the user did not type loses
|
||||
* the `name` field, while a load-bearing inference (cadence inferred
|
||||
* from "weekly") is preserved.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
applyCitationGuard,
|
||||
buildMedicationExtractionPrompt,
|
||||
buildMedicationExtractionSystemPrompt,
|
||||
buildMedicationExtractionUserPrompt,
|
||||
type MedicationExtractionResult,
|
||||
} from "../medication-extract-prompt";
|
||||
|
||||
describe("buildMedicationExtractionSystemPrompt", () => {
|
||||
it("pins the system prompt body", () => {
|
||||
expect(buildMedicationExtractionSystemPrompt()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("carries the citation-coverage guard wording", () => {
|
||||
const prompt = buildMedicationExtractionSystemPrompt();
|
||||
expect(prompt).toMatch(/Do NOT invent a name or dose/i);
|
||||
});
|
||||
|
||||
it("lists every cadence kind the wizard exposes", () => {
|
||||
const prompt = buildMedicationExtractionSystemPrompt();
|
||||
for (const kind of [
|
||||
"daily",
|
||||
"weekdays",
|
||||
"everyNWeeks",
|
||||
"monthly",
|
||||
"everyNMonths",
|
||||
"yearly",
|
||||
"rolling",
|
||||
"oneShot",
|
||||
]) {
|
||||
expect(prompt).toContain(kind);
|
||||
}
|
||||
});
|
||||
|
||||
it("lists every supported dose unit", () => {
|
||||
const prompt = buildMedicationExtractionSystemPrompt();
|
||||
for (const unit of [
|
||||
"mg",
|
||||
"ml",
|
||||
"iu",
|
||||
"tablets",
|
||||
"drops",
|
||||
"puffs",
|
||||
"sprays",
|
||||
]) {
|
||||
expect(prompt).toContain(unit);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMedicationExtractionPrompt — cadence snapshots", () => {
|
||||
// The reference date stays fixed across the snapshot deck so a
|
||||
// future contributor running `vitest -u` does not silently rebake
|
||||
// a date drift into the file.
|
||||
const FIXED_TODAY = "2026-05-28";
|
||||
|
||||
it("snapshot — weekly GLP-1 starting next Monday", () => {
|
||||
const assembled = buildMedicationExtractionPrompt({
|
||||
text: "Mounjaro 5mg weekly Wednesday morning starting next Monday",
|
||||
today: FIXED_TODAY,
|
||||
locale: "en",
|
||||
});
|
||||
expect(assembled).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("snapshot — daily NSAID, three times a day", () => {
|
||||
const assembled = buildMedicationExtractionPrompt({
|
||||
text: "Ibuprofen 200mg every day, 3 times a day",
|
||||
today: FIXED_TODAY,
|
||||
locale: "en",
|
||||
});
|
||||
expect(assembled).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("snapshot — weekly vitamin D, Sunday morning", () => {
|
||||
const assembled = buildMedicationExtractionPrompt({
|
||||
text: "Vitamin D 1000IU once per week on Sunday morning",
|
||||
today: FIXED_TODAY,
|
||||
locale: "en",
|
||||
});
|
||||
expect(assembled).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("snapshot — single flu shot on a specific date", () => {
|
||||
const assembled = buildMedicationExtractionPrompt({
|
||||
text: "Single flu shot on October 15",
|
||||
today: FIXED_TODAY,
|
||||
locale: "en",
|
||||
});
|
||||
expect(assembled).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("snapshot — flexible rolling dose, every 7 days from last intake", () => {
|
||||
const assembled = buildMedicationExtractionPrompt({
|
||||
text: "Methotrexate 7.5mg every 7 days from last injection",
|
||||
today: FIXED_TODAY,
|
||||
locale: "en",
|
||||
});
|
||||
expect(assembled).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMedicationExtractionUserPrompt", () => {
|
||||
it("includes the reference date and the trimmed description", () => {
|
||||
const out = buildMedicationExtractionUserPrompt({
|
||||
text: " Mounjaro 5mg weekly ",
|
||||
today: "2026-05-28",
|
||||
locale: "de",
|
||||
});
|
||||
expect(out).toContain("today=2026-05-28");
|
||||
expect(out).toContain("locale=de");
|
||||
expect(out).toContain("Mounjaro 5mg weekly");
|
||||
// The trim helper must strip the leading + trailing whitespace so
|
||||
// the model does not see ragged framing.
|
||||
expect(out).not.toMatch(/ Mounjaro/);
|
||||
});
|
||||
|
||||
it("defaults the locale label to en when omitted", () => {
|
||||
const out = buildMedicationExtractionUserPrompt({
|
||||
text: "Daily 5mg",
|
||||
today: "2026-05-28",
|
||||
});
|
||||
expect(out).toContain("locale=en");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyCitationGuard", () => {
|
||||
it("keeps name + dose when both appear verbatim in the original text", () => {
|
||||
const result: MedicationExtractionResult = {
|
||||
name: "Mounjaro",
|
||||
dose: "5",
|
||||
doseUnit: "mg",
|
||||
cadenceKind: "everyNWeeks",
|
||||
intervalWeeks: 1,
|
||||
weekdays: ["WE"],
|
||||
timesOfDay: ["08:00"],
|
||||
};
|
||||
const guarded = applyCitationGuard(
|
||||
result,
|
||||
"Mounjaro 5mg weekly Wednesday morning",
|
||||
);
|
||||
expect(guarded.name).toBe("Mounjaro");
|
||||
expect(guarded.dose).toBe("5");
|
||||
expect(guarded.doseUnit).toBe("mg");
|
||||
// Load-bearing inference (cadence + weekday) is preserved.
|
||||
expect(guarded.cadenceKind).toBe("everyNWeeks");
|
||||
expect(guarded.weekdays).toEqual(["WE"]);
|
||||
});
|
||||
|
||||
it("drops a hallucinated name that does not appear in the user's text", () => {
|
||||
const result: MedicationExtractionResult = {
|
||||
name: "Wegovy",
|
||||
dose: "5",
|
||||
doseUnit: "mg",
|
||||
};
|
||||
const guarded = applyCitationGuard(
|
||||
result,
|
||||
"5mg every week on Wednesday morning",
|
||||
);
|
||||
expect(guarded.name).toBeUndefined();
|
||||
// The dose IS in the text, so it stays.
|
||||
expect(guarded.dose).toBe("5");
|
||||
expect(guarded.doseUnit).toBe("mg");
|
||||
});
|
||||
|
||||
it("drops a hallucinated dose AND the trailing unit", () => {
|
||||
const result: MedicationExtractionResult = {
|
||||
name: "Mounjaro",
|
||||
dose: "10",
|
||||
doseUnit: "mg",
|
||||
};
|
||||
const guarded = applyCitationGuard(result, "Mounjaro weekly Wednesday");
|
||||
expect(guarded.name).toBe("Mounjaro");
|
||||
expect(guarded.dose).toBeUndefined();
|
||||
// The unit must vanish alongside the dose so the wizard does not
|
||||
// land on a free-floating "mg" with no number to attach it to.
|
||||
expect(guarded.doseUnit).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is case-insensitive on the substring check", () => {
|
||||
const result: MedicationExtractionResult = {
|
||||
name: "MOUNJARO",
|
||||
dose: "5",
|
||||
};
|
||||
const guarded = applyCitationGuard(result, "mounjaro 5mg weekly");
|
||||
expect(guarded.name).toBe("MOUNJARO");
|
||||
expect(guarded.dose).toBe("5");
|
||||
});
|
||||
|
||||
it("never mutates the input object", () => {
|
||||
const result: MedicationExtractionResult = {
|
||||
name: "Wegovy",
|
||||
dose: "5",
|
||||
doseUnit: "mg",
|
||||
};
|
||||
const guarded = applyCitationGuard(result, "5mg weekly");
|
||||
// Input still carries the hallucinated name.
|
||||
expect(result.name).toBe("Wegovy");
|
||||
// Output dropped it.
|
||||
expect(guarded.name).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* v1.5.0 — natural-language medication extraction prompt + schema.
|
||||
*
|
||||
* Step 1 of the new medication-create wizard ships an optional
|
||||
* "Beschreiben" overlay that runs the user's free-text description
|
||||
* ("Mounjaro 5mg weekly Wednesday morning starting next Monday")
|
||||
* through the Coach provider chain and pre-fills the wizard fields.
|
||||
*
|
||||
* This module owns the deterministic prompt assembly + the Zod schema
|
||||
* the response is validated against. Every field is optional — the
|
||||
* wizard merges the partial result onto whatever the user already
|
||||
* typed; the model is encouraged to leave a slot empty rather than
|
||||
* guess.
|
||||
*
|
||||
* Three load-bearing safety properties baked into the prompt:
|
||||
*
|
||||
* 1. Citation-coverage guard. The system prompt forbids inventing a
|
||||
* `name` / `dose` pair that the user's text does not contain;
|
||||
* `applyCitationGuard()` then post-validates each extracted token
|
||||
* against the original text (case-insensitive substring) and drops
|
||||
* anything that fails the check.
|
||||
* 2. Closed enums for `cadenceKind`, `doseUnit`, and `weekdays` — the
|
||||
* Zod schema rejects every other token rather than echoing a
|
||||
* model-hallucinated string back into the wizard.
|
||||
* 3. Bounded numeric ranges (`intervalWeeks`, `intervalMonths`,
|
||||
* `dayOfMonth`, `rollingIntervalDays`) — a clamp on the schema
|
||||
* layer so a `9999` answer cannot poison the form.
|
||||
*/
|
||||
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
WEEKDAY_TOKENS,
|
||||
type CadenceKind,
|
||||
type WeekdayToken,
|
||||
} from "@/components/medications/scheduling/types";
|
||||
|
||||
/** Closed list — matches the design-synthesis dose-unit dropdown. */
|
||||
export const DOSE_UNITS = [
|
||||
"mg",
|
||||
"ml",
|
||||
"iu",
|
||||
"tablets",
|
||||
"drops",
|
||||
"puffs",
|
||||
"sprays",
|
||||
] as const;
|
||||
export type DoseUnit = (typeof DOSE_UNITS)[number];
|
||||
|
||||
/** `CadenceKind` from the wizard types, mirrored as a Zod-friendly enum. */
|
||||
export const CADENCE_KINDS = [
|
||||
"daily",
|
||||
"weekdays",
|
||||
"everyNWeeks",
|
||||
"monthly",
|
||||
"everyNMonths",
|
||||
"yearly",
|
||||
"rolling",
|
||||
"oneShot",
|
||||
] as const;
|
||||
|
||||
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const HH_MM_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
|
||||
/**
|
||||
* Zod schema for the extracted medication payload. Every field is
|
||||
* optional; downstream wizard code merges the partial onto whatever the
|
||||
* user already typed. The bounds match the wizard's own primitives so
|
||||
* the merge target accepts every shape we emit here.
|
||||
*/
|
||||
export const medicationExtractionSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
dose: z.string().min(1).max(50).optional(),
|
||||
doseUnit: z.enum(DOSE_UNITS).optional(),
|
||||
cadenceKind: z.enum(CADENCE_KINDS).optional(),
|
||||
weekdays: z.array(z.enum(WEEKDAY_TOKENS)).max(7).optional(),
|
||||
intervalWeeks: z.number().int().min(1).max(52).optional(),
|
||||
intervalMonths: z.number().int().min(1).max(12).optional(),
|
||||
dayOfMonth: z.number().int().min(1).max(31).optional(),
|
||||
rollingIntervalDays: z.number().int().min(1).max(365).optional(),
|
||||
timesOfDay: z
|
||||
.array(z.string().regex(HH_MM_RE, "HH:MM"))
|
||||
.max(12)
|
||||
.optional(),
|
||||
startsOn: z.string().regex(ISO_DATE_RE, "YYYY-MM-DD").optional(),
|
||||
endsOn: z.string().regex(ISO_DATE_RE, "YYYY-MM-DD").optional(),
|
||||
oneShot: z.boolean().optional(),
|
||||
confidence: z.record(z.string(), z.number().min(0).max(1)).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type MedicationExtractionResult = z.infer<
|
||||
typeof medicationExtractionSchema
|
||||
>;
|
||||
|
||||
/**
|
||||
* Re-export the wizard `CadenceKind` + `WeekdayToken` types so callers
|
||||
* (the route, the tests) import a single module rather than chasing
|
||||
* the wizard types directory.
|
||||
*/
|
||||
export type { CadenceKind, WeekdayToken };
|
||||
|
||||
/**
|
||||
* Build the system prompt. Same shape across calls — the model only
|
||||
* sees the user's free text via `buildUserPrompt()`. The system prompt
|
||||
* itself carries the contract; we pin it as a snapshot test so a
|
||||
* silent drift surfaces in CI.
|
||||
*/
|
||||
export function buildMedicationExtractionSystemPrompt(): string {
|
||||
return [
|
||||
"You are HealthLog's medication-schedule extractor.",
|
||||
"",
|
||||
"TASK",
|
||||
"The user describes a medication course in free text (any locale).",
|
||||
"Return a single JSON object that fills the wizard's structured",
|
||||
"fields. Do not invent details the user did not write.",
|
||||
"",
|
||||
"OUTPUT SHAPE",
|
||||
"Reply with ONE JSON object (no prose, no Markdown, no code fences).",
|
||||
"Every field is optional — omit a field rather than guess. Keys:",
|
||||
"",
|
||||
" name — medication brand or active substance, verbatim",
|
||||
" dose — numeric portion only, as a string: \"5\", \"7.5\"",
|
||||
" doseUnit — one of: mg | ml | iu | tablets | drops | puffs | sprays",
|
||||
" cadenceKind — one of: daily | weekdays | everyNWeeks |",
|
||||
" monthly | everyNMonths | yearly | rolling |",
|
||||
" oneShot",
|
||||
" weekdays — array of RFC-5545 BYDAY tokens (MO,TU,WE,TH,",
|
||||
" FR,SA,SU), used with weekdays / everyNWeeks",
|
||||
" intervalWeeks — 1..52, used with everyNWeeks",
|
||||
" intervalMonths — 1..12, used with everyNMonths",
|
||||
" dayOfMonth — 1..31, used with monthly / everyNMonths",
|
||||
" rollingIntervalDays — 1..365, used with rolling (\"every N days",
|
||||
" from the last intake\")",
|
||||
" timesOfDay — array of \"HH:MM\" wall-clock strings (24h)",
|
||||
" startsOn — ISO YYYY-MM-DD, course start date",
|
||||
" endsOn — ISO YYYY-MM-DD, course end date",
|
||||
" oneShot — true when the user describes a single-time",
|
||||
" dose (one flu shot, a single follow-up dose)",
|
||||
" confidence — optional { fieldName: number in [0,1] }",
|
||||
" so the wizard can render uncertainty",
|
||||
"",
|
||||
"GROUND RULES",
|
||||
"",
|
||||
" 1. Do NOT invent a name or dose the user did not write. If the",
|
||||
" text does not name a medication, omit `name`. If the text does",
|
||||
" not state a numeric dose, omit `dose` and `doseUnit`.",
|
||||
"",
|
||||
" 2. Map vague time-of-day words to canonical 24h slots:",
|
||||
" morning → \"08:00\", noon / midday → \"12:00\",",
|
||||
" afternoon → \"14:00\", evening → \"18:00\", night → \"22:00\".",
|
||||
" Multiple times in one description → emit each as its own entry.",
|
||||
"",
|
||||
" 3. \"Every day\" / \"daily\" / \"once a day\" → `cadenceKind:",
|
||||
" \"daily\"`. \"Twice a day\" / \"3 times a day\" → still",
|
||||
" `daily` with the right `timesOfDay[]` count; never invent a",
|
||||
" weekly cadence.",
|
||||
"",
|
||||
" 4. \"Every N days from my last injection / dose\" /",
|
||||
" \"7 days after the previous one\" → `rolling` with",
|
||||
" `rollingIntervalDays = N`. This is distinct from",
|
||||
" `everyNWeeks` (calendar-anchored).",
|
||||
"",
|
||||
" 5. Single-shot wording (\"single flu shot\", \"one-off dose\",",
|
||||
" \"on October 15 only\") → `oneShot: true` and a `startsOn`",
|
||||
" date. Do not also emit `cadenceKind`.",
|
||||
"",
|
||||
" 6. Resolve relative dates against today's date supplied in the",
|
||||
" CONTEXT block. \"Next Monday\", \"tomorrow\", \"in 2 weeks\"",
|
||||
" become absolute ISO dates.",
|
||||
"",
|
||||
" 7. If a field is ambiguous, leave it empty. The wizard prefers",
|
||||
" a missing slot over a confidently wrong one.",
|
||||
"",
|
||||
" 8. Output JSON only. No prose. No code fences. No comments.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export interface BuildUserPromptArgs {
|
||||
/** User's free-text description. */
|
||||
text: string;
|
||||
/** Reference date for relative phrases — ISO YYYY-MM-DD. */
|
||||
today: string;
|
||||
/** UI locale ("en" | "de" | …). Informational; the model handles any locale. */
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
/** Assemble the user-prompt body around the free-text description. */
|
||||
export function buildMedicationExtractionUserPrompt(
|
||||
args: BuildUserPromptArgs,
|
||||
): string {
|
||||
return [
|
||||
"CONTEXT",
|
||||
`today=${args.today}`,
|
||||
`locale=${args.locale ?? "en"}`,
|
||||
"",
|
||||
"DESCRIPTION",
|
||||
args.text.trim(),
|
||||
"",
|
||||
"Reply now with the JSON object only.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: assemble both halves so snapshot tests can pin the full
|
||||
* payload without having to glue the two helpers together at every call
|
||||
* site.
|
||||
*/
|
||||
export function buildMedicationExtractionPrompt(
|
||||
args: BuildUserPromptArgs,
|
||||
): { systemPrompt: string; userPrompt: string } {
|
||||
return {
|
||||
systemPrompt: buildMedicationExtractionSystemPrompt(),
|
||||
userPrompt: buildMedicationExtractionUserPrompt(args),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Citation-coverage guard. The system prompt forbids inventing
|
||||
* `name` / `dose` tokens, but a determined model may still hallucinate
|
||||
* — every commercial extractor we surveyed (R-1) does it occasionally.
|
||||
* Drop any extracted token whose lower-cased trimmed form is not a
|
||||
* substring of the original lower-cased trimmed text.
|
||||
*
|
||||
* Other fields stay (`cadenceKind`, `timesOfDay`, dates, intervals) —
|
||||
* those are normalised codes / numbers rather than verbatim quotes
|
||||
* from the user. A weekly cadence inferred from "every Monday" is
|
||||
* load-bearing inference, not hallucination.
|
||||
*/
|
||||
export function applyCitationGuard(
|
||||
result: MedicationExtractionResult,
|
||||
originalText: string,
|
||||
): MedicationExtractionResult {
|
||||
const haystack = originalText.toLowerCase();
|
||||
const cleaned: MedicationExtractionResult = { ...result };
|
||||
|
||||
if (cleaned.name) {
|
||||
const needle = cleaned.name.trim().toLowerCase();
|
||||
if (needle.length === 0 || !haystack.includes(needle)) {
|
||||
delete cleaned.name;
|
||||
}
|
||||
}
|
||||
|
||||
if (cleaned.dose) {
|
||||
const needle = cleaned.dose.trim().toLowerCase();
|
||||
if (needle.length === 0 || !haystack.includes(needle)) {
|
||||
delete cleaned.dose;
|
||||
// The unit only makes sense alongside the dose; drop both
|
||||
// together so the wizard doesn't pre-fill a free-floating "mg".
|
||||
delete cleaned.doseUnit;
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
Reference in new issue
Block a user