mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user