From 0790a96ca4be3e24bcd9a79d2f3ede61fea4b457 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 13:43:14 +0200 Subject: [PATCH] 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. --- docs/api/openapi.yaml | 102 +++++ .../insights/biomarker-assessment/route.ts | 72 ++++ src/components/labs/lab-biomarker-detail.tsx | 65 +++- src/hooks/use-insight-status.ts | 54 +++ src/lib/insights/biomarker-status.ts | 354 ++++++++++++++++++ .../jobs/insight-status-generate-shared.ts | 11 +- src/lib/jobs/insight-status-generate.ts | 22 ++ src/lib/openapi/routes/insights/paths.ts | 27 ++ src/lib/openapi/routes/insights/schemas.ts | 64 ++++ src/lib/query-keys/insights.ts | 10 + 10 files changed, 779 insertions(+), 2 deletions(-) create mode 100644 src/app/api/insights/biomarker-assessment/route.ts create mode 100644 src/lib/insights/biomarker-status.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index afefa5a85..37c195fc2 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -6052,6 +6052,47 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/insights/biomarker-assessment: + get: + tags: + - Insights + summary: Per-biomarker assessment + description: "Data-driven plain-language assessment for one user-scoped lab biomarker, reading its `LabResult` history. + Identical envelope to the metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only: a + cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate); the + assessment regenerates only when a new reading lands. A marker with no numeric readings returns `insufficient` + without an LLM call. Auth via cookie or Bearer." + parameters: + - in: query + name: biomarkerId + schema: + type: string + minLength: 1 + maxLength: 64 + description: User-scoped biomarker id to assess. A cross-user or unknown id returns an `insufficient` envelope, not a + 404 (existence sealed). + required: true + description: User-scoped biomarker id to assess. A cross-user or unknown id returns an `insufficient` envelope, not a + 404 (existence sealed). + - in: query + name: locale + schema: + description: Optional UI-locale override; defaults to the session locale. + type: string + enum: + - de + - en + description: Optional UI-locale override; defaults to the session locale. + responses: + "200": + description: Assessment envelope (fresh, cached, or preparing). + content: + application/json: + schema: + $ref: "#/components/schemas/BiomarkerAssessmentResponseEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 /api/insights/derived: get: tags: @@ -20229,6 +20270,67 @@ components: description: "Generic per-metric assessment envelope. Identical shape to the seven specialised `*-status` cards so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile." + BiomarkerAssessmentResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/BiomarkerAssessmentResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + BiomarkerAssessmentResponse: + type: object + properties: + hasProvider: + type: boolean + description: False when the user has no usable AI provider — `text` then carries the generic no-key guidance. + text: + anyOf: + - type: string + - type: "null" + description: The assessment narrative (plain text, rendered as React text children). Null while a first generation is + preparing, or when the marker has no numeric readings. + cached: + type: boolean + description: True when `text` is served from cache (incl. last-good). + updatedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + description: When the served assessment was generated; null when none. + preparing: + description: True when a first assessment is being generated out of band and no prior text exists yet — the client polls + until it lands. + type: boolean + revalidating: + description: True when `text` is served from last-good cache (stale-while-revalidate) while a fresh generation is in + flight. The client keeps polling on `preparing || revalidating` (bounded) so the open card upgrades to the + warmed assessment without a remount. + type: boolean + insufficient: + description: True when the marker has no numeric readings; no assessment is generated (no LLM call). The card is not + rendered. + type: boolean + required: + - hasProvider + - text + - cached + - updatedAt + additionalProperties: false + description: "Per-biomarker assessment envelope. Identical shape to the generic metric-status card so the + `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation + out of band and serves the last-good text meanwhile; the assessment regenerates only when a new reading lands." DerivedMetricResponseEnvelope: type: object properties: diff --git a/src/app/api/insights/biomarker-assessment/route.ts b/src/app/api/insights/biomarker-assessment/route.ts new file mode 100644 index 000000000..1bc0bd5af --- /dev/null +++ b/src/app/api/insights/biomarker-assessment/route.ts @@ -0,0 +1,72 @@ +/** + * Per-biomarker assessment route. + * + * `GET /api/insights/biomarker-assessment?biomarkerId=` 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); +}); diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index 06994ed7d..f4ddab48a 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -14,7 +14,12 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Skeleton } from "@/components/ui/skeleton"; +import { InsightStatusCard } from "@/components/insights/insight-status-card"; +import { MetricStatStrip } from "@/components/insights/metric-stat-strip"; +import { useInsightBiomarkerAssessment } from "@/hooks/use-insight-status"; +import { useMounted } from "@/hooks/use-mounted"; import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch"; +import { summarize, type DataSummary } from "@/lib/analytics/trends"; import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog"; import { classifyReferenceRange } from "@/lib/labs/reference-range"; import { formatLabValue } from "@/lib/labs/format-value"; @@ -117,7 +122,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { ), }); - const readings: LabResultDto[] = list?.results ?? []; + const readings: LabResultDto[] = useMemo(() => list?.results ?? [], [list]); // The reading feed caps at 500 server-side; surface a hint when truncated. const total = list?.meta?.total ?? 0; const truncated = total > readings.length; @@ -129,6 +134,32 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { )[0] : null; + // v1.24 — the numbers-first stat strip + the AI assessment card mirror the + // metric sub-pages. The strip reads a client-side `DataSummary` over the + // numeric readings; the assessment card consumes the read-only biomarker + // route (stale-while-revalidate, regenerated only on a new reading). The + // strip + card both self-gate on data, so a qualitative-only or brand-new + // marker paints neither. + const numericReadings = useMemo( + () => readings.filter((r) => r.value !== null), + [readings], + ); + const summary = useMemo( + () => + numericReadings.length > 0 + ? summarize( + numericReadings.map((r) => ({ + date: new Date(r.takenAt), + value: r.value as number, + })), + ) + : null, + [numericReadings], + ); + const mounted = useMounted(); + const { data: assessment, isLoading: assessmentLoading } = + useInsightBiomarkerAssessment(biomarkerId, numericReadings.length > 0); + function afterAdd() { setAddOpen(false); queryClient.invalidateQueries({ queryKey: queryKeys.labResults() }); @@ -295,6 +326,16 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { ) : null; })()} + {/* Numbers-first stat strip — Min / Max / Median / Mean over the + numeric readings, mirroring the metric sub-pages. Self-gating: + renders nothing for a qualitative-only or empty marker. */} + + {isLoading ? ( @@ -340,6 +381,28 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { ) : null} + {/* AI assessment — the spine's closing block, mirroring the metric + sub-pages (intro → stat strip → chart → history → assessment). The + card self-suppresses when the operator disabled status cards, and + the hook is gated on the marker having numeric readings. */} + {numericReadings.length > 0 ? ( + } + text={assessment?.text ?? null} + hasProvider={assessment?.hasProvider ?? false} + updatedAt={assessment?.updatedAt ?? null} + coachQuestion={ + marker?.name + ? t("insights.coach.assessmentPrompt", { metric: marker.name }) + : undefined + } + coachAutoSend + loading={!mounted || assessmentLoading} + preparing={assessment?.preparing ?? false} + /> + ) : null} + => { + const controller = new AbortController(); + const timeoutHandle = setTimeout( + () => controller.abort(), + STATUS_TIMEOUT_MS, + ); + try { + return await apiGet( + `/api/insights/biomarker-assessment?biomarkerId=${encodeURIComponent( + biomarkerId, + )}&locale=${locale}`, + { signal: controller.signal }, + ); + } finally { + clearTimeout(timeoutHandle); + } + }, + enabled: isAuthenticated && enabled && biomarkerId.length > 0, + staleTime: 60 * 1000, + retry: 0, + refetchInterval: (query) => + nextStatusPollInterval( + query.state.data?.preparing, + query.state.dataUpdateCount, + query.state.data?.revalidating, + ), + }); +} diff --git a/src/lib/insights/biomarker-status.ts b/src/lib/insights/biomarker-status.ts new file mode 100644 index 000000000..e39e547ed --- /dev/null +++ b/src/lib/insights/biomarker-status.ts @@ -0,0 +1,354 @@ +/** + * Per-biomarker assessment generator. + * + * Mirrors `generateMetricStatus` (the generic HealthKit-metric generator) + * but reads `LabResult` rows for one user-scoped `Biomarker` instead of + * `Measurement` rows for a `MeasurementType`. It reuses the exact same + * machinery the metric cards use: + * - the shared provider runner (`runStatusCompletion`), + * - the stale-while-revalidate read-only path (`resolveReadOnlyStatusMiss`), + * - the standard cache-row persist (`persistStatusInsight`), + * - the cheap INPUT gate (`gateUnchangedStatusInput`) keyed on the latest + * reading fingerprint so a fresh assessment is produced ONLY when a new + * reading lands — an idle day re-stamps the cached text without an LLM + * round-trip. + * + * The return shape is byte-identical to the metric generator so + * `InsightStatusCard` consumes it unchanged. The cache scope is + * `biomarker:`, which the on-demand queue worker routes back here. + * + * Empty-data guard: a marker with no numeric readings returns an + * `insufficient` marker WITHOUT touching the provider chain. + */ +import { prisma } from "@/lib/db"; +import { PROMPT_VERSION } from "@/lib/ai/prompts/base-system"; +import { classifyReferenceRange } from "@/lib/labs/reference-range"; +import { getNoKeyGeneralStatusText } from "@/lib/insights/no-key-fallbacks"; +import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; +import { runStatusCompletion } from "@/lib/insights/status-provider"; +import { + type SupportedLocale, + normalizeLocale, + normalizeSummaryText, + parseSummaryFromContent, + persistStatusInsight, + summarizeSeries, +} from "@/lib/insights/status-shared"; +import { + gateUnchangedStatusInput, + readFreshStatusText, + resolveReadOnlyStatusMiss, + statusCacheAction, +} from "@/lib/insights/status-cache"; +import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; +import { annotate } from "@/lib/logging/context"; +import { toBerlinDayKey } from "@/lib/tz/resolver"; + +export interface BiomarkerStatusResult { + hasProvider: boolean; + text: string | null; + cached: boolean; + updatedAt: string | null; + preparing?: boolean; + /** True when last-good text is served while a fresh generation runs. */ + revalidating?: boolean; + /** True when the marker has no numeric readings; no LLM was called. */ + insufficient?: boolean; +} + +/** The `biomarker:` cache/queue scope for one marker. */ +export function biomarkerStatusScope( + biomarkerId: string, +): `biomarker:${string}` { + return `biomarker:${biomarkerId}`; +} + +/** Cap on the recent reading rows folded into the prompt snapshot. */ +const SNAPSHOT_READING_CAP = 60; + +interface BiomarkerReading { + id: string; + value: number; + takenAt: Date; +} + +function insufficient(): BiomarkerStatusResult { + return { + hasProvider: true, + text: null, + cached: false, + updatedAt: null, + insufficient: true, + }; +} + +function getSystemPrompt(locale: SupportedLocale, markerName: string): string { + if (locale === "de") { + return [ + `Du bist ein vorsichtiger, faktenbasierter Gesundheitsbegleiter und beurteilst den Laborwert „${markerName}" einer Person.`, + "Beziehe dich AUSSCHLIESSLICH auf die im Snapshot gelieferten Zahlen. Erfinde keine Werte, keine Diagnosen und keine Medikamente.", + "Schreibe 2 bis 4 ruhige Sätze in klarer Alltagssprache: nenne den aktuellen Wert und seinen Bezug zum Referenzbereich, ordne die Tendenz über die letzten Messungen ein und gib höchstens einen sachlichen Hinweis.", + "Keine Panikmache, keine Marketing-Sprache, keine Befehle. Bei auffälligen Werten verweise neutral auf eine ärztliche Einordnung.", + 'Antworte als JSON-Objekt der Form { "summary": "…" } ohne weiteren Text.', + ].join(" "); + } + return [ + `You are a careful, fact-based health companion assessing a person's lab marker "${markerName}".`, + "Use ONLY the figures provided in the snapshot. Do not invent values, diagnoses, or medications.", + "Write 2 to 4 calm sentences in plain language: state the current value and how it sits against the reference range, place the trend across recent readings, and add at most one factual pointer.", + "No alarmism, no marketing tone, no commands. For notably out-of-range values, refer neutrally to a clinician for interpretation.", + 'Respond as a JSON object of the form { "summary": "…" } with no other text.', + ].join(" "); +} + +function getUserPrompt( + snapshotJson: string, + todayKey: string, + locale: SupportedLocale, +): string { + return locale === "de" + ? `Heutiges Datum: ${todayKey}. Beurteile den folgenden Laborwert-Snapshot:\n${snapshotJson}` + : `Today's date: ${todayKey}. Assess the following lab-marker snapshot:\n${snapshotJson}`; +} + +/** + * Generate (or read from cache) the assessment for one biomarker. Mirrors + * the metric generator's contract: + * - cache hit (today, non-stub) → serve it, + * - `readOnly` miss → enqueue out-of-band + serve last-good, + * - no numeric readings → `insufficient`, no LLM call, + * - unchanged latest reading → re-stamp cached text, no LLM call, + * - otherwise → build snapshot, run completion, persist. + */ +export async function generateBiomarkerStatus(args: { + biomarkerId: string; + userId: string; + locale?: string | null; + force?: boolean; + /** Read-only navigation path — never blocks on the provider. */ + readOnly?: boolean; +}): Promise { + const marker = await prisma.biomarker.findFirst({ + where: { id: args.biomarkerId, userId: args.userId }, + select: { + id: true, + name: true, + unit: true, + lowerBound: true, + upperBound: true, + }, + }); + if (!marker) { + // Unknown / cross-user marker — degrade gracefully rather than throwing. + return insufficient(); + } + + const locale = normalizeLocale(args.locale); + const force = args.force === true; + const readOnly = args.readOnly === true; + const scope = biomarkerStatusScope(marker.id); + const cacheAction = statusCacheAction(scope, locale); + const todayKey = toBerlinDayKey(new Date()); + + const cached = await readFreshStatusText({ + userId: args.userId, + cacheAction, + todayKey, + force, + }); + if (cached) { + return { + hasProvider: true, + text: cached.text, + cached: true, + updatedAt: cached.updatedAt, + }; + } + + // Numeric readings only — a qualitative-only marker carries no range verdict + // or trend, so there is nothing to assess. Done BEFORE any enqueue so an + // empty marker never queues an LLM job nor blocks on the provider. + const rows = await prisma.labResult.findMany({ + where: { + userId: args.userId, + biomarkerId: marker.id, + deletedAt: null, + value: { not: null }, + }, + orderBy: { takenAt: "desc" }, + take: SNAPSHOT_READING_CAP, + select: { id: true, value: true, takenAt: true }, + }); + if (rows.length === 0) { + annotate({ + action: { name: "insights.biomarker-status.insufficient" }, + meta: { biomarkerId: marker.id }, + }); + return insufficient(); + } + + const readings: BiomarkerReading[] = rows.map((r) => ({ + id: r.id, + value: r.value as number, + takenAt: r.takenAt, + })); + const latest = readings[0]; + const totalCount = await prisma.labResult.count({ + where: { + userId: args.userId, + biomarkerId: marker.id, + deletedAt: null, + value: { not: null }, + }, + }); + + if (readOnly) { + const outcome = await resolveReadOnlyStatusMiss({ + userId: args.userId, + metric: scope, + locale, + }); + if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { + return { + hasProvider: false, + text: getNoKeyGeneralStatusText(locale), + cached: true, + updatedAt: null, + }; + } + return { + hasProvider: true, + text: outcome.lastGood?.text ?? null, + cached: outcome.lastGood !== null, + updatedAt: outcome.lastGood?.updatedAt ?? null, + preparing: outcome.lastGood === null, + revalidating: outcome.revalidating, + }; + } + + // INPUT gate: fingerprint the latest reading + count + bounds + locale + + // prompt version. When the fingerprint matches the cached assessment's + // stored `inputHash`, nothing the prompt could see has changed since the + // last reading, so re-stamp that text under today's day key and skip the + // LLM entirely. A new reading flips the latest id / value / count and + // forces a real regeneration. + const inputHash = hashInsightSnapshot({ + scope, + locale, + promptVersion: PROMPT_VERSION, + count: totalCount, + bounds: { lower: marker.lowerBound, upper: marker.upperBound }, + latest: { + id: latest.id, + takenAt: latest.takenAt.toISOString(), + value: latest.value, + }, + }); + const unchanged = await gateUnchangedStatusInput({ + userId: args.userId, + cacheAction, + todayKey, + inputHash, + force, + }); + if (unchanged) { + return { + hasProvider: true, + text: unchanged.text, + cached: true, + updatedAt: unchanged.updatedAt, + }; + } + + // Ascending series (oldest → newest) for the trend summary the prompt reads. + const ascending = [...readings].reverse(); + const summary = summarizeSeries(ascending.map((r) => ({ value: r.value }))); + const latestStatus = classifyReferenceRange( + latest.value, + marker.lowerBound, + marker.upperBound, + ); + + const snapshot = { + locale, + promptVersion: PROMPT_VERSION, + generatedForDay: todayKey, + marker: { + name: marker.name, + unit: marker.unit, + referenceRange: + marker.lowerBound != null || marker.upperBound != null + ? { lower: marker.lowerBound, upper: marker.upperBound } + : null, + }, + dataCoverage: { + totalReadings: totalCount, + readingsInSnapshot: readings.length, + newestTakenAt: latest.takenAt.toISOString(), + oldestInSnapshot: ascending[0].takenAt.toISOString(), + }, + latest: { + value: latest.value, + takenAt: latest.takenAt.toISOString(), + rangeStatus: latestStatus, + }, + summary, + series: ascending.map((r) => ({ + day: toBerlinDayKey(r.takenAt), + value: r.value, + })), + }; + const snapshotJson = JSON.stringify(snapshot, null, 2); + + annotate({ + action: { name: cacheAction }, + meta: { payload_size_bytes: snapshotJson.length }, + }); + + const outcome = await runStatusCompletion({ + userId: args.userId, + cacheAction, + consentSurface: "insights", + systemPrompt: getSystemPrompt(locale, marker.name), + userPrompt: getUserPrompt(snapshotJson, todayKey, locale), + temperature: 0.45, + maxTokens: 1000, + }); + + if (outcome.kind === "none") { + return { + hasProvider: false, + text: getNoKeyGeneralStatusText(locale), + cached: true, + updatedAt: null, + }; + } + if (outcome.kind === "timeout" || outcome.kind === "error") { + return returnTimeoutFallback({ + cacheAction, + reason: outcome.kind, + userId: args.userId, + todayKey, + stubText: getNoKeyGeneralStatusText(locale), + }); + } + + const text = normalizeSummaryText(parseSummaryFromContent(outcome.content)); + if (!text) { + throw new Error(`Biomarker-status summary was empty for ${marker.id}`); + } + + const updatedAt = await persistStatusInsight({ + userId: args.userId, + cacheAction, + todayKey, + locale, + text, + providerType: outcome.providerType, + model: outcome.model, + tokensUsed: outcome.tokensUsed, + inputHash, + }); + + return { hasProvider: true, text, cached: false, updatedAt }; +} diff --git a/src/lib/jobs/insight-status-generate-shared.ts b/src/lib/jobs/insight-status-generate-shared.ts index 0811e46d2..906847142 100644 --- a/src/lib/jobs/insight-status-generate-shared.ts +++ b/src/lib/jobs/insight-status-generate-shared.ts @@ -67,7 +67,16 @@ export type InsightStatusScope = * always fills the field synchronously — this queue only warms the AI prose * that overrides it on the next read. */ - | `derived-score:${string}`; + | `derived-score:${string}` + /** + * Per-biomarker assessment scopes ride the same on-demand queue. Their + * scope id is `biomarker:`; the worker routes the prefix to the + * biomarker generator, which reads `LabResult` rows for the marker and + * applies its own empty-data + input-hash gates, so a job enqueued for a + * marker with no new reading costs only a cheap fingerprint, not an LLM + * call. + */ + | `biomarker:${string}`; export interface InsightStatusGeneratePayload { userId: string; diff --git a/src/lib/jobs/insight-status-generate.ts b/src/lib/jobs/insight-status-generate.ts index 1ca8d2a28..4b96cd16d 100644 --- a/src/lib/jobs/insight-status-generate.ts +++ b/src/lib/jobs/insight-status-generate.ts @@ -35,6 +35,7 @@ import { generateBmiStatusForUser } from "@/lib/insights/bmi-status"; import { generateMoodStatusForUser } from "@/lib/insights/mood-status"; import { generateMedicationComplianceStatusForUser } from "@/lib/insights/medication-compliance-status"; import { generateMetricStatus } from "@/lib/insights/metric-status"; +import { generateBiomarkerStatus } from "@/lib/insights/biomarker-status"; import { isMetricStatusId } from "@/lib/insights/metric-status-registry"; import { prisma } from "@/lib/db"; import { @@ -115,6 +116,27 @@ export async function runInsightStatusGenerate( return; } + // A `biomarker:` scope routes to the per-biomarker generator, which + // reads `LabResult` rows for the marker. Its own empty-data + input-hash + // gates keep a job for an unchanged marker cheap (no LLM call). + if (payload.metric.startsWith("biomarker:")) { + const biomarkerId = payload.metric.slice("biomarker:".length); + if (!biomarkerId) { + annotate({ + action: { name: "insights.status.generate.unknown_metric" }, + meta: { metric: payload.metric }, + }); + return; + } + await generateBiomarkerStatus({ + biomarkerId, + userId: payload.userId, + locale: payload.locale, + force: true, + }); + return; + } + // v1.13.2 — a `derived-score:` scope warms the per-score assessment AI // prose. The route already filled the deterministic text synchronously; this // recomputes the derived value and persists the warmer prose so the next diff --git a/src/lib/openapi/routes/insights/paths.ts b/src/lib/openapi/routes/insights/paths.ts index a3826a351..59ef64879 100644 --- a/src/lib/openapi/routes/insights/paths.ts +++ b/src/lib/openapi/routes/insights/paths.ts @@ -9,6 +9,8 @@ import { insightsComprehensiveResponse, metricStatusQuery, metricStatusResponse, + biomarkerAssessmentQuery, + biomarkerAssessmentResponse, derivedMetricQuery, derivedMetricResponse, derivedBatchQuery, @@ -300,6 +302,31 @@ export const insightsPaths: NonNullable = { }, }, }, + "/api/insights/biomarker-assessment": { + get: { + tags: ["Insights"], + summary: "Per-biomarker assessment", + description: + "Data-driven plain-language assessment for one user-scoped lab biomarker, reading its `LabResult` history. Identical envelope to the metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate); the assessment regenerates only when a new reading lands. A marker with no numeric readings returns `insufficient` without an LLM call. Auth via cookie or Bearer.", + requestParams: { + query: biomarkerAssessmentQuery, + }, + responses: { + "200": { + description: "Assessment envelope (fresh, cached, or preparing).", + content: { + "application/json": { + schema: dataEnvelope( + biomarkerAssessmentResponse, + "BiomarkerAssessmentResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/insights/derived": { get: { tags: ["Insights"], diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 82971e74f..09f805707 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -473,6 +473,70 @@ export const insightStatusResponse = z "Specialised per-metric assessment envelope (blood-pressure, pulse, weight, bmi, mood). Identical shape to the generic metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile.", }); +// Per-biomarker assessment. The marker is identified by a user-scoped id +// (the generic metric-status route fixes its metric by a closed registry +// enum; biomarkers are user-defined, so the id is a free-form string). +export const biomarkerAssessmentQuery = z + .object({ + biomarkerId: z + .string() + .min(1) + .max(64) + .describe( + "User-scoped biomarker id to assess. A cross-user or unknown id returns an `insufficient` envelope, not a 404 (existence sealed).", + ), + locale: z + .enum(["de", "en"]) + .optional() + .describe("Optional UI-locale override; defaults to the session locale."), + }) + .meta({ id: "BiomarkerAssessmentQuery" }); + +export const biomarkerAssessmentResponse = z + .object({ + hasProvider: z + .boolean() + .describe( + "False when the user has no usable AI provider — `text` then carries the generic no-key guidance.", + ), + text: z + .string() + .nullable() + .describe( + "The assessment narrative (plain text, rendered as React text children). Null while a first generation is preparing, or when the marker has no numeric readings.", + ), + cached: z + .boolean() + .describe("True when `text` is served from cache (incl. last-good)."), + updatedAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe("When the served assessment was generated; null when none."), + preparing: z + .boolean() + .optional() + .describe( + "True when a first assessment is being generated out of band and no prior text exists yet — the client polls until it lands.", + ), + revalidating: z + .boolean() + .optional() + .describe( + "True when `text` is served from last-good cache (stale-while-revalidate) while a fresh generation is in flight. The client keeps polling on `preparing || revalidating` (bounded) so the open card upgrades to the warmed assessment without a remount.", + ), + insufficient: z + .boolean() + .optional() + .describe( + "True when the marker has no numeric readings; no assessment is generated (no LLM call). The card is not rendered.", + ), + }) + .meta({ + id: "BiomarkerAssessmentResponse", + description: + "Per-biomarker assessment envelope. Identical shape to the generic metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile; the assessment regenerates only when a new reading lands.", + }); + // The medication-compliance route carries a richer envelope than the // other six: a `summary` narrative plus a per-medication `text` array, // instead of a single `text` field. diff --git a/src/lib/query-keys/insights.ts b/src/lib/query-keys/insights.ts index 0a3607438..ff08733d1 100644 --- a/src/lib/query-keys/insights.ts +++ b/src/lib/query-keys/insights.ts @@ -43,6 +43,16 @@ export const insightsKeys = { */ insightsMetricStatus: (metric: string, locale: string) => ["insights", "metric-status", metric, locale] as const, + /** + * Per-biomarker assessment for the lab-marker detail page. One generic + * route (`/api/insights/biomarker-assessment?biomarkerId=…`) backs every + * marker, so the key is parameterised by the marker id alongside the + * locale. Same read-only + stale-while-revalidate contract as + * `insightsMetricStatus`; the `["insights"]` prefix keeps it in the + * standard invalidation fan-out. + */ + insightsBiomarkerAssessment: (biomarkerId: string, locale: string) => + ["insights", "biomarker-assessment", biomarkerId, locale] as const, /** * v1.8.7.1 — mutationKey for the on-demand full-warm POST * (`/api/insights/pregenerate`). Lives in the factory so the bare-array