feat(insights): fold a confidence-gated derived signal into the daily briefing

When a derived wellness score (readiness or recovery) crosses a notability gate
— outside the green band AND confidence at least medium — the insights
generator now appends a SYSTEM CONTEXT block so the briefing can narrate the
shift observationally. Mirrors the GLP-1 plateau detector's append mechanism:
reads the one derived contract, returns null (the common case) at zero token
cost, and instructs the model to frame the signal as an observed pattern, never
a diagnosis or a dose recommendation. Add the readiness/recovery sourceMetric
enum values plus their briefing href + icon + trend-chart mappings.
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 23:13:26 +02:00
1 parent 8c24f3ec2a
commit 78c8177cd3
6 files changed
+282

No files matched your search

+24
View File
@@ -11,6 +11,11 @@ import {
detectGlp1Plateau,
buildGlp1PlateauPrompt,
} from "@/lib/insights/glp1-plateau";
import {
detectDerivedBriefingSignals,
buildDerivedBriefingPrompt,
} from "@/lib/insights/derived-briefing";
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
import {
buildUserPrompt,
type ComparisonSnapshot,
@@ -199,6 +204,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
// LLM never sees the excluded blocks.
insightsExcludeMetrics: true,
locale: true,
// v1.10.0 — profile for the derived-signal briefing detector.
dateOfBirth: true,
gender: true,
heightCm: true,
},
});
@@ -397,6 +406,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (plateauContext) {
userPrompt += buildGlp1PlateauPrompt(plateauContext, locale);
}
// v1.10.0 — fold a notable derived wellness signal (readiness / recovery
// shift, confidence-gated) into the briefing as a SYSTEM CONTEXT block.
// Reads the one derived contract; null (the common case) costs nothing.
const derivedSex =
dbUser?.gender === "MALE" || dbUser?.gender === "FEMALE"
? (dbUser.gender as "MALE" | "FEMALE")
: null;
const derivedBriefing = await detectDerivedBriefingSignals(userId, {
ageYears: getAgeFromDateOfBirth(dbUser?.dateOfBirth ?? null),
sex: derivedSex,
heightCm: dbUser?.heightCm ?? null,
});
if (derivedBriefing) {
userPrompt += buildDerivedBriefingPrompt(derivedBriefing, locale);
}
let result;
let workingProviderType: string;
@@ -56,6 +56,11 @@ const METRIC_HREF: Record<DailyBriefingKeyFinding["sourceMetric"], string | null
vo2_max: "/insights/pulse",
body_temp: null,
glp1_plateau: "/insights/medications",
// ── v1.10.0 derived-wellness additive ──
// Readiness has a score-anatomy detail page; recovery is a read-only
// ring with no dedicated sub-page yet (renders as a static row).
readiness: "/insights/scores/readiness",
recovery: null,
};
/**
@@ -119,6 +124,9 @@ const METRIC_ICON: Record<
// adherence-context observation about the user's GLP-1 therapy.
// Non-GLP-1 accounts never see this finding type.
glp1_plateau: Pill,
// ── v1.10.0 derived-wellness additive ──
readiness: Sparkles,
recovery: HeartPulse,
};
const TONE_BAR_CLASSNAME: Record<DailyBriefingKeyFinding["tone"], string> = {
+7
View File
@@ -277,6 +277,13 @@ export const dailyBriefingKeyFindingSchema = z.object({
// user's clinician (no dose recommendation); the enum value
// exists so the UI can pin the right icon + filter.
"glp1_plateau",
// ── v1.10.0 derived-wellness additive ──
// Findings emitted when a notable derived signal (a readiness /
// recovery shift gated on confidence) crosses the briefing's
// notability gate. Descriptive, never a clinical attribution; the
// enum value lets the UI pin the right icon + filter.
"readiness",
"recovery",
]),
});
@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/insights/derived", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/insights/derived")>();
return { ...actual, computeDerivedMetric: vi.fn() };
});
import { computeDerivedMetric } from "@/lib/insights/derived";
import {
detectDerivedBriefingSignals,
buildDerivedBriefingPrompt,
} from "../derived-briefing";
const compute = computeDerivedMetric as unknown as ReturnType<typeof vi.fn>;
const PROFILE = { ageYears: 40, sex: "MALE" as const, heightCm: 180 };
const NOW = new Date("2026-06-02T08:00:00Z");
function ok(score: number, band: string, confidence: number) {
return {
status: "ok" as const,
value: { score, band },
coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 14, missing: [] },
confidence: { score: confidence, band: "medium" as const },
provenance: { inputs: [], source: "DAY" as const, windowDays: 14, computedAt: "x" },
};
}
const insufficient = {
status: "insufficient" as const,
coverage: { requiredInputs: 1, presentInputs: 0, historyDays: 0, missing: [] },
provenance: { inputs: [], source: "none" as const, windowDays: 0, computedAt: "x" },
reason: "x",
};
beforeEach(() => compute.mockReset());
describe("detectDerivedBriefingSignals", () => {
it("returns null when nothing is notable", async () => {
// green band → not notable even at high confidence
compute.mockResolvedValue(ok(82, "green", 90));
expect(await detectDerivedBriefingSignals("u1", PROFILE, NOW)).toBeNull();
});
it("gates out low-confidence signals", async () => {
compute.mockResolvedValue(ok(35, "red", 30));
expect(await detectDerivedBriefingSignals("u1", PROFILE, NOW)).toBeNull();
});
it("surfaces a notable, confident yellow/red signal", async () => {
compute.mockImplementation(async (args?: { metric?: string }) => {
if (args?.metric === "READINESS") return ok(48, "yellow", 70);
return insufficient;
});
const ctx = await detectDerivedBriefingSignals("u1", PROFILE, NOW);
expect(ctx).not.toBeNull();
expect(ctx!.signals).toHaveLength(1);
expect(ctx!.signals[0]).toMatchObject({
sourceMetric: "readiness",
score: 48,
band: "yellow",
});
});
it("isolates a per-metric compute failure", async () => {
compute.mockImplementation(async (args?: { metric?: string }) => {
if (args?.metric === "READINESS") throw new Error("boom");
if (args?.metric === "RECOVERY_SCORE") return ok(30, "red", 88);
return insufficient;
});
const ctx = await detectDerivedBriefingSignals("u1", PROFILE, NOW);
expect(ctx!.signals.map((s) => s.sourceMetric)).toEqual(["recovery"]);
});
});
describe("buildDerivedBriefingPrompt", () => {
it("renders an EN system-context block citing the value + band", () => {
const prompt = buildDerivedBriefingPrompt(
{ signals: [{ sourceMetric: "readiness", label: "readiness", score: 48, band: "yellow", confidence: 70 }] },
"en",
);
expect(prompt).toContain("DERIVED WELLNESS SIGNALS");
expect(prompt).toContain("readiness: 48/100");
expect(prompt).toContain("NEVER recommend");
});
it("renders a DE block for the de locale", () => {
const prompt = buildDerivedBriefingPrompt(
{ signals: [{ sourceMetric: "recovery", label: "recovery", score: 30, band: "red", confidence: 88 }] },
"de",
);
expect(prompt).toContain("ABGELEITETE WELLNESS-SIGNALE");
});
});
+146
View File
@@ -0,0 +1,146 @@
/**
* v1.10.0 — derived-signal context for the daily briefing.
*
* Picks the most relevant 1–2 derived wellness signals (a notable
* readiness / recovery shift) and folds them into the insight-generator
* prompt as a SYSTEM CONTEXT block — the same mechanism the GLP-1 plateau
* detector uses. Gated on confidence: a signal only surfaces when its
* derived value is `ok` AND its confidence band is `high` or `medium`, so
* the briefing never narrates a shaky few-day number.
*
* Reads the one `computeDerivedMetric` contract — no recompute. Returns
* `null` (the common case) when nothing notable crosses the gate, so the
* generate route pays zero token cost for users with no derived signal.
*
* Server-only.
*/
import {
computeDerivedMetric,
isDerivedOk,
type BaselineProfile,
} from "@/lib/insights/derived";
import type { Locale } from "@/lib/i18n/config";
/** One derived signal worth a sentence in the briefing. */
export interface DerivedBriefingSignal {
/** Sentinel id the model echoes into `sourceMetric` (e.g. "readiness"). */
sourceMetric: string;
/** Human label ("readiness", "recovery"). */
label: string;
/** Latest 0–100 score. */
score: number;
/** Band ("green" / "yellow" / "red"). */
band: string;
/** Confidence 0–100. */
confidence: number;
}
export interface DerivedBriefingContext {
signals: DerivedBriefingSignal[];
}
/** The notable-signal candidates, in priority order. */
const CANDIDATES: Array<{
metric: "READINESS" | "RECOVERY_SCORE";
sourceMetric: string;
label: string;
}> = [
{ metric: "READINESS", sourceMetric: "readiness", label: "readiness" },
{ metric: "RECOVERY_SCORE", sourceMetric: "recovery", label: "recovery" },
];
/** Confidence floor — only `high`/`medium` bands surface in the briefing. */
const CONFIDENCE_FLOOR = 50;
/** A signal is notable when it sits outside the comfortable green band. */
function isNotable(band: string, confidence: number): boolean {
return confidence >= CONFIDENCE_FLOOR && (band === "yellow" || band === "red");
}
/**
* Detect the 1–2 most relevant derived signals for the briefing, or `null`
* when nothing crosses the confidence + notability gate. Computes the
* candidates off the one shared profile (no per-metric profile re-read).
*/
export async function detectDerivedBriefingSignals(
userId: string,
profile: BaselineProfile,
now: Date = new Date(),
): Promise<DerivedBriefingContext | null> {
const signals: DerivedBriefingSignal[] = [];
for (const cand of CANDIDATES) {
let derived;
try {
derived = await computeDerivedMetric({
metric: cand.metric,
userId,
profile,
now,
});
} catch {
continue;
}
if (!isDerivedOk(derived)) continue;
const v = derived.value as { score?: number; band?: string };
if (typeof v.score !== "number" || typeof v.band !== "string") continue;
if (!isNotable(v.band, derived.confidence.score)) continue;
signals.push({
sourceMetric: cand.sourceMetric,
label: cand.label,
score: v.score,
band: v.band,
confidence: derived.confidence.score,
});
if (signals.length >= 2) break;
}
return signals.length > 0 ? { signals } : null;
}
/**
* Build the SYSTEM CONTEXT block appended to the user prompt. Mirrors the
* GLP-1 plateau detector's append mechanism. DE + EN bodies; other locales
* fall through to EN (the same chain the message bundles use).
*/
export function buildDerivedBriefingPrompt(
ctx: DerivedBriefingContext,
locale: Locale,
): string {
const lines = ctx.signals
.map(
(s) =>
`- ${s.label}: ${s.score}/100 (${s.band} band, confidence ${s.confidence})`,
)
.join("\n");
const sentinels = ctx.signals.map((s) => `"${s.sourceMetric}"`).join(" / ");
if (locale === "de") {
return `
SYSTEM CONTEXT — ABGELEITETE WELLNESS-SIGNALE
Die App hat folgende abgeleitete Wellness-Werte berechnet (beschreibend, keine klinische Beurteilung):
${lines}
Wenn du einen dailyBriefing-keyFinding zu einem dieser Signale emittierst:
- sourceMetric: ${sentinels}
- tone: "info" (gelb) bzw. "watch" (rot)
- Rahmen es als beobachtetes Muster aus den eigenen Daten des Nutzers, keine Diagnose
- nenne den Wert und das Band; verweise NICHT auf eine medizinische Ursache
- KEINE Empfehlung zu Medikamenten oder Dosierung`;
}
return `
SYSTEM CONTEXT — DERIVED WELLNESS SIGNALS
The app computed these derived wellness scores (descriptive, not a clinical assessment):
${lines}
If you emit a dailyBriefing keyFinding for one of these signals:
- sourceMetric: ${sentinels}
- tone: "info" (yellow) or "watch" (red)
- frame as an observed pattern from the user's own data, never a diagnosis
- state the value and the band; do NOT attribute it to a medical cause
- NEVER recommend a medication or dose change`;
}
+5
View File
@@ -225,6 +225,11 @@ export const TREND_CHART_CONFIG: Record<
// the next finding (or the default fallback) takes the slot.
compliance: null,
glp1_plateau: null,
// ── v1.10.0 derived-wellness additive ──
// The wellness scores are 0–100 composites whose deeper view is the
// score-anatomy page, not a single plottable trend series in this row.
readiness: null,
recovery: null,
};
/**