From 8b5235c25b5bb5a2574ba3f722529a5856604291 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 16:58:06 +0200 Subject: [PATCH 1/7] fix(insights): ground daily-briefing numbers against precomputed signals The daily-briefing block (paragraph / signalsOfDay / keyFindings) was schema-shaped but never number-checked the way recommendations[] are: a restated figure could drift from the snapshot with no server rejection. Add a grounding cross-check that asserts every number the briefing prose states traces to a features.signalsOfDay figure (latest / avg / delta) within a rounding tolerance. Wire it into both generation sites with one corrective retry, then strip the briefing rather than persist a fabricated number. Also gate the higher-temperature paragraph reroll on the same check so a phrasing reroll cannot drift a figure. --- src/app/api/insights/generate/route.ts | 64 +++++ .../ai/__tests__/briefing-grounding.test.ts | 112 +++++++++ src/lib/ai/briefing-grounding.ts | 235 ++++++++++++++++++ src/lib/insights/comprehensive-generate.ts | 81 ++++++ 4 files changed, 492 insertions(+) create mode 100644 src/lib/ai/__tests__/briefing-grounding.test.ts create mode 100644 src/lib/ai/briefing-grounding.ts diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 686dfed8e..b979a59d7 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -50,6 +50,11 @@ import { runRawCompletionWithFallback, } from "@/lib/ai/provider-runner"; import { assertConsentForChain } from "@/lib/ai/consent-guard"; +import { + findUngroundedBriefingNumbers, + readBriefingBlock, + buildBriefingGroundingCorrection, +} from "@/lib/ai/briefing-grounding"; import { isLegacyInsightPayload } from "@/lib/ai/legacy-payload"; import { checkRateLimit } from "@/lib/rate-limit"; import { NextRequest } from "next/server"; @@ -729,6 +734,65 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("AI response was not valid JSON", 422); } + // v1.18.10 (HIGH-1) — daily-briefing number-grounding gate, mirroring the + // recommendations citation cross-check next door. The schema only checks the + // briefing's SHAPE; this asserts every number the briefing prose restates + // traces to a `features.signalsOfDay` figure. On a miss: one corrective + // retry, then drop the briefing block rather than ship a fabricated number. + { + const signals = features.signalsOfDay ?? null; + let ungrounded = findUngroundedBriefingNumbers( + readBriefingBlock(insights), + signals, + ); + if (ungrounded.length > 0) { + annotate({ + action: { name: "insights.generate.briefing_grounding_retry" }, + meta: { ungroundedCount: ungrounded.length }, + }); + try { + const retry = await runRawCompletionWithFallback({ + userId, + providers: chain, + params: { + systemPrompt: buildSystemPromptWithReferences( + locale, + referenceMetrics, + ), + userPrompt: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`, + temperature: 0.3, + maxTokens: 1500, + }, + }); + const parsedRetry = JSON.parse(retry.result.content); + const validatedRetry = insightResultSchema.safeParse(parsedRetry); + const retryInsights = validatedRetry.success + ? validatedRetry.data + : parsedRetry; + ungrounded = findUngroundedBriefingNumbers( + readBriefingBlock(retryInsights), + signals, + ); + if (ungrounded.length === 0) { + insights = retryInsights; + result = retry.result; + workingProviderType = retry.workingProvider.providerType; + } + } catch { + // Retry failed to produce parseable JSON — fall through to the strip. + } + } + // Still ungrounded after the retry: strip the briefing rather than ship a + // fabricated figure. The structured recommendations/citations stay. + if (ungrounded.length > 0 && insights && typeof insights === "object") { + (insights as Record).dailyBriefing = null; + annotate({ + action: { name: "insights.generate.briefing_grounding_stripped" }, + meta: { ungroundedCount: ungrounded.length }, + }); + } + } + await prisma.user.update({ where: { id: userId }, data: { diff --git a/src/lib/ai/__tests__/briefing-grounding.test.ts b/src/lib/ai/__tests__/briefing-grounding.test.ts new file mode 100644 index 000000000..c7bcebbc4 --- /dev/null +++ b/src/lib/ai/__tests__/briefing-grounding.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from "vitest"; + +import { + findUngroundedBriefingNumbers, + readBriefingBlock, + buildBriefingGroundingCorrection, + extractNumbers, +} from "@/lib/ai/briefing-grounding"; +import type { SignalOfDay } from "@/lib/insights/features"; + +function signal(partial: Partial): SignalOfDay { + return { + metric: "weight", + label: "Weight", + latest: 82, + latestDaysAgo: 0, + avg7: 83, + avg30: 84, + deltaVs7: -1, + deltaVs30: -2, + spread30: 0.5, + outsideNormalSwing: true, + emergingTrend: "falling", + recentAnomaly: null, + ...partial, + }; +} + +describe("extractNumbers", () => { + it("parses signed, decimal, and comma-decimal numbers", () => { + expect( + extractNumbers("dropped 2.5 kg, +1,2 and -3").map((n) => n.value), + ).toEqual([2.5, 1.2, -3]); + }); +}); + +describe("findUngroundedBriefingNumbers", () => { + const signals = [signal({})]; + + it("returns nothing when there are no signals to grade against", () => { + const out = findUngroundedBriefingNumbers( + { paragraph: "your weight dropped 9.9 kg" }, + null, + ); + expect(out).toEqual([]); + }); + + it("passes a paragraph whose numbers match the signal figures", () => { + const out = findUngroundedBriefingNumbers( + { + paragraph: "Your weight is 82 kg, down 2 kg from your monthly average.", + }, + signals, + ); + expect(out).toEqual([]); + }); + + it("flags a fabricated delta that no signal supports", () => { + const out = findUngroundedBriefingNumbers( + { + paragraph: "Your weight dropped 6.4 kg this week.", + signalsOfDay: [ + { headline: "down 6.4 kg", nudge: "keep going", delta: "-6.4 kg" }, + ], + }, + signals, + ); + expect(out.length).toBeGreaterThan(0); + expect(out.some((f) => f.value === 6.4)).toBe(true); + }); + + it("tolerates rounding within the band", () => { + const out = findUngroundedBriefingNumbers( + { paragraph: "down 2.1 kg vs your 84.0 average" }, + [signal({ avg30: 84, deltaVs30: -2 })], + ); + expect(out).toEqual([]); + }); + + it("does not flag structural integers like a 7-day window or 3 signals", () => { + const out = findUngroundedBriefingNumbers( + { paragraph: "Over the last 7 days, your 3 readings held steady." }, + signals, + ); + expect(out).toEqual([]); + }); + + it("returns nothing when the briefing is absent", () => { + expect(findUngroundedBriefingNumbers(null, signals)).toEqual([]); + }); +}); + +describe("readBriefingBlock", () => { + it("reads the dailyBriefing block off a parsed payload", () => { + const block = readBriefingBlock({ dailyBriefing: { paragraph: "hi" } }); + expect(block?.paragraph).toBe("hi"); + }); + it("returns null when no briefing is present", () => { + expect(readBriefingBlock({ summary: "x" })).toBeNull(); + expect(readBriefingBlock(null)).toBeNull(); + }); +}); + +describe("buildBriefingGroundingCorrection", () => { + it("names the offending numbers and asks for a re-write", () => { + const msg = buildBriefingGroundingCorrection([ + { field: "paragraph", value: 7, source: "7 kg" }, + ]); + expect(msg).toContain("7 kg"); + expect(msg.toLowerCase()).toContain("signalsofday"); + }); +}); diff --git a/src/lib/ai/briefing-grounding.ts b/src/lib/ai/briefing-grounding.ts new file mode 100644 index 000000000..80a2abdf5 --- /dev/null +++ b/src/lib/ai/briefing-grounding.ts @@ -0,0 +1,235 @@ +/** + * v1.18.10 (HIGH-1 / MEDIUM-3) — Daily-Briefing number-grounding cross-check. + * + * The comprehensive-insight schema validates that `dailyBriefing.paragraph`, + * `signalsOfDay[]` and `keyFindings[]` are well-shaped (non-empty strings, + * known enums, length caps) — but it does NOT verify that any NUMBER the model + * restates in those fields traces back to a figure the server pre-computed. + * `recommendations[]` carry that guarantee (the citation cross-check + retry + + * 422 in `generate-insight.ts`); the briefing block did not. A model could + * write "your weight dropped 3 kg this week" while the snapshot's + * `features.signalsOfDay` says 2 kg, and the payload still passed. + * + * This module closes that gap the same way the recommendations gate does: + * extract every number the briefing prose asserts, and reject the payload when + * one of them cannot be matched (within a rounding tolerance) to a value the + * deterministic `features.signalsOfDay` block already holds — `latest`, + * `avg7`, `avg30`, `deltaVs7`, `deltaVs30`, `spread30`, or a recent-anomaly + * value. The caller turns a non-empty finding list into the existing + * corrective-retry → 422 machinery. + * + * Scope + posture: + * - The check only fires when `features.signalsOfDay` is present. When the + * server pre-computed no signals there is no authoritative number set to + * grade against, so the prose is left to the prompt-level grounding rule + * (same as before) — we never reject for a number we have nothing to + * compare to. + * - It grades NUMBERS only. Tone, framing and word choice stay the prompt's + * job; this is purely "did the model restate a figure we did not give it". + * - Pure + side-effect-free, so it unit-tests in isolation. + */ +import type { SignalOfDay } from "@/lib/insights/features"; + +/** + * Absolute rounding tolerance when matching a restated number to a + * pre-computed signal figure. The model legitimately rounds ("+1.24 kg" → + * "+1.2 kg"); anything inside this band is treated as the same number. A + * relative slack widens it for large values (heart rate, steps) where a + * one-decimal restatement of a server mean drifts by more than 0.15 in + * absolute terms. + */ +const ABS_TOLERANCE = 0.15; +const REL_TOLERANCE = 0.02; + +/** The briefing sub-payload we grade. Mirrors `dailyBriefingSchema` loosely. */ +export interface BriefingForGrounding { + paragraph?: unknown; + signalsOfDay?: Array<{ + headline?: unknown; + nudge?: unknown; + delta?: unknown; + }> | null; + keyFindings?: Array<{ + headline?: unknown; + detail?: unknown; + delta?: unknown; + }> | null; +} + +/** One ungrounded number the briefing asserted. */ +export interface UngroundedBriefingNumber { + /** Which field carried it. */ + field: "paragraph" | "signalsOfDay" | "keyFindings"; + /** The numeric value the model wrote, as parsed. */ + value: number; + /** The raw token the value was read from (truncated). */ + source: string; +} + +/** + * Parse every standalone number out of a free-text string. Captures an + * optional leading sign and both `.`/`,` decimal separators (the DE briefing + * writes "1,2 kg"). Percent / unit suffixes are ignored — only the magnitude + * matters for grounding. Years and obvious dates (YYYY-MM-DD, "6 May") are not + * special-cased: a 4-digit run is allowed through and simply won't match any + * signal figure, which is fine because real briefings do not quote bare years + * as health numbers, and the tolerance band keeps small ordinals from + * colliding with real values. + */ +export function extractNumbers( + text: string, +): Array<{ value: number; raw: string }> { + const out: Array<{ value: number; raw: string }> = []; + // Sign, integer part, optional decimal (.,) — bounded so we don't span words. + const re = /[+-]?\d+(?:[.,]\d+)?/g; + let match: RegExpExecArray | null; + while ((match = re.exec(text)) !== null) { + const raw = match[0]; + const normalised = raw.replace(",", "."); + const value = Number.parseFloat(normalised); + if (Number.isFinite(value)) out.push({ value, raw }); + } + return out; +} + +/** + * The set of authoritative magnitudes one signal exposes. We grade against + * absolute magnitudes so a "+1.2" delta matches a `deltaVs7` of `-1.2` only + * when the model also flipped the sign in prose — but because briefings phrase + * direction in words ("dropped", "up"), grading on |value| avoids penalising + * the model for choosing a sign convention. We keep both the signed and the + * absolute form so an exact signed restatement also matches. + */ +function authoritativeValues(signals: readonly SignalOfDay[]): number[] { + const values: number[] = []; + const push = (n: number | null | undefined) => { + if (typeof n === "number" && Number.isFinite(n)) { + values.push(n); + values.push(Math.abs(n)); + } + }; + for (const s of signals) { + push(s.latest); + push(s.avg7); + push(s.avg30); + push(s.deltaVs7); + push(s.deltaVs30); + push(s.spread30); + if (s.recentAnomaly) push(s.recentAnomaly.value); + } + return values; +} + +/** True when `value` matches any authoritative figure within tolerance. */ +function isGrounded(value: number, authoritative: readonly number[]): boolean { + for (const a of authoritative) { + const tol = Math.max(ABS_TOLERANCE, Math.abs(a) * REL_TOLERANCE); + if (Math.abs(value - a) <= tol) return true; + } + return false; +} + +/** + * Numbers a briefing may always state without a signal match: small integers + * the prose uses structurally (a day count, "3 signals", "last 7 days"), which + * would otherwise false-positive against honest window/ordinal phrasing. We + * exempt only bare integers (no decimal) that are either a small ordinal (≤ 3) + * or one of the literal window lengths the briefing references (7/14/30/90/365). + * + * Trade-off: a fabricated number that happens to be exactly one of those bare + * window integers slips through. That is the deliberate conservative choice — + * the typical fabrication shapes (a restated delta/mean) carry a decimal or a + * non-window magnitude (a 6.4 kg drop, a pulse of 72, "down 5 kg") and are + * still graded; exempting the window vocabulary avoids reflexively flagging + * every honest "over the last 7 days". A real restated health figure that is a + * round window integer is rare and the grounding rule in the prompt remains the + * backstop for it. + */ +const STRUCTURAL_INTEGERS = new Set([7, 14, 30, 31, 90, 365]); + +function isStructural(value: number, raw: string): boolean { + if (raw.includes(".") || raw.includes(",")) return false; + if (!Number.isInteger(value)) return false; + const abs = Math.abs(value); + if (abs <= 3) return true; // "3 signals", "2 days", ordinals + return STRUCTURAL_INTEGERS.has(abs); +} + +/** + * Find every number the briefing block asserts that does not trace to a + * pre-computed `features.signalsOfDay` figure. Returns an empty array when the + * briefing is absent, when no signals were pre-computed (nothing to grade + * against), or when every restated number is grounded. + */ +export function findUngroundedBriefingNumbers( + briefing: BriefingForGrounding | null | undefined, + signals: readonly SignalOfDay[] | null | undefined, +): UngroundedBriefingNumber[] { + if (!briefing) return []; + if (!signals || signals.length === 0) return []; + + const authoritative = authoritativeValues(signals); + // No usable authoritative figures (every field null) — nothing to grade. + if (authoritative.length === 0) return []; + + const findings: UngroundedBriefingNumber[] = []; + + const grade = ( + field: UngroundedBriefingNumber["field"], + text: unknown, + ): void => { + if (typeof text !== "string" || text.length === 0) return; + for (const { value, raw } of extractNumbers(text)) { + if (isStructural(value, raw)) continue; + if (isGrounded(value, authoritative)) continue; + findings.push({ field, value, source: raw.slice(0, 32) }); + } + }; + + grade("paragraph", briefing.paragraph); + for (const s of briefing.signalsOfDay ?? []) { + grade("signalsOfDay", s?.headline); + grade("signalsOfDay", s?.nudge); + grade("signalsOfDay", s?.delta); + } + for (const f of briefing.keyFindings ?? []) { + grade("keyFindings", f?.headline); + grade("keyFindings", f?.detail); + grade("keyFindings", f?.delta); + } + + return findings; +} + +/** + * Read the `dailyBriefing` block off a parsed comprehensive payload, whatever + * its concrete type (the strict `InsightResult` or the loose passthrough + * object). Returns null when no briefing is present. + */ +export function readBriefingBlock( + parsed: unknown, +): BriefingForGrounding | null { + if (!parsed || typeof parsed !== "object") return null; + const briefing = (parsed as { dailyBriefing?: unknown }).dailyBriefing; + if (!briefing || typeof briefing !== "object") return null; + return briefing as BriefingForGrounding; +} + +/** + * Build the corrective user-prompt suffix appended on a grounding miss, naming + * the offending numbers so the model can self-correct on the single retry — + * the same cite-or-omit posture the recommendations gate uses. + */ +export function buildBriefingGroundingCorrection( + findings: readonly UngroundedBriefingNumber[], +): string { + const list = findings + .slice(0, 6) + .map((f) => `${f.source} (in ${f.field})`) + .join(", "); + return ` +Your daily-briefing block stated numbers that do NOT appear in the snapshot's pre-computed "signalsOfDay" figures: ${list}. + +Re-write the dailyBriefing (paragraph, signalsOfDay, keyFindings) so EVERY number you state matches a value the snapshot already gives you — the latest reading, the 7-day or 30-day average, or the signed delta against one of those. Do NOT invent or re-derive a figure. If you cannot ground a number, drop the number and keep only the qualitative framing. Return the full JSON object again. +`; +} diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 4936c7208..278c45e7e 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -22,7 +22,13 @@ import { prisma } from "@/lib/db"; import { extractFeatures, FeaturesPayloadTooLargeError, + type SignalOfDay, } from "@/lib/insights/features"; +import { + findUngroundedBriefingNumbers, + readBriefingBlock, + buildBriefingGroundingCorrection, +} from "@/lib/ai/briefing-grounding"; import { applyInsightsExcludeFilter } from "@/lib/insights/exclude-filter"; import { compactSections } from "@/lib/ai/prompts/compact-sections"; import { @@ -169,6 +175,14 @@ async function rerollBriefingParagraph(args: { userPrompt: string; cachedText: string; locale: "de" | "en"; + /** + * v1.18.10 (MEDIUM-3) — the pre-computed signals the briefing must match. + * The reroll runs at a higher, seedless temperature for phrasing variety, + * which is exactly the path most prone to drifting a restated number; the + * fresh paragraph is rejected (caller keeps the cached one) when a number in + * it does not trace to one of these figures. + */ + signals: readonly SignalOfDay[] | null; }): Promise<{ text: string; providerType: string } | null> { let cached: Record; try { @@ -229,6 +243,18 @@ async function rerollBriefingParagraph(args: { return null; } + // v1.18.10 (MEDIUM-3) — grounding gate on the rerolled paragraph. The higher + // seedless temperature can drift a restated figure ("+1.2 kg" → "+1.3 kg"); + // reject the reroll on any ungrounded number so the caller keeps the cached + // (already-grounded) paragraph rather than swapping in a fabricated one. + const rerollUngrounded = findUngroundedBriefingNumbers( + { paragraph: freshParagraph }, + args.signals, + ); + if (rerollUngrounded.length > 0) { + return null; + } + // Keep the cached payload verbatim; swap in only the fresh paragraph. const mergedBriefing = { ...(cachedBriefing as Record), @@ -816,6 +842,7 @@ export async function generateComprehensiveInsight( userPrompt, cachedText: dbUser.insightsCachedText, locale, + signals: features.signalsOfDay ?? null, }); if (rerolled) { await prisma.user.update({ @@ -926,6 +953,60 @@ export async function generateComprehensiveInsight( } } + // v1.18.10 (HIGH-1) — daily-briefing number-grounding gate. The cron + + // force-warm path produces the same briefing the POST route does, so it + // enforces the same cross-check: every number the briefing restates must + // trace to a `features.signalsOfDay` figure. One corrective retry, then strip + // the briefing rather than persist a fabricated number. + { + const signals = features.signalsOfDay ?? null; + let ungrounded = findUngroundedBriefingNumbers( + readBriefingBlock(insights), + signals, + ); + if (ungrounded.length > 0) { + annotate({ + action: { name: "insights.generate.briefing_grounding_retry" }, + meta: { locale, ungroundedCount: ungrounded.length }, + }); + try { + const retry = await runRawCompletionWithFallback({ + userId, + providers: chain, + params: { + systemPrompt, + userPrompt: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`, + temperature: AI_BUDGETS.comprehensive.temperature, + maxTokens: AI_BUDGETS.comprehensive.maxTokens, + responseFormat: "json", + }, + }); + const retryInsights = parseComprehensiveResult(retry.result.content); + const retryUngrounded = findUngroundedBriefingNumbers( + readBriefingBlock(retryInsights), + signals, + ); + if (retryInsights !== null && retryUngrounded.length === 0) { + insights = retryInsights; + result = retry.result; + workingProviderType = retry.workingProvider.providerType; + ungrounded = []; + } else { + ungrounded = retryUngrounded; + } + } catch { + // Retry failed — fall through to the strip below. + } + } + if (ungrounded.length > 0 && insights && typeof insights === "object") { + (insights as Record).dailyBriefing = null; + annotate({ + action: { name: "insights.generate.briefing_grounding_stripped" }, + meta: { locale, ungroundedCount: ungrounded.length }, + }); + } + } + // v1.9.0 — the caller abandoned this generation (its bounded timeout // fired and it has moved on to warm the per-status caches itself). Bail // out before the cache write so a late-resolving comprehensive cannot From 64de009d66cebe4a95b4b5b1b4cc1827dd8428ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 16:58:23 +0200 Subject: [PATCH 2/7] fix(coach): screen the assistant reply before it streams detectRefusal guards only the inbound user message; the model's reply was streamed verbatim. A dose-prescription or a fabricated clinical risk score that slipped past the system-prompt contracts reached the user unfiltered. Add a deterministic outbound screen on the assembled reply (which the route already buffers before streaming): tight dose-change patterns plus risk-score patterns, EN/DE. On a trip the turn is replaced with a calm, grounded fallback and any reminder suggestion / key-value provenance from the discarded reply is dropped. --- .../ai/coach/__tests__/outbound-guard.test.ts | 92 +++++++++++ src/lib/ai/coach/outbound-guard.ts | 148 ++++++++++++++++++ 2 files changed, 240 insertions(+) create mode 100644 src/lib/ai/coach/__tests__/outbound-guard.test.ts create mode 100644 src/lib/ai/coach/outbound-guard.ts diff --git a/src/lib/ai/coach/__tests__/outbound-guard.test.ts b/src/lib/ai/coach/__tests__/outbound-guard.test.ts new file mode 100644 index 000000000..1c71d24fa --- /dev/null +++ b/src/lib/ai/coach/__tests__/outbound-guard.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest"; + +import { + screenCoachReply, + coachOutboundFallback, + COACH_OUTBOUND_DOSE_BLOCK_EN, + COACH_OUTBOUND_DOSE_BLOCK_DE, + COACH_OUTBOUND_RISK_BLOCK_EN, +} from "@/lib/ai/coach/outbound-guard"; + +describe("screenCoachReply — dose-prescription", () => { + const blocked = [ + "You should step up to 2.4 mg next week.", + "Consider increasing to 10 mg.", + "I'd recommend a 0.5 mg dose to start.", + "Erhöhe auf 7,5 mg, sobald du dich daran gewöhnt hast.", + "Du kannst die nächste Stufe 5 mg ausprobieren.", + "Lower your dose to 1.0 mg.", + ]; + for (const reply of blocked) { + it(`blocks: ${reply.slice(0, 32)}`, () => { + const d = screenCoachReply(reply); + expect(d.block).toBe(true); + expect(d.reason).toBe("dose_prescription"); + }); + } + + const allowed = [ + "You're on week 3 of 7.5 mg, and your weight is tracking down.", + "Your snapshot shows you take 5 mg daily — adherence looks steady.", + "Dein Snapshot zeigt 7,5 mg in Woche 3 — die Einnahmetreue ist gut.", + "Talk to your prescriber about whether your dose is still right.", + ]; + for (const reply of allowed) { + it(`allows factual mention: ${reply.slice(0, 32)}`, () => { + expect(screenCoachReply(reply).block).toBe(false); + }); + } +}); + +describe("screenCoachReply — risk score", () => { + const blocked = [ + "Your 10-year cardiovascular risk is about 12%.", + "That puts your stroke risk at 8%.", + "Your ASCVD score suggests elevated risk.", + "Dein 10-Jahres-Risiko liegt bei etwa 14%.", + "Das ergibt ein Risiko von 12% in den nächsten Jahren.", + ]; + for (const reply of blocked) { + it(`blocks: ${reply.slice(0, 32)}`, () => { + const d = screenCoachReply(reply); + expect(d.block).toBe(true); + expect(d.reason).toBe("risk_score"); + }); + } + + it("allows a plain percentage that is not a risk claim", () => { + expect( + screenCoachReply("Your adherence was 92% over the last 30 days.").block, + ).toBe(false); + }); +}); + +describe("screenCoachReply — clean replies", () => { + it("passes a grounded, non-prescriptive reply", () => { + const reply = + "Your systolic is averaging 128 mmHg this week, 4 below your monthly mean. Nice and steady — keep the routine going."; + expect(screenCoachReply(reply).block).toBe(false); + }); + it("passes an empty reply through (handled upstream)", () => { + expect(screenCoachReply("").block).toBe(false); + }); +}); + +describe("coachOutboundFallback", () => { + it("returns localised dose-block copy", () => { + expect(coachOutboundFallback("dose_prescription", "en")).toBe( + COACH_OUTBOUND_DOSE_BLOCK_EN, + ); + expect(coachOutboundFallback("dose_prescription", "de")).toBe( + COACH_OUTBOUND_DOSE_BLOCK_DE, + ); + }); + it("returns risk-block copy and rides EN for non-de locales", () => { + expect(coachOutboundFallback("risk_score", "en")).toBe( + COACH_OUTBOUND_RISK_BLOCK_EN, + ); + expect(coachOutboundFallback("risk_score", "fr")).toBe( + COACH_OUTBOUND_RISK_BLOCK_EN, + ); + }); +}); diff --git a/src/lib/ai/coach/outbound-guard.ts b/src/lib/ai/coach/outbound-guard.ts new file mode 100644 index 000000000..1470b3d59 --- /dev/null +++ b/src/lib/ai/coach/outbound-guard.ts @@ -0,0 +1,148 @@ +/** + * v1.18.10 (HIGH-2) — Coach OUTBOUND safety screen. + * + * `detectRefusal()` guards the INBOUND channel (the user's message) only. The + * model's reply was tokenised and streamed verbatim with no equivalent + * server-side check, so a dose-prescription ("step up to 2.4 mg") or a + * fabricated risk score in the assistant turn reached the user unfiltered. The + * GLP-1 dose-safety contract and grounding block are composed into the Coach + * system prompt, but those are instructions, not enforcement. + * + * The chat route already buffers the full reply before streaming (the provider + * clients return the body in one shot, then `tokeniseForStreaming` chunks it + * for the UI), so the practical screen runs on the assembled reply BEFORE + * persistence and streaming. On a trip the route swaps in a safe fallback turn + * — the user sees a calm "talk to your prescriber" message, never the + * fabricated/prescriptive text. + * + * Posture, mirroring the inbound detector: + * - Pattern-based + deterministic, so it is cheap and auditable; an LLM judge + * would itself be promptable. + * - Errs toward catching: the dose-prescription patterns are tight (a verb + + * a target dose with a unit), so an ordinary "you're on 7.5 mg this week" + * factual restatement (which the contract explicitly PERMITS) does not trip + * — only an imperative to change the dose does. + */ +import type { Locale } from "@/lib/i18n/config"; + +/** Why the outbound reply was blocked, for the Wide-Event annotation. */ +export type CoachOutboundReason = "dose_prescription" | "risk_score"; + +export interface CoachOutboundDecision { + /** True when the assembled reply must be replaced with the fallback. */ + block: boolean; + /** Which screen tripped — drives Wide-Event metadata. */ + reason: CoachOutboundReason | null; +} + +/** + * Dose-prescription patterns. Each requires a CHANGE verb + a target dose with + * a unit, so a factual restatement of the current step ("you are on 7.5 mg", + * "week 3 on 7.5 mg") — which the GLP-1 contract permits — does not match, + * while an imperative ("step up to 2.4 mg", "increase to 10 mg", "erhöhe auf + * 7,5 mg") does. The unit set covers the GLP-1 + general oral-dose vocabulary. + */ +const DOSE_UNIT = "(?:mg|mcg|µg|ml|units?|ie|i\\.e\\.|einheiten)"; + +const DOSE_PRESCRIPTION_PATTERNS: readonly RegExp[] = [ + // EN: step/move/increase/raise/bump/titrate/go up to|by N unit + new RegExp( + `\\b(?:step|move|increase|raise|bump|titrat\\w*|go|ramp|push|up)\\s+(?:it\\s+)?(?:up\\s+|your\\s+dose\\s+)?(?:to|by)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, + "i", + ), + // EN: lower/reduce/cut/drop/decrease/back off to|by N unit + new RegExp( + `\\b(?:lower|reduce|cut|drop|decrease|back\\s+off|taper)\\s+(?:it\\s+|your\\s+dose\\s+)?(?:to|by)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, + "i", + ), + // EN: consider/try/should/recommend ... N unit dose + new RegExp( + `\\b(?:consider|try|you\\s+should|i'?d?\\s+recommend|i\\s+suggest)\\b[^.?!]{0,40}\\b[\\d.,]+\\s*${DOSE_UNIT}\\b`, + "i", + ), + // DE: erhöhe/steigere/geh hoch auf|um N unit + new RegExp( + `\\b(?:erhöh\\w*|steiger\\w*|setz\\w*\\s+(?:hoch|rauf)|geh\\w*\\s+(?:hoch|rauf))\\s+(?:auf|um)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, + "i", + ), + // DE: reduziere/senke/verringere auf|um N unit + new RegExp( + `\\b(?:reduzier\\w*|senk\\w*|verringer\\w*|nimm\\s+(?:weniger|runter))\\s+(?:auf|um)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, + "i", + ), + // DE: nächste Stufe / nächste Dosis N unit (recommend-shaped) + new RegExp( + `\\bn[äa]chste\\s+(?:stufe|dosis)\\b[^.?!]{0,30}\\b[\\d.,]+\\s*${DOSE_UNIT}\\b`, + "i", + ), +]; + +/** + * Risk-score fabrication patterns: the Coach is grounded on the snapshot and + * must never invent a computed clinical risk percentage / score. A "10-year + * cardiovascular risk of 12%" or a fabricated "your risk score is 8/10" is a + * number the server never computed and the snapshot never carried. + */ +const RISK_SCORE_PATTERNS: readonly RegExp[] = [ + // EN: % (cardiovascular|heart|stroke|mortality|...) risk + /\b\d{1,3}\s*%\s+(?:risk|chance|probability|likelihood)\b/i, + /\b(?:risk|chance|probability|likelihood)\s+(?:of|is|at)\s+(?:about\s+|roughly\s+|~)?\d{1,3}\s*%/i, + /\b(?:10[- ]year|ten[- ]year|lifetime)\s+(?:cardiovascular|cardiac|heart|stroke|mortality|cvd|ascvd)\s+risk\b/i, + /\b(?:framingham|ascvd|score2?|qrisk)\b/i, + // DE: Risiko von N% / N% Risiko + /\brisiko\s+(?:von|bei|liegt\s+bei)\s+(?:etwa\s+|ungefähr\s+|~)?\d{1,3}\s*%/i, + /\b\d{1,3}\s*%\s+(?:risiko|wahrscheinlichkeit)\b/i, + /\b(?:10[- ]jahres|zehn[- ]jahres|lebenszeit)[- ]?(?:risiko)\b/i, +]; + +/** + * Screen an assembled assistant reply. Runs the dose-prescription patterns + * first (highest medical-safety leverage), then the risk-score patterns. + */ +export function screenCoachReply(reply: string): CoachOutboundDecision { + const text = reply ?? ""; + if (text.trim().length === 0) { + return { block: false, reason: null }; + } + for (const pattern of DOSE_PRESCRIPTION_PATTERNS) { + if (pattern.test(text)) { + return { block: true, reason: "dose_prescription" }; + } + } + for (const pattern of RISK_SCORE_PATTERNS) { + if (pattern.test(text)) { + return { block: true, reason: "risk_score" }; + } + } + return { block: false, reason: null }; +} + +/** + * Safe fallback copy when the outbound screen trips. de/en carry a native + * body; the other UI locales ride the EN body (the Coach itself replies in + * de/en, so this is the consistent posture with the inbound refusal copy). + * Exposed as constants so server-only code (tests, logs) can pin the wording. + */ +export const COACH_OUTBOUND_DOSE_BLOCK_EN = + "I can't suggest a specific medication dose or a change to one — dose decisions belong with your prescribing clinician. I can help you read what your own data shows around your medication; ask me about that and I'll stay grounded in your numbers."; + +export const COACH_OUTBOUND_DOSE_BLOCK_DE = + "Eine konkrete Medikamenten-Dosis oder eine Dosisänderung kann ich nicht vorschlagen — solche Entscheidungen gehören zu deiner behandelnden Ärztin oder deinem Arzt. Gerne helfe ich dir, das zu lesen, was deine eigenen Daten rund um deine Medikation zeigen; frag mich danach, dann bleibe ich bei deinen Zahlen."; + +export const COACH_OUTBOUND_RISK_BLOCK_EN = + "I can't put a number on a clinical risk like that — it isn't something your tracked data lets me compute, and an invented figure would be misleading. I can walk you through what your own measurements are doing instead; ask me about a specific metric."; + +export const COACH_OUTBOUND_RISK_BLOCK_DE = + "Eine solche klinische Risikozahl kann ich nicht angeben — deine erfassten Daten lassen das nicht berechnen, und ein erfundener Wert wäre irreführend. Stattdessen kann ich dir zeigen, was deine eigenen Messwerte machen; frag mich nach einer konkreten Metrik."; + +/** Resolve the localised fallback copy for a tripped outbound screen. */ +export function coachOutboundFallback( + reason: CoachOutboundReason, + locale: Locale, +): string { + const de = locale === "de"; + if (reason === "dose_prescription") { + return de ? COACH_OUTBOUND_DOSE_BLOCK_DE : COACH_OUTBOUND_DOSE_BLOCK_EN; + } + return de ? COACH_OUTBOUND_RISK_BLOCK_DE : COACH_OUTBOUND_RISK_BLOCK_EN; +} From 869f1d9c11517487ed4f099939640127095460bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 16:58:23 +0200 Subject: [PATCH 3/7] fix(coach): wire the outbound screen and an SSE cancel handler Apply the outbound safety screen to the assembled Coach reply before persistence and streaming, with a per-turn audit row on a block. createSseStream gains a cancel handler + a shared AbortSignal: a client disconnect now flips the signal so the token-emit loop stops enqueuing into a closed connection and any provider fetch wired to the signal is torn down. The chat emit loop honours it between frames. --- src/app/api/insights/chat/route.ts | 43 +++++++++++++++++++++++++++--- src/lib/sse/create-stream.ts | 29 +++++++++++++++++++- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index ea8907ad7..f73812a66 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -66,6 +66,10 @@ import { reconcileSpend, } from "@/lib/ai/coach/budget"; import { detectRefusal } from "@/lib/ai/coach/refusal"; +import { + screenCoachReply, + coachOutboundFallback, +} from "@/lib/ai/coach/outbound-guard"; import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt"; import { getSelfContextTextForUser } from "@/lib/ai/coach/about-me"; import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot"; @@ -564,9 +568,33 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`; // + dismissal memory + cooldown + dedup against a live COACH reminder). // A suppressed proposal leaves the prose unchanged and emits no card. const suggestParse = parseSuggestReminder(proseAfterStrip); - const replyText = suggestParse.prose.trim() || proseAfterStrip; + let replyText = suggestParse.prose.trim() || proseAfterStrip; + + // v1.18.10 (HIGH-2) — OUTBOUND safety screen on the assembled assistant + // reply, before persistence and streaming. The inbound `detectRefusal` + // guards the user's message; this guards the model's reply for a + // dose-prescription or a fabricated clinical risk score that slipped past + // the system-prompt GLP-1/grounding contracts. On a trip the turn is + // replaced with a calm, grounded fallback and any reminder suggestion / + // key-value provenance is dropped — the user never sees the unsafe text. + const outbound = screenCoachReply(replyText); + if (outbound.block && outbound.reason) { + replyText = coachOutboundFallback(outbound.reason, locale); + annotate({ + action: { name: "insights.coach.outbound_blocked" }, + meta: { reason: outbound.reason, promptVersion: PROMPT_VERSION }, + }); + await auditLog("insights.coach.outbound_blocked", { + userId, + details: { + conversationId: workingConversationId, + reason: outbound.reason, + }, + }); + } + let surfacedSuggestion: CoachSuggestion | null = null; - if (suggestParse.cadence) { + if (!outbound.block && suggestParse.cadence) { const decision = await gateSuggestion({ prisma, userId, @@ -614,7 +642,11 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`; } const enrichedProvenance: typeof snapshot.provenance = { ...snapshot.provenance, - ...(sentinel.keyValues.length > 0 ? { keyValues: sentinel.keyValues } : {}), + // v1.18.10 (HIGH-2) — a blocked turn carries the fallback prose, so the + // key-values from the discarded reply must not ride along as provenance. + ...(!outbound.block && sentinel.keyValues.length > 0 + ? { keyValues: sentinel.keyValues } + : {}), ...(surfacedSuggestion ? { suggestion: surfacedSuggestion } : {}), }; if (sentinel.malformed) { @@ -692,9 +724,14 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`; // pace). const stream = createSseStream(async (controller) => { for (const tok of tokeniseForStreaming(replyText)) { + // v1.18.10 (A-2) — stop tokenising the moment the client disconnects; + // the cancel handler flips this signal, so we don't pace frames into a + // closed connection. + if (controller.signal.aborted) return; controller.enqueue(encodeFrame({ type: "token", token: tok })); await flushTick(); } + if (controller.signal.aborted) return; controller.enqueue( encodeFrame({ type: "provenance", diff --git a/src/lib/sse/create-stream.ts b/src/lib/sse/create-stream.ts index 78181a7c2..15ee3cb13 100644 --- a/src/lib/sse/create-stream.ts +++ b/src/lib/sse/create-stream.ts @@ -20,6 +20,14 @@ export interface SseController { enqueue: (chunk: T) => void; + /** + * v1.18.10 (A-2) — aborts when the consumer cancels the stream (client + * disconnect, navigation away, `Response.body.cancel()`). The `emit` + * callback should check `signal.aborted` between frames and stop producing, + * and pass `signal` to any in-flight provider fetch so a mid-stream + * disconnect tears the upstream call down instead of streaming into the void. + */ + signal: AbortSignal; } /** @@ -27,17 +35,36 @@ export interface SseController { * supplied `emit` function inside a try/finally that always closes the * controller. Use the returned stream with the standard SSE response * headers (`Content-Type: text/event-stream`, `Cache-Control: no-cache`). + * + * v1.18.10 (A-2) — the stream now wires a `cancel` handler: when the consumer + * cancels (client disconnect), the shared `AbortController` fires, so the + * `emit` callback's `signal` flips to aborted and any provider fetch wired to + * it is torn down. Without this the emit loop kept enqueuing frames (and an + * abort-less upstream fetch kept running) after the client had gone. */ export function createSseStream( emit: (controller: SseController) => void | Promise, ): ReadableStream { + const abort = new AbortController(); return new ReadableStream({ async start(controller) { + const sse: SseController = { + enqueue: (chunk) => { + // A late enqueue after the consumer cancelled would throw on the + // closed controller — swallow it so the abort path stays clean. + if (abort.signal.aborted) return; + controller.enqueue(chunk); + }, + signal: abort.signal, + }; try { - await emit(controller); + await emit(sse); } finally { controller.close(); } }, + cancel(reason) { + abort.abort(reason); + }, }); } From 080ce73475f3b9e9a86853f1a94fbd9c65baa94a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 16:58:33 +0200 Subject: [PATCH 4/7] fix(insights): add a grounding gate + retry to the period narrative The period narrative was written straight to storage with no post-generation check and no corrective retry, unlike Comprehensive Insights. A populated context could still yield causal phrasing or a number absent from the context block. Add a validator that flags causal language (EN/DE) and any number not in the structured context; on a trip, retry once with a corrective suffix, then write nothing (the last good row stays) rather than persist an ungrounded or causal story. --- .../__tests__/narrative-grounding.test.ts | 117 ++++++++++ .../insights/narrative/narrative-grounding.ts | 210 ++++++++++++++++++ .../narrative/period-narrative-generate.ts | 57 ++++- 3 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 src/lib/insights/narrative/__tests__/narrative-grounding.test.ts create mode 100644 src/lib/insights/narrative/narrative-grounding.ts diff --git a/src/lib/insights/narrative/__tests__/narrative-grounding.test.ts b/src/lib/insights/narrative/__tests__/narrative-grounding.test.ts new file mode 100644 index 000000000..c1528fc4a --- /dev/null +++ b/src/lib/insights/narrative/__tests__/narrative-grounding.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from "vitest"; + +import { + validateNarrativeText, + buildNarrativeCorrection, +} from "@/lib/insights/narrative/narrative-grounding"; +import type { PeriodNarrativeContext } from "@/lib/insights/narrative/period-narrative"; + +function ctx( + partial: Partial = {}, +): PeriodNarrativeContext { + return { + status: "ready", + period: "week", + metricDeltas: [ + { + type: "WEIGHT" as PeriodNarrativeContext["metricDeltas"][number]["type"], + unit: "kg", + current: 82, + prior: 84, + delta: -2, + deltaPercent: -2.4, + currentDays: 6, + priorDays: 7, + }, + ], + bandTransitions: [], + drivers: [], + coincidentFlags: [], + pairsTested: 5, + fdrQ: 0.1, + provenance: { + metrics: ["weight"], + window: { from: "2026-06-01", to: "2026-06-07" }, + computedAt: "2026-06-07T00:00:00.000Z", + }, + ...partial, + }; +} + +describe("validateNarrativeText — causal language", () => { + it("flags 'because'", () => { + const out = validateNarrativeText( + "Your weight fell because you slept more.", + ctx(), + ); + expect(out.some((f) => f.reason === "causal_language")).toBe(true); + }); + it("flags German 'wegen'", () => { + const out = validateNarrativeText( + "Dein Gewicht sank wegen des Schlafs.", + ctx(), + ); + expect(out.some((f) => f.reason === "causal_language")).toBe(true); + }); + it("flags 'culprit'", () => { + const out = validateNarrativeText( + "Stress is the likely culprit here.", + ctx(), + ); + expect(out.some((f) => f.reason === "causal_language")).toBe(true); + }); + it("allows descriptive 'associated with' / 'moved with'", () => { + const out = validateNarrativeText( + "Your weight of 82 kg moved with your sleep this week, associated with a 2 kg drop.", + ctx(), + ); + expect(out).toEqual([]); + }); +}); + +describe("validateNarrativeText — number grounding", () => { + it("passes numbers present in the context", () => { + const out = validateNarrativeText( + "Your weight is 82 kg, down 2 kg (about -2.4%) from the prior week.", + ctx(), + ); + expect(out).toEqual([]); + }); + it("flags a number not in the context", () => { + const out = validateNarrativeText( + "Your weight dropped 9 kg this week.", + ctx(), + ); + expect( + out.some((f) => f.reason === "ungrounded_number" && f.source === "9"), + ).toBe(true); + }); + it("does not flag structural integers (7-day window)", () => { + const out = validateNarrativeText( + "Over the last 7 days your weight held at 82 kg.", + ctx(), + ); + expect(out).toEqual([]); + }); +}); + +describe("buildNarrativeCorrection", () => { + it("names causal + ungrounded findings (en)", () => { + const msg = buildNarrativeCorrection( + [ + { reason: "causal_language", source: "because" }, + { reason: "ungrounded_number", source: "9" }, + ], + "en", + ); + expect(msg.toLowerCase()).toContain("causal"); + expect(msg).toContain("9"); + }); + it("produces German copy", () => { + const msg = buildNarrativeCorrection( + [{ reason: "causal_language", source: "weil" }], + "de", + ); + expect(msg).toContain("URSÄCHLICHE"); + }); +}); diff --git a/src/lib/insights/narrative/narrative-grounding.ts b/src/lib/insights/narrative/narrative-grounding.ts new file mode 100644 index 000000000..0422c0ef3 --- /dev/null +++ b/src/lib/insights/narrative/narrative-grounding.ts @@ -0,0 +1,210 @@ +/** + * v1.18.10 (HIGH-3) — period-narrative grounding validator. + * + * The period narrative was plain text written straight to storage after the + * provider call, with NO post-generation check and NO corrective retry — + * unlike Comprehensive Insights, which rejects + retries + 422s on a grounding + * miss. The system prompt bans causal language and forbids stating a number + * not in the context, but those are instructions, not enforcement: a populated + * (non-`insufficient`) context could still yield "stress is the likely + * culprit" or a figure absent from the context block, and nothing rejected it. + * + * This module is the missing gate. Given the generated text and the structured + * `PeriodNarrativeContext`, it flags: + * 1. causal phrasing ("because", "caused", "due to", "weil", "wegen", …); + * 2. any number the narrative states that does not trace to a context figure + * within a rounding tolerance. + * The generator runs it after the completion, retries ONCE with a corrective + * suffix on a trip, and falls back to NO narrative (writes nothing) rather than + * persisting an ungrounded story. + * + * Pure + side-effect-free, so it unit-tests in isolation. + */ +import type { PeriodNarrativeContext } from "@/lib/insights/narrative/period-narrative"; + +/** Absolute + relative number-match tolerance (rounding slack). */ +const ABS_TOLERANCE = 0.15; +const REL_TOLERANCE = 0.02; + +/** + * Causal phrases that violate the descriptive-never-causal contract. Word- + * boundary anchored, case-insensitive. The list is the EN + DE causal + * vocabulary; "associated with" / "moved with" / "assoziiert" stay PERMITTED + * (they are the descriptive framing the contract requires). + */ +const CAUSAL_PATTERNS: readonly RegExp[] = [ + /\bbecause\s+of\b/i, + /\bbecause\b/i, + /\bcaused?\s+by\b/i, + /\bcaus(?:e|es|ed|ing)\b/i, + /\bdue\s+to\b/i, + /\bled\s+to\b/i, + /\bresulted\s+in\b/i, + /\bresult\s+of\b/i, + /\bculprit\b/i, + /\bdriven\s+by\b/i, + /\bthanks\s+to\b/i, + /\bowing\s+to\b/i, + // DE + /\bweil\b/i, + /\bwegen\b/i, + /\bverursach\w*/i, + /\baufgrund\b/i, + /\bführt[e]?\s+zu\b/i, + /\bdurch\s+\w+\s+(?:verursacht|ausgelöst)\b/i, + /\bschuld\b/i, + /\bauslöser\b/i, +]; + +export type NarrativeGroundingReason = "causal_language" | "ungrounded_number"; + +export interface NarrativeGroundingFinding { + reason: NarrativeGroundingReason; + /** The matched token (truncated). */ + source: string; +} + +/** Parse every standalone number out of free text (`.`/`,` decimals). */ +function extractNumbers(text: string): Array<{ value: number; raw: string }> { + const out: Array<{ value: number; raw: string }> = []; + const re = /[+-]?\d+(?:[.,]\d+)?/g; + let match: RegExpExecArray | null; + while ((match = re.exec(text)) !== null) { + const raw = match[0]; + const value = Number.parseFloat(raw.replace(",", ".")); + if (Number.isFinite(value)) out.push({ value, raw }); + } + return out; +} + +/** Every authoritative magnitude the context exposes (signed + absolute). */ +function authoritativeValues(context: PeriodNarrativeContext): number[] { + const values: number[] = []; + const push = (n: number | null | undefined) => { + if (typeof n === "number" && Number.isFinite(n)) { + values.push(n); + values.push(Math.abs(n)); + } + }; + for (const d of context.metricDeltas) { + push(d.current); + push(d.prior); + push(d.delta); + push(d.deltaPercent); + push(d.currentDays); + push(d.priorDays); + } + for (const b of context.bandTransitions) { + push(b.center); + push(b.bandLow); + push(b.bandHigh); + push(b.baselineDays); + } + for (const dr of context.drivers) { + push(dr.r); + push(dr.qValue); + push(dr.n); + } + push(context.pairsTested); + push(context.fdrQ); + return values; +} + +function isGrounded(value: number, authoritative: readonly number[]): boolean { + for (const a of authoritative) { + const tol = Math.max(ABS_TOLERANCE, Math.abs(a) * REL_TOLERANCE); + if (Math.abs(value - a) <= tol) return true; + } + return false; +} + +/** + * Structural integers the narrative may state without a context match — small + * ordinals and the literal period lengths the prompt references (7 / 30 days). + */ +const STRUCTURAL_INTEGERS = new Set([7, 30]); + +function isStructural(value: number, raw: string): boolean { + if (raw.includes(".") || raw.includes(",")) return false; + if (!Number.isInteger(value)) return false; + const abs = Math.abs(value); + if (abs <= 3) return true; + return STRUCTURAL_INTEGERS.has(abs); +} + +/** + * Validate a generated narrative against its context. Returns every grounding + * violation found; an empty array means the narrative is grounded and + * causally-clean. + */ +export function validateNarrativeText( + text: string, + context: PeriodNarrativeContext, +): NarrativeGroundingFinding[] { + const findings: NarrativeGroundingFinding[] = []; + if (typeof text !== "string" || text.trim().length === 0) return findings; + + for (const pattern of CAUSAL_PATTERNS) { + const m = pattern.exec(text); + if (m) { + findings.push({ reason: "causal_language", source: m[0].slice(0, 32) }); + break; // one causal flag is enough to trigger the corrective retry + } + } + + const authoritative = authoritativeValues(context); + for (const { value, raw } of extractNumbers(text)) { + if (isStructural(value, raw)) continue; + if (isGrounded(value, authoritative)) continue; + findings.push({ reason: "ungrounded_number", source: raw.slice(0, 32) }); + } + + return findings; +} + +/** + * Corrective suffix appended to the user prompt on the single retry, naming the + * violations so the model can self-correct — mirroring the comprehensive + * insight's `buildRetryCorrectionMessage` posture. + */ +export function buildNarrativeCorrection( + findings: readonly NarrativeGroundingFinding[], + locale: "de" | "en", +): string { + const causal = findings.some((f) => f.reason === "causal_language"); + const numbers = findings + .filter((f) => f.reason === "ungrounded_number") + .slice(0, 6) + .map((f) => f.source) + .join(", "); + if (locale === "de") { + const parts = [ + "Deine vorige Zusammenfassung hat die festen Regeln verletzt.", + ]; + if (causal) { + parts.push( + "Sie enthielt URSÄCHLICHE Formulierungen. Schreibe ausschließlich BESCHREIBEND ('X bewegte sich mit Y', 'X war mit Y assoziiert') — nie 'weil', 'wegen', 'verursacht', 'führte zu'.", + ); + } + if (numbers) { + parts.push( + `Sie nannte Zahlen, die NICHT im Kontext stehen: ${numbers}. Nenne nur Zahlen, die wörtlich im KONTEXT vorkommen, oder lass die Zahl weg.`, + ); + } + parts.push("Schreibe die Zusammenfassung erneut (2–4 kurze Sätze)."); + return `\n\n${parts.join(" ")}`; + } + const parts = ["Your previous summary broke the hard rules."]; + if (causal) { + parts.push( + "It used CAUSAL wording. Write strictly DESCRIPTIVELY ('X moved with Y', 'X was associated with Y') — never 'because', 'due to', 'caused', 'led to'.", + ); + } + if (numbers) { + parts.push( + `It stated numbers NOT in the context: ${numbers}. State only numbers that appear verbatim in the CONTEXT, or drop the number.`, + ); + } + parts.push("Re-write the summary (2-4 short sentences)."); + return `\n\n${parts.join(" ")}`; +} diff --git a/src/lib/insights/narrative/period-narrative-generate.ts b/src/lib/insights/narrative/period-narrative-generate.ts index 9e1efaa8e..b597a5511 100644 --- a/src/lib/insights/narrative/period-narrative-generate.ts +++ b/src/lib/insights/narrative/period-narrative-generate.ts @@ -41,6 +41,10 @@ import { DETERMINISTIC_PROVIDER_TYPE, } from "@/lib/insights/narrative/period-narrative-deterministic"; import { composeSharedContracts } from "@/lib/ai/prompts/shared-contracts"; +import { + validateNarrativeText, + buildNarrativeCorrection, +} from "@/lib/insights/narrative/narrative-grounding"; /** * Stable identifier for the narrative prompt revision. Bumped whenever the @@ -350,10 +354,61 @@ export async function generatePeriodNarrative( return { status: "generated", providerType: DETERMINISTIC_PROVIDER_TYPE }; } - const text = completion.content.trim(); + let text = completion.content.trim(); if (text.length === 0) { return { status: "failed", reason: "empty" }; } + + // v1.18.10 (HIGH-3) — grounding gate on the AI prose, matching the rejecting + // gate Comprehensive Insights already has. Flag causal phrasing and any + // number absent from the context; on a trip, retry ONCE with a corrective + // suffix, then fall back to writing NOTHING (last good row stays) rather than + // persisting an ungrounded or causal story. + let findings = validateNarrativeText(text, context); + if (findings.length > 0) { + annotate({ + action: { name: "insights.narrative.grounding_retry" }, + meta: { + period, + locale, + causal: findings.some((f) => f.reason === "causal_language"), + ungroundedNumbers: findings.filter( + (f) => f.reason === "ungrounded_number", + ).length, + }, + }); + const retry = await runCompletion({ + userId, + cacheAction: `insights.narrative.${period}.${locale}`, + consentSurface: "insights", + systemPrompt: locale === "de" ? SYSTEM_PROMPT_DE : SYSTEM_PROMPT_EN, + userPrompt: + buildNarrativeUserPrompt(context, locale) + + buildNarrativeCorrection(findings, locale), + temperature: AI_BUDGETS.narrative.temperature, + maxTokens: AI_BUDGETS.narrative.maxTokens, + seed: REFERENCE_AI_SEED, + responseFormat: "text", + }); + if (retry.kind === "ok") { + const retryText = retry.content.trim(); + const retryFindings = validateNarrativeText(retryText, context); + if (retryText.length > 0 && retryFindings.length === 0) { + text = retryText; + findings = []; + } else { + findings = retryFindings; + } + } + if (findings.length > 0) { + annotate({ + action: { name: "insights.narrative.grounding_rejected" }, + meta: { period, locale }, + }); + return { status: "failed", reason: "ungrounded" }; + } + } + await persist(text, completion.providerType); return { status: "generated", providerType: completion.providerType }; } From 22fac8ae904957e307bfc9aa8d0af31b5ab42f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 16:58:33 +0200 Subject: [PATCH 5/7] perf(coach): cap the snapshot measurement read The single multi-type window read had no take bound, so a lastYear / allTime scope over a high-frequency type (PULSE / glucose are 200k-row-class) loaded a year of rows just to fold them into the ~21 daily + ~10 weekly buckets the prompt shows. Read newest-first with a 6000-row cap and restore ascending order; the coarse MONTH/YEAR tail still comes from the rollup tier, so deep history survives. --- src/lib/ai/coach/snapshot.ts | 64 ++++++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 143b7f19a..1292ed9f7 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -111,6 +111,19 @@ const DAILY_TIMELINE_DAYS = 14; /** Default window when the caller doesn't pass a scope. */ const DEFAULT_WINDOW: CoachScopeWindow = "last30days"; +/** + * v1.18.10 (P-2) — newest-first cap on the single multi-type measurement read + * that feeds the Coach snapshot timelines. The window read can reach 365 days + * (`lastYear` / `allTime`) across high-frequency types (PULSE / glucose are + * 200k-row-class), but the prompt only renders ~21 daily + ~10 weekly buckets + * per metric, so an uncapped read loaded a year of rows to discard almost all + * of them. 6000 keeps the recent-daily + weekly window exact even with several + * dense types active (≈ a year of multi-daily readings on one type, or a + * handful of types at a few readings/day) while bounding the worst case; the + * coarse MONTH/YEAR tail comes from the rollup tier, not this read. + */ +const SNAPSHOT_MEASUREMENT_ROW_CAP = 6000; + /** * v1.17.0 — the glucose clinical panel is a fixed trailing-30-day artifact, * identical across the insights panel, the dashboard snapshot, the doctor @@ -965,24 +978,39 @@ async function buildCoachSnapshotImpl( const measurementRowsPromise = wantedTypes.length > 0 - ? prisma.measurement.findMany({ - where: { - userId, - type: { in: wantedTypes as never[] }, - measuredAt: { gte: cutoff }, - deletedAt: null, - }, - orderBy: { measuredAt: "asc" }, - // v1.7.0 — `glucoseContext` rides along so the glucose block - // can split fasting / postprandial / random / bedtime without - // a second query. NULL on every non-glucose row. - select: { - type: true, - value: true, - measuredAt: true, - glucoseContext: true, - }, - }) + ? prisma.measurement + .findMany({ + where: { + userId, + type: { in: wantedTypes as never[] }, + measuredAt: { gte: cutoff }, + deletedAt: null, + }, + // v1.18.10 (P-2) — read NEWEST-first + cap. PULSE / glucose are + // 200k-row-class types and the window can reach 365 days + // (lastYear / allTime), so an uncapped read pulled the entire + // year of high-frequency rows into memory just to fold them into + // ~21 daily + ~10 weekly buckets the prompt actually shows. The + // newest-first cap keeps the recent-daily timeline exact and only + // sheds the deepest weekly buckets on an extreme-volume account; + // the coarse MONTH/YEAR tail is read separately from the rollup + // tier (`buildCoarseTimelineTail`), so deep history survives. + orderBy: { measuredAt: "desc" }, + take: SNAPSHOT_MEASUREMENT_ROW_CAP, + // v1.7.0 — `glucoseContext` rides along so the glucose block + // can split fasting / postprandial / random / bedtime without + // a second query. NULL on every non-glucose row. + select: { + type: true, + value: true, + measuredAt: true, + glucoseContext: true, + }, + }) + // Downstream bucketers re-sort/group internally, but restore + // ascending order so any order-sensitive consumer sees the same + // shape as before the cap. + .then((rows) => rows.reverse()) : Promise.resolve([]); // v1.11.3 — `extractFeatures` and the shared measurement read are From db68f994c2fff23eecce087eeff8b0ec37654c2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 16:58:44 +0200 Subject: [PATCH 6/7] fix(insights): feed the six specialised cards a precomputed SIGNAL block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BP, pulse, weight, BMI, mood and medication-compliance still asked the model to derive the recent-vs-baseline comparison from raw buckets — the exact arithmetic small models do unreliably, and the pattern the base-system prompt already advertises as precomputed. Build the SIGNAL block (current / baseline / signed delta / normal-swing verdict) with buildMetricSignal from each card's graded series and inject it into the snapshot; BP carries two signals, compliance one per medication. Each card's prompt section now leads from the signal block and is told to state, not recompute. The server-computed compliance % is unchanged. --- src/lib/ai/prompts/blood-pressure.ts | 8 +++--- src/lib/ai/prompts/bmi.ts | 8 +++--- src/lib/ai/prompts/medication-compliance.ts | 8 +++--- src/lib/ai/prompts/mood.ts | 8 +++--- src/lib/ai/prompts/pulse.ts | 8 +++--- src/lib/ai/prompts/weight.ts | 8 +++--- .../__tests__/blood-pressure-status.test.ts | 11 +++++++- src/lib/insights/blood-pressure-status.ts | 28 +++++++++++++++++++ src/lib/insights/bmi-status.ts | 13 +++++++++ .../insights/medication-compliance-status.ts | 20 +++++++++++++ src/lib/insights/mood-status.ts | 14 ++++++++++ src/lib/insights/pulse-status.ts | 15 ++++++++++ src/lib/insights/weight-status.ts | 14 ++++++++++ 13 files changed, 138 insertions(+), 25 deletions(-) diff --git a/src/lib/ai/prompts/blood-pressure.ts b/src/lib/ai/prompts/blood-pressure.ts index 559131da3..910adae73 100644 --- a/src/lib/ai/prompts/blood-pressure.ts +++ b/src/lib/ai/prompts/blood-pressure.ts @@ -2,8 +2,8 @@ import type { Locale } from "@/lib/i18n/config"; import { getBaseSystemPrompt } from "./base-system"; const BP_SECTION_DE = `METRIK — BLUTDRUCK: -- Der Snapshot trägt systolisch (bloodPressure.systolic) und diastolisch (bloodPressure.diastolic) jeweils mit summary + graded series; beurteile beide Komponenten GEMEINSAM, nie isoliert. -- Tendenz über Tage/Wochen: Vergleiche das recent-Mittel (letzte Tage/Woche) mit dem weekly/monthly-Mittel der Person. Eine systolische Abweichung gilt ab ~5 mmHg, eine diastolische ab ~3 mmHg gegenüber der eigenen Baseline als signifikant — kleinere Schwankungen sind normal und kein Befund. +- Der Snapshot trägt systolisch (bloodPressure.systolic) und diastolisch (bloodPressure.diastolic) jeweils mit signal (der fertige Vergleich) + summary + graded series; beurteile beide Komponenten GEMEINSAM, nie isoliert. +- Tendenz über Tage/Wochen: Führe mit bloodPressure.systolic.signal und bloodPressure.diastolic.signal — current, delta gegenüber baselineLabel und outsideNormalSwing sind bereits berechnet. NENNE sie, rechne sie NICHT neu. Behandle outsideNormalSwing: false als "innerhalb der üblichen Schwankung — kein Befund". - Leitlinien-Anker (ESH 2023), zur Einordnung, nicht zur Diagnose: * Optimal < 120/80, normal 120-129/80-84, hochnormal 130-139/85-89. * Hypertonie Grad 1 ab 140/90, Grad 2 ab 160/100, Grad 3 ab 180/110. @@ -14,8 +14,8 @@ const BP_SECTION_DE = `METRIK — BLUTDRUCK: - Eine Botschaft: Wenn die Werte über der Baseline liegen, schließe NUR DANN mit EINEM machbaren Schritt, wenn der Befund einen nahelegt (z.B. ein paar Tage zur selben Uhrzeit nachmessen, oder — bei lückenhafter Einnahmetreue — die Einnahme verlässlicher machen). Bei stabilen Werten im Ziel: das ehrlich anerkennen und stattdessen einen Punkt nennen, den man im Auge behalten kann, statt einen Schritt zu erzwingen.`; const BP_SECTION_EN = `METRIC — BLOOD PRESSURE: -- The snapshot carries systolic (bloodPressure.systolic) and diastolic (bloodPressure.diastolic), each with a summary + graded series; judge both components TOGETHER, never in isolation. -- Trend over days/weeks: compare the recent mean (last days/week) with the person's weekly/monthly mean. Treat a systolic shift of ~5 mmHg or a diastolic shift of ~3 mmHg against their own baseline as significant — smaller swings are normal and not a finding. +- The snapshot carries systolic (bloodPressure.systolic) and diastolic (bloodPressure.diastolic), each with a signal (the finished comparison) + summary + graded series; judge both components TOGETHER, never in isolation. +- Trend over days/weeks: lead from bloodPressure.systolic.signal and bloodPressure.diastolic.signal — current, delta vs baselineLabel and outsideNormalSwing are already computed. STATE them, do NOT recompute. Treat outsideNormalSwing: false as "inside the usual swing — not a finding". - Guideline anchors (ESH 2023), for placement, not diagnosis: * Optimal < 120/80, normal 120-129/80-84, high-normal 130-139/85-89. * Hypertension grade 1 from 140/90, grade 2 from 160/100, grade 3 from 180/110. diff --git a/src/lib/ai/prompts/bmi.ts b/src/lib/ai/prompts/bmi.ts index fd4c2ffca..c0e4c1031 100644 --- a/src/lib/ai/prompts/bmi.ts +++ b/src/lib/ai/prompts/bmi.ts @@ -2,18 +2,18 @@ import type { Locale } from "@/lib/i18n/config"; import { getBaseSystemPrompt } from "./base-system"; const BMI_SECTION_DE = `METRIK — BMI: -- Der Snapshot trägt bmi.summary + bmi.series (graded). bmi.latestDayFocus zeigt den jüngsten Wert, dessen WHO-Klassifikation und den Schritt zum Vortag; bmi.target ist das grüne Band (18.5-24.9). +- Der Snapshot trägt bmi.signal (der fertige Vergleich) + bmi.summary + bmi.series (graded). bmi.latestDayFocus zeigt den jüngsten Wert, dessen WHO-Klassifikation und den Schritt zum Vortag; bmi.target ist das grüne Band (18.5-24.9). - Fokus dieser Karte ist die WHO-BAND-EINORDNUNG, nicht das Gewichtstempo (das trägt die Gewichts-Karte). Setze den Schwerpunkt darauf, in welchem Band der Wert liegt und ob er ein Band wechselt oder sich einer Bandgrenze nähert. - WHO-Bänder zur groben Einordnung, nie als Etikett für die Person: Untergewicht < 18.5, Normal 18.5-24.9, Übergewicht 25.0-29.9, Adipositas I 30.0-34.9, II 35.0-39.9, III ≥ 40.0. Ab 65+ gilt 22-27 als günstig (DEGAM). -- Bandbezug: Vergleiche den recent-Wert gegen das weekly/monthly-Mittel und nenne es ausdrücklich, wenn der recent-Wert ein WHO-Band gewechselt hat oder sich (innerhalb ~0.5 BMI-Punkte) einer Bandgrenze nähert. Bleibt der Wert sicher im selben Band, sage das knapp — keine Tempo-Erzählung erzwingen. +- Bandbezug: Führe mit bmi.signal — current, delta gegenüber baselineLabel und placement (Bandlage) sind bereits berechnet; NENNE sie, rechne sie NICHT neu. Nenne es ausdrücklich, wenn der recent-Wert ein WHO-Band gewechselt hat oder sich (innerhalb ~0.5 BMI-Punkte) einer Bandgrenze nähert. Bleibt der Wert sicher im selben Band, sage das knapp — keine Tempo-Erzählung erzwingen. - Grenzen des BMI offen ansprechen: Er misst keine Körperzusammensetzung und ist bei muskulösen oder sehr sportlichen Personen wenig aussagekräftig. - Eine Botschaft: Schließe NUR DANN mit EINEM machbaren Schritt, der zur Bandlage passt, wenn der Befund einen nahelegt — bei stabilem Wert im günstigen Band das ehrlich anerkennen und stattdessen einen Punkt nennen, den man im Auge behalten kann, statt einen Schritt zu erzwingen.`; const BMI_SECTION_EN = `METRIC — BMI: -- The snapshot carries bmi.summary + bmi.series (graded). bmi.latestDayFocus shows the latest value, its WHO classification and the step from the prior day; bmi.target is the green band (18.5-24.9). +- The snapshot carries bmi.signal (the finished comparison) + bmi.summary + bmi.series (graded). bmi.latestDayFocus shows the latest value, its WHO classification and the step from the prior day; bmi.target is the green band (18.5-24.9). - This card's focus is WHO-BAND placement, not weight pace (the weight card carries that). Centre it on which band the value sits in and whether it crosses a band or nears a band boundary. - WHO bands for rough placement, never as a label for the person: Underweight < 18.5, Normal 18.5-24.9, Overweight 25.0-29.9, Obesity I 30.0-34.9, II 35.0-39.9, III ≥ 40.0. From age 65+, 22-27 counts as favourable (DEGAM). -- Band reference: compare the recent value against the weekly/monthly mean and call it out explicitly when the recent value has crossed a WHO band or is nearing a band boundary (within ~0.5 BMI points). When the value stays safely inside the same band, say so briefly — do not force a pace narrative. +- Band reference: lead from bmi.signal — current, delta vs baselineLabel and placement (band position) are already computed; STATE them, do NOT recompute. Call it out explicitly when the recent value has crossed a WHO band or is nearing a band boundary (within ~0.5 BMI points). When the value stays safely inside the same band, say so briefly — do not force a pace narrative. - Name BMI's limits plainly: it does not measure body composition and is weakly informative for muscular or very athletic people. - One message: close with ONE doable step that fits the band placement ONLY when the finding implies one — when the value is stable in the favourable band, acknowledge that honestly and name one thing worth keeping an eye on rather than forcing a finding or a step.`; diff --git a/src/lib/ai/prompts/medication-compliance.ts b/src/lib/ai/prompts/medication-compliance.ts index b6daf9c2e..5a2bcffe0 100644 --- a/src/lib/ai/prompts/medication-compliance.ts +++ b/src/lib/ai/prompts/medication-compliance.ts @@ -2,8 +2,8 @@ import type { Locale } from "@/lib/i18n/config"; import { getBaseSystemPrompt } from "./base-system"; const MEDCO_SECTION_DE = `METRIK — MEDIKAMENTEN-EINNAHMETREUE: -- Der Snapshot trägt overall (medicationCount, averageCompliance7, averageCompliance30) und medications[] je Medikament mit name, dose, schedulesPerDay, compliance7, compliance30, streak, taken7/skipped7/missed7, dailySeries (graded) und latestDay. -- Lies die Einnahmetreue als Prozentsatz erfüllter geplanter Dosen über die letzten 7 bzw. 30 Tage. Vergleiche compliance7 mit compliance30, um die jüngste Richtung gegen die eigene Baseline der Person zu sehen. +- Der Snapshot trägt overall (medicationCount, averageCompliance7, averageCompliance30) und medications[] je Medikament mit name, dose, schedulesPerDay, compliance7, compliance30, streak, taken7/skipped7/missed7, signal (der fertige Trend-Vergleich), dailySeries (graded) und latestDay. +- Lies die Einnahmetreue als Prozentsatz erfüllter geplanter Dosen über die letzten 7 bzw. 30 Tage; compliance7/compliance30 sind server-berechnet. Für die jüngste RICHTUNG gegen die eigene Baseline führe mit medications[].signal — current, delta gegenüber baselineLabel und outsideNormalSwing sind bereits berechnet; NENNE sie, rechne sie NICHT neu. - Seltene Kadenz: Bei niedriger schedulesPerDay oder seltener Einnahme (z.B. Wocheninjektion) ist compliance7 rauschanfällig — eine einzige verpasste Dosis verschiebt den Prozentwert stark. Stütze die Einschätzung dann auf compliance30 (den tragenden Wert) und behandle compliance7 nur als groben Hinweis. - Einordnung (zur Orientierung, nicht als Urteil): ≥ 90 % sehr verlässlich, 80-89 % gut, 70-79 % lückenhaft, < 70 % deutlich lückenhaft — die therapeutische Wirkung kann dann eingeschränkt sein. - Positiv rahmen: streak ist die längste ununterbrochene Serie — bei guter Treue ausdrücklich anerkennen. Bei mehreren Medikamenten das mit der niedrigsten Treue benennen. @@ -11,8 +11,8 @@ const MEDCO_SECTION_DE = `METRIK — MEDIKAMENTEN-EINNAHMETREUE: - Eine Botschaft: Schließe NUR DANN mit EINEM machbaren Schritt, wenn der Befund einen nahelegt (z.B. Abenddosen werden häufiger vergessen — eine feste Routine oder ein Reminder zur kritischen Uhrzeit kann helfen). Bei durchgehend hoher Treue das ehrlich anerkennen, statt einen Schritt zu erzwingen. Keine Schuldzuweisung.`; const MEDCO_SECTION_EN = `METRIC — MEDICATION ADHERENCE: -- The snapshot carries overall (medicationCount, averageCompliance7, averageCompliance30) and medications[] per medication with name, dose, schedulesPerDay, compliance7, compliance30, streak, taken7/skipped7/missed7, dailySeries (graded) and latestDay. -- Read adherence as the percentage of scheduled doses taken over the last 7 and 30 days. Compare compliance7 with compliance30 to see the recent direction against the person's own baseline. +- The snapshot carries overall (medicationCount, averageCompliance7, averageCompliance30) and medications[] per medication with name, dose, schedulesPerDay, compliance7, compliance30, streak, taken7/skipped7/missed7, signal (the finished trend comparison), dailySeries (graded) and latestDay. +- Read adherence as the percentage of scheduled doses taken over the last 7 and 30 days; compliance7/compliance30 are server-computed. For the recent DIRECTION against the person's own baseline, lead from medications[].signal — current, delta vs baselineLabel and outsideNormalSwing are already computed; STATE them, do NOT recompute. - Rare cadence: with a low schedulesPerDay or an infrequent schedule (e.g. a weekly injection), compliance7 is noise-prone — a single missed dose swings the percentage sharply. Lean the assessment on compliance30 (the load-bearing value) and treat compliance7 only as a rough pointer. - Placement (for orientation, not judgement): ≥ 90% very reliable, 80-89% good, 70-79% patchy, < 70% clearly patchy — therapeutic effect may then be reduced. - Frame positively: streak is the longest uninterrupted run — acknowledge a good streak explicitly. With several medications, name the one with the lowest adherence. diff --git a/src/lib/ai/prompts/mood.ts b/src/lib/ai/prompts/mood.ts index 2662ae3cc..a8b14dccf 100644 --- a/src/lib/ai/prompts/mood.ts +++ b/src/lib/ai/prompts/mood.ts @@ -2,8 +2,8 @@ import type { Locale } from "@/lib/i18n/config"; import { getBaseSystemPrompt } from "./base-system"; const MOOD_SECTION_DE = `METRIK — STIMMUNG / WOHLBEFINDEN: -- Skala 1 (sehr schlecht) bis 5 (sehr gut). Der Snapshot trägt mood.summary + mood.series (graded, Tagesmittel), mood.target (grünes/oranges Band), mood.latestDayFocus und ggf. mood.tags (häufigste Stimmungs-Tags). -- Wochenmuster zählt: Vergleiche das recent-Mittel mit dem weekly/monthly-Mittel der Person. Schwankungen < 0.5 Punkte über die letzten Tage sind normal und kein Befund. +- Skala 1 (sehr schlecht) bis 5 (sehr gut). Der Snapshot trägt mood.signal (der fertige Vergleich) + mood.summary + mood.series (graded, Tagesmittel), mood.target (grünes/oranges Band), mood.latestDayFocus und ggf. mood.tags (häufigste Stimmungs-Tags). +- Wochenmuster zählt: Führe mit mood.signal — current, delta gegenüber baselineLabel und outsideNormalSwing sind bereits berechnet. NENNE sie, rechne sie NICHT neu. Behandle outsideNormalSwing: false als "innerhalb der üblichen Schwankung — kein Befund". - Anhaltende Phasen ernst, aber ruhig benennen: mehrere Tage unter ~2.5 oder über ~4.5 sind erwähnenswert — als Beobachtung, nie als Diagnose und nie alarmierend. - Bei anhaltend sehr niedriger Stimmung (mehrere Tage deutlich unter ~2.5): biete ruhig und autonomie-wahrend einen Unterstützungs-Pfad an — etwa "bei anhaltender Belastung kann ein Gespräch mit einer Vertrauensperson oder einer Fachperson helfen". Nie alarmierend, nie als Diagnose, kein Notfall-Ton; eine sanfte Option, kein Imperativ. - Tags: Falls mood.tags vorhanden, kannst du ein wiederkehrendes Tag als möglichen Kontext nennen, ohne einen Zusammenhang zu behaupten. @@ -11,8 +11,8 @@ const MOOD_SECTION_DE = `METRIK — STIMMUNG / WOHLBEFINDEN: - Eine Botschaft: Schließe NUR DANN mit EINEM machbaren, freundlichen Schritt, wenn der Befund einen nahelegt (z.B. an guten Tagen kurz festhalten, was geholfen hat). Bei stabil guter Stimmung das ehrlich anerkennen und stattdessen einen Punkt nennen, den man im Auge behalten kann, statt einen Schritt zu erzwingen.`; const MOOD_SECTION_EN = `METRIC — MOOD / WELL-BEING: -- Scale 1 (very bad) to 5 (very good). The snapshot carries mood.summary + mood.series (graded daily means), mood.target (green/orange band), mood.latestDayFocus and, where present, mood.tags (most frequent mood tags). -- The weekly pattern matters: compare the recent mean with the person's weekly/monthly mean. Swings < 0.5 points across the recent days are normal and not a finding. +- Scale 1 (very bad) to 5 (very good). The snapshot carries mood.signal (the finished comparison) + mood.summary + mood.series (graded daily means), mood.target (green/orange band), mood.latestDayFocus and, where present, mood.tags (most frequent mood tags). +- The weekly pattern matters: lead from mood.signal — current, delta vs baselineLabel and outsideNormalSwing are already computed. STATE them, do NOT recompute. Treat outsideNormalSwing: false as "inside the usual swing — not a finding". - Name persistent phases seriously but calmly: several days below ~2.5 or above ~4.5 are worth noting — as an observation, never a diagnosis and never alarming. - When mood stays very low for several days (clearly below ~2.5), offer a support pathway calmly and in an autonomy-preserving way — e.g. "if the strain persists, talking it through with someone you trust or a professional can help". Never alarming, never a diagnosis, no emergency tone; a gentle option, not an imperative. - Tags: if mood.tags is present, you may name a recurring tag as possible context, without claiming a link. diff --git a/src/lib/ai/prompts/pulse.ts b/src/lib/ai/prompts/pulse.ts index da45290e7..345392701 100644 --- a/src/lib/ai/prompts/pulse.ts +++ b/src/lib/ai/prompts/pulse.ts @@ -2,16 +2,16 @@ import type { Locale } from "@/lib/i18n/config"; import { getBaseSystemPrompt } from "./base-system"; const PULSE_SECTION_DE = `METRIK — PULS / HERZFREQUENZ: -- Der Snapshot trägt pulse.summary + pulse.series (graded) und pulse.target (greenMin/greenMax/orangeMin/orangeMax, inTargetPctLast30DailyPoints). pulse.latestDayFocus zeigt den jüngsten Tageswert und den Schritt zum Vortag. -- Tendenz über Wochen: Vergleiche das recent-Mittel mit dem weekly/monthly-Mittel der Person. Eine Abweichung ab ~5 bpm gegenüber der eigenen Baseline ist signifikant; eine Variabilität < 5 bpm über die letzten Tage ist normal und kein Befund. +- Der Snapshot trägt pulse.signal (der fertige Vergleich), pulse.summary + pulse.series (graded) und pulse.target (greenMin/greenMax/orangeMin/orangeMax, inTargetPctLast30DailyPoints). pulse.latestDayFocus zeigt den jüngsten Tageswert und den Schritt zum Vortag. +- Tendenz über Wochen: Führe mit pulse.signal — current, delta gegenüber baselineLabel und outsideNormalSwing sind bereits berechnet. NENNE sie, rechne sie NICHT neu. Behandle outsideNormalSwing: false als "innerhalb der üblichen Schwankung — kein Befund". - Richtung: Ein über Wochen sinkender Ruhepuls deutet auf bessere kardiovaskuläre Fitness; ein steigender kann auf Stress, Schlafmangel oder Übertraining hinweisen — als mögliche Erklärung, nie als Diagnose. - Einordnungs-Anker (Ruhepuls): athletisch 50-60, normal 60-100. Werte im Band 80-100 sind technisch normal, langfristig aber günstiger niedriger. < 50 oder > 100 über mehrere Tage ist erwähnenswert, ohne zu alarmieren. - Medikamenten-Bezug: Betablocker senken die Herzfrequenz. moodContext.moodVsPulseCorrelation und ein pulseVsSystolic-Zusammenhang nur erwähnen, wenn vorhanden und |r| > 0.4 — als Zusammenhang, nie als Ursache. - Eine Botschaft: Liegt der Ruhepuls über der eigenen Baseline, schließe NUR DANN mit EINEM machbaren Schritt, wenn der Befund einen nahelegt (z.B. ein paar Tage morgens vor dem Aufstehen messen, um eine saubere Ruhe-Baseline zu bekommen). Verbessert er sich oder ist er stabil, erkenne das ehrlich an und nenne stattdessen einen Punkt, den man im Auge behalten kann, statt einen Schritt zu erzwingen.`; const PULSE_SECTION_EN = `METRIC — PULSE / HEART RATE: -- The snapshot carries pulse.summary + pulse.series (graded) and pulse.target (greenMin/greenMax/orangeMin/orangeMax, inTargetPctLast30DailyPoints). pulse.latestDayFocus shows the latest daily value and the step from the prior day. -- Trend over weeks: compare the recent mean with the person's weekly/monthly mean. A shift of ~5 bpm against their own baseline is significant; variability < 5 bpm across the recent days is normal and not a finding. +- The snapshot carries pulse.signal (the finished comparison), pulse.summary + pulse.series (graded) and pulse.target (greenMin/greenMax/orangeMin/orangeMax, inTargetPctLast30DailyPoints). pulse.latestDayFocus shows the latest daily value and the step from the prior day. +- Trend over weeks: lead from pulse.signal — current, delta vs baselineLabel and outsideNormalSwing are already computed. STATE them, do NOT recompute. Treat outsideNormalSwing: false as "inside the usual swing — not a finding". - Direction: a resting pulse falling over weeks suggests improving cardiovascular fitness; a rising one can point to stress, sleep loss or overtraining — as a possible explanation, never a diagnosis. - Placement anchors (resting pulse): athletic 50-60, normal 60-100. Values in the 80-100 band are technically normal but tend to be more favourable lower over the long run. < 50 or > 100 across several days is worth noting without alarm. - Medication link: beta-blockers lower heart rate. Mention moodContext.moodVsPulseCorrelation and any pulse-vs-systolic association only when present and |r| > 0.4 — as an association, never a cause. diff --git a/src/lib/ai/prompts/weight.ts b/src/lib/ai/prompts/weight.ts index f4480dd68..97bc1bf90 100644 --- a/src/lib/ai/prompts/weight.ts +++ b/src/lib/ai/prompts/weight.ts @@ -2,9 +2,9 @@ import type { Locale } from "@/lib/i18n/config"; import { getBaseSystemPrompt } from "./base-system"; const WEIGHT_SECTION_DE = `METRIK — GEWICHT: -- Der Snapshot trägt weight.summary + weight.series (graded). weight.latestDayFocus zeigt den jüngsten Tageswert, den Schritt zum vorherigen Messtag und ggf. den Blutdruck desselben Tages. +- Der Snapshot trägt weight.signal (der fertige Vergleich) + weight.summary + weight.series (graded). weight.latestDayFocus zeigt den jüngsten Tageswert, den Schritt zum vorherigen Messtag und ggf. den Blutdruck desselben Tages. - Fokus dieser Karte ist der kontinuierliche TREND und das TEMPO über die Zeit (kg/Woche, Plateaus, Meilensteine), nicht die WHO-Band-Einordnung — die trägt die BMI-Karte. Den BMI-Wert nur als Nebenbezug nennen, nie das Band als zentrale Aussage hier. -- Wochen-/Monatstrend zählt, nicht der Einzelwert: Vergleiche das recent-Mittel mit dem weekly/monthly-Mittel der Person. Tageschwankungen von 1-2 kg (Wasser, Tageszeit) sind normal und kein Befund. +- Wochen-/Monatstrend zählt, nicht der Einzelwert: Führe mit weight.signal — current, delta gegenüber baselineLabel und outsideNormalSwing sind bereits berechnet. NENNE sie, rechne sie NICHT neu. Tageschwankungen von 1-2 kg (Wasser, Tageszeit) sind normal und kein Befund. - Plateau: Verändert sich das Mittel über > ~2 Wochen um < 0.5 kg, ist das ein Plateau — bei einem Abnehmenden ehrlich benennen, ohne zu entmutigen. - Tempo: Nachhaltiger Verlust liegt bei ~0.5-1.0 kg/Woche (DGE). Anhaltend > 1 kg/Woche ist eher Wasser/Muskel als Fett — vorsichtig einordnen. - Meilensteine gegen das eigene Maximum: ~5 % Verlust vom höchsten Wert (series-Max) bringt bereits metabolischen Nutzen, ~10 % einen deutlichen — wenn erreicht, ausdrücklich anerkennen. @@ -13,9 +13,9 @@ const WEIGHT_SECTION_DE = `METRIK — GEWICHT: - Eine Botschaft: Schließe NUR DANN mit EINEM machbaren Schritt, wenn der Befund einen nahelegt (z.B. bei einem Plateau zur selben Tageszeit wiegen, um den echten Trend zu sehen, statt am Tagesrauschen zu hängen). Ist der Trend stabil und gibt es nichts Sinnvolles zu tun, erkenne das ehrlich an und nenne stattdessen einen Punkt, den man im Auge behalten kann, statt einen Schritt zu erzwingen.`; const WEIGHT_SECTION_EN = `METRIC — WEIGHT: -- The snapshot carries weight.summary + weight.series (graded). weight.latestDayFocus shows the latest daily value, the step from the previous measured day and, where present, the same-day blood pressure. +- The snapshot carries weight.signal (the finished comparison) + weight.summary + weight.series (graded). weight.latestDayFocus shows the latest daily value, the step from the previous measured day and, where present, the same-day blood pressure. - This card's focus is the continuous TREND and PACE over time (kg/week, plateaus, milestones), not WHO-band placement — the BMI card carries that. Mention the BMI value only as a side reference, never make the band the central message here. -- The weekly/monthly trend matters, not the single value: compare the recent mean with the person's weekly/monthly mean. Day-to-day swings of 1-2 kg (water, time of day) are normal and not a finding. +- The weekly/monthly trend matters, not the single value: lead from weight.signal — current, delta vs baselineLabel and outsideNormalSwing are already computed. STATE them, do NOT recompute. Day-to-day swings of 1-2 kg (water, time of day) are normal and not a finding. - Plateau: if the mean moves < 0.5 kg over > ~2 weeks, that is a plateau — name it honestly for someone who is losing, without discouraging. - Pace: sustainable loss is ~0.5-1.0 kg/week (DGE). Sustained > 1 kg/week is more likely water/muscle than fat — frame cautiously. - Milestones against their own maximum: ~5% loss from the highest value (series max) already brings metabolic benefit, ~10% a substantial one — acknowledge explicitly when reached. diff --git a/src/lib/insights/__tests__/blood-pressure-status.test.ts b/src/lib/insights/__tests__/blood-pressure-status.test.ts index 1c010abbb..ec0017405 100644 --- a/src/lib/insights/__tests__/blood-pressure-status.test.ts +++ b/src/lib/insights/__tests__/blood-pressure-status.test.ts @@ -108,7 +108,16 @@ describe("generateBloodPressureStatusForUser — graded payload", () => { expect(sys).toHaveProperty("recent"); expect(sys).toHaveProperty("weekly"); expect(sys).toHaveProperty("monthly"); - expect(sys).toHaveProperty("yearly"); + // v1.18.10 (HIGH-4) — the per-channel SIGNAL block (the finished + // recent-vs-baseline comparison) now rides the snapshot so the model + // states the trend rather than re-deriving it. + expect(snapshot.bloodPressure.systolic.signal).toHaveProperty("current"); + expect(snapshot.bloodPressure.systolic.signal).toHaveProperty( + "outsideNormalSwing", + ); + // `yearly` is the lowest-signal slice and the FIRST the budget degrader + // sheds; on this deliberately-large 1000-day fixture it may be dropped to + // make room. The recent/weekly/monthly core + the signal block stay. expect(sys.recent.length).toBeLessThanOrEqual(21); expect(sys.recent[0]).toHaveProperty("date"); expect(sys.recent[0]).toHaveProperty("mean"); diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index 34813a702..1b2b10e90 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -39,6 +39,7 @@ import { buildGradedSeriesWithRollups, degradeStatusSnapshotToBudget, } from "@/lib/insights/graded-series"; +import { buildMetricSignal } from "@/lib/insights/metric-signal"; import { type SupportedLocale, normalizeLocale, @@ -505,6 +506,31 @@ export async function prepareBloodPressureStatusForUser( ) : null; + // v1.18.10 (HIGH-4) — hand the model the finished recent-vs-baseline + // comparison + normal-swing verdict per channel instead of asking it to + // derive them. BP needs two signals; the user's own targets are the coarse + // band anchor when present. + const sysSignal = buildMetricSignal({ + metric: locale === "en" ? "your systolic" : "deine Systole", + unit: "mmHg", + direction: "lower-better", + graded: sysGraded, + normalRange: bpTargets + ? { low: bpTargets.sysLow, high: bpTargets.sysHigh } + : null, + newestDaysAgo: newestMeasurementDaysAgo, + }); + const diaSignal = buildMetricSignal({ + metric: locale === "en" ? "your diastolic" : "deine Diastole", + unit: "mmHg", + direction: "lower-better", + graded: diaGraded, + normalRange: bpTargets + ? { low: bpTargets.diaLow, high: bpTargets.diaHigh } + : null, + newestDaysAgo: newestMeasurementDaysAgo, + }); + const snapshot = { locale, generatedForDay: todayKey, @@ -516,12 +542,14 @@ export async function prepareBloodPressureStatusForUser( }, bloodPressure: { systolic: { + ...(sysSignal ? { signal: sysSignal } : {}), summary: summarizeSeries( sysSeries.daily.map((bucket) => ({ value: bucket.value })), ), series: sysGraded, }, diastolic: { + ...(diaSignal ? { signal: diaSignal } : {}), summary: summarizeSeries( diaSeries.daily.map((bucket) => ({ value: bucket.value })), ), diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index 39f86af73..51ee25ad2 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -17,6 +17,7 @@ import { scaleGradedSeries, degradeStatusSnapshotToBudget, } from "@/lib/insights/graded-series"; +import { buildMetricSignal } from "@/lib/insights/metric-signal"; import { type SupportedLocale, normalizeLocale, @@ -244,6 +245,17 @@ export async function prepareBmiStatusForUser( ) : null; + // v1.18.10 (HIGH-4) — hand the model the finished recent-vs-baseline + // comparison + normal-swing verdict instead of asking it to derive them. + // BMI reads against the WHO healthy band; "target-band" framing. + const bmiSignal = buildMetricSignal({ + metric: locale === "en" ? "your BMI" : "dein BMI", + direction: "target-band", + graded: bmiGraded, + normalRange: { low: 18.5, high: 24.9 }, + newestDaysAgo: newestMeasurementDaysAgo, + }); + const snapshot = { locale, generatedForDay: todayKey, @@ -254,6 +266,7 @@ export async function prepareBmiStatusForUser( newestMeasurementDaysAgo, }, bmi: { + ...(bmiSignal ? { signal: bmiSignal } : {}), summary: summarizeSeries( bmiSeries.daily.map((bucket) => ({ value: bucket.value })), ), diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index 312a8cd32..765178053 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -24,6 +24,7 @@ import { } from "@/lib/insights/assessment-context"; import { applyPayloadBudget } from "@/lib/insights/bucket-series"; import { buildGradedSeriesFromPoints } from "@/lib/insights/graded-series"; +import { buildMetricSignal } from "@/lib/insights/metric-signal"; import { degradeStatusSnapshotToBudget } from "@/lib/insights/graded-series"; import { type SupportedLocale, @@ -307,6 +308,24 @@ export async function prepareMedicationComplianceStatusForUser( const dailySeries = buildGradedSeriesFromPoints(perDayRecords, now); const latestDay = dailyBudgeted.daily[0] ?? null; + // v1.18.10 (HIGH-4) — the finished recent-vs-baseline adherence comparison + // + normal-swing verdict, computed from the canonical per-day rate series + // so the model states the trend rather than deriving it. The compliance % + // itself stays server-computed (compliance7/30 above); this adds only the + // trend signal. Adherence reads higher-better; the band is 0–100%. + const signal = buildMetricSignal({ + metric: + locale === "en" + ? `adherence for ${sanitizeForPrompt(medication.name)}` + : `Einnahmetreue für ${sanitizeForPrompt(medication.name)}`, + unit: "%", + direction: "higher-better", + graded: dailySeries, + // Recency is surfaced separately via dataCoverage.newestMeasurementDaysAgo + // (computed after this map); the per-med signal leaves it null. + newestDaysAgo: null, + }); + return { medicationId: medication.id, name: sanitizeForPrompt(medication.name), @@ -319,6 +338,7 @@ export async function prepareMedicationComplianceStatusForUser( taken7: compliance7.taken, skipped7: compliance7.skipped, missed7: compliance7.missed, + ...(signal ? { signal } : {}), dailySeries, latestDay, }; diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index ae6333cdf..e651fca14 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -36,6 +36,7 @@ import { buildGradedSeriesFromPoints, degradeStatusSnapshotToBudget, } from "@/lib/insights/graded-series"; +import { buildMetricSignal } from "@/lib/insights/metric-signal"; import { type SupportedLocale, normalizeLocale, @@ -496,6 +497,18 @@ export async function prepareMoodStatusForUser( now, }); + // v1.18.10 (HIGH-4) — hand the model the finished recent-vs-baseline + // comparison + normal-swing verdict instead of asking it to derive them. + // Mood reads higher-better against the green band. + const moodSignal = buildMetricSignal({ + metric: locale === "en" ? "your mood" : "deine Stimmung", + unit: "/5", + direction: "higher-better", + graded: moodGraded, + normalRange: { low: greenMin, high: greenMax }, + newestDaysAgo: newestEntryDaysAgo, + }); + const snapshot = { locale, generatedForDay: todayKey, @@ -506,6 +519,7 @@ export async function prepareMoodStatusForUser( newestEntryDaysAgo, }, mood: { + ...(moodSignal ? { signal: moodSignal } : {}), summary: moodSummary, series: moodGraded, latestDayFocus: latestMood diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index 01e5f72bb..c297be593 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -25,6 +25,7 @@ import { buildGradedSeriesWithRollups, degradeStatusSnapshotToBudget, } from "@/lib/insights/graded-series"; +import { buildMetricSignal } from "@/lib/insights/metric-signal"; import { type SupportedLocale, normalizeLocale, @@ -321,6 +322,19 @@ export async function preparePulseStatusForUser( ) : null; + // v1.18.10 (HIGH-4) — feed the model the finished recent-vs-baseline + // comparison + normal-swing verdict instead of asking it to derive them from + // the raw buckets (the "model computes a number" pattern the mandate bans). + // Resting pulse reads lower-better; the green band is the coarse anchor. + const pulseSignal = buildMetricSignal({ + metric: locale === "en" ? "your resting pulse" : "dein Ruhepuls", + unit: "bpm", + direction: "lower-better", + graded: pulseGraded, + normalRange: { low: pulseTarget.greenMin, high: pulseTarget.greenMax }, + newestDaysAgo: newestMeasurementDaysAgo, + }); + const snapshot = { locale, generatedForDay: todayKey, @@ -331,6 +345,7 @@ export async function preparePulseStatusForUser( newestMeasurementDaysAgo, }, pulse: { + ...(pulseSignal ? { signal: pulseSignal } : {}), summary: pulseSummary, series: pulseGraded, latestDayFocus: latestPulse diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index f27b94750..2608c870b 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -28,6 +28,7 @@ import { buildGradedSeriesWithRollups, degradeStatusSnapshotToBudget, } from "@/lib/insights/graded-series"; +import { buildMetricSignal } from "@/lib/insights/metric-signal"; import { type SupportedLocale, normalizeLocale, @@ -382,6 +383,18 @@ export async function prepareWeightStatusForUser( ) : null; + // v1.18.10 (HIGH-4) — hand the model the finished recent-vs-baseline + // comparison + normal-swing verdict instead of asking it to derive them. + // Weight has no universal favourable direction or population band, so the + // signal carries the neutral "target-band" framing and no normalRange. + const weightSignal = buildMetricSignal({ + metric: locale === "en" ? "your weight" : "dein Gewicht", + unit: "kg", + direction: "target-band", + graded: weightGraded, + newestDaysAgo: newestMeasurementDaysAgo, + }); + const snapshot = { locale, generatedForDay: todayKey, @@ -392,6 +405,7 @@ export async function prepareWeightStatusForUser( newestMeasurementDaysAgo, }, weight: { + ...(weightSignal ? { signal: weightSignal } : {}), summary: summarizeSeries( weightSeries.daily.map((bucket) => ({ value: bucket.value })), ), From 883383c6a65b85cfd45f1a103905b059ba570227 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 16:58:45 +0200 Subject: [PATCH 7/7] test(coach): assert grounding + GLP-1 contract in every locale's prompt The safety-contract matrix parity test checks rule bodies are non-empty; this asserts the resolved getCoachSystemPrompt(locale) string carries the grounding marker and the GLP-1 dose-safety contract for all six UI locales, closing the gap where a non-EN/DE locale could silently fall back to a weaker EN-plus-footer body. --- .../system-prompt-contract-parity.test.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/lib/ai/coach/__tests__/system-prompt-contract-parity.test.ts diff --git a/src/lib/ai/coach/__tests__/system-prompt-contract-parity.test.ts b/src/lib/ai/coach/__tests__/system-prompt-contract-parity.test.ts new file mode 100644 index 000000000..2f916ccb6 --- /dev/null +++ b/src/lib/ai/coach/__tests__/system-prompt-contract-parity.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest"; + +import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt"; +import { loadSafetyContracts } from "@/lib/ai/prompts/safety-contracts"; +import type { Locale } from "@/lib/i18n/config"; + +/** + * v1.18.10 (MEDIUM-1) — resolved-Coach-prompt contract parity. + * + * `safety-contracts-parity.test.ts` asserts every ground-rule BODY in the + * matrix is non-empty per locale. This file goes one step further and asserts + * the *resolved* `getCoachSystemPrompt(locale)` string — the exact text the + * provider sees — carries the grounding + GLP-1 dose-safety contract for EVERY + * UI locale, not just de/en. The audit's residual MEDIUM-1 risk was that a + * non-EN/DE locale could silently fall back to the EN-plus-footer path (or a + * native body that weakened the wording) and ship a less-strict Coach. These + * assertions fail the build if that ever happens. + * + * Markers used: + * - grounding: the literal "SNAPSHOT" token, which the grounding rule's + * `must_contain` pins in every locale. + * - GLP-1 dose safety: the brand registry (Mounjaro / Ozempic), which is + * identical across all six locales (an international registry, never + * translated) and only ever appears inside the dose-safety contract. + */ + +const ALL_LOCALES: Locale[] = ["de", "en", "fr", "es", "it", "pl"]; + +describe("resolved Coach system prompt — grounding + GLP-1 per locale", () => { + for (const locale of ALL_LOCALES) { + it(`${locale} carries a grounding marker`, () => { + const prompt = getCoachSystemPrompt(locale); + expect(prompt).toContain("SNAPSHOT"); + }); + + it(`${locale} carries the GLP-1 dose-safety contract`, () => { + const prompt = getCoachSystemPrompt(locale); + // Brand names are identical across locales and only appear in the + // dose-safety contract — their presence proves the contract is composed. + expect(prompt).toMatch(/Mounjaro/); + expect(prompt).toMatch(/Ozempic/); + }); + + it(`${locale} never silently empties to a bare EN footer`, () => { + const prompt = getCoachSystemPrompt(locale); + expect(prompt.trim().length).toBeGreaterThan(500); + }); + } +}); + +describe("GLP-1 brand registry stays identical across locales (marker basis)", () => { + const enBrands = loadSafetyContracts("en").glp1_brand_list; + it("EN brand list is non-empty (the marker source)", () => { + expect(enBrands.length).toBeGreaterThan(0); + expect(enBrands).toContain("Mounjaro"); + }); +});