mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
refactor(insights): compose the biomarker card from the pipeline stages
This commit is contained in:
1 parent
1a99b8f4f0
commit
6a58a7df78
1 file changed
+118
-158
@@ -3,12 +3,12 @@
|
||||
*
|
||||
* 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
|
||||
* `Measurement` rows for a `MeasurementType`. It runs on the same status
|
||||
* pipeline stages (`status-pipeline.ts`) the metric cards use:
|
||||
* - the single-card provider run (`runPreparedStatusCard`),
|
||||
* - the stale-while-revalidate read-only path (`resolveReadOnlyStatus`),
|
||||
* - the standard note persist (`textStatusCodec`),
|
||||
* - the cheap INPUT gate (`gateUnchangedInputs`) 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.
|
||||
@@ -35,25 +35,25 @@ import {
|
||||
} from "@/lib/labs/reference-range";
|
||||
import { getNoKeyBiomarkerStatusText } from "@/lib/insights/no-key-fallbacks";
|
||||
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
|
||||
import { runStatusCompletion } from "@/lib/insights/status-provider";
|
||||
import { summarizeSeries } from "@/lib/insights/status-shared";
|
||||
import {
|
||||
normalizeLocale,
|
||||
finalizeStatusSummary,
|
||||
persistStatusInsight,
|
||||
summarizeSeries,
|
||||
} from "@/lib/insights/status-shared";
|
||||
runPreparedStatusCard,
|
||||
type PreparedStatusCard,
|
||||
} from "@/lib/insights/status-card-generation";
|
||||
import {
|
||||
gateUnchangedStatusInput,
|
||||
readFreshStatusText,
|
||||
resolveReadOnlyStatusMiss,
|
||||
statusCacheAction,
|
||||
} from "@/lib/insights/status-cache";
|
||||
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
|
||||
gateUnchangedInputs,
|
||||
openStatusCard,
|
||||
pendingStatusCard,
|
||||
readCachedStatus,
|
||||
resolveReadOnlyStatus,
|
||||
textStatusCodec,
|
||||
type StatusCardContext,
|
||||
} from "@/lib/insights/status-pipeline";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
|
||||
import { userDayKey } from "@/lib/tz/resolver";
|
||||
import { sanitizeForPrompt } from "@/lib/insights/sanitize";
|
||||
|
||||
export interface BiomarkerStatusResult {
|
||||
export type BiomarkerStatusResult = {
|
||||
hasProvider: boolean;
|
||||
text: string | null;
|
||||
cached: boolean;
|
||||
@@ -63,7 +63,7 @@ export interface BiomarkerStatusResult {
|
||||
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(
|
||||
@@ -91,6 +91,14 @@ function insufficient(): BiomarkerStatusResult {
|
||||
};
|
||||
}
|
||||
|
||||
interface BiomarkerMarker {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
lowerBound: number | null;
|
||||
upperBound: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate (or read from cache) the assessment for one biomarker. Mirrors
|
||||
* the metric generator's contract:
|
||||
@@ -123,39 +131,39 @@ export async function generateBiomarkerStatus(args: {
|
||||
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);
|
||||
// v1.30.3 — resolve the user's own tz BEFORE the day-key so the
|
||||
// cache rolls over at the user's local midnight, not Berlin's. Has to
|
||||
// happen ahead of the cache read below (the earliest possible return
|
||||
// path).
|
||||
const userTz = await resolveUserTimezone(args.userId);
|
||||
const todayKey = userDayKey(new Date(), userTz);
|
||||
const prepared = await prepareBiomarkerStatus(marker, args);
|
||||
return (await runPreparedStatusCard(prepared)) as BiomarkerStatusResult;
|
||||
}
|
||||
|
||||
const cached = await readFreshStatusText({
|
||||
userId: args.userId,
|
||||
cacheAction,
|
||||
todayKey,
|
||||
force,
|
||||
});
|
||||
if (cached) {
|
||||
return {
|
||||
hasProvider: true,
|
||||
text: cached.text,
|
||||
cached: true,
|
||||
updatedAt: cached.updatedAt,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Everything up to (not including) the provider call, on the shared
|
||||
* pipeline stages, with the empty-data gate between the cache read and the
|
||||
* read-only path and the input gate in place of the snapshot gate.
|
||||
*/
|
||||
async function prepareBiomarkerStatus(
|
||||
marker: BiomarkerMarker,
|
||||
args: {
|
||||
userId: string;
|
||||
locale?: string | null;
|
||||
force?: boolean;
|
||||
readOnly?: boolean;
|
||||
},
|
||||
): Promise<PreparedStatusCard> {
|
||||
const ctx = await openStatusCard(
|
||||
args.userId,
|
||||
biomarkerStatusScope(marker.id),
|
||||
args,
|
||||
);
|
||||
|
||||
const cached = await readCachedStatus(ctx, textStatusCodec);
|
||||
if (cached) return { phase: "served", result: cached };
|
||||
|
||||
// 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,
|
||||
userId: ctx.userId,
|
||||
biomarkerId: marker.id,
|
||||
deletedAt: null,
|
||||
value: { not: null },
|
||||
@@ -180,7 +188,7 @@ export async function generateBiomarkerStatus(args: {
|
||||
action: { name: "insights.biomarker-status.insufficient" },
|
||||
meta: { biomarkerId: marker.id },
|
||||
});
|
||||
return insufficient();
|
||||
return { phase: "served", result: insufficient() };
|
||||
}
|
||||
|
||||
const readings: BiomarkerReading[] = rows.map((r) => ({
|
||||
@@ -199,34 +207,21 @@ export async function generateBiomarkerStatus(args: {
|
||||
);
|
||||
const totalCount = await prisma.labResult.count({
|
||||
where: {
|
||||
userId: args.userId,
|
||||
userId: ctx.userId,
|
||||
biomarkerId: marker.id,
|
||||
deletedAt: null,
|
||||
value: { not: null },
|
||||
},
|
||||
});
|
||||
|
||||
if (readOnly) {
|
||||
const outcome = await resolveReadOnlyStatusMiss({
|
||||
userId: args.userId,
|
||||
metric: scope,
|
||||
locale,
|
||||
});
|
||||
if (outcome.kind === "unavailable") {
|
||||
return {
|
||||
hasProvider: outcome.hasProvider,
|
||||
text: getNoKeyBiomarkerStatusText(locale, marker.name),
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
if (ctx.readOnly) {
|
||||
return {
|
||||
hasProvider: true,
|
||||
text: outcome.lastGood?.text ?? null,
|
||||
cached: outcome.lastGood !== null,
|
||||
updatedAt: outcome.lastGood?.updatedAt ?? null,
|
||||
preparing: outcome.lastGood === null,
|
||||
revalidating: outcome.revalidating,
|
||||
phase: "served",
|
||||
result: await resolveReadOnlyStatus(
|
||||
ctx,
|
||||
textStatusCodec,
|
||||
getNoKeyBiomarkerStatusText(ctx.locale, marker.name),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -245,8 +240,8 @@ export async function generateBiomarkerStatus(args: {
|
||||
// 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,
|
||||
scope: ctx.scope,
|
||||
locale: ctx.locale,
|
||||
promptVersion: PROMPT_VERSION,
|
||||
count: totalCount,
|
||||
bounds: { lower: latestRange.low, upper: latestRange.high },
|
||||
@@ -256,22 +251,58 @@ export async function generateBiomarkerStatus(args: {
|
||||
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,
|
||||
};
|
||||
}
|
||||
const unchanged = await gateUnchangedInputs(ctx, inputHash, false);
|
||||
if (unchanged) return { phase: "served", result: unchanged };
|
||||
|
||||
const { snapshotJson, safeMarkerName } = biomarkerSnapshot({
|
||||
ctx,
|
||||
marker,
|
||||
readings,
|
||||
latestRange,
|
||||
totalCount,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: ctx.cacheAction },
|
||||
meta: { payload_size_bytes: snapshotJson.length },
|
||||
});
|
||||
|
||||
return pendingStatusCard(ctx, textStatusCodec, {
|
||||
systemPrompt: getBiomarkerSystemPrompt(
|
||||
safeMarkerName,
|
||||
ctx.locale as Locale,
|
||||
),
|
||||
userPrompt: getBiomarkerUserPrompt(
|
||||
snapshotJson,
|
||||
ctx.todayKey,
|
||||
ctx.locale as Locale,
|
||||
// The rotating opener hint the sibling cards carry, keyed per
|
||||
// (user, marker, day) so consecutive markers do not all open alike.
|
||||
openerArchetypeHint(
|
||||
`${ctx.userId}:biomarker:${marker.id}:${ctx.todayKey}`,
|
||||
ctx.locale as Locale,
|
||||
),
|
||||
),
|
||||
hashes: { inputHash },
|
||||
temperature: AI_BUDGETS.statusArchetype.temperature,
|
||||
maxTokens: AI_BUDGETS.statusArchetype.maxTokens,
|
||||
fallbackText: getNoKeyBiomarkerStatusText(ctx.locale, marker.name),
|
||||
emptyMessage: `Biomarker-status summary was empty for ${marker.id}`,
|
||||
detail: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** The marker snapshot: the range, the latest verdict and the series. */
|
||||
function biomarkerSnapshot(args: {
|
||||
ctx: StatusCardContext;
|
||||
marker: BiomarkerMarker;
|
||||
readings: BiomarkerReading[];
|
||||
latestRange: ReturnType<typeof resolveEffectiveReferenceRange>;
|
||||
totalCount: number;
|
||||
}): { snapshotJson: string; safeMarkerName: string } {
|
||||
const { marker, readings, latestRange, totalCount } = args;
|
||||
const { locale, todayKey, userTz } = args.ctx;
|
||||
const latest = readings[0];
|
||||
// Ascending series (oldest → newest) for the trend summary the prompt reads.
|
||||
const ascending = [...readings].reverse();
|
||||
const summary = summarizeSeries(ascending.map((r) => ({ value: r.value })));
|
||||
@@ -317,76 +348,5 @@ export async function generateBiomarkerStatus(args: {
|
||||
};
|
||||
const snapshotJson = JSON.stringify(snapshot);
|
||||
|
||||
annotate({
|
||||
action: { name: cacheAction },
|
||||
meta: { payload_size_bytes: snapshotJson.length },
|
||||
});
|
||||
|
||||
const outcome = await runStatusCompletion({
|
||||
userId: args.userId,
|
||||
cacheAction,
|
||||
capability: "statusText",
|
||||
systemPrompt: getBiomarkerSystemPrompt(safeMarkerName, locale as Locale),
|
||||
userPrompt: getBiomarkerUserPrompt(
|
||||
snapshotJson,
|
||||
todayKey,
|
||||
locale as Locale,
|
||||
// The rotating opener hint the sibling cards carry, keyed per
|
||||
// (user, marker, day) so consecutive markers do not all open alike.
|
||||
openerArchetypeHint(
|
||||
`${args.userId}:biomarker:${marker.id}:${todayKey}`,
|
||||
locale as Locale,
|
||||
),
|
||||
),
|
||||
temperature: AI_BUDGETS.statusArchetype.temperature,
|
||||
maxTokens: AI_BUDGETS.statusArchetype.maxTokens,
|
||||
});
|
||||
|
||||
if (outcome.kind === "none") {
|
||||
return {
|
||||
hasProvider: false,
|
||||
text: getNoKeyBiomarkerStatusText(locale, marker.name),
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
if (outcome.kind === "timeout" || outcome.kind === "error") {
|
||||
return returnTimeoutFallback({
|
||||
cacheAction,
|
||||
reason: outcome.kind,
|
||||
userId: args.userId,
|
||||
todayKey,
|
||||
stubText: getNoKeyBiomarkerStatusText(locale, marker.name),
|
||||
});
|
||||
}
|
||||
|
||||
// Outbound safety screen — WITHHOLD policy for a background-generated card.
|
||||
const screened = finalizeStatusSummary(outcome.content, locale);
|
||||
if (!screened.ok) {
|
||||
annotate({
|
||||
action: { name: "insights.status.outbound_blocked" },
|
||||
meta: { cacheAction, reason: screened.reason },
|
||||
});
|
||||
return returnTimeoutFallback({
|
||||
cacheAction,
|
||||
reason: "screened",
|
||||
userId: args.userId,
|
||||
todayKey,
|
||||
stubText: getNoKeyBiomarkerStatusText(locale, marker.name),
|
||||
});
|
||||
}
|
||||
const text = screened.text;
|
||||
if (!text) {
|
||||
throw new Error(`Biomarker-status summary was empty for ${marker.id}`);
|
||||
}
|
||||
|
||||
const updatedAt = await persistStatusInsight({
|
||||
userId: args.userId,
|
||||
cacheAction,
|
||||
todayKey,
|
||||
text,
|
||||
inputHash,
|
||||
});
|
||||
|
||||
return { hasProvider: true, text, cached: false, updatedAt };
|
||||
return { snapshotJson, safeMarkerName };
|
||||
}
|
||||
Reference in new issue
Block a user