feat(labs): add an AI assessment and stat strip to the biomarker detail page

Give each biomarker a detail page that matches the metric sub-pages: a
numbers-first Min/Max/Median/Mean strip over the numeric readings and a
data-grounded AI assessment card.

The assessment is served by a new read-only endpoint
GET /api/insights/biomarker-assessment?biomarkerId=&locale=, returning the
same envelope as /api/insights/metric-status so InsightStatusCard consumes
it unchanged. A new generator reads the marker's LabResult history and
reuses the shared provider runner, the stale-while-revalidate read-only
path, and the persist helper. Regeneration is gated on a fingerprint of the
latest reading, so the assessment is rebuilt only when a new reading lands;
an idle marker re-stamps the cached text without an LLM call. The
biomarker: scope rides the existing on-demand status queue.

The stat strip and the assessment card both self-gate on numeric data, so a
qualitative-only or brand-new marker paints neither.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 13:43:14 +02:00
1 parent 02d527720a
commit 0790a96ca4
10 files changed
+779 -2

No files matched your search

@@ -0,0 +1,72 @@
/**
* Per-biomarker assessment route.
*
* `GET /api/insights/biomarker-assessment?biomarkerId=<id>` serves the
* data-driven assessment for one user-scoped biomarker. The shape is
* byte-identical to `/api/insights/metric-status` so `InsightStatusCard`
* consumes it unchanged.
*
* Read-only by construction: a cache miss enqueues an out-of-band
* generation (via `resolveReadOnlyStatusMiss` inside the generator) and
* serves the last-good text (stale-while-revalidate), never blocking on the
* provider. The generator regenerates ONLY when the latest reading
* fingerprint changes, so an idle marker re-stamps cached text without an
* LLM round-trip.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { generateBiomarkerStatus } from "@/lib/insights/biomarker-status";
import { resolveMetricStatusLocale } from "@/lib/insights/metric-status";
export const dynamic = "force-dynamic";
const querySchema = z.object({
biomarkerId: z.string().min(1).max(64),
});
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
const parsed = querySchema.safeParse({
biomarkerId: request.nextUrl.searchParams.get("biomarkerId"),
});
if (!parsed.success) {
annotate({
action: { name: "insights.biomarker-status.invalid" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422);
}
const localeParam = request.nextUrl.searchParams.get("locale");
const resolved = await resolveServerLocale({
request,
userLocale: user.locale ?? null,
override: localeParam,
});
const locale = resolveMetricStatusLocale(resolved);
const result = await generateBiomarkerStatus({
biomarkerId: parsed.data.biomarkerId,
userId: user.id,
locale,
force: false,
readOnly: true,
});
annotate({
action: { name: "insights.biomarker-status" },
meta: { biomarkerId: parsed.data.biomarkerId },
});
return apiSuccess(result);
});