mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
Merge branch 'feat/biomarker-detail-page' into release/v1.25.0
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user