mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
8c24f3ec2a
commit
78c8177cd3
6 files changed
+282
No files matched your search
@@ -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> = {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user