feat(labs): add an AI assessment and stat strip to the biomarker detail page

Give each biomarker a detail page that matches the metric sub-pages: a
numbers-first Min/Max/Median/Mean strip over the numeric readings and a
data-grounded AI assessment card.

The assessment is served by a new read-only endpoint
GET /api/insights/biomarker-assessment?biomarkerId=&locale=, returning the
same envelope as /api/insights/metric-status so InsightStatusCard consumes
it unchanged. A new generator reads the marker's LabResult history and
reuses the shared provider runner, the stale-while-revalidate read-only
path, and the persist helper. Regeneration is gated on a fingerprint of the
latest reading, so the assessment is rebuilt only when a new reading lands;
an idle marker re-stamps the cached text without an LLM call. The
biomarker: scope rides the existing on-demand status queue.

The stat strip and the assessment card both self-gate on numeric data, so a
qualitative-only or brand-new marker paints neither.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 13:43:14 +02:00
1 parent 02d527720a
commit 0790a96ca4
10 files changed
+779 -2

No files matched your search

+64 -1
View File
@@ -14,7 +14,12 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { useInsightBiomarkerAssessment } from "@/hooks/use-insight-status";
import { useMounted } from "@/hooks/use-mounted";
import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch";
import { summarize, type DataSummary } from "@/lib/analytics/trends";
import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog";
import { classifyReferenceRange } from "@/lib/labs/reference-range";
import { formatLabValue } from "@/lib/labs/format-value";
@@ -117,7 +122,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
),
});
const readings: LabResultDto[] = list?.results ?? [];
const readings: LabResultDto[] = useMemo(() => list?.results ?? [], [list]);
// The reading feed caps at 500 server-side; surface a hint when truncated.
const total = list?.meta?.total ?? 0;
const truncated = total > readings.length;
@@ -129,6 +134,32 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
)[0]
: null;
// v1.24 — the numbers-first stat strip + the AI assessment card mirror the
// metric sub-pages. The strip reads a client-side `DataSummary` over the
// numeric readings; the assessment card consumes the read-only biomarker
// route (stale-while-revalidate, regenerated only on a new reading). The
// strip + card both self-gate on data, so a qualitative-only or brand-new
// marker paints neither.
const numericReadings = useMemo(
() => readings.filter((r) => r.value !== null),
[readings],
);
const summary = useMemo<DataSummary | null>(
() =>
numericReadings.length > 0
? summarize(
numericReadings.map((r) => ({
date: new Date(r.takenAt),
value: r.value as number,
})),
)
: null,
[numericReadings],
);
const mounted = useMounted();
const { data: assessment, isLoading: assessmentLoading } =
useInsightBiomarkerAssessment(biomarkerId, numericReadings.length > 0);
function afterAdd() {
setAddOpen(false);
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
@@ -295,6 +326,16 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
) : null;
})()}
{/* Numbers-first stat strip — Min / Max / Median / Mean over the
numeric readings, mirroring the metric sub-pages. Self-gating:
renders nothing for a qualitative-only or empty marker. */}
<MetricStatStrip
summary={summary}
unit={marker?.unit ?? latest?.unit ?? ""}
seriesLabel={marker?.name}
icon={FlaskConical}
/>
<Card>
<CardContent className="pt-6">
{isLoading ? (
@@ -340,6 +381,28 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
</div>
) : null}
{/* AI assessment — the spine's closing block, mirroring the metric
sub-pages (intro → stat strip → chart → history → assessment). The
card self-suppresses when the operator disabled status cards, and
the hook is gated on the marker having numeric readings. */}
{numericReadings.length > 0 ? (
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<FlaskConical className="h-5 w-5" />}
text={assessment?.text ?? null}
hasProvider={assessment?.hasProvider ?? false}
updatedAt={assessment?.updatedAt ?? null}
coachQuestion={
marker?.name
? t("insights.coach.assessmentPrompt", { metric: marker.name })
: undefined
}
coachAutoSend
loading={!mounted || assessmentLoading}
preparing={assessment?.preparing ?? false}
/>
) : null}
<ResponsiveSheet
open={addOpen}
onOpenChange={setAddOpen}