mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
feat(meds): natural-language extraction overlay for the create wizard
Adds an optional Coach-backed overlay that extracts a structured medication payload from a free-text description so the wizard can pre-fill its steps. The route is rate-limited and budget-gated; the prompt carries a citation-coverage guard so the extraction cannot hallucinate a name + dose pair the user did not write. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
53695f9434
commit
1bdbc2106f
12 files changed
+1944
No files matched your search
@@ -0,0 +1,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";
|
||||
Reference in new issue
Block a user