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

+102
View File
@@ -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:
@@ -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);
});
+64 -1
View File
@@ -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<DataSummary | null>(
() =>
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. */}
<MetricStatStrip
summary={summary}
unit={marker?.unit ?? latest?.unit ?? ""}
seriesLabel={marker?.name}
icon={FlaskConical}
/>
<Card>
<CardContent className="pt-6">
{isLoading ? (
@@ -340,6 +381,28 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
</div>
) : 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 ? (
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<FlaskConical className="h-5 w-5" />}
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}
<ResponsiveSheet
open={addOpen}
onOpenChange={setAddOpen}
+54
View File
@@ -241,3 +241,57 @@ export function useInsightMetricStatus(
),
});
}
/**
* Per-biomarker assessment loader for the lab-marker detail page. Mirrors
* `useInsightMetricStatus` exactly — the same `InsightStatusData` envelope,
* the 8 s client ceiling, the no-retry policy, and the bounded `preparing`
* poll — but keyed by the marker id against
* `GET /api/insights/biomarker-assessment?biomarkerId=…`.
*
* Read-only + stale-while-revalidate: the route serves cached text and warms
* a regeneration out of band on a cache miss, exactly like every metric
* page. Generation never warms on mount beyond that shared seam; the worker
* cron + the read-only enqueue are the only producers.
*
* `enabled` lets the page suppress the fetch when the marker has no readings
* (the page renders its empty state instead of the card), so a brand-new
* marker never fires an assessment round-trip.
*/
export function useInsightBiomarkerAssessment(
biomarkerId: string,
enabled = true,
) {
const { isAuthenticated } = useAuth();
const { locale } = useTranslations();
return useQuery({
queryKey: queryKeys.insightsBiomarkerAssessment(biomarkerId, locale),
queryFn: async (): Promise<InsightStatusData> => {
const controller = new AbortController();
const timeoutHandle = setTimeout(
() => controller.abort(),
STATUS_TIMEOUT_MS,
);
try {
return await apiGet<InsightStatusData>(
`/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,
),
});
}
+354
View File
@@ -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:<id>`, 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:<id>` 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<BiomarkerStatusResult> {
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 };
}
+10 -1
View File
@@ -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:<id>`; 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;
+22
View File
@@ -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:<id>` 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:<ID>` 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
+27
View File
@@ -9,6 +9,8 @@ import {
insightsComprehensiveResponse,
metricStatusQuery,
metricStatusResponse,
biomarkerAssessmentQuery,
biomarkerAssessmentResponse,
derivedMetricQuery,
derivedMetricResponse,
derivedBatchQuery,
@@ -300,6 +302,31 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/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"],
@@ -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.
+10
View File
@@ -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