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", },