From f8bb8f4b9b2534cd5ecdd1a1ac6e81dc577549b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 09:11:57 +0200 Subject: [PATCH] fix(daily): surface a due dose on Today and keep the last grounded briefing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Today hero stayed on its all-clear line while a dose was actually due: the dose-window item only fired once the slot was already overdue, so a dose the user could take right now produced no rail item at all. Emit the item from the moment the dose becomes takeable — the on-time window's lead plus the early grace, the same instant logging it starts crediting the slot — and give the two faces distinct wording and status: a due dose reads calm and informational, an overdue one keeps the warning. A settled day still emits nothing, and a cached block whose anchor has since passed keeps rendering neither face. Push reminders are untouched; they still fire only on overdue. The briefing grounding gate discarded the whole block over one number it could not verify, and it could not verify plenty of legitimate ones: the allow-set was a hand-maintained list of feature blocks that had already drifted from the prompt three times, and still omitted medication compliance, the context counters, the correlation coefficients, every coverage object and the comparison snapshot — all of which the prompt hands the model verbatim. Derive the allow-set by walking the prompt payload instead, so grounded means exactly what it says: a figure the server gave the model. A fabricated number still matches nothing. Fix the disposal to match. A content-failed retry left a hole while a transport-failed one fell back to the previous cached briefing, though in both cases the rejected text is discarded and the previous briefing passed this same gate when it was written. Fall back on both paths, and only report the briefing as withheld when there is genuinely nothing to show. The on-demand route had no fallback at all; it now mirrors the background generator that writes the same cached payload. --- messages/de.json | 7 +- messages/en.json | 7 +- messages/es.json | 7 +- messages/fr.json | 7 +- messages/it.json | 7 +- messages/pl.json | 7 +- src/app/api/insights/generate/route.ts | 38 ++- src/lib/ai/briefing-grounding.ts | 255 +++++++-------------- src/lib/daily/digest.ts | 100 ++++++-- src/lib/insights/comprehensive-generate.ts | 45 ++-- 10 files changed, 252 insertions(+), 228 deletions(-) diff --git a/messages/de.json b/messages/de.json index 12a12c74c..023619012 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8740,8 +8740,11 @@ "item": { "doseWindow": { "title": "Medikament fällig", - "body": "Heute ist eine Dosis fällig.", - "bodyNamed": "{name} ist heute fällig." + "body": "Eine Dosis ist bald fällig.", + "bodyNamed": "{name} ist bald fällig.", + "overdueTitle": "Medikament überfällig", + "overdueBody": "Eine Dosis ist überfällig.", + "overdueBodyNamed": "{name} ist überfällig." }, "preventiveCare": { "title": "Vorsorge fällig", diff --git a/messages/en.json b/messages/en.json index 3f4f70771..2bc2542a7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8740,8 +8740,11 @@ "item": { "doseWindow": { "title": "Medication due", - "body": "A dose is due today.", - "bodyNamed": "{name} is due today." + "body": "A dose is due soon.", + "bodyNamed": "{name} is due soon.", + "overdueTitle": "Medication overdue", + "overdueBody": "A dose is past due.", + "overdueBodyNamed": "{name} is past due." }, "preventiveCare": { "title": "Check-up due", diff --git a/messages/es.json b/messages/es.json index 8d7e0c8fb..92cca712b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8740,8 +8740,11 @@ "item": { "doseWindow": { "title": "Medicación pendiente", - "body": "Hoy toca una dosis.", - "bodyNamed": "{name} toca hoy." + "body": "Pronto toca una dosis.", + "bodyNamed": "Pronto toca {name}.", + "overdueTitle": "Medicación atrasada", + "overdueBody": "Una dosis está atrasada.", + "overdueBodyNamed": "{name} está atrasado." }, "preventiveCare": { "title": "Revisión pendiente", diff --git a/messages/fr.json b/messages/fr.json index cd66a28a8..40e9fa34d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8740,8 +8740,11 @@ "item": { "doseWindow": { "title": "Médicament à prendre", - "body": "Une dose est prévue aujourd'hui.", - "bodyNamed": "{name} est prévu aujourd'hui." + "body": "Une dose est bientôt à prendre.", + "bodyNamed": "{name} est bientôt à prendre.", + "overdueTitle": "Médicament en retard", + "overdueBody": "Une dose est en retard.", + "overdueBodyNamed": "{name} est en retard." }, "preventiveCare": { "title": "Examen à faire", diff --git a/messages/it.json b/messages/it.json index 303022426..d4c44431f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8740,8 +8740,11 @@ "item": { "doseWindow": { "title": "Farmaco da assumere", - "body": "Oggi è prevista una dose.", - "bodyNamed": "{name} è previsto oggi." + "body": "Una dose è prevista a breve.", + "bodyNamed": "{name} è previsto a breve.", + "overdueTitle": "Farmaco in ritardo", + "overdueBody": "Una dose è in ritardo.", + "overdueBodyNamed": "{name} è in ritardo." }, "preventiveCare": { "title": "Controllo da fare", diff --git a/messages/pl.json b/messages/pl.json index 22cdd6c6f..18ed6fef7 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8740,8 +8740,11 @@ "item": { "doseWindow": { "title": "Lek do przyjęcia", - "body": "Dziś przypada dawka.", - "bodyNamed": "{name} przypada dziś." + "body": "Dawka wkrótce do przyjęcia.", + "bodyNamed": "{name} wkrótce do przyjęcia.", + "overdueTitle": "Lek zaległy", + "overdueBody": "Dawka jest zaległa.", + "overdueBodyNamed": "{name} jest zaległy." }, "preventiveCare": { "title": "Badanie do wykonania", diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index fb2d59a8f..c17109db5 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -847,6 +847,7 @@ export const POST = apiHandler((request: NextRequest) => readBriefingBlock(insights), signals, features, + comparisonSnapshot, ); if (ungrounded.length > 0) { annotate({ @@ -879,6 +880,7 @@ export const POST = apiHandler((request: NextRequest) => readBriefingBlock(retryInsights), signals, features, + comparisonSnapshot, ); if (ungrounded.length === 0) { insights = retryInsights; @@ -891,14 +893,40 @@ export const POST = apiHandler((request: NextRequest) => // strip. An ungrounded figure is never persisted. } } - // Still ungrounded after the retry: strip the briefing rather than ship a - // fabricated figure. The structured recommendations/citations stay. + // Still ungrounded after the retry: the freshly generated briefing is + // discarded rather than shipped — the anti-fabrication guarantee is + // absolute here too. But rather than leave a hole, stand the PREVIOUS + // cached briefing in its place: it passed this identical gate when it + // was written, so it carries no fabricated figure, and a day-old + // grounded briefing beats a vanished one. This mirrors the background + // generator, which is the path that writes the same cached payload; + // the two must not disagree on disposal. + // + // `briefingOmittedReason` is set ONLY when no previous briefing exists + // to stand in, so the card's honest "a figure couldn't be verified" + // state still fires exactly when the user really has no briefing. if (ungrounded.length > 0 && insights && typeof insights === "object") { - (insights as Record).dailyBriefing = null; - briefingOmittedReason = "ungrounded"; + let fallbackBriefing: unknown = null; + if (dbUser?.insightsCachedText) { + try { + const prev = JSON.parse(dbUser.insightsCachedText) as Record< + string, + unknown + >; + fallbackBriefing = prev.dailyBriefing ?? null; + } catch { + // Unparseable previous payload — nothing safe to stand in. + } + } + (insights as Record).dailyBriefing = fallbackBriefing; + briefingOmittedReason = fallbackBriefing == null ? "ungrounded" : null; annotate({ action: { name: "insights.generate.briefing_grounding_stripped" }, - meta: { ungroundedCount: ungrounded.length }, + meta: { + ungroundedCount: ungrounded.length, + briefing_fallback: + fallbackBriefing != null ? "previous-cached" : "stripped", + }, }); } } diff --git a/src/lib/ai/briefing-grounding.ts b/src/lib/ai/briefing-grounding.ts index 16cb11def..d75c0a926 100644 --- a/src/lib/ai/briefing-grounding.ts +++ b/src/lib/ai/briefing-grounding.ts @@ -12,11 +12,15 @@ * * 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. + * one of them cannot be matched (within a rounding tolerance) to a figure the + * server itself pre-computed and handed to the model. The caller turns a + * non-empty finding list into the existing corrective-retry machinery. + * + * The authoritative set is derived by WALKING the prompt payload — the whole + * aggregated-features object plus any extra section the prompt carries — not + * by re-listing its blocks by hand. That is the same guarantee stated exactly: + * a number is grounded iff the server gave it to the model. See + * `authoritativeValues` for why the previous hand-maintained list had to go. * * Scope + posture: * - The check only fires when `features.signalsOfDay` is present. When the @@ -92,6 +96,40 @@ export function extractNumbers( return out; } +/** + * Depth ceiling for the payload walk. The aggregated-features object is a + * shallow JSON tree (block → field, occasionally block → array → row), so this + * is pure defence: a malformed or unexpectedly deep shape cannot spin the walk. + */ +const WALK_MAX_DEPTH = 8; + +/** + * Push every finite number reachable inside a JSON-shaped value. Strings are + * NOT mined for embedded digits — only real numeric fields count as figures the + * server computed, so a user-supplied label ("Vitamin D3 1000") can never widen + * the allow-set. + */ +function walkNumbers( + value: unknown, + push: (n: number) => void, + depth = 0, +): void { + if (depth > WALK_MAX_DEPTH) return; + if (typeof value === "number") { + if (Number.isFinite(value)) push(value); + return; + } + if (Array.isArray(value)) { + for (const entry of value) walkNumbers(entry, push, depth + 1); + return; + } + if (value && typeof value === "object") { + for (const entry of Object.values(value)) { + walkNumbers(entry, push, depth + 1); + } + } +} + /** * 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 @@ -103,6 +141,7 @@ export function extractNumbers( function authoritativeValues( signals: readonly SignalOfDay[], features?: AggregatedFeatures | null, + extra?: unknown, ): number[] { const values: number[] = []; const push = (n: number | null | undefined) => { @@ -121,177 +160,29 @@ function authoritativeValues( if (s.recentAnomaly) push(s.recentAnomaly.value); } - // v1.22 (W6, W8 seam) — W8 wired four real aggregate blocks into the briefing - // (glucose / labs / preventive-care / workouts) but the gate only graded - // `signalsOfDay`, so the model was steered to drop any number from them. Add - // those REAL figures to the allow-set so the prose may cite them — without - // opening the gate to ungrounded values (only numbers the server actually - // pre-computed are admitted). - if (features) { - const g = features.glucose; - if (g) { - push(g.avg7); - push(g.avg30); - push(g.avg90); - push(g.latest); - push(g.slope30); - } - for (const m of features.labs?.flagged ?? []) push(m.value); - if (features.labs) push(features.labs.flaggedCount); - for (const o of features.preventiveCare?.overdue ?? []) push(o.daysOverdue); - for (const d of features.preventiveCare?.due ?? []) push(d.daysUntil); - const w = features.workouts; - if (w) { - for (const win of [w.last7, w.last30]) { - push(win.count); - push(win.totalDurationMin); - push(win.totalDistanceKm); - } - if (w.latest) { - push(w.latest.durationMin); - push(w.latest.distanceKm); - push(w.latest.daysAgo); - } - } - // S10 — ECG device-verdict descriptor. Admit the pre-computed counts + the - // latest recording's device-reported HR / recency so a briefing that - // references the recordings ("your device logged 2 ECG recordings this - // month") stays grounded. Only server-computed figures are added; the - // waveform never reaches the payload, so no trace-derived number exists. - const ecg = features.ecg; - if (ecg) { - push(ecg.recordingCount); - push(ecg.deviceVerdicts.irregular); - push(ecg.deviceVerdicts.notDetected); - push(ecg.deviceVerdicts.inconclusive); - push(ecg.latestRecordedDaysAgo); - push(ecg.latestAverageHeartRate); - } - // v1.25.1 — clinical-depth aggregate blocks (grip / waist / pain) wired - // into the briefing. Admit their pre-computed figures so the prose may cite - // them, same posture as the glucose / labs / workout blocks above. - const grip = features.gripStrength; - if (grip) { - push(grip.latest); - push(grip.avg30); - push(grip.slope30); - } - const waist = features.waist; - if (waist) { - push(waist.latest); - push(waist.avg30); - push(waist.slope30); - push(waist.whtrLatest); - } - const pain = features.pain; - if (pain) { - push(pain.latest); - push(pain.avg7); - push(pain.avg30); - push(pain.slope30); - } - - // v1.25.13 — the core vitals blocks (weight, blood pressure, pulse, body - // fat, mood, sleep, activity, RPP, seasonal, historical) are ALL fed to the - // briefing prompt, but the allow-set only admitted signalsOfDay + the W8/ - // clinical aggregates — so a verdict-first briefing that restated a blood- - // pressure average or a resting pulse (values every BP-tracking user has) - // tripped the gate and got the WHOLE dailyBriefing stripped to null while - // the rest of the insight refreshed. Admit every figure the server actually - // pre-computed for these blocks; the anti-fabrication guarantee is intact - // (only server-computed numbers are ever added to the set). - const weight = features.weight; - if (weight) { - push(weight.latest); - push(weight.avg7); - push(weight.avg30); - push(weight.avg90); - push(weight.allTimeAvg); - push(weight.allTimeMin); - push(weight.allTimeMax); - push(weight.slope30); - push(weight.bmi); - } - const bp = features.bloodPressure; - if (bp) { - push(bp.avgSys30); - push(bp.avgDia30); - push(bp.avgSys90); - push(bp.avgDia90); - push(bp.allTimeAvgSys); - push(bp.allTimeAvgDia); - push(bp.allTimeMinSys); - push(bp.allTimeMaxSys); - push(bp.allTimeMinDia); - push(bp.allTimeMaxDia); - push(bp.slopeSys30); - push(bp.slopeDia30); - push(bp.sdSys30); - push(bp.sdDia30); - push(bp.pulsePressure30); - push(bp.pctInTarget); - } - const pulse = features.pulse; - if (pulse) { - push(pulse.avg7); - push(pulse.avg30); - push(pulse.avg90); - push(pulse.allTimeAvg); - push(pulse.allTimeMin); - push(pulse.allTimeMax); - push(pulse.slope30); - } - const bodyFat = features.bodyFat; - if (bodyFat) { - push(bodyFat.latest); - push(bodyFat.avg30); - push(bodyFat.slope30); - } - const mood = features.mood; - if (mood) { - push(mood.avg7); - push(mood.avg30); - push(mood.latest); - } - const sleep = features.sleep; - if (sleep) { - push(sleep.avg7); - push(sleep.avg30); - push(sleep.latest); - } - const activity = features.activity; - if (activity) { - push(activity.avg7); - push(activity.avg30); - push(activity.latest); - } - const rpp = features.ratePressureProduct; - if (rpp) { - push(rpp.rpp7); - push(rpp.rpp30); - } - const seasonal = features.seasonalVariation; - if (seasonal) { - push(seasonal.winterAvgSys); - push(seasonal.summerAvgSys); - push(seasonal.delta); - } - const hist = features.historicalComparison; - if (hist) { - for (const block of [ - hist.weight, - hist.systolic, - hist.diastolic, - hist.pulse, - ]) { - if (block) { - push(block.current7dAvg); - push(block.previous30dAvg); - push(block.change); - } - } - } - } + // The allow-set is now derived by WALKING the payload rather than by + // re-listing its blocks by hand. `buildUserPrompt` serialises the WHOLE + // aggregated-features object into the prompt, so "a number the server gave + // the model" is precisely "a number that appears somewhere in that object" — + // and every value in it is server-computed, so the anti-fabrication + // guarantee is unchanged: a figure the model invented still matches nothing. + // + // The hand-maintained list this replaces drifted from the prompt three times + // (v1.22 wired in the glucose / labs / preventive / workout blocks, v1.25.1 + // the clinical-depth blocks, v1.25.13 the core vitals), and each drift + // presented as the whole briefing vanishing for users whose prose happened + // to cite the newest block. The blocks the list still omitted — medication + // compliance rates and streaks, the `context` counters, the correlation + // coefficients, and every block's `coverage` object — are all fed to the + // model and were all ungradeable, so a briefing that cited an adherence rate + // was discarded for quoting a figure the server itself computed. Walking the + // object removes that whole failure class instead of adding a fourth block. + walkNumbers(features, push); + // The comparison snapshot is handed to the model as its own prompt section + // (`buildComparisonBlock`) with explicit current / baseline / delta figures, + // and the system prompt asks the model to narrate them — so its numbers are + // authoritative on exactly the same footing. + walkNumbers(extra, push); return values; } @@ -341,11 +232,21 @@ export function findUngroundedBriefingNumbers( briefing: BriefingForGrounding | null | undefined, signals: readonly SignalOfDay[] | null | undefined, features?: AggregatedFeatures | null, + /** + * Any further server-computed payload the prompt hands the model alongside + * the features — today the comparison snapshot. Walked for numbers exactly + * like `features`; pass nothing when the prompt carried no extra section. + */ + extraPromptPayload?: unknown, ): UngroundedBriefingNumber[] { if (!briefing) return []; if (!signals || signals.length === 0) return []; - const authoritative = authoritativeValues(signals, features); + const authoritative = authoritativeValues( + signals, + features, + extraPromptPayload, + ); // No usable authoritative figures (every field null) — nothing to grade. if (authoritative.length === 0) return []; diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index c4ca4f3e5..8fe0ff7b4 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -43,6 +43,7 @@ import { COACH_CHECKIN_LETGO_INTENT, COACH_CHECKIN_ADJUST_INTENT, } from "@/lib/daily/coach-checkin-intents"; +import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults"; /** At most three rail items — a glance, never a wall (§2.5, never padded). */ export const MAX_WORTH_A_LOOK = 3; @@ -247,37 +248,91 @@ function integrationLabel(token: string): string { } /** - * Dose-window item — fires when an active-medication dose is open and overdue. + * How far ahead of its anchor a not-yet-overdue dose reaches the Today card. + * + * DERIVED, not picked: `dailyOnTimeMinutes` opens a slot's on-time window + * ahead of the target instant, and `earlyGraceMinutes` grants a bounded take + * before that window still credits the slot. Their sum is therefore exactly + * the moment a dose becomes TAKEABLE — the moment the card's one action + * ("Log dose") starts landing on the slot it names. Surfacing earlier would + * offer an action that does not yet credit anything; surfacing later would + * hide a dose the user could already take. A dose further out than this is a + * scheduled event, not today's pending business, and stays off the rail. + */ +export const DOSE_DUE_LOOKAHEAD_MS = + (DOSE_WINDOW_DEFAULTS.dailyOnTimeMinutes + + DOSE_WINDOW_DEFAULTS.earlyGraceMinutes) * + 60_000; + +/** + * Dose-window item — the day's medication action, in two distinct faces. + * + * OVERDUE: an open slot whose anchor has passed. `warning` status, past-due + * wording — the one genuinely time-critical daily action. + * + * DUE: a slot the user can already take (within `DOSE_DUE_LOOKAHEAD_MS`) but + * that is not yet late. `info` status, "due soon" wording — present on the + * card because Today is a surface the user OPENS to see what the day asks of + * them, not a notification that arrives uninvited. The no-nagging rule governs + * PUSH (the reminder cron still fires only on overdue); a deliberately-opened + * card that stays silent about a dose the user can take right now is simply + * withholding the answer the surface exists to give. + * + * Neither face is manufactured on a settled day: once every scheduled dose is + * resolved, the next display-due slot lies beyond the lookahead and nothing is + * emitted. A `nextDueAt` in the PAST with `nextDueOverdue: false` is the + * documented cached-block shape (`MedsTodayBlock`) — the anchor passed after + * the block was built — and renders as neither face until a fresh block lands. + * * Gated on the `medications` module; carries a single log-dose action. */ function buildDoseWindowItem( meds: MedsTodayBlock, modules: DigestModuleMap, + now: Date, t: Translate, ): PriorityItem | null { if (!moduleEnabled(modules, "medications")) return null; - if (!meds.nextDueOverdue) return null; + const name = meds.nextDueMedicationName; const id = meds.nextDueMedicationId; + const action = { + labelKey: "daily.action.logDose", + intent: "dose.log", + // Deep-link straight to the due medication's card so the tap lands on the + // right one instead of a generic list the user then has to scan (the id is + // known server-side whenever the name is). The bare list stays the honest + // fallback for an older cached block that predates the id field. + href: id ? `/medications?highlight=${id}` : "/medications", + }; + + if (meds.nextDueOverdue) { + return { + kind: "dose_window", + title: t("daily.item.doseWindow.overdueTitle"), + body: name + ? t("daily.item.doseWindow.overdueBodyNamed", { name }) + : t("daily.item.doseWindow.overdueBody"), + status: "warning", + actions: [action], + moduleKey: "medications", + }; + } + + if (!meds.nextDueAt) return null; + const dueAt = Date.parse(meds.nextDueAt); + if (!Number.isFinite(dueAt)) return null; + const lead = dueAt - now.getTime(); + if (lead < 0 || lead > DOSE_DUE_LOOKAHEAD_MS) return null; + return { kind: "dose_window", title: t("daily.item.doseWindow.title"), body: name ? t("daily.item.doseWindow.bodyNamed", { name }) : t("daily.item.doseWindow.body"), - status: "warning", - actions: [ - { - labelKey: "daily.action.logDose", - intent: "dose.log", - // Deep-link straight to the overdue medication's card so the tap - // lands on the right one instead of a generic list the user then - // has to scan (the id is known server-side whenever the name is). - // The bare list stays the honest fallback for an older cached - // block that predates the id field. - href: id ? `/medications?highlight=${id}` : "/medications", - }, - ], + status: "info", + actions: [action], moduleKey: "medications", }; } @@ -592,12 +647,17 @@ export function buildDailyDigest( const topSignal = input.briefing?.signalsOfDay?.[0] ?? null; const briefingLead = firstSentence(input.briefing?.paragraph); - // Priority order: an overdue dose is the most time-sensitive daily action, a - // broken sync next, then the calm coach check-in, a preventive check-up - // least urgent. Bounded to 3 — a check-in the cap crowds out resurfaces on a - // following day (within its window), never lost. + // Priority order: a due or overdue dose is the most time-sensitive daily + // action, a broken sync next, then the calm coach check-in, a preventive + // check-up least urgent. Bounded to 3 — a check-in the cap crowds out + // resurfaces on a following day (within its window), never lost. const worthALook: PriorityItem[] = []; - const dose = buildDoseWindowItem(input.medsToday, input.modules, t); + const dose = buildDoseWindowItem( + input.medsToday, + input.modules, + input.now, + t, + ); if (dose) worthALook.push(dose); // A freshly-reached milestone is rare and one-per-day — surface it ahead of // the ambient sync / check-in items so the calm reward is not buried, but diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 8e2b1cfcc..bc35a2a7e 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -175,6 +175,12 @@ async function rerollBriefingParagraph(args: { * care / workouts) the reroll may now cite. */ features?: AggregatedFeatures | null; + /** + * The comparison snapshot the SAME `userPrompt` carries as its own section. + * The gate walks it for authoritative figures too, so a rerolled paragraph + * may narrate the comparison the prompt asked it to narrate. + */ + comparison?: unknown; /** * The upstream provider timeout for this re-roll, resolved from the user's * response-timeout setting (falling back to the comprehensive budget). The @@ -255,6 +261,7 @@ async function rerollBriefingParagraph(args: { { paragraph: freshParagraph }, args.signals, args.features, + args.comparison, ); if (rerollUngrounded.length > 0) { return null; @@ -777,6 +784,7 @@ export async function generateComprehensiveInsight( locale, signals: features.signalsOfDay ?? null, features, + comparison: comparisonSnapshot, effectiveTimeoutMs, }); if (rerolled) { @@ -993,6 +1001,7 @@ export async function generateComprehensiveInsight( readBriefingBlock(insights), signals, features, + comparisonSnapshot, ); if (ungrounded.length > 0) { annotate({ @@ -1017,6 +1026,7 @@ export async function generateComprehensiveInsight( readBriefingBlock(retryInsights), signals, features, + comparisonSnapshot, ); if (retryInsights !== null && retryUngrounded.length === 0) { insights = retryInsights; @@ -1027,24 +1037,30 @@ export async function generateComprehensiveInsight( ungrounded = retryUngrounded; } } catch { - // Retry failed on TRANSPORT (provider outage / timeout), not on - // content — the model never got its correction chance. A - // `BriefingBudgetExceededError` lands here too and is treated the same - // way on purpose: the model never got its correction chance either, so - // preferring the previously-cached (already-grounded) briefing over a - // hole is the right call. The ungrounded text is still never persisted. + // Retry failed on TRANSPORT (provider outage / timeout) or on the + // day's budget ceiling, rather than on content. Recorded for the + // annotation only — the disposal below no longer forks on it. retryTransportFailed = true; } } if (ungrounded.length > 0 && insights && typeof insights === "object") { - // A content-failed retry (the model repeated ungrounded numbers) - // still strips hard — a fabricated figure must never persist. But - // when the corrective retry died on transport, prefer the previous - // payload's briefing (it passed this same gate when it was written) - // over a hole: a day-old briefing beats a vanished one, and the next - // successful run replaces it. + // The freshly generated briefing is DISCARDED either way — that is the + // anti-fabrication guarantee and it is absolute. The only question left + // is what stands in its place, and the answer does not depend on WHY the + // retry failed. + // + // This used to fall back to the previous payload's briefing on a + // transport failure but leave a hole on a content failure. The asymmetry + // had no justification: the previous briefing passed this identical gate + // when it was written, so it carries no fabricated figure in either + // case, and the user never sees the rejected text either way. All the + // hole did was cost the reader their paragraph, signals-of-day, key + // findings and recommendations over one unverifiable number — while the + // "briefing withheld" notice told them something had gone wrong without + // giving them anything usable. A day-old grounded briefing beats a + // vanished one on both paths, and the next successful run replaces it. let fallbackBriefing: unknown = null; - if (retryTransportFailed && dbUser?.insightsCachedText) { + if (dbUser?.insightsCachedText) { try { const prev = JSON.parse(dbUser.insightsCachedText) as Record< string, @@ -1052,7 +1068,7 @@ export async function generateComprehensiveInsight( >; fallbackBriefing = prev.dailyBriefing ?? null; } catch { - // Unparseable previous payload — keep the hard strip. + // Unparseable previous payload — nothing safe to stand in. } } (insights as Record).dailyBriefing = fallbackBriefing; @@ -1062,6 +1078,7 @@ export async function generateComprehensiveInsight( meta: { locale, ungroundedCount: ungrounded.length, + retryFailure: retryTransportFailed ? "transport" : "content", briefing_fallback: fallbackBriefing != null ? "previous-cached" : "stripped", },