From 1bdbc2106f64d5382e892c08b8a0ed78cb0e352f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 28 May 2026 09:18:07 +0200 Subject: [PATCH] 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) --- messages/de.json | 22 + messages/en.json | 22 + messages/es.json | 22 + messages/fr.json | 22 + messages/it.json | 22 + messages/pl.json | 22 + .../extract/__tests__/route.test.ts | 246 ++++++++++ src/app/api/medications/extract/route.ts | 257 ++++++++++ .../scheduling/NaturalLanguageExtractor.tsx | 377 ++++++++++++++ .../medication-extract-prompt.test.ts.snap | 459 ++++++++++++++++++ .../medication-extract-prompt.test.ts | 217 +++++++++ src/lib/ai/coach/medication-extract-prompt.ts | 256 ++++++++++ 12 files changed, 1944 insertions(+) create mode 100644 src/app/api/medications/extract/__tests__/route.test.ts create mode 100644 src/app/api/medications/extract/route.ts create mode 100644 src/components/medications/scheduling/NaturalLanguageExtractor.tsx create mode 100644 src/lib/ai/coach/__tests__/__snapshots__/medication-extract-prompt.test.ts.snap create mode 100644 src/lib/ai/coach/__tests__/medication-extract-prompt.test.ts create mode 100644 src/lib/ai/coach/medication-extract-prompt.ts diff --git a/messages/de.json b/messages/de.json index cba61802b..d6028beca 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index dd98a06ff..057e1288c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index e39aac326..a68476904 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index f810713d1..67bffdead 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 9493c8106..3d9f1dc4a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index 43c4c685b..21d0877ee 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/app/api/medications/extract/__tests__/route.test.ts b/src/app/api/medications/extract/__tests__/route.test.ts new file mode 100644 index 000000000..a570df213 --- /dev/null +++ b/src/app/api/medications/extract/__tests__/route.test.ts @@ -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( + "@/lib/api-handler", + ); + return { + ...actual, + apiHandler: 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( + "@/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); + }); +}); diff --git a/src/app/api/medications/extract/route.ts b/src/app/api/medications/extract/route.ts new file mode 100644 index 000000000..34994079d --- /dev/null +++ b/src/app/api/medications/extract/route.ts @@ -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 { + 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"; diff --git a/src/components/medications/scheduling/NaturalLanguageExtractor.tsx b/src/components/medications/scheduling/NaturalLanguageExtractor.tsx new file mode 100644 index 000000000..a555cc8ca --- /dev/null +++ b/src/components/medications/scheduling/NaturalLanguageExtractor.tsx @@ -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) => 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(null); + const abortRef = useRef(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 | 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 ( + { + if (!next) handleClose(); + }} + > + + + + + + {t("medications.scheduling.naturalLanguage.description")} + + + +
+
+ +