Merge branch 'feat/biomarker-detail-page' into release/v1.25.0

This commit is contained in:
Marc-André Bombeck committed 2026-06-28 14:42:04 +02:00
commit 95ee088646
36 files changed
+2209 -147

No files matched your search

@@ -0,0 +1,249 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
biomarker: { findFirst: vi.fn() },
labResult: { findMany: vi.fn(), count: vi.fn() },
auditLog: { findFirst: vi.fn(), create: vi.fn() },
},
}));
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
// Consent never blocks in these fixtures — the gate has its own tests.
statusConsentBlocksGeneration: vi.fn(async () => false),
}));
import { prisma } from "@/lib/db";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import { PROMPT_VERSION } from "@/lib/ai/prompts/base-system";
import { toBerlinDayKey } from "@/lib/tz/resolver";
import {
generateBiomarkerStatus,
biomarkerStatusScope,
} from "../biomarker-status";
const MARKER = {
id: "bm-1",
name: "LDL Cholesterol",
unit: "mg/dL",
lowerBound: 0,
upperBound: 100,
};
const TAKEN_AT = new Date("2026-06-20T08:00:00.000Z");
function stubCompletion(content: string, capture?: { userPrompt: string }) {
vi.mocked(runStatusCompletion).mockImplementation(
async (args: { userPrompt: string }) => {
if (capture) capture.userPrompt = args.userPrompt;
return {
kind: "ok",
content,
providerType: "anthropic",
model: "x",
tokensUsed: 1,
} as never;
},
);
}
/** The fingerprint the generator computes for a single-reading marker. */
function inputHashFor(reading: {
id: string;
value: number;
takenAt: Date;
}): string {
return hashInsightSnapshot({
scope: biomarkerStatusScope(MARKER.id),
locale: "en",
promptVersion: PROMPT_VERSION,
count: 1,
bounds: { lower: MARKER.lowerBound, upper: MARKER.upperBound },
latest: {
id: reading.id,
takenAt: reading.takenAt.toISOString(),
value: reading.value,
},
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(MARKER as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date("2026-06-20T09:00:00.000Z"),
} as never);
});
describe("generateBiomarkerStatus — empty-data guard", () => {
it("returns insufficient WITHOUT calling the provider for a marker with no numeric readings", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
const result = await generateBiomarkerStatus({
biomarkerId: MARKER.id,
userId: "u1",
locale: "en",
readOnly: true,
});
expect(result.insufficient).toBe(true);
expect(result.text).toBeNull();
expect(runStatusCompletion).not.toHaveBeenCalled();
expect(prisma.labResult.count).not.toHaveBeenCalled();
});
it("returns insufficient for an unknown / cross-user marker", async () => {
vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(null as never);
const result = await generateBiomarkerStatus({
biomarkerId: "nope",
userId: "u1",
locale: "en",
readOnly: true,
});
expect(result.insufficient).toBe(true);
expect(runStatusCompletion).not.toHaveBeenCalled();
});
});
describe("generateBiomarkerStatus — cache read", () => {
it("serves today's cached text without calling the provider", async () => {
const todayKey = toBerlinDayKey(new Date());
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: new Date(),
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Your LDL is steady.",
model: "x",
}),
} as never);
const result = await generateBiomarkerStatus({
biomarkerId: MARKER.id,
userId: "u1",
locale: "en",
});
expect(result.cached).toBe(true);
expect(result.text).toBe("Your LDL is steady.");
expect(runStatusCompletion).not.toHaveBeenCalled();
// Cache hit short-circuits before the reading read.
expect(prisma.labResult.findMany).not.toHaveBeenCalled();
});
});
describe("generateBiomarkerStatus — generation path", () => {
it("builds a snapshot, runs the completion, and persists with an inputHash", async () => {
const reading = { id: "r1", value: 95, takenAt: TAKEN_AT };
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([reading] as never);
vi.mocked(prisma.labResult.count).mockResolvedValue(1 as never);
const capture = { userPrompt: "" };
stubCompletion(
'{"summary":"Your LDL sits inside the reference range."}',
capture,
);
const result = await generateBiomarkerStatus({
biomarkerId: MARKER.id,
userId: "u1",
locale: "en",
});
expect(runStatusCompletion).toHaveBeenCalledTimes(1);
expect(result.text).toBe("Your LDL sits inside the reference range.");
expect(result.cached).toBe(false);
expect(result.hasProvider).toBe(true);
// The snapshot carries the marker metadata + the latest reading.
const match = capture.userPrompt.match(/\{[\s\S]*\}/);
const snapshot = JSON.parse(match![0]);
expect(snapshot.marker.name).toBe("LDL Cholesterol");
expect(snapshot.marker.unit).toBe("mg/dL");
expect(snapshot.latest.value).toBe(95);
// Persisted under the biomarker scope cache action, carrying the inputHash.
const createCall = vi
.mocked(prisma.auditLog.create)
.mock.calls.at(-1)![0] as {
data: { action: string; details: string };
};
expect(createCall.data.action).toBe("insights.biomarker:bm-1-status.en");
const persisted = JSON.parse(createCall.data.details);
expect(persisted.inputHash).toBe(inputHashFor(reading));
});
});
describe("generateBiomarkerStatus — input gate (regenerate only on a new reading)", () => {
it("re-stamps the cached text WITHOUT calling the provider when the latest reading is unchanged", async () => {
const reading = { id: "r1", value: 95, takenAt: TAKEN_AT };
// A prior assessment from an earlier day whose inputHash matches the
// current latest reading — readFreshStatusText misses on the day key, but
// the input gate matches the fingerprint.
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: new Date(),
details: JSON.stringify({
dateKey: "2000-01-01",
locale: "en",
text: "Stable LDL from a previous reading.",
model: "x",
inputHash: inputHashFor(reading),
}),
} as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([reading] as never);
vi.mocked(prisma.labResult.count).mockResolvedValue(1 as never);
const result = await generateBiomarkerStatus({
biomarkerId: MARKER.id,
userId: "u1",
locale: "en",
});
expect(runStatusCompletion).not.toHaveBeenCalled();
expect(result.cached).toBe(true);
expect(result.text).toBe("Stable LDL from a previous reading.");
});
it("regenerates when a new reading lands (inputHash differs)", async () => {
const oldReading = { id: "r1", value: 95, takenAt: TAKEN_AT };
const newReading = {
id: "r2",
value: 130,
takenAt: new Date("2026-06-25T08:00:00.000Z"),
};
// The cached row's inputHash is for the OLD reading; the live latest is the
// new reading, so the fingerprint differs and the gate misses.
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: new Date(),
details: JSON.stringify({
dateKey: "2000-01-01",
locale: "en",
text: "Stale assessment.",
model: "x",
inputHash: inputHashFor(oldReading),
}),
} as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([
newReading,
] as never);
vi.mocked(prisma.labResult.count).mockResolvedValue(1 as never);
stubCompletion('{"summary":"LDL rose above the reference range."}');
const result = await generateBiomarkerStatus({
biomarkerId: MARKER.id,
userId: "u1",
locale: "en",
});
expect(runStatusCompletion).toHaveBeenCalledTimes(1);
expect(result.text).toBe("LDL rose above the reference range.");
expect(result.cached).toBe(false);
});
});
+362
View File
@@ -0,0 +1,362 @@
/**
* 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 — so the assessment regenerates ONLY on a new
// reading.
//
// The gate runs with `force: false` even on the worker's forced path: the
// outer `force` exists to bypass the SAME-DAY text cache (so a fresh day
// re-evaluates the fingerprint), not to burn an LLM call on an unchanged
// marker. The worker is re-enqueued whenever today's text cache misses, so
// letting the input gate run keeps an idle marker to one cheap re-stamp per
// day instead of a daily 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: false,
});
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 };
}
+31 -5
View File
@@ -67,6 +67,12 @@ interface CoachLaunchValue {
open: boolean;
/** Current prefill string (or null when the next open should start blank). */
prefill: string | null;
/**
* When true, the opened conversation auto-sends the prefill as its first
* turn (exactly once), so a card's "ask about this" hand-off lands the
* answer directly instead of only seeding the composer. Cleared on close.
*/
autoSend: boolean;
/**
* Scope the next/open conversation is narrowed to (or null for the
* default all-source snapshot). Set by `askCoach(prefill, scope)`, or
@@ -74,8 +80,16 @@ interface CoachLaunchValue {
* explicit one. `<LayoutCoachMount>` forwards it to the drawer.
*/
scope: CoachLaunchScope | null;
/** Open the drawer with an optional prefill + scope hint. */
askCoach: (prefill?: string | null, scope?: CoachLaunchScope) => void;
/**
* Open the drawer with an optional prefill + scope hint. When `autoSend`
* is true the prefill is dispatched as the conversation's first turn
* automatically (used by the assessment hand-off).
*/
askCoach: (
prefill?: string | null,
scope?: CoachLaunchScope,
autoSend?: boolean,
) => void;
/**
* v1.21.0 (C4 H1) — register the metric surface the user is currently
* looking at so the global FAB (which calls `askCoach()` with no args)
@@ -110,6 +124,8 @@ export interface CoachLaunchProviderProps {
export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
const [open, setOpen] = useState<boolean>(false);
const [prefill, setPrefill] = useState<string | null>(null);
// Whether the next open auto-sends its prefill as the first turn.
const [autoSend, setAutoSend] = useState<boolean>(false);
// Scope the open conversation is narrowed to. `null` → default snapshot.
const [scope, setScope] = useState<CoachLaunchScope | null>(null);
// Ambient scope + seed opener of the metric surface currently on screen.
@@ -121,7 +137,11 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
const ambientPrefillRef = useRef<string | null>(null);
const askCoach = useCallback(
(nextPrefill?: string | null, nextScope?: CoachLaunchScope) => {
(
nextPrefill?: string | null,
nextScope?: CoachLaunchScope,
nextAutoSend?: boolean,
) => {
// v1.21.0 (C4 H1/H4) — scope is live. An explicit scope (insight
// card, metric-card affordance) wins; otherwise inherit the metric
// page's ambient scope so the FAB opens contextual to where the user
@@ -135,6 +155,9 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
(usingAmbientScope ? ambientPrefillRef.current : null) ??
null,
);
// Auto-send only applies when an explicit prefill is given (a card
// hand-off), never for an ambient/blank open.
setAutoSend(Boolean(nextAutoSend) && Boolean(nextPrefill));
setOpen(true);
},
[],
@@ -160,9 +183,11 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
const handleSetOpen = useCallback((next: boolean) => {
setOpen(next);
if (!next) {
// Drop the prefill + scope on close so the next open starts clean.
// Drop the prefill + scope + auto-send on close so the next open
// starts clean.
setPrefill(null);
setScope(null);
setAutoSend(false);
}
}, []);
@@ -170,12 +195,13 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
() => ({
open,
prefill,
autoSend,
scope,
askCoach,
registerScope,
setOpen: handleSetOpen,
}),
[open, prefill, scope, askCoach, registerScope, handleSetOpen],
[open, prefill, autoSend, scope, askCoach, registerScope, handleSetOpen],
);
return (
+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