feat(meds): natural-language extraction overlay for the create wizard

Adds an optional Coach-backed overlay that extracts a structured
medication payload from a free-text description so the wizard can
pre-fill its steps. The route is rate-limited and budget-gated; the
prompt carries a citation-coverage guard so the extraction cannot
hallucinate a name + dose pair the user did not write.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-28 09:19:46 +02:00
1 parent 53695f9434
commit 1bdbc2106f
12 files changed
+1944

No files matched your search

@@ -0,0 +1,246 @@
/**
* v1.5.0 — auth + rate-limit + happy-path coverage for the natural-
* language medication extraction route.
*
* The provider client is stubbed so the test runs without the network
* and without a real model in the loop. The relevant property under
* test is the route plumbing: auth → rate-limit → budget → schema →
* citation guard.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(),
};
});
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: vi.fn(async () => undefined),
AssistantDisabledError: class extends Error {},
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-05-28"),
enforceBudget: vi.fn(async () => undefined),
recordSpend: vi.fn(async () => undefined),
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn(async () => ({ type: "none" })),
resolveProviderChain: vi.fn(async () => []),
}));
vi.mock("@/lib/ai/provider-runner", async () => {
const actual =
await vi.importActual<typeof import("@/lib/ai/provider-runner")>(
"@/lib/ai/provider-runner",
);
return {
...actual,
runRawCompletionWithFallback: vi.fn(),
};
});
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
eventStorage: { getStore: () => undefined, run: (_s: unknown, f: () => unknown) => f() },
}));
import { POST } from "../route";
import { requireAuth, HttpError } from "@/lib/api-handler";
import { checkRateLimit } from "@/lib/rate-limit";
import { enforceBudget } from "@/lib/ai/coach/budget";
import {
resolveProvider,
resolveProviderChain,
} from "@/lib/ai/provider";
import { runRawCompletionWithFallback } from "@/lib/ai/provider-runner";
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/medications/extract", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
const AUTH_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 60_000,
});
vi.mocked(enforceBudget).mockResolvedValue(undefined);
vi.mocked(resolveProviderChain).mockResolvedValue([
{
providerType: "openai",
instance: {
type: "openai",
generateCompletion: vi.fn(),
},
} as never,
]);
});
describe("POST /api/medications/extract — auth", () => {
it("requires authentication — apiHandler wrapper converts the throw to a 401", async () => {
// The route mock replaces `apiHandler` with the identity function
// so the test surface invokes the handler directly. That means
// `requireAuth()` throws an HttpError straight to the caller —
// the apiHandler wrapper in production catches it and emits the
// 401 JSON envelope. Asserting the throw confirms the handler
// gates the request before any provider work begins.
vi.mocked(requireAuth).mockRejectedValueOnce(
new HttpError(401, "Not authenticated"),
);
await expect(
POST(postReq({ text: "Mounjaro 5mg weekly" }) as never),
).rejects.toThrow(/Not authenticated/);
});
});
describe("POST /api/medications/extract — rate limit", () => {
it("returns 429 when the per-user bucket is exhausted", async () => {
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
});
const res = await POST(
postReq({ text: "Mounjaro 5mg weekly" }) as never,
);
expect(res.status).toBe(429);
const body = await res.json();
expect(body.error).toMatch(/Too many requests/i);
});
});
describe("POST /api/medications/extract — happy path", () => {
it("returns 200 with the citation-guarded extraction envelope", async () => {
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
// Provider returns a clean JSON reply that maps onto the schema.
vi.mocked(runRawCompletionWithFallback).mockResolvedValueOnce({
result: {
content: JSON.stringify({
name: "Mounjaro",
dose: "5",
doseUnit: "mg",
cadenceKind: "everyNWeeks",
intervalWeeks: 1,
weekdays: ["WE"],
timesOfDay: ["08:00"],
}),
tokensUsed: 120,
model: "gpt-4",
providerType: "anthropic",
},
workingProvider: { providerType: "openai", instance: {} as never },
fallbackHops: [],
});
const res = await POST(
postReq({
text: "Mounjaro 5mg weekly on Wednesday at 08:00",
locale: "en",
}) as never,
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.error).toBeNull();
expect(body.data).toMatchObject({
name: "Mounjaro",
dose: "5",
doseUnit: "mg",
cadenceKind: "everyNWeeks",
});
});
it("strips a hallucinated name that is absent from the user's text", async () => {
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
vi.mocked(runRawCompletionWithFallback).mockResolvedValueOnce({
result: {
content: JSON.stringify({
name: "Wegovy",
dose: "5",
doseUnit: "mg",
}),
tokensUsed: 60,
model: "gpt-4",
providerType: "anthropic",
},
workingProvider: { providerType: "openai", instance: {} as never },
fallbackHops: [],
});
const res = await POST(
postReq({ text: "5mg weekly on Wednesday" }) as never,
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.name).toBeUndefined();
expect(body.data.dose).toBe("5");
});
});
describe("POST /api/medications/extract — validation + provider errors", () => {
it("returns 422 on a malformed body", async () => {
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
const res = await POST(postReq({ text: "" }) as never);
expect(res.status).toBe(422);
});
it("returns 503 when no provider is configured for the user", async () => {
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
vi.mocked(resolveProviderChain).mockResolvedValueOnce([]);
vi.mocked(resolveProvider).mockResolvedValueOnce({
type: "none",
} as never);
const res = await POST(
postReq({ text: "Mounjaro 5mg weekly" }) as never,
);
expect(res.status).toBe(503);
});
it("returns 502 when the provider returns unparseable JSON", async () => {
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
vi.mocked(runRawCompletionWithFallback).mockResolvedValueOnce({
result: {
content: "not json at all",
tokensUsed: 10,
model: "gpt-4",
providerType: "anthropic",
},
workingProvider: { providerType: "openai", instance: {} as never },
fallbackHops: [],
});
const res = await POST(
postReq({ text: "Mounjaro 5mg weekly" }) as never,
);
expect(res.status).toBe(502);
});
});
+257
View File
@@ -0,0 +1,257 @@
/**
* v1.5.0 — POST /api/medications/extract
*
* Coach-backed natural-language overlay for step 1 of the medication
* create wizard. The user types a free-form description ("Mounjaro 5mg
* weekly Wednesday morning"); the route runs that string through the
* provider chain and returns a structured partial payload the wizard
* merges onto whatever the user already typed.
*
* Why a dedicated route instead of re-using /api/insights/chat:
*
* - The Coach chat surface is conversational + streaming. The
* extraction is a single round-trip JSON payload — different shape,
* different validation contract, different prompt body.
* - The chat route does not enforce a structured-output schema; this
* route Zod-parses the model's reply and only ever returns a
* citation-guarded result. A wizard that pre-fills the form has to
* be defensive about hallucinated names + doses; the chat surface
* does not.
* - Separate rate-limit bucket ("coach.medication.extract") so a
* wizard that the user mashes the button on cannot exhaust the
* 20/min chat ceiling and lock the drawer for an hour.
*
* Behaviour:
*
* 1. requireAuth() — cookie OR Bearer.
* 2. requireAssistantSurface("coach") — operator can disable.
* 3. checkRateLimit(...) — 10 requests / 5 min / user.
* 4. enforceBudget() — daily Coach token ceiling.
* 5. resolveProviderChain() — fall back through providers.
* 6. runRawCompletionWithFallback() — same machinery the Coach
* uses; structured-output is enforced via JSON.parse + Zod here
* rather than via the strict-schema wrapper.
* 7. applyCitationGuard() — drop hallucinated name/dose.
* 8. recordSpend() — bump the day's ledger.
*
* Response: `{ data: MedicationExtractionResult, error: null }`. Every
* field on the result is optional; the wizard is the merge target.
*/
import type { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { requireAssistantSurface } from "@/lib/feature-flags";
import {
AllProvidersFailedError,
runRawCompletionWithFallback,
} from "@/lib/ai/provider-runner";
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import {
buildDateKey,
enforceBudget,
recordSpend,
} from "@/lib/ai/coach/budget";
import {
applyCitationGuard,
buildMedicationExtractionPrompt,
medicationExtractionSchema,
type MedicationExtractionResult,
} from "@/lib/ai/coach/medication-extract-prompt";
/** Hard cap on the free-text payload; mirrors `coachChatRequestSchema.message`. */
const MAX_TEXT_LENGTH = 2000;
const requestSchema = z.object({
text: z.string().min(1).max(MAX_TEXT_LENGTH),
/** Optional UI locale; informational hint for the model. */
locale: z.enum(["en", "de", "es", "fr", "it", "pl"]).optional(),
/**
* Optional override of the reference date used to resolve relative
* phrases ("tomorrow", "next Monday"). Defaults to the server's
* UTC `YYYY-MM-DD`. Bounded format so a hostile client cannot
* inject prose into the prompt body.
*/
today: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
});
/**
* Strip leading code-fence noise that some chat models wrap around
* structured-output replies, then JSON.parse. Returns null on any
* failure so the caller can shape a single structured error rather
* than re-throwing a SyntaxError.
*/
function parseModelJson(content: string): unknown | null {
let trimmed = content.trim();
if (trimmed.startsWith("```")) {
trimmed = trimmed.replace(/^```(?:json)?\s*/i, "").replace(/```$/i, "");
trimmed = trimmed.trim();
}
try {
return JSON.parse(trimmed);
} catch {
return null;
}
}
async function handleExtract(request: NextRequest): Promise<Response> {
const auth = await requireAuth();
// Same operator-disable gate the Coach SSE route honours — when
// "coach" is off, the overlay also disappears so the wizard simply
// falls back to the manual path.
await requireAssistantSurface("coach");
const userId = auth.user.id;
// Body parse + Zod validation.
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const validated = requestSchema.safeParse(body);
if (!validated.success) {
annotate({
action: { name: "medications.extract.invalid" },
meta: { issues: validated.error.issues.length },
});
return returnAllZodIssues(validated.error);
}
const { text, locale, today: todayOverride } = validated.data;
// 10 / 5 minutes / user. Generous enough for a power user iterating
// on a description, tight enough that a stuck button cannot pin a
// provider slot for the full daily budget. Keyed on the user, not
// the IP, so a household sharing a NAT does not collide.
const rl = await checkRateLimit(
`coach.medication.extract:${userId}`,
10,
5 * 60 * 1000,
);
if (!rl.allowed) {
annotate({
action: { name: "medications.extract.rate-limited" },
meta: { userId, resetAt: rl.resetAt },
});
return apiError("Too many requests, please wait a moment", 429, {
headers: rateLimitHeaders(rl),
});
}
await enforceBudget(userId);
// Resolve the same provider chain the Coach uses. If the user has no
// provider configured AND the operator has not seeded an admin key,
// surface a structured 503 — the wizard hides the overlay.
const chain = await resolveProviderChain(userId);
if (chain.length === 0) {
const legacy = await resolveProvider(userId);
if (legacy.type === "none") {
annotate({ action: { name: "medications.extract.no-provider" } });
return apiError("No AI provider configured", 503);
}
chain.push({ providerType: "admin-openai", instance: legacy });
}
const today = todayOverride ?? buildDateKey();
const { systemPrompt, userPrompt } = buildMedicationExtractionPrompt({
text,
today,
locale,
});
let completion;
try {
completion = await runRawCompletionWithFallback({
userId,
providers: chain,
params: {
systemPrompt,
userPrompt,
// Low temperature because we want a deterministic field
// extractor, not a creative rephrase.
temperature: 0.1,
// The reply body is a single small JSON object; 600 is well
// over the empirical max we have seen on a 2k-char input.
maxTokens: 600,
},
});
} catch (err) {
if (err instanceof AllProvidersFailedError) {
annotate({
action: { name: "medications.extract.provider-failed" },
meta: { attempts: err.attempts.length },
});
return apiError("AI provider unavailable", 503);
}
throw err;
}
const reply = completion.result.content?.trim() ?? "";
if (!reply) {
annotate({ action: { name: "medications.extract.empty-reply" } });
return apiError("AI provider returned an empty response", 502);
}
const json = parseModelJson(reply);
if (json === null) {
annotate({
action: { name: "medications.extract.unparseable" },
meta: { length: reply.length },
});
return apiError("Could not parse the AI provider response", 502);
}
const validatedJson = medicationExtractionSchema.safeParse(json);
if (!validatedJson.success) {
annotate({
action: { name: "medications.extract.schema-mismatch" },
meta: { issues: validatedJson.error.issues.length },
});
return apiError("AI provider returned an unexpected shape", 502);
}
// Citation-coverage guard: drop hallucinated name / dose tokens that
// are not actually present in the user's original text.
const guarded: MedicationExtractionResult = applyCitationGuard(
validatedJson.data,
text,
);
// Bump the day's spend ledger so the daily budget gate stays in sync.
await recordSpend({
userId,
tokens: completion.result.tokensUsed ?? 0,
dateKey: buildDateKey(),
});
annotate({
action: { name: "medications.extract.ok" },
meta: {
provider: completion.workingProvider.providerType,
tokens: completion.result.tokensUsed ?? null,
filledFieldCount: Object.keys(guarded).length,
},
});
return apiSuccess(guarded);
}
export const POST = apiHandler(handleExtract);
export const dynamic = "force-dynamic";
export const runtime = "nodejs";