mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
02d527720a
commit
0790a96ca4
10 files changed
+779
-2
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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