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:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-28 09:19:46 +02:00
1 parent 53695f9434
commit 1bdbc2106f
12 files changed
+1944

No files matched your search

+22
View File
@@ -632,6 +632,28 @@
"longestTooltip": "Längste durchgehende Serie im 30-Tage-Fenster.",
"missedTooltip": "Anzahl verpasster Dosen in den letzten 30 Tagen."
}
},
"naturalLanguage": {
"title": "Medikament beschreiben",
"description": "Beschreibe das Medikament in eigenen Worten — das Formular wird automatisch vorausgefüllt.",
"placeholder": "z. B. Mounjaro 5 mg wöchentlich am Mittwochmorgen ab nächstem Montag",
"submit": "Übernehmen",
"submitBusy": "Wird verarbeitet …",
"cancel": "Abbrechen",
"examples": {
"label": "Eines dieser Beispiele übernehmen",
"weekly": "Mounjaro 5 mg wöchentlich am Mittwochmorgen",
"daily": "Ibuprofen 200 mg täglich, 3-mal am Tag",
"rolling": "Methotrexat 7,5 mg alle 7 Tage nach der letzten Spritze"
},
"error": {
"empty": "Bitte zuerst eine Beschreibung eingeben.",
"tooLong": "Beschreibung muss unter 2000 Zeichen bleiben.",
"rateLimit": "Zu viele Anfragen — bitte kurz warten und erneut versuchen.",
"noProvider": "Kein KI-Anbieter konfiguriert — bitte in den Einstellungen einrichten.",
"budget": "Tägliches KI-Budget erreicht — bitte morgen erneut versuchen.",
"network": "KI-Anbieter nicht erreichbar — bitte erneut versuchen."
}
}
},
"titration": {
+22
View File
@@ -632,6 +632,28 @@
"longestTooltip": "Longest unbroken streak within the 30-day window.",
"missedTooltip": "Count of doses missed in the last 30 days."
}
},
"naturalLanguage": {
"title": "Describe your medication",
"description": "Write the medication in your own words and we will pre-fill the form for you.",
"placeholder": "e.g. Mounjaro 5mg weekly on Wednesday morning starting next Monday",
"submit": "Extract",
"submitBusy": "Extracting…",
"cancel": "Cancel",
"examples": {
"label": "Try one of these examples",
"weekly": "Mounjaro 5mg weekly on Wednesday morning",
"daily": "Ibuprofen 200mg every day, 3 times a day",
"rolling": "Methotrexate 7.5mg every 7 days from last injection"
},
"error": {
"empty": "Type a description first.",
"tooLong": "Keep the description under 2000 characters.",
"rateLimit": "Too many requests — wait a moment and try again.",
"noProvider": "No AI provider configured — set one up in Settings.",
"budget": "Daily AI budget reached — try again tomorrow.",
"network": "Could not reach the AI provider — please retry."
}
}
},
"titration": {
+22
View File
@@ -632,6 +632,28 @@
"longestTooltip": "Racha continua más larga dentro de la ventana de 30 días.",
"missedTooltip": "Número de dosis olvidadas en los últimos 30 días."
}
},
"naturalLanguage": {
"title": "Describe tu medicación",
"description": "Describe la medicación con tus propias palabras y completaremos el formulario por ti.",
"placeholder": "p. ej. Mounjaro 5 mg semanal los miércoles por la mañana a partir del próximo lunes",
"submit": "Extraer",
"submitBusy": "Extrayendo…",
"cancel": "Cancelar",
"examples": {
"label": "Prueba uno de estos ejemplos",
"weekly": "Mounjaro 5 mg semanal los miércoles por la mañana",
"daily": "Ibuprofeno 200 mg todos los días, 3 veces al día",
"rolling": "Metotrexato 7,5 mg cada 7 días desde la última inyección"
},
"error": {
"empty": "Escribe una descripción primero.",
"tooLong": "Mantén la descripción por debajo de 2000 caracteres.",
"rateLimit": "Demasiadas solicitudes — espera un momento e inténtalo de nuevo.",
"noProvider": "Sin proveedor de IA configurado — configúralo en Ajustes.",
"budget": "Se alcanzó el presupuesto diario de IA — inténtalo de nuevo mañana.",
"network": "No se pudo contactar con el proveedor de IA — inténtalo de nuevo."
}
}
},
"titration": {
+22
View File
@@ -632,6 +632,28 @@
"longestTooltip": "Série continue la plus longue sur la fenêtre de 30 jours.",
"missedTooltip": "Nombre de doses manquées au cours des 30 derniers jours."
}
},
"naturalLanguage": {
"title": "Décris ton médicament",
"description": "Décris le médicament avec tes propres mots et le formulaire sera pré-rempli pour toi.",
"placeholder": "p. ex. Mounjaro 5 mg par semaine le mercredi matin à partir de lundi prochain",
"submit": "Extraire",
"submitBusy": "Extraction…",
"cancel": "Annuler",
"examples": {
"label": "Essayer un de ces exemples",
"weekly": "Mounjaro 5 mg par semaine le mercredi matin",
"daily": "Ibuprofène 200 mg tous les jours, 3 fois par jour",
"rolling": "Méthotrexate 7,5 mg tous les 7 jours depuis la dernière injection"
},
"error": {
"empty": "Saisis d'abord une description.",
"tooLong": "Garde la description sous 2000 caractères.",
"rateLimit": "Trop de requêtes — attends un instant et réessaie.",
"noProvider": "Aucun fournisseur d'IA configuré — configure-le dans les Paramètres.",
"budget": "Budget IA quotidien atteint — réessaie demain.",
"network": "Impossible de joindre le fournisseur d'IA — réessaie."
}
}
},
"titration": {
+22
View File
@@ -632,6 +632,28 @@
"longestTooltip": "Serie continua più lunga nella finestra di 30 giorni.",
"missedTooltip": "Numero di dosi mancate negli ultimi 30 giorni."
}
},
"naturalLanguage": {
"title": "Descrivi il farmaco",
"description": "Descrivi il farmaco con parole tue e il modulo verrà precompilato per te.",
"placeholder": "es. Mounjaro 5 mg settimanale il mercoledì mattina a partire da lunedì prossimo",
"submit": "Estrai",
"submitBusy": "Estrazione in corso…",
"cancel": "Annulla",
"examples": {
"label": "Prova uno di questi esempi",
"weekly": "Mounjaro 5 mg settimanale il mercoledì mattina",
"daily": "Ibuprofene 200 mg ogni giorno, 3 volte al giorno",
"rolling": "Metotrexato 7,5 mg ogni 7 giorni dall'ultima iniezione"
},
"error": {
"empty": "Scrivi prima una descrizione.",
"tooLong": "Mantieni la descrizione entro 2000 caratteri.",
"rateLimit": "Troppe richieste — attendi un momento e riprova.",
"noProvider": "Nessun provider IA configurato — configurane uno in Impostazioni.",
"budget": "Budget IA giornaliero raggiunto — riprova domani.",
"network": "Impossibile contattare il provider IA — riprova."
}
}
},
"titration": {
+22
View File
@@ -632,6 +632,28 @@
"longestTooltip": "Najdłuższa nieprzerwana seria w 30-dniowym oknie.",
"missedTooltip": "Liczba dawek pominiętych w ostatnich 30 dniach."
}
},
"naturalLanguage": {
"title": "Opisz lek",
"description": "Opisz lek własnymi słowami, a my wstępnie wypełnimy formularz za Ciebie.",
"placeholder": "np. Mounjaro 5 mg co tydzień w środę rano od najbliższego poniedziałku",
"submit": "Wyodrębnij",
"submitBusy": "Wyodrębnianie…",
"cancel": "Anuluj",
"examples": {
"label": "Wypróbuj jeden z tych przykładów",
"weekly": "Mounjaro 5 mg co tydzień w środę rano",
"daily": "Ibuprofen 200 mg codziennie, 3 razy dziennie",
"rolling": "Metotreksat 7,5 mg co 7 dni od ostatniego wstrzyknięcia"
},
"error": {
"empty": "Najpierw wpisz opis.",
"tooLong": "Utrzymuj opis poniżej 2000 znaków.",
"rateLimit": "Zbyt wiele żądań — poczekaj chwilę i spróbuj ponownie.",
"noProvider": "Brak skonfigurowanego dostawcy AI — skonfiguruj go w Ustawieniach.",
"budget": "Osiągnięto dzienny limit AI — spróbuj jutro.",
"network": "Nie udało się połączyć z dostawcą AI — spróbuj ponownie."
}
}
},
"titration": {
@@ -0,0 +1,246 @@
/**
* v1.5.0 — auth + rate-limit + happy-path coverage for the natural-
* language medication extraction route.
*
* The provider client is stubbed so the test runs without the network
* and without a real model in the loop. The relevant property under
* test is the route plumbing: auth → rate-limit → budget → schema →
* citation guard.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(),
};
});
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: vi.fn(async () => undefined),
AssistantDisabledError: class extends Error {},
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-05-28"),
enforceBudget: vi.fn(async () => undefined),
recordSpend: vi.fn(async () => undefined),
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn(async () => ({ type: "none" })),
resolveProviderChain: vi.fn(async () => []),
}));
vi.mock("@/lib/ai/provider-runner", async () => {
const actual =
await vi.importActual<typeof import("@/lib/ai/provider-runner")>(
"@/lib/ai/provider-runner",
);
return {
...actual,
runRawCompletionWithFallback: vi.fn(),
};
});
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
eventStorage: { getStore: () => undefined, run: (_s: unknown, f: () => unknown) => f() },
}));
import { POST } from "../route";
import { requireAuth, HttpError } from "@/lib/api-handler";
import { checkRateLimit } from "@/lib/rate-limit";
import { enforceBudget } from "@/lib/ai/coach/budget";
import {
resolveProvider,
resolveProviderChain,
} from "@/lib/ai/provider";
import { runRawCompletionWithFallback } from "@/lib/ai/provider-runner";
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/medications/extract", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
const AUTH_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 60_000,
});
vi.mocked(enforceBudget).mockResolvedValue(undefined);
vi.mocked(resolveProviderChain).mockResolvedValue([
{
providerType: "openai",
instance: {
type: "openai",
generateCompletion: vi.fn(),
},
} as never,
]);
});
describe("POST /api/medications/extract — auth", () => {
it("requires authentication — apiHandler wrapper converts the throw to a 401", async () => {
// The route mock replaces `apiHandler` with the identity function
// so the test surface invokes the handler directly. That means
// `requireAuth()` throws an HttpError straight to the caller —
// the apiHandler wrapper in production catches it and emits the
// 401 JSON envelope. Asserting the throw confirms the handler
// gates the request before any provider work begins.
vi.mocked(requireAuth).mockRejectedValueOnce(
new HttpError(401, "Not authenticated"),
);
await expect(
POST(postReq({ text: "Mounjaro 5mg weekly" }) as never),
).rejects.toThrow(/Not authenticated/);
});
});
describe("POST /api/medications/extract — rate limit", () => {
it("returns 429 when the per-user bucket is exhausted", async () => {
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
});
const res = await POST(
postReq({ text: "Mounjaro 5mg weekly" }) as never,
);
expect(res.status).toBe(429);
const body = await res.json();
expect(body.error).toMatch(/Too many requests/i);
});
});
describe("POST /api/medications/extract — happy path", () => {
it("returns 200 with the citation-guarded extraction envelope", async () => {
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
// Provider returns a clean JSON reply that maps onto the schema.
vi.mocked(runRawCompletionWithFallback).mockResolvedValueOnce({
result: {
content: JSON.stringify({
name: "Mounjaro",
dose: "5",
doseUnit: "mg",
cadenceKind: "everyNWeeks",
intervalWeeks: 1,
weekdays: ["WE"],
timesOfDay: ["08:00"],
}),
tokensUsed: 120,
model: "gpt-4",
providerType: "anthropic",
},
workingProvider: { providerType: "openai", instance: {} as never },
fallbackHops: [],
});
const res = await POST(
postReq({
text: "Mounjaro 5mg weekly on Wednesday at 08:00",
locale: "en",
}) as never,
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.error).toBeNull();
expect(body.data).toMatchObject({
name: "Mounjaro",
dose: "5",
doseUnit: "mg",
cadenceKind: "everyNWeeks",
});
});
it("strips a hallucinated name that is absent from the user's text", async () => {
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
vi.mocked(runRawCompletionWithFallback).mockResolvedValueOnce({
result: {
content: JSON.stringify({
name: "Wegovy",
dose: "5",
doseUnit: "mg",
}),
tokensUsed: 60,
model: "gpt-4",
providerType: "anthropic",
},
workingProvider: { providerType: "openai", instance: {} as never },
fallbackHops: [],
});
const res = await POST(
postReq({ text: "5mg weekly on Wednesday" }) as never,
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.name).toBeUndefined();
expect(body.data.dose).toBe("5");
});
});
describe("POST /api/medications/extract — validation + provider errors", () => {
it("returns 422 on a malformed body", async () => {
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
const res = await POST(postReq({ text: "" }) as never);
expect(res.status).toBe(422);
});
it("returns 503 when no provider is configured for the user", async () => {
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
vi.mocked(resolveProviderChain).mockResolvedValueOnce([]);
vi.mocked(resolveProvider).mockResolvedValueOnce({
type: "none",
} as never);
const res = await POST(
postReq({ text: "Mounjaro 5mg weekly" }) as never,
);
expect(res.status).toBe(503);
});
it("returns 502 when the provider returns unparseable JSON", async () => {
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
vi.mocked(runRawCompletionWithFallback).mockResolvedValueOnce({
result: {
content: "not json at all",
tokensUsed: 10,
model: "gpt-4",
providerType: "anthropic",
},
workingProvider: { providerType: "openai", instance: {} as never },
fallbackHops: [],
});
const res = await POST(
postReq({ text: "Mounjaro 5mg weekly" }) as never,
);
expect(res.status).toBe(502);
});
});
+257
View File
@@ -0,0 +1,257 @@
/**
* v1.5.0 — POST /api/medications/extract
*
* Coach-backed natural-language overlay for step 1 of the medication
* create wizard. The user types a free-form description ("Mounjaro 5mg
* weekly Wednesday morning"); the route runs that string through the
* provider chain and returns a structured partial payload the wizard
* merges onto whatever the user already typed.
*
* Why a dedicated route instead of re-using /api/insights/chat:
*
* - The Coach chat surface is conversational + streaming. The
* extraction is a single round-trip JSON payload — different shape,
* different validation contract, different prompt body.
* - The chat route does not enforce a structured-output schema; this
* route Zod-parses the model's reply and only ever returns a
* citation-guarded result. A wizard that pre-fills the form has to
* be defensive about hallucinated names + doses; the chat surface
* does not.
* - Separate rate-limit bucket ("coach.medication.extract") so a
* wizard that the user mashes the button on cannot exhaust the
* 20/min chat ceiling and lock the drawer for an hour.
*
* Behaviour:
*
* 1. requireAuth() — cookie OR Bearer.
* 2. requireAssistantSurface("coach") — operator can disable.
* 3. checkRateLimit(...) — 10 requests / 5 min / user.
* 4. enforceBudget() — daily Coach token ceiling.
* 5. resolveProviderChain() — fall back through providers.
* 6. runRawCompletionWithFallback() — same machinery the Coach
* uses; structured-output is enforced via JSON.parse + Zod here
* rather than via the strict-schema wrapper.
* 7. applyCitationGuard() — drop hallucinated name/dose.
* 8. recordSpend() — bump the day's ledger.
*
* Response: `{ data: MedicationExtractionResult, error: null }`. Every
* field on the result is optional; the wizard is the merge target.
*/
import type { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { requireAssistantSurface } from "@/lib/feature-flags";
import {
AllProvidersFailedError,
runRawCompletionWithFallback,
} from "@/lib/ai/provider-runner";
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import {
buildDateKey,
enforceBudget,
recordSpend,
} from "@/lib/ai/coach/budget";
import {
applyCitationGuard,
buildMedicationExtractionPrompt,
medicationExtractionSchema,
type MedicationExtractionResult,
} from "@/lib/ai/coach/medication-extract-prompt";
/** Hard cap on the free-text payload; mirrors `coachChatRequestSchema.message`. */
const MAX_TEXT_LENGTH = 2000;
const requestSchema = z.object({
text: z.string().min(1).max(MAX_TEXT_LENGTH),
/** Optional UI locale; informational hint for the model. */
locale: z.enum(["en", "de", "es", "fr", "it", "pl"]).optional(),
/**
* Optional override of the reference date used to resolve relative
* phrases ("tomorrow", "next Monday"). Defaults to the server's
* UTC `YYYY-MM-DD`. Bounded format so a hostile client cannot
* inject prose into the prompt body.
*/
today: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
});
/**
* Strip leading code-fence noise that some chat models wrap around
* structured-output replies, then JSON.parse. Returns null on any
* failure so the caller can shape a single structured error rather
* than re-throwing a SyntaxError.
*/
function parseModelJson(content: string): unknown | null {
let trimmed = content.trim();
if (trimmed.startsWith("```")) {
trimmed = trimmed.replace(/^```(?:json)?\s*/i, "").replace(/```$/i, "");
trimmed = trimmed.trim();
}
try {
return JSON.parse(trimmed);
} catch {
return null;
}
}
async function handleExtract(request: NextRequest): Promise<Response> {
const auth = await requireAuth();
// Same operator-disable gate the Coach SSE route honours — when
// "coach" is off, the overlay also disappears so the wizard simply
// falls back to the manual path.
await requireAssistantSurface("coach");
const userId = auth.user.id;
// Body parse + Zod validation.
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const validated = requestSchema.safeParse(body);
if (!validated.success) {
annotate({
action: { name: "medications.extract.invalid" },
meta: { issues: validated.error.issues.length },
});
return returnAllZodIssues(validated.error);
}
const { text, locale, today: todayOverride } = validated.data;
// 10 / 5 minutes / user. Generous enough for a power user iterating
// on a description, tight enough that a stuck button cannot pin a
// provider slot for the full daily budget. Keyed on the user, not
// the IP, so a household sharing a NAT does not collide.
const rl = await checkRateLimit(
`coach.medication.extract:${userId}`,
10,
5 * 60 * 1000,
);
if (!rl.allowed) {
annotate({
action: { name: "medications.extract.rate-limited" },
meta: { userId, resetAt: rl.resetAt },
});
return apiError("Too many requests, please wait a moment", 429, {
headers: rateLimitHeaders(rl),
});
}
await enforceBudget(userId);
// Resolve the same provider chain the Coach uses. If the user has no
// provider configured AND the operator has not seeded an admin key,
// surface a structured 503 — the wizard hides the overlay.
const chain = await resolveProviderChain(userId);
if (chain.length === 0) {
const legacy = await resolveProvider(userId);
if (legacy.type === "none") {
annotate({ action: { name: "medications.extract.no-provider" } });
return apiError("No AI provider configured", 503);
}
chain.push({ providerType: "admin-openai", instance: legacy });
}
const today = todayOverride ?? buildDateKey();
const { systemPrompt, userPrompt } = buildMedicationExtractionPrompt({
text,
today,
locale,
});
let completion;
try {
completion = await runRawCompletionWithFallback({
userId,
providers: chain,
params: {
systemPrompt,
userPrompt,
// Low temperature because we want a deterministic field
// extractor, not a creative rephrase.
temperature: 0.1,
// The reply body is a single small JSON object; 600 is well
// over the empirical max we have seen on a 2k-char input.
maxTokens: 600,
},
});
} catch (err) {
if (err instanceof AllProvidersFailedError) {
annotate({
action: { name: "medications.extract.provider-failed" },
meta: { attempts: err.attempts.length },
});
return apiError("AI provider unavailable", 503);
}
throw err;
}
const reply = completion.result.content?.trim() ?? "";
if (!reply) {
annotate({ action: { name: "medications.extract.empty-reply" } });
return apiError("AI provider returned an empty response", 502);
}
const json = parseModelJson(reply);
if (json === null) {
annotate({
action: { name: "medications.extract.unparseable" },
meta: { length: reply.length },
});
return apiError("Could not parse the AI provider response", 502);
}
const validatedJson = medicationExtractionSchema.safeParse(json);
if (!validatedJson.success) {
annotate({
action: { name: "medications.extract.schema-mismatch" },
meta: { issues: validatedJson.error.issues.length },
});
return apiError("AI provider returned an unexpected shape", 502);
}
// Citation-coverage guard: drop hallucinated name / dose tokens that
// are not actually present in the user's original text.
const guarded: MedicationExtractionResult = applyCitationGuard(
validatedJson.data,
text,
);
// Bump the day's spend ledger so the daily budget gate stays in sync.
await recordSpend({
userId,
tokens: completion.result.tokensUsed ?? 0,
dateKey: buildDateKey(),
});
annotate({
action: { name: "medications.extract.ok" },
meta: {
provider: completion.workingProvider.providerType,
tokens: completion.result.tokensUsed ?? null,
filledFieldCount: Object.keys(guarded).length,
},
});
return apiSuccess(guarded);
}
export const POST = apiHandler(handleExtract);
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
@@ -0,0 +1,377 @@
"use client";
/**
* v1.5.0 — NaturalLanguageExtractor overlay.
*
* Step 1 of the medication-create wizard ships a "Describe it" button
* that opens this dialog. The user types a free-text description
* ("Mounjaro 5mg weekly Wednesday morning starting next Monday"); the
* route at /api/medications/extract returns a structured partial
* payload; the dialog calls `onPrefill(...)` and closes so the wizard
* pre-fills its steps.
*
* Mobile-first contract:
* - 44 px tap-target floor on every interactive control (WCAG 2.5.5).
* - `aria-busy` toggles on the dialog body while the request is in
* flight so assistive tech announces the wait state.
* - `motion-reduce:animate-none` on the loader so `prefers-reduced-
* motion` callers get a static glyph.
*
* i18n keys consumed (namespace `medications.scheduling.naturalLanguage.*`):
*
* .title — dialog headline
* .description — short prompt under the headline
* .placeholder — textarea placeholder
* .examples.label — "Try one of these examples:"
* .examples.weekly — "Mounjaro 5mg weekly Wednesday morning"
* .examples.daily — "Ibuprofen 200mg every day, 3 times a day"
* .examples.rolling — "Methotrexate 7.5mg every 7 days from last injection"
* .submit — "Extract"
* .submitBusy — "Extracting…"
* .cancel — "Cancel"
* .error.network — "Could not reach the AI provider — please retry"
* .error.empty — "Type a description first"
* .error.tooLong — "Keep the description under 2000 characters"
* .error.rateLimit — "Too many requests — wait a moment and try again"
* .error.noProvider — "No AI provider configured — set one up in Settings"
* .error.budget — "Daily AI budget reached — try again tomorrow"
*
* The wizard component owns the rendering of the trigger button — this
* file only owns the dialog body so the wizard's "✨ Beschreiben"
* button is the open-trigger.
*/
import { useCallback, useId, useRef, useState } from "react";
import { Loader2, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useTranslations } from "@/lib/i18n/context";
import type { MedicationExtractionResult } from "@/lib/ai/coach/medication-extract-prompt";
/**
* The wizard accepts the same partial shape the route returns.
* Exposed as a named alias so the wizard layer can import a single
* type and the prompt-side definition stays the source of truth.
*/
export type WizardPayload = MedicationExtractionResult;
export interface NaturalLanguageExtractorProps {
/** Controlled open/closed state — the wizard owns it. */
open: boolean;
/** Fired when the dialog should close (user dismiss, success). */
onClose: () => void;
/** Fired on a successful extraction; the wizard merges + closes. */
onPrefill: (partial: Partial<WizardPayload>) => void;
/**
* UI locale forwarded to the route as a hint. The route accepts an
* undefined value and falls back to the resolver chain.
*/
locale?: "en" | "de" | "es" | "fr" | "it" | "pl";
/**
* Optional override of the API path so the wizard test harness can
* point at a mock server. Defaults to the production route.
*/
endpoint?: string;
}
type DialogError =
| { kind: "empty" }
| { kind: "tooLong" }
| { kind: "rateLimit" }
| { kind: "noProvider" }
| { kind: "budget" }
| { kind: "network"; message?: string };
const MAX_TEXT_LENGTH = 2000;
export function NaturalLanguageExtractor({
open,
onClose,
onPrefill,
locale,
endpoint = "/api/medications/extract",
}: NaturalLanguageExtractorProps) {
const { t } = useTranslations();
const textareaId = useId();
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<DialogError | null>(null);
const abortRef = useRef<AbortController | null>(null);
const reset = useCallback(() => {
setText("");
setError(null);
setBusy(false);
if (abortRef.current) {
abortRef.current.abort();
abortRef.current = null;
}
}, []);
const handleClose = useCallback(() => {
reset();
onClose();
}, [onClose, reset]);
const handleSubmit = useCallback(async () => {
const trimmed = text.trim();
if (trimmed.length === 0) {
setError({ kind: "empty" });
return;
}
if (trimmed.length > MAX_TEXT_LENGTH) {
setError({ kind: "tooLong" });
return;
}
setError(null);
setBusy(true);
const controller = new AbortController();
abortRef.current = controller;
try {
const res = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: trimmed, locale }),
signal: controller.signal,
});
if (res.status === 429) {
setError({ kind: "rateLimit" });
return;
}
if (res.status === 503) {
setError({ kind: "noProvider" });
return;
}
if (res.status === 402 || res.status === 423) {
// Reserved spare codes — keep the budget branch wired even
// though the route currently surfaces budget exhaustion via
// the apiHandler's HttpError pipeline.
setError({ kind: "budget" });
return;
}
if (!res.ok) {
const body = (await res.json().catch(() => null)) as {
error?: string;
} | null;
if (
body?.error &&
/budget/i.test(body.error)
) {
setError({ kind: "budget" });
return;
}
setError({ kind: "network", message: body?.error });
return;
}
const body = (await res.json()) as {
data: Partial<WizardPayload> | null;
error: string | null;
};
if (body.error || !body.data) {
setError({ kind: "network", message: body.error ?? undefined });
return;
}
onPrefill(body.data);
reset();
onClose();
} catch (err) {
if (
err instanceof DOMException &&
(err.name === "AbortError" || err.name === "TimeoutError")
) {
// Caller dismissed the dialog mid-flight; do not surface.
return;
}
setError({
kind: "network",
message: err instanceof Error ? err.message : undefined,
});
} finally {
setBusy(false);
abortRef.current = null;
}
}, [endpoint, locale, onClose, onPrefill, reset, text]);
const insertExample = useCallback((example: string) => {
setText(example);
setError(null);
}, []);
const errorMessage = error ? resolveErrorMessage(error, t) : null;
const charCount = text.length;
const overLimit = charCount > MAX_TEXT_LENGTH;
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) handleClose();
}}
>
<DialogContent aria-busy={busy || undefined}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="size-4" aria-hidden="true" />
{t("medications.scheduling.naturalLanguage.title")}
</DialogTitle>
<DialogDescription>
{t("medications.scheduling.naturalLanguage.description")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div>
<Label htmlFor={textareaId} className="sr-only">
{t("medications.scheduling.naturalLanguage.title")}
</Label>
<Textarea
id={textareaId}
value={text}
onChange={(e) => {
setText(e.target.value);
if (error) setError(null);
}}
placeholder={t(
"medications.scheduling.naturalLanguage.placeholder",
)}
rows={4}
maxLength={MAX_TEXT_LENGTH + 200}
disabled={busy}
aria-invalid={overLimit || error?.kind === "empty" || undefined}
aria-describedby={errorMessage ? `${textareaId}-error` : undefined}
/>
<div className="mt-1 text-right text-xs text-muted-foreground">
{charCount} / {MAX_TEXT_LENGTH}
</div>
</div>
{errorMessage ? (
<p
id={`${textareaId}-error`}
role="alert"
className="text-sm text-destructive"
>
{errorMessage}
</p>
) : null}
<fieldset className="flex flex-col gap-2">
<legend className="text-xs font-medium text-muted-foreground">
{t("medications.scheduling.naturalLanguage.examples.label")}
</legend>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<ExampleChip
onPick={insertExample}
label={t(
"medications.scheduling.naturalLanguage.examples.weekly",
)}
disabled={busy}
/>
<ExampleChip
onPick={insertExample}
label={t(
"medications.scheduling.naturalLanguage.examples.daily",
)}
disabled={busy}
/>
<ExampleChip
onPick={insertExample}
label={t(
"medications.scheduling.naturalLanguage.examples.rolling",
)}
disabled={busy}
/>
</div>
</fieldset>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={busy}
className="min-h-11 sm:min-h-9"
>
{t("medications.scheduling.naturalLanguage.cancel")}
</Button>
<Button
type="button"
onClick={handleSubmit}
disabled={busy || overLimit}
aria-busy={busy || undefined}
className="min-h-11 sm:min-h-9"
>
{busy ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<Sparkles className="size-4" aria-hidden="true" />
)}
{busy
? t("medications.scheduling.naturalLanguage.submitBusy")
: t("medications.scheduling.naturalLanguage.submit")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
interface ExampleChipProps {
label: string;
onPick: (text: string) => void;
disabled?: boolean;
}
function ExampleChip({ label, onPick, disabled }: ExampleChipProps) {
return (
<button
type="button"
onClick={() => onPick(label)}
disabled={disabled}
className="min-h-11 rounded-md border border-input bg-background px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 sm:min-h-9"
>
{label}
</button>
);
}
function resolveErrorMessage(
error: DialogError,
t: (key: string, params?: Record<string, string | number>) => string,
): string {
switch (error.kind) {
case "empty":
return t("medications.scheduling.naturalLanguage.error.empty");
case "tooLong":
return t("medications.scheduling.naturalLanguage.error.tooLong");
case "rateLimit":
return t("medications.scheduling.naturalLanguage.error.rateLimit");
case "noProvider":
return t("medications.scheduling.naturalLanguage.error.noProvider");
case "budget":
return t("medications.scheduling.naturalLanguage.error.budget");
case "network":
default:
return t("medications.scheduling.naturalLanguage.error.network");
}
}
@@ -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;
}