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] 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