diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index f8f9bf91f..73b33cd9a 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -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; diff --git a/src/components/insights/daily-briefing.tsx b/src/components/insights/daily-briefing.tsx index 7c83dfc13..bd2cea693 100644 --- a/src/components/insights/daily-briefing.tsx +++ b/src/components/insights/daily-briefing.tsx @@ -56,6 +56,11 @@ const METRIC_HREF: Record = { diff --git a/src/lib/ai/schema.ts b/src/lib/ai/schema.ts index a86614d44..92cf8bc26 100644 --- a/src/lib/ai/schema.ts +++ b/src/lib/ai/schema.ts @@ -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", ]), }); diff --git a/src/lib/insights/__tests__/derived-briefing.test.ts b/src/lib/insights/__tests__/derived-briefing.test.ts new file mode 100644 index 000000000..88215e6c1 --- /dev/null +++ b/src/lib/insights/__tests__/derived-briefing.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/insights/derived", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, computeDerivedMetric: vi.fn() }; +}); + +import { computeDerivedMetric } from "@/lib/insights/derived"; +import { + detectDerivedBriefingSignals, + buildDerivedBriefingPrompt, +} from "../derived-briefing"; + +const compute = computeDerivedMetric as unknown as ReturnType; +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"); + }); +}); diff --git a/src/lib/insights/derived-briefing.ts b/src/lib/insights/derived-briefing.ts new file mode 100644 index 000000000..1c38b0519 --- /dev/null +++ b/src/lib/insights/derived-briefing.ts @@ -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 { + 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`; +} diff --git a/src/lib/insights/trend-chart-select.ts b/src/lib/insights/trend-chart-select.ts index 25f4ff7eb..8bcb85dd2 100644 --- a/src/lib/insights/trend-chart-select.ts +++ b/src/lib/insights/trend-chart-select.ts @@ -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, }; /**