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 1/8] 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", }, From cbb9528cb28696126849ec8743b2e958337baafc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 09:14:09 +0200 Subject: [PATCH 2/8] test(daily): cover the due-dose rail item and the briefing grounding disposal --- src/lib/daily/__tests__/digest.test.ts | 115 +++++++++- ...ensive-briefing-grounding-disposal.test.ts | 201 ++++++++++++++++++ 2 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts diff --git a/src/lib/daily/__tests__/digest.test.ts b/src/lib/daily/__tests__/digest.test.ts index 2167be54f..4cc3fd4e1 100644 --- a/src/lib/daily/__tests__/digest.test.ts +++ b/src/lib/daily/__tests__/digest.test.ts @@ -6,6 +6,7 @@ import type { MedsTodayBlock } from "@/lib/dashboard/meds-today"; import { buildDailyDigest, COACH_CHECKIN_RESURFACE_DAYS, + DOSE_DUE_LOOKAHEAD_MS, MAX_WORTH_A_LOOK, type DailyDigestCoachPlan, type DailyDigestInput, @@ -211,8 +212,8 @@ describe("buildDailyDigest — worth-a-look rail item builders", () => { expect(dose).toBeDefined(); expect(dose?.status).toBe("warning"); expect(dose?.moduleKey).toBe("medications"); - expect(dose?.title).toBe("Medication due"); - expect(dose?.body).toBe("Ramipril is due today."); + expect(dose?.title).toBe("Medication overdue"); + expect(dose?.body).toBe("Ramipril is past due."); expect(dose?.actions).toHaveLength(1); expect(dose?.actions[0].intent).toBe("dose.log"); // Deep-links straight to the overdue medication's card, not the bare @@ -249,7 +250,7 @@ describe("buildDailyDigest — worth-a-look rail item builders", () => { expect(d.worthALook.some((i) => i.kind === "dose_window")).toBe(false); }); - it("does NOT emit a dose-window item when nothing is overdue", () => { + it("does NOT emit a dose-window item when nothing is due at all", () => { const d = buildDailyDigest( input({ medsToday: meds({ nextDueOverdue: false }) }), t, @@ -257,6 +258,114 @@ describe("buildDailyDigest — worth-a-look rail item builders", () => { expect(d.worthALook.some((i) => i.kind === "dose_window")).toBe(false); }); + it("emits a calm due item for a takeable dose that is not yet overdue", () => { + const d = buildDailyDigest( + input({ + medsToday: meds({ + activeCount: 1, + scheduledToday: 1, + nextDueAt: new Date(NOW.getTime() + 30 * 60_000).toISOString(), + nextDueOverdue: false, + nextDueMedicationName: "Ramipril", + nextDueMedicationId: "med-1", + }), + }), + t, + ); + const dose = d.worthALook.find((i) => i.kind === "dose_window"); + expect(dose).toBeDefined(); + // Reads as informational, NOT as the overdue warning. + expect(dose?.status).toBe("info"); + expect(dose?.title).toBe(t("daily.item.doseWindow.title")); + expect(dose?.body).toBe( + t("daily.item.doseWindow.bodyNamed", { name: "Ramipril" }), + ); + expect(dose?.actions[0]?.href).toBe("/medications?highlight=med-1"); + }); + + it("reads an overdue dose differently from a merely due one", () => { + const due = buildDailyDigest( + input({ + medsToday: meds({ + nextDueAt: new Date(NOW.getTime() + 30 * 60_000).toISOString(), + nextDueOverdue: false, + nextDueMedicationName: "Ramipril", + }), + }), + t, + ).worthALook.find((i) => i.kind === "dose_window"); + const overdue = buildDailyDigest( + input({ + medsToday: meds({ + nextDueAt: new Date(NOW.getTime() - 30 * 60_000).toISOString(), + nextDueOverdue: true, + nextDueMedicationName: "Ramipril", + }), + }), + t, + ).worthALook.find((i) => i.kind === "dose_window"); + + expect(overdue?.status).toBe("warning"); + expect(overdue?.title).toBe(t("daily.item.doseWindow.overdueTitle")); + expect(overdue?.body).toBe( + t("daily.item.doseWindow.overdueBodyNamed", { name: "Ramipril" }), + ); + // The two faces must not collapse into the same copy. + expect(overdue?.title).not.toBe(due?.title); + expect(overdue?.body).not.toBe(due?.body); + expect(overdue?.status).not.toBe(due?.status); + }); + + it("does NOT manufacture a dose item on a day whose doses are all taken", () => { + // Every scheduled dose resolved; the next display-due slot is tomorrow. + const d = buildDailyDigest( + input({ + medsToday: meds({ + activeCount: 2, + scheduledToday: 2, + takenToday: 2, + nextDueAt: new Date(NOW.getTime() + 22 * 60 * 60_000).toISOString(), + nextDueOverdue: false, + nextDueMedicationName: "Ramipril", + }), + }), + t, + ); + expect(d.worthALook.some((i) => i.kind === "dose_window")).toBe(false); + }); + + it("keeps a dose beyond the takeable lead off the rail", () => { + const d = buildDailyDigest( + input({ + medsToday: meds({ + nextDueAt: new Date( + NOW.getTime() + DOSE_DUE_LOOKAHEAD_MS + 60_000, + ).toISOString(), + nextDueOverdue: false, + nextDueMedicationName: "Ramipril", + }), + }), + t, + ); + expect(d.worthALook.some((i) => i.kind === "dose_window")).toBe(false); + }); + + it("renders neither face for a cached block whose anchor has since passed", () => { + // `MedsTodayBlock` contract: a past `nextDueAt` with `nextDueOverdue: + // false` means the anchor passed AFTER the block was built. + const d = buildDailyDigest( + input({ + medsToday: meds({ + nextDueAt: new Date(NOW.getTime() - 5 * 60_000).toISOString(), + nextDueOverdue: false, + nextDueMedicationName: "Ramipril", + }), + }), + t, + ); + expect(d.worthALook.some((i) => i.kind === "dose_window")).toBe(false); + }); + it("maps one sync-issue item per broken integration", () => { const d = buildDailyDigest( input({ diff --git a/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts new file mode 100644 index 000000000..5159ef7e3 --- /dev/null +++ b/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts @@ -0,0 +1,201 @@ +/** + * Daily-briefing grounding gate — what stands in place of a rejected briefing. + * + * The gate itself is absolute: a briefing carrying a number the server never + * computed is discarded, always. These tests pin the DISPOSAL, which used to + * fork on why the corrective retry failed — a transport failure fell back to + * the previous cached briefing, a content failure left a hole and the reader + * lost the paragraph, signals, findings and recommendations over one figure. + * Both paths now fall back, and the hard strip survives only when there is + * genuinely no previous briefing to show. + * + * They also pin the widened allow-set: the prompt hands the model the WHOLE + * features object, so a figure from any block in it (here a medication + * compliance rate) is grounded — while an invented number still is not. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const findUnique = vi.fn(); +const userUpdate = vi.fn(); +const resolveProviderChain = vi.fn(); +const resolveProvider = vi.fn(); +const runRawCompletionWithFallback = vi.fn(); +const extractFeatures = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + $queryRaw: vi.fn(async () => [{ total_tokens: 0 }]), + $executeRaw: vi.fn(async () => 0), + user: { + findUnique: (...a: unknown[]) => findUnique(...a), + update: (...a: unknown[]) => userUpdate(...a), + }, + auditLog: { deleteMany: vi.fn() }, + }, +})); +vi.mock("@/lib/ai/provider", () => ({ + resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), + resolveProvider: (...a: unknown[]) => resolveProvider(...a), +})); +vi.mock("@/lib/ai/provider-runner", () => ({ + AllProvidersFailedError: class extends Error {}, + runRawCompletionWithFallback: (...a: unknown[]) => + runRawCompletionWithFallback(...a), +})); +vi.mock("@/lib/insights/features", () => ({ + FeaturesPayloadTooLargeError: class extends Error { + sizeBytes = 0; + }, + extractFeatures: (...a: unknown[]) => extractFeatures(...a), + BRIEFING_FEATURE_WINDOW_DAYS: 400, +})); +vi.mock("@/lib/insights/illness-cycle-briefing", () => ({ + buildBriefingIllnessCycleContext: vi.fn().mockResolvedValue(null), + buildBriefingIllnessCyclePrompt: vi.fn().mockReturnValue(""), +})); +vi.mock("@/lib/insights/glp1-plateau", () => ({ + detectGlp1Plateau: vi.fn(async () => null), + buildGlp1PlateauPrompt: vi.fn(() => ""), +})); +vi.mock("@/lib/ai/coach/about-me", () => ({ + getSelfContextTextForUser: vi.fn(async () => null), + buildAboutMeInsightBlock: vi.fn(() => ""), +})); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserInsights: vi.fn(), +})); + +import { generateComprehensiveInsight } from "../comprehensive-generate"; + +/** + * A signals block (so the gate engages at all) plus a medications block. The + * medication figures are server-computed and reach the prompt, but the old + * hand-maintained allow-set never admitted them. + */ +const FEATURES = { + signalsOfDay: [ + { + sourceMetric: "weight", + latest: 81.4, + avg7: 82, + avg30: 82.1, + deltaVs7: -0.6, + deltaVs30: -0.7, + spread30: 1.2, + recentAnomaly: null, + }, + ], + medications: [ + { + name: "Ramipril", + dose: "5 mg", + category: "cardio", + compliance7: 100, + compliance30: 86, + compliance90: 88, + streak: 12, + missedLast7: 0, + }, + ], +}; + +/** A number that appears nowhere in the features payload. */ +const FABRICATED = JSON.stringify({ + dailyBriefing: { paragraph: "Your weight is down 47.9 kg this week." }, +}); + +/** Cites the medication compliance rate the server itself computed. */ +const GROUNDED_VIA_MEDS = JSON.stringify({ + dailyBriefing: { paragraph: "Adherence sits at 86 % over the last 30 days." }, +}); + +const PREVIOUS_PAYLOAD = JSON.stringify({ + dailyBriefing: { paragraph: "Yesterday's grounded briefing." }, + recommendations: [], +}); + +function cachedUser(over: Record = {}) { + return { + insightsPrivacyMode: "aggregated", + insightsCachedAt: null, + insightsCachedText: null, + insightsExcludeMetrics: [], + // Never equal to the freshly computed hash, so a full generation runs. + insightsSnapshotHash: null, + insightsBriefingRerollDate: null, + ...over, + }; +} + +function completion(content: string) { + return { + result: { content, tokensUsed: 10, model: "m" }, + workingProvider: { providerType: "openai" }, + fallbackHops: [], + }; +} + +/** The `dailyBriefing` the generation persisted. */ +function persistedBriefing(): unknown { + const call = userUpdate.mock.calls.at(-1); + const text = call?.[0]?.data?.insightsCachedText as string | undefined; + if (!text) return undefined; + return (JSON.parse(text) as Record).dailyBriefing; +} + +beforeEach(() => { + vi.clearAllMocks(); + resolveProviderChain.mockResolvedValue([ + { providerType: "openai", instance: {} }, + ]); + resolveProvider.mockResolvedValue({ type: "none" }); + extractFeatures.mockResolvedValue(FEATURES); + userUpdate.mockResolvedValue({}); + findUnique.mockResolvedValue(cachedUser()); +}); + +describe("briefing grounding — disposal", () => { + it("keeps the previous briefing when the corrective retry fails on CONTENT", async () => { + findUnique.mockResolvedValue( + cachedUser({ + insightsCachedAt: new Date("2026-07-15T03:00:00.000Z"), + insightsCachedText: PREVIOUS_PAYLOAD, + }), + ); + // Both passes carry the same fabricated figure: a content failure. + runRawCompletionWithFallback.mockResolvedValue(completion(FABRICATED)); + + await generateComprehensiveInsight("u1", { locale: "en" }); + + // The fabricated text never persists... + expect(JSON.stringify(persistedBriefing())).not.toContain("47.9"); + // ...and the reader keeps yesterday's grounded briefing rather than a hole. + expect(persistedBriefing()).toEqual({ + paragraph: "Yesterday's grounded briefing.", + }); + }); + + it("still strips hard when there is no previous briefing to stand in", async () => { + runRawCompletionWithFallback.mockResolvedValue(completion(FABRICATED)); + + await generateComprehensiveInsight("u1", { locale: "en" }); + + expect(persistedBriefing()).toBeNull(); + }); + + it("admits a figure from a features block the old allow-set never listed", async () => { + runRawCompletionWithFallback.mockResolvedValue( + completion(GROUNDED_VIA_MEDS), + ); + + const outcome = await generateComprehensiveInsight("u1", { locale: "en" }); + + expect(outcome).toEqual({ status: "generated", providerType: "openai" }); + // No corrective retry: the compliance rate is grounded, so the gate is + // satisfied on the first pass and the briefing survives intact. + expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(1); + expect(persistedBriefing()).toEqual({ + paragraph: "Adherence sits at 86 % over the last 30 days.", + }); + }); +}); From db86b4f76da0969b59cf78e43544b6b45ab3b70f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 09:23:45 +0200 Subject: [PATCH 3/8] =?UTF-8?q?chore(release):=20v1.30.31=20=E2=80=94=20su?= =?UTF-8?q?rface=20a=20due=20dose=20on=20Today;=20ground=20the=20briefing?= =?UTF-8?q?=20against=20what=20the=20model=20was=20actually=20given?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 ++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c856438e5..fe5ad5b69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## [Unreleased] +## [1.30.31] — 2026-07-19 + +Today shows a dose before it is late, and the briefing stops disappearing over a number it was given. + +- **A dose that is due but not yet late now appears on Today.** The card only ever showed a dose once it was already overdue, so it could say nothing needs your attention while a dose was due. A due dose reads calmly and an overdue one as a warning; a day where everything is taken still shows the all-clear. Reminder notifications are unchanged — they still only fire once a dose is late. +- **The briefing was being discarded for quoting figures the server itself had given it.** The check that verifies every number against your own data compared against a hand-maintained list of places to look, while the model receives the complete set. Medication adherence rates, comparison changes — the very figures the briefing is asked to describe — were not on that list, so a correct briefing was withheld. The check now derives its list from exactly what was handed to the model, so it means what it says: a number is accepted only if it came from your data. +- **A briefing that genuinely cannot be verified no longer takes the previous one down with it.** It falls back to the last good briefing instead of showing nothing, the way it already did when the provider was unreachable. A figure that cannot be verified is still never shown. + +No breaking changes. + ## [1.30.28] — 2026-07-19 The daily briefing counts its spend too. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 2f3b39b2c..984cab8f1 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.30.28 + version: 1.30.31 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 7f30d6795..31ea6793e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.30.28", + "version": "1.30.31", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 9536f54db..69ce584f4 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.30.28"; + /* @sw-version-fallback */ "v1.30.31"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` From e4f5aa0b09f177de59e81263bce07e969d605d3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 09:46:57 +0200 Subject: [PATCH 4/8] fix(ui): drop the redundant Today briefing link, align the sheet close control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Today hero carried a read-the-full-briefing link to /insights while the health-score ring above it already opened the same page — two doors to one room in a single card. The ring stays; the link and its six locale strings are gone. The document sheet's close control sat at the plain p-4 right edge, but an icon button carries its own inner inset, so the glyph landed ~28px from the frame against a title starting at 16px. Trim the right padding by that inset when a consumer renders its own trailing control, leaving the tap target reaching the frame. Also rewrites the v1.30.15-v1.30.31 changelog entries: they had drifted into summary lines, internal vocabulary and reasoning asides that do not belong in a release note. --- CHANGELOG.md | 191 ++++++++++-------- messages/de.json | 1 - messages/en.json | 1 - messages/es.json | 1 - messages/fr.json | 1 - messages/it.json | 1 - messages/pl.json | 1 - .../daily/__tests__/today-hero.test.tsx | 7 +- src/components/daily/today-hero.tsx | 11 - src/components/ui/responsive-sheet.tsx | 14 +- 10 files changed, 127 insertions(+), 102 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe5ad5b69..2abd0911e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,152 +4,181 @@ ## [1.30.31] — 2026-07-19 -Today shows a dose before it is late, and the briefing stops disappearing over a number it was given. - -- **A dose that is due but not yet late now appears on Today.** The card only ever showed a dose once it was already overdue, so it could say nothing needs your attention while a dose was due. A due dose reads calmly and an overdue one as a warning; a day where everything is taken still shows the all-clear. Reminder notifications are unchanged — they still only fire once a dose is late. -- **The briefing was being discarded for quoting figures the server itself had given it.** The check that verifies every number against your own data compared against a hand-maintained list of places to look, while the model receives the complete set. Medication adherence rates, comparison changes — the very figures the briefing is asked to describe — were not on that list, so a correct briefing was withheld. The check now derives its list from exactly what was handed to the model, so it means what it says: a number is accepted only if it came from your data. -- **A briefing that genuinely cannot be verified no longer takes the previous one down with it.** It falls back to the last good briefing instead of showing nothing, the way it already did when the provider was unreachable. A figure that cannot be verified is still never shown. +- **Today shows a dose before it is late.** The card only surfaced a + medication once it was already overdue, so it could report nothing + needing attention while a dose was due. A due dose now reads as a calm + note, an overdue one as a warning, and a day with everything taken + still shows the all-clear. Reminders are unchanged — they still only + fire once a dose is late. +- **The daily briefing stops disappearing.** The check that verifies + every figure against your own data was looking in fewer places than the + briefing draws from, so a briefing quoting your medication adherence or + a change since last month was thrown away as unverifiable. It now + checks against everything the briefing is built from. And when a figure + genuinely cannot be verified, yesterday's briefing stays up instead of + the section going blank. +- **The "read the full briefing" link is gone from Today.** Tapping the + health-score ring already opens the same page. +- **The close button on a document sits flush with the edge again.** No breaking changes. ## [1.30.28] — 2026-07-19 -The daily briefing counts its spend too. - -- **Generating the daily briefing did not count against any budget.** It is the one remaining path that a sync can set off on its own: new sleep data arriving in the morning triggers a fresh briefing, and each attempt — including its correction passes — could reach a provider without being recorded. All of them now reserve before and settle after, against the same ceiling as everything else, so a self-hoster on their own key is measured against their own limit. -- Reaching the ceiling now skips the run rather than reporting a failure, because a failure schedules a retry — and retrying against a limit that does not move until the next day would loop. +- **Daily briefings count against the AI budget.** They were the one + remaining kind of generated text that did not, so a morning's briefings + could run past the limit you set. On your own key they count against + your limit, not the server's. ## [1.30.27] — 2026-07-19 -Three quiet miscounts. - -- **The resting heart-rate series stopped at the point where older readings get condensed.** Once a day's raw readings are condensed, its resting figure is kept as a derived value — but the reading side treated the presence of any such value as "this account reports resting directly" and stopped estimating for the recent days that have not been condensed yet. The series now uses the stored value for days that have one and the estimate for days that do not. Where any part is estimated it is still labelled as an estimate: a partly-estimated series called "resting heart rate" would be wrong about those days, while calling a partly-stored one an estimate is merely cautious. -- **Recomputing summaries from a point in the middle of a day or month replaced the whole period with a partial figure.** The recompute now starts at the beginning of the period it touches. Existing summaries recompute when that day is written to again or on the next scheduled pass. -- **Two entries for the same daily total in one upload silently kept the older one.** The newer value now wins, matching how a repeat upload behaves. +- **The resting heart-rate chart no longer stops partway.** Older + readings get condensed after a while, and the chart treated the + condensed days as the whole story — so recent days went missing. It now + shows both, and says when a day is an estimate. +- **Summaries recompute for the whole day or month.** Recomputing from a + point in the middle replaced the full period with a partial figure. + Affected summaries correct themselves the next time that day is written + to, or on the next nightly pass. +- **A repeated daily total in one upload keeps the newer value.** It kept + the older one. No breaking changes. ## [1.30.26] — 2026-07-19 -Text that comes out of your documents is treated as data, not as instructions. - -- **Names read out of an uploaded document went into the assistant's context unchecked.** A lab result's analyte, panel and unit names were passed through raw, so a document whose field labels were written to read like instructions became part of the assistant's context on every turn once the result was confirmed. Those names are now cleaned, and the whole block carries an explicit boundary marking it as your data rather than something addressed to the assistant. The same boundary now wraps the text read out of a scanned document. -- **The cleaning step had a flaw that helped the very thing it was meant to stop.** It removed line breaks instead of replacing them, which glued neighbouring words together and broke the word boundaries its own checks rely on — so a payload written the most natural way, on its own line, slipped through. It now replaces them. -- **A reminder the assistant captured from its own reply was saved as active.** It is now saved as proposed, so it appears for you to confirm — the same way the assistant's other suggestions already work — and does not feed back into its context before you do. Reminders captured before this release keep working unchanged; only new ones need a confirmation. -- The test that was supposed to guarantee this class of coverage listed four files, three of which no longer took part at all. It is now derived from the actual code, with a floor so it cannot quietly pass by covering nothing. +- **Text read out of your documents is treated as data.** Lab field + names were passed to the assistant unchecked, so a document whose + labels were written to look like instructions could influence later + answers. Those names are cleaned now, and document text is marked as + yours rather than as something addressed to the assistant. +- **A reminder the assistant picks up from a chat is proposed, not + active.** It waits for you to confirm it, like its other suggestions. + Reminders from before this release keep working unchanged. No breaking changes. ## [1.30.25] — 2026-07-19 -Generated cards now count against a budget, and long conversations get cheaper again. - -- **The tier that generates the metric cards, biomarker cards, derived scores and the period narrative did not count its spend at all.** Nine families of generated text ran through one place that recorded nothing — while a comment elsewhere stated the opposite, so the gap was invisible to anyone checking. All of them now reserve before and settle after, against the right ceiling: a self-hoster on their own key is measured against their own limit, never the operator's. -- **A client could multiply generations by asking for a different language.** The de-duplication that collapses repeated polls included the language, so the same card in six languages was six separate generations — with no limit and no record. It no longer does. Switching language still regenerates the card; only the duplication is gone. -- **A long assistant conversation started re-sending the full context on every turn** once it passed the point where older turns get summarised, instead of once at that point. A sixty-turn conversation sent it twenty-one times; it now sends it twice. -- **The provider test button could reach the server-wide key without a daily limit.** It now has one, and a failed test is refunded so a misconfigured provider does not consume it. +- **Generated cards count against the AI budget.** Metric cards, + biomarker cards, scores and the period narrative were not counted at + all. On your own key they count against your limit, not the server's. +- **Switching language no longer regenerates everything twice.** The same + card in six languages counted as six separate jobs. +- **Long assistant conversations get cheaper again.** Past a certain + length the full context was re-sent on every turn instead of once. No breaking changes. ## [1.30.24] — 2026-07-19 -The safety check on generated text now covers every surface and every language. - -- **The check that stops a generated text from suggesting a dose change or naming a risk score ran on three surfaces out of roughly forty.** The same sentence the assistant refuses to send in chat could be written onto a metric card and shown for a day, or returned as a document summary. Every place a generated text reaches you now goes through one check. -- **It could not see your language.** The check took the text alone, so its wording lists were English and German only — for French, Spanish, Italian and Polish the rule existed in the prompt and was enforced nowhere. The reader's language is now part of the check, and the lists cover all six alongside English, because a provider may answer in English regardless of what it was asked. -- **The rule against stating that one thing caused another is now enforced, not just written.** It shipped in all six languages and only one surface checked it. -- What happens on a violation depends on the surface: something you asked for right now is replaced with an honest short text; something generated in the background is withheld and the plain non-generated version is kept instead. A document transcription is deliberately never filtered — it reproduces your own document, and a letter from your prescriber saying to increase a dose is a record, not advice from us. -- A check on whether generated recommendations cite the data they rest on had been unreachable for some time and is now wired into the nightly run. +- **The safety check on generated text covers every card, in every + language.** It ran on the assistant's replies but not on the metric + cards, biomarker cards or document summaries — so a sentence the + assistant would refuse to send could still appear on a card. It also + only knew English and German wording, leaving four languages + unprotected. Both are fixed. +- A document transcription is deliberately never filtered: it reproduces + your own document, and a letter from your prescriber is a record, not + advice from us. No breaking changes. ## [1.30.23] — 2026-07-19 -Switching a module off holds on the connector wire too. - -- **Assistant connectors honour your module switches.** Three read paths reached the data directly instead of through the gated builder, so with a module off a connected assistant could still read the whole-record doctor summary, and could still get baselines, comparisons and level-shift detection for a metric whose own series correctly reported no data. Correlations were reachable the same way — and through the in-app assistant as well, which had the identical gap. All of them now check first. -- **A shared clinician link served the whole-record summary without checking that module** — the same aggregate, reachable by someone who is not signed in at all. It now degrades to the documents-only view instead. -- **The function that assembles that summary now refuses on its own** when the module is off, rather than trusting each of its five callers to remember. Three of them had not. -- **The confirmation that background queues were migrated is now visible.** The previous release reported it at a log level that is deliberately discarded, so there was no way to tell from the outside whether it had taken effect. It is now a boot entry that appears on every start, including one where nothing needed migrating. +- **Switching a module off now also applies to connected assistants.** + With a module off, a connected assistant could still read that data — + including the full doctor summary — and so could a shared clinician + link. Both now honour the switch. No breaking changes. ## [1.30.22] — 2026-07-19 -Background work stops piling up on itself. - -- **Duplicate background jobs are now actually suppressed.** Jobs were queued with a de-duplication key, but the queues were configured in a way that made that key have no effect — so the key looked right in the code and did nothing at run time. A large sync could fan out thousands of identical recompute jobs where tens were intended; a restart during a long history import could append another full import per restart; and the once-per-morning refresh had no debounce, so several paths could each trigger their own run. Each queue now carries an explicit policy chosen for what that queue does, recorded with its reason. -- Existing installations are migrated on the next start — the setting could not be changed after a queue was created, so setting it at creation alone would have fixed nothing on any running instance. The first start after this release logs one line per migrated queue, which is how you can confirm it took effect. -- One queue is deliberately left as it was: the explicit-range environment backfill sends without a key on purpose, and any de-duplication there would merge two different requested date ranges into one. +- **Background jobs stop piling up on themselves.** Duplicate jobs were + meant to be collapsed but were not, so a large sync could queue + thousands of identical recomputations and a restart could re-queue a + running history import. Existing installations are updated on the next + start. No breaking changes. ## [1.30.21] — 2026-07-19 -Dose reminders land on the right hour across a clock change. - -- **A reminder on a clock-change day now fires at the time you set.** The conversion from your local time to a real instant read the timezone offset at the moment the scheduler happened to run, not at the time the dose was actually due. On the autumn change in a timezone that observes one, a 20:00 dose could resolve to 19:00 when the scheduler ticked before the change. Two days a year, and only in timezones that change their clocks — but a dose reminder should never be an hour off. Nothing stored changed; the same schedule now produces the correct instant. -- **A duplicate pending dose was possible on those same days.** The reminder projector, the worker and the intake write derived the instant separately and could disagree on the change day, which broke the matching that keeps them on one row. -- **The next-dose day label could show the wrong day** when your device timezone differs from the one in your profile, because the offset was applied twice. The two copies of that label — in the card and in the table — are now one. -- The tests could not have caught any of this: they used times with no timezone attached, so the day boundary could not be distinguished from a UTC one, and the suite ran only in one host timezone. They now state their timezone explicitly, cover both clock changes, and the suite is pinned to match the build. +- **Dose reminders land on the right hour across a clock change.** On the + day the clocks change, a reminder could resolve an hour off, and the + same dose could end up recorded twice. Nothing stored changed. +- **The next-dose day label was wrong** when your phone's timezone + differed from the one in your profile. No breaking changes. ## [1.30.20] — 2026-07-19 -- **A restore now refuses when the backup file names a different account than the backup record.** The restore target was read from inside the encrypted file rather than from the record the operator selected, so a file claiming a different account would have been written into that account instead — while the audit entry recorded the one the operator picked. Both values were already available at that point; they are now compared, and a mismatch is refused and recorded with both. +- **A restore refuses when the backup file names a different account** + than the backup you selected. No breaking changes. ## [1.30.19] — 2026-07-19 -Switching a module off now holds everywhere. +- **Switching a module off now holds everywhere.** The sync feed still + sent cycle data, the FHIR export still returned the full record, and + the dashboard summary still built glucose and sleep cards regardless of + the switch. Nothing is deleted — turning a module back on finds the + history complete. +- **Two assistant features sent data out without checking your consent + first.** Both now check, and fall back to their plain text when consent + is absent. -- **Three surfaces ignored your module switches.** The per-topic endpoints have always honoured them, but the sync feed still sent cycle data, the FHIR export still returned the full record including the insurance number, and the dashboard summary still built glucose and sleep cards — all regardless of whether those modules were on. Each now honours the switch: the sync feed leaves the cycle rows out, the FHIR export refuses the same way the health-record export already did, and the summary omits the cards. Nothing is deleted — turning a module back on finds the history complete, and the sync feed picks up where it left off rather than skipping what changed while it was hidden. -- **Two assistant features reached a provider without a consent record.** The follow-up questions after saving your profile, and the assistant's own nudges, both sent data to a provider without checking whether consent was on file — and could fall back to the server-wide key. Both now check first and fall back to their non-generated text when consent is absent. The follow-up questions also moved to the same reservation-based spend accounting everything else uses; a self-hoster on their own key is no longer measured against the operator's daily ceiling. -- The check that was supposed to catch the module gaps accepted a helper being imported as proof that a gate existed. It now pins which module each surface must check and names the tests that exercise a disabled one. - -No breaking changes to the web app. The native client may see a 403 from the FHIR endpoints and absent cards where a module is off — see the coordination note. +No breaking changes. The native app may see a 403 from the FHIR +endpoints where a module is off. ## [1.30.18] — 2026-07-19 -Health data and credentials no longer reach the logs or the backups. +- **Health data no longer reaches the logs or the backups.** Cached + responses are encrypted at rest — they echo what was just saved, so + cycle notes and mood text sat unencrypted in every backup for a day. + Diagnostic log entries are now scrubbed where they are written, and a + failed AI request no longer records what came back with it. -- **Cached responses are encrypted at rest.** A write endpoint's response is kept for 24 hours so a retried request cannot run twice. Those responses echo what was just saved — cycle notes, mood text, allergy reactions — and were stored unencrypted, in a column that lands in every backup. They are now encrypted like every other stored health field. If encryption is unavailable the response is simply not cached, rather than being written in the clear. -- **Diagnostic log entries are scrubbed at the point they are written.** Two of the places that build a log entry did not run the redaction the others did, so an outbound request that timed out could put a notification bot's credential into the logs verbatim. -- **A failed AI request no longer quotes what the provider sent back.** Some providers echo the request they rejected, which on this path contains the prompt and the health figures in it. The log entry now names the kind of rejection instead of quoting the response, which answers the same diagnostic question without carrying the content. - -Operator note: no action required, and no configuration change. Log lines and backups written before this release may contain the values described above. - -No breaking changes. +Log lines and backups written before this release may still contain those +values. No breaking changes. ## [1.30.17] — 2026-07-19 -API tokens now enforce their scope. +- **API tokens enforce their scope.** A token issued for medication + intake reached every endpoint, including the full backup export. Routes + that accept a narrow token now have to say so. +- **Revoke and re-issue any token you gave to a third party.** Treat it + as having had that reach for its whole lifetime. +- **The generic "create API token" button is gone.** The token it made + never worked for the medication intake it advertised, while reaching + everything else. Tokens are issued per medication from that + medication's own toggle. Existing tokens stay listed and revocable. -- **A token minted for medication intake is refused outside the ingest endpoint.** Previously any valid token reached every authenticated route regardless of the scope it carried, so a token handed to a third-party automation could read the full-account backup export, lab results, measurements and the assistant. A route that accepts a narrow-scope token now has to name that scope; a route that names none accepts browser sessions and the app's own full-access tokens only. Tokens issued to the web session, the native client and the MCP connector are unaffected. -- **Revoke and re-issue any token you handed to a third party.** A token that could reach the backup export should be treated as having had that reach for its whole lifetime, not only from the moment it was noticed. -- **The generic "create API token" button is gone from settings.** The token it minted never worked for the medication intake it advertised — the ingest endpoint also requires a per-medication grant that this button never issued — while reaching everything else. Tokens are issued per medication from the medication's own API-endpoint toggle, which grants exactly that one medication. Existing tokens stay listed and revocable. -- **MCP connector tokens are now bound to the MCP endpoint alone.** They previously also passed on read-only REST requests. No connector flow used that. - -No breaking changes to the web session, the native client, or the MCP connector. +No breaking changes to the web app, the native client or the MCP +connector. ## [1.30.16] — 2026-07-19 -Stops an ongoing loss of resting heart-rate history. - -- **Resting heart rate is preserved again for accounts without a device-supplied resting figure.** Older heart-rate readings are condensed on a schedule to keep the database small, and a resting value is derived and stored before the raw readings go. The check that decides whether an account already has its own resting data was matching the derived values it had written itself, so from the second run onward it concluded the data was already there and stopped deriving — while the raw readings it derives from were still being cleared. Each run lost another day's resting figure permanently. The check now looks only at readings the account actually supplied. -- Days already affected cannot be reconstructed; from this release forward, nothing further is lost. +- **Resting heart rate stops being lost.** Older heart-rate readings are + condensed on a schedule, and a resting value is derived before the raw + readings go. A faulty check stopped that derivation after the first + run while the raw readings were still cleared, so each run lost another + day's resting figure for good. Days already affected cannot be + recovered; from here nothing further is lost. No breaking changes. ## [1.30.15] — 2026-07-18 -Generated health texts now come in the language you set. - -- **French, Spanish, Italian and Polish accounts get their assessments in their own language.** Every locale other than English previously fell back to the German instruction set, so those four either read German text or — where the pipeline narrowed them earlier — English text. Insight cards, the per-metric assessments, derived scores, the biomarker card, the period narrative and the assistant's tone settings all now follow the account's own language, with the wording rules that were already translated for each of the six languages. German and English output is unchanged, character for character. -- **The language instruction is the last thing the model reads**, rather than being buried before the metric-specific section that follows it. -- Under the hood: one shared resolver replaces the scattered English-or-else-German branches, and the locale now survives the whole generation path instead of being narrowed on the way in. Stored texts for the four languages regenerate on their next scheduled run; German and English keep reading exactly the rows they already had. +- **Assessments come in the language you set.** French, Spanish, Italian + and Polish accounts got German or English text instead of their own. + Insight cards, the per-metric assessments, scores, the biomarker card + and the period narrative now follow your language. German and English + are unchanged. Stored texts in the four languages refresh on their next + nightly run. No breaking changes. diff --git a/messages/de.json b/messages/de.json index 023619012..276a694fb 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8798,7 +8798,6 @@ "deltaVsBaseline": "{delta} ggü. deinem Ausgangswert", "deltaFlat": "Im Bereich deines Ausgangswerts", "sleepPending": "Der Schlaf von letzter Nacht fehlt noch – der heutige Wert ist vorläufig.", - "readFullBriefing": "Vollständiges Briefing lesen", "worthALook": "Einen Blick wert", "allClear": "Heute braucht nichts deine Aufmerksamkeit.", "ringLink": "{metric}-Details öffnen" diff --git a/messages/en.json b/messages/en.json index 2bc2542a7..7d80addaa 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8798,7 +8798,6 @@ "deltaVsBaseline": "{delta} vs your baseline", "deltaFlat": "In line with your baseline", "sleepPending": "Last night's sleep isn't in yet — today's read is provisional.", - "readFullBriefing": "Read the full briefing", "worthALook": "Worth a look", "allClear": "Nothing needs your attention today.", "ringLink": "Open {metric} details" diff --git a/messages/es.json b/messages/es.json index 92cca712b..2216d146d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8798,7 +8798,6 @@ "deltaVsBaseline": "{delta} frente a tu valor de referencia", "deltaFlat": "En línea con tu valor de referencia", "sleepPending": "El sueño de anoche aún no ha llegado; la lectura de hoy es provisional.", - "readFullBriefing": "Leer el informe completo", "worthALook": "Merece un vistazo", "allClear": "Hoy nada necesita tu atención.", "ringLink": "Abrir detalles de {metric}" diff --git a/messages/fr.json b/messages/fr.json index 40e9fa34d..accdc780a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8798,7 +8798,6 @@ "deltaVsBaseline": "{delta} par rapport à ta référence", "deltaFlat": "Conforme à ta référence", "sleepPending": "Le sommeil de la nuit dernière n'est pas encore arrivé — la lecture du jour est provisoire.", - "readFullBriefing": "Lire le briefing complet", "worthALook": "À regarder", "allClear": "Rien ne requiert ton attention aujourd'hui.", "ringLink": "Ouvrir les détails de {metric}" diff --git a/messages/it.json b/messages/it.json index d4c44431f..ecf996377 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8798,7 +8798,6 @@ "deltaVsBaseline": "{delta} rispetto al tuo valore di riferimento", "deltaFlat": "In linea con il tuo valore di riferimento", "sleepPending": "Il sonno di stanotte non è ancora arrivato: la lettura di oggi è provvisoria.", - "readFullBriefing": "Leggi il briefing completo", "worthALook": "Da tenere d'occhio", "allClear": "Oggi nulla richiede la tua attenzione.", "ringLink": "Apri i dettagli di {metric}" diff --git a/messages/pl.json b/messages/pl.json index 18ed6fef7..f7877399d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8798,7 +8798,6 @@ "deltaVsBaseline": "{delta} wzgl. Twojej wartości bazowej", "deltaFlat": "Zgodnie z Twoją wartością bazową", "sleepPending": "Sen z ostatniej nocy jeszcze nie dotarł — dzisiejszy odczyt jest wstępny.", - "readFullBriefing": "Przeczytaj pełny briefing", "worthALook": "Warto sprawdzić", "allClear": "Dziś nic nie wymaga Twojej uwagi.", "ringLink": "Otwórz szczegóły: {metric}" diff --git a/src/components/daily/__tests__/today-hero.test.tsx b/src/components/daily/__tests__/today-hero.test.tsx index 393b954c3..5a7539b68 100644 --- a/src/components/daily/__tests__/today-hero.test.tsx +++ b/src/components/daily/__tests__/today-hero.test.tsx @@ -95,9 +95,10 @@ describe("", () => { // link to the existing surface — S2 invents no new backend action. expect(html).toContain('href="/medications"'); expect(html).toContain('href="/settings/integrations"'); - // The read-the-full-briefing affordance routes to Insights. - expect(html).toContain('data-slot="today-hero-briefing-link"'); + // The score ring is the one door to Insights — the separate + // read-the-full-briefing link was redundant with it and is gone. expect(html).toContain('href="/insights"'); + expect(html).not.toContain('data-slot="today-hero-briefing-link"'); }); it("shows the honest sleep-pending note and provisional score", () => { @@ -118,7 +119,7 @@ describe("", () => { expect(html).toContain("Last night"); // Null score → the ring's provisional face, never a zero. expect(html).toContain('data-provisional="true"'); - // No briefing lead → no full-briefing link. + // The score ring stays the only route to Insights. expect(html).not.toContain('data-slot="today-hero-briefing-link"'); }); diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index 2f089bdad..9550dd18d 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -145,17 +145,6 @@ export function TodayHero({ digest }: { digest: DailyDigest }) { ) : null}

) : null} - {/* Read-the-full-briefing affordance — only when a briefing - actually backs the lead. Routes to the Insights overview. */} - {digest.briefingLead ? ( - - {t("daily.today.readFullBriefing")} - - ) : null} {/* Health score ring — `flat` (no sweep/bloom), server-computed diff --git a/src/components/ui/responsive-sheet.tsx b/src/components/ui/responsive-sheet.tsx index 39d0c4733..743470921 100644 --- a/src/components/ui/responsive-sheet.tsx +++ b/src/components/ui/responsive-sheet.tsx @@ -174,7 +174,19 @@ export function ResponsiveSheet({ // the `p-4` right edge instead of being pushed in by an empty // reserved gutter. showCloseButton && "pr-12", - headerAction && "flex-row items-start justify-between gap-3", + // Optical alignment for a consumer-rendered trailing control. + // An icon button carries its own inner inset (a 20px glyph + // centred in a 44px tap target sits 12px in), so at the plain + // `p-4` right edge the GLYPH lands ~28px from the frame while + // the title starts at 16px — visibly not flush. Trim the right + // padding by that inset so the glyph aligns with the title's + // edge; the tap target still reaches the frame. + headerAction && + !showCloseButton && + "flex-row items-start justify-between gap-3 pr-1 sm:pr-2", + headerAction && + showCloseButton && + "flex-row items-start justify-between gap-3", )} > {headerAction ? ( From d773f460e57df202b059e011810360792b1fd928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 10:01:06 +0200 Subject: [PATCH 5/8] Tell the four document-summary outcomes apart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a document said "the summary is being prepared" and kept saying it. The view had one fallback for a null summary column and used it for every outcome: a document nobody ever enqueued, a missing vision provider, a spent budget, a withdrawn consent, an unreadable file, a provider error, and a summary the outbound screen withheld all rendered as work in progress. The common case is not the screen. The background job is enqueued at exactly one place — a fresh upload with the auto-read opt-in already on — and no backfill reaches anything else. Every document uploaded before the toggle went on has a null column no job will ever fill, and the view promised all of them a summary that was never coming. Record the outcome as `summary_state` (migration 0254, additive, defaults to NONE which is truthful for every existing row) and branch on it. Only PENDING, set when a job genuinely exists, may claim generation is running. NONE, WITHHELD and UNAVAILABLE each say what happened and offer to generate one. The withhold keeps its rule and loses its dead end. Blocked prose is still never shown and never stored — only the fact of the refusal is kept, and it is no longer terminal, so asking again re-runs the gauntlet. That was the original intent; leaving the column null defeated it, because null read as "generating" and the guard meant nothing would ever overwrite it. The on-demand summary now persists what it produces. It is the only repair path for the documents the job never ran for, and re-deriving the same paragraph on every open costs a provider call for nothing. Generation is still only ever a click — nothing runs because a view rendered. Transcription stays session-only. Also drop the "read by AI" line from the detail footer. --- docs/api/openapi.yaml | 24 ++- messages/de.json | 8 +- messages/en.json | 8 +- messages/es.json | 8 +- messages/fr.json | 8 +- messages/it.json | 8 +- messages/pl.json | 8 +- .../0254_document_summary_state/migration.sql | 26 ++++ prisma/schema.prisma | 32 ++++ .../[id]/summary/__tests__/route.test.ts | 96 +++++++++++- .../documents/inbound/[id]/summary/route.ts | 92 +++++++++++- .../__tests__/document-summary-block.test.tsx | 141 ++++++++++++++++++ .../documents/document-detail-sheet.tsx | 78 +++++----- .../documents/document-summary-block.tsx | 117 +++++++++++++++ src/lib/documents/store.ts | 12 ++ .../jobs/__tests__/document-summary.test.ts | 50 ++++++- src/lib/jobs/document-summary.ts | 52 ++++++- src/lib/openapi/routes/documents.ts | 4 +- src/lib/validations/inbound-documents.ts | 23 +++ 19 files changed, 713 insertions(+), 82 deletions(-) create mode 100644 prisma/migrations/0254_document_summary_state/migration.sql create mode 100644 src/components/documents/__tests__/document-summary-block.test.tsx create mode 100644 src/components/documents/document-summary-block.tsx diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 2f3b39b2c..7f107916c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -29359,6 +29359,14 @@ components: anyOf: - type: string - type: "null" + summaryState: + type: string + enum: + - NONE + - PENDING + - READY + - WITHHELD + - UNAVAILABLE required: - id - kind @@ -29383,12 +29391,18 @@ components: - facts - summary - summaryGeneratedAt + - summaryState additionalProperties: false - description: A stored document plus its staged facts. `summary` is a short (3-4 sentence) plain-language description of - WHAT the document is, generated once in the background right after upload when the `documentsAutoAiRead` opt-in - is ON (that flag is also the standing AI-egress consent); it is descriptive only, never a diagnosis, and is null - when auto-read is OFF, no provider is configured, or the background summary has not run yet. - `summaryGeneratedAt` is when it was generated (null until then). + description: 'A stored document plus its staged facts. `summary` is a short (3-4 sentence) plain-language description of + WHAT the document is, generated once and then served from storage; it is descriptive only, never a diagnosis. + `summaryGeneratedAt` is when it was generated (null until then). `summaryState` says what became of it, because + a null `summary` on its own is ambiguous: NONE = never attempted (the `documentsAutoAiRead` opt-in was off at + upload, or the document predates it — no backfill reaches these), PENDING = a job is enqueued or running, READY + = stored and returned in `summary`, WITHHELD = generated but blocked by the outbound safety screen and therefore + never returned as text, UNAVAILABLE = attempted and could not produce one (no vision provider, spent budget, + withdrawn consent, unreadable file, provider error). Only PENDING may be presented as "being generated"; + WITHHELD and UNAVAILABLE are both re-attemptable via POST `/api/documents/inbound/{id}/summary`. Treat an + unknown value as UNAVAILABLE when decoding.' ExtractedFact: type: object properties: diff --git a/messages/de.json b/messages/de.json index 12a12c74c..d4f4b44ab 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8476,7 +8476,12 @@ "title": "Dokumentdetails", "summary": { "title": "Zusammenfassung", - "pending": "Die Zusammenfassung wird erstellt." + "pending": "Die Zusammenfassung wird erstellt.", + "none": "Für dieses Dokument wurde noch keine Zusammenfassung erstellt.", + "withheld": "Eine Zusammenfassung wurde erstellt, hat die Sicherheitsprüfung aber nicht bestanden und wird deshalb nicht angezeigt. Du kannst es erneut versuchen oder das Dokument selbst lesen.", + "unavailable": "Für dieses Dokument konnte keine Zusammenfassung erstellt werden.", + "generate": "Zusammenfassung erstellen", + "generating": "Wird erstellt…" }, "loadError": "Dieses Dokument konnte nicht geladen werden.", "saveError": "Die Änderung konnte nicht gespeichert werden.", @@ -8558,7 +8563,6 @@ "close": "Ausblenden" }, "ai": { - "statusAiRead": "Von KI gelesen", "readDone": "Die KI hat dein Dokument gelesen.", "read": "Mit KI auslesen", "reread": "Erneut lesen", diff --git a/messages/en.json b/messages/en.json index 3f4f70771..0b38e5136 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8476,7 +8476,12 @@ "title": "Document details", "summary": { "title": "Summary", - "pending": "The summary is being prepared." + "pending": "The summary is being prepared.", + "none": "No summary has been generated for this document yet.", + "withheld": "A summary was generated but did not pass the safety check, so it is not shown. You can try again, or read the document itself.", + "unavailable": "No summary could be generated for this document.", + "generate": "Generate summary", + "generating": "Generating…" }, "loadError": "Couldn't load this document.", "saveError": "Couldn't save the change.", @@ -8558,7 +8563,6 @@ "close": "Hide" }, "ai": { - "statusAiRead": "Read by AI", "readDone": "The AI read your document.", "read": "Read with AI", "reread": "Read again", diff --git a/messages/es.json b/messages/es.json index 8d7e0c8fb..4709a36ed 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8476,7 +8476,12 @@ "title": "Detalles del documento", "summary": { "title": "Resumen", - "pending": "Se está preparando el resumen." + "pending": "Se está preparando el resumen.", + "none": "Todavía no se ha generado ningún resumen para este documento.", + "withheld": "Se generó un resumen, pero no superó la comprobación de seguridad, así que no se muestra. Puedes intentarlo de nuevo o leer el documento.", + "unavailable": "No se pudo generar ningún resumen para este documento.", + "generate": "Generar resumen", + "generating": "Generando…" }, "loadError": "No se pudo cargar este documento.", "saveError": "No se pudo guardar el cambio.", @@ -8558,7 +8563,6 @@ "close": "Ocultar" }, "ai": { - "statusAiRead": "Leído por IA", "readDone": "La IA leyó tu documento.", "read": "Leer con IA", "reread": "Leer de nuevo", diff --git a/messages/fr.json b/messages/fr.json index cd66a28a8..f7555ad87 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8476,7 +8476,12 @@ "title": "Détails du document", "summary": { "title": "Résumé", - "pending": "Le résumé est en cours de préparation." + "pending": "Le résumé est en cours de préparation.", + "none": "Aucun résumé n’a encore été généré pour ce document.", + "withheld": "Un résumé a été généré mais n’a pas passé le contrôle de sécurité, il n’est donc pas affiché. Vous pouvez réessayer ou lire le document lui-même.", + "unavailable": "Aucun résumé n’a pu être généré pour ce document.", + "generate": "Générer le résumé", + "generating": "Génération…" }, "loadError": "Impossible de charger ce document.", "saveError": "Impossible d'enregistrer la modification.", @@ -8558,7 +8563,6 @@ "close": "Masquer" }, "ai": { - "statusAiRead": "Lu par l’IA", "readDone": "L’IA a lu votre document.", "read": "Lire avec l’IA", "reread": "Relire", diff --git a/messages/it.json b/messages/it.json index 303022426..9f9cdf118 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8476,7 +8476,12 @@ "title": "Dettagli del documento", "summary": { "title": "Riepilogo", - "pending": "Il riepilogo è in preparazione." + "pending": "Il riepilogo è in preparazione.", + "none": "Non è ancora stato generato alcun riepilogo per questo documento.", + "withheld": "Un riepilogo è stato generato ma non ha superato il controllo di sicurezza, quindi non viene mostrato. Puoi riprovare o leggere il documento stesso.", + "unavailable": "Non è stato possibile generare un riepilogo per questo documento.", + "generate": "Genera riepilogo", + "generating": "Generazione…" }, "loadError": "Impossibile caricare questo documento.", "saveError": "Impossibile salvare la modifica.", @@ -8558,7 +8563,6 @@ "close": "Nascondi" }, "ai": { - "statusAiRead": "Letto dall’IA", "readDone": "L’IA ha letto il tuo documento.", "read": "Leggi con l’IA", "reread": "Leggi di nuovo", diff --git a/messages/pl.json b/messages/pl.json index 22cdd6c6f..a094e9fac 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8476,7 +8476,12 @@ "title": "Szczegóły dokumentu", "summary": { "title": "Podsumowanie", - "pending": "Podsumowanie jest przygotowywane." + "pending": "Podsumowanie jest przygotowywane.", + "none": "Dla tego dokumentu nie wygenerowano jeszcze podsumowania.", + "withheld": "Podsumowanie zostało wygenerowane, ale nie przeszło kontroli bezpieczeństwa, więc nie jest wyświetlane. Możesz spróbować ponownie lub przeczytać sam dokument.", + "unavailable": "Nie udało się wygenerować podsumowania dla tego dokumentu.", + "generate": "Wygeneruj podsumowanie", + "generating": "Generowanie…" }, "loadError": "Nie udało się wczytać tego dokumentu.", "saveError": "Nie udało się zapisać zmiany.", @@ -8558,7 +8563,6 @@ "close": "Ukryj" }, "ai": { - "statusAiRead": "Odczytane przez AI", "readDone": "AI odczytało Twój dokument.", "read": "Odczytaj z AI", "reread": "Odczytaj ponownie", diff --git a/prisma/migrations/0254_document_summary_state/migration.sql b/prisma/migrations/0254_document_summary_state/migration.sql new file mode 100644 index 000000000..3c3a87462 --- /dev/null +++ b/prisma/migrations/0254_document_summary_state/migration.sql @@ -0,0 +1,26 @@ +-- Record what happened to a document's summary. +-- +-- `summary_encrypted` alone cannot tell four outcomes apart: a document nobody +-- ever enqueued, one whose provider is missing, one the safety screen withheld, +-- and one still in flight all leave it NULL. The detail view read that NULL as +-- "still generating" and said so forever. +-- +-- Additive and backfill-free: NONE is the default and is the truthful value for +-- every pre-existing row, because none of them were ever attempted. Rows that +-- already carry a summary are moved to READY below. + +CREATE TYPE "document_summary_state" AS ENUM ( + 'NONE', + 'PENDING', + 'READY', + 'WITHHELD', + 'UNAVAILABLE' +); + +ALTER TABLE "inbound_documents" + ADD COLUMN "summary_state" "document_summary_state" NOT NULL DEFAULT 'NONE'; + +-- A document that already holds a summary is READY, not NONE. +UPDATE "inbound_documents" + SET "summary_state" = 'READY' + WHERE "summary_encrypted" IS NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f62e1a639..f525464f7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -6080,6 +6080,31 @@ enum InboundDocumentStatus { @@map("inbound_document_status") } +/// Outcome of a document's plain-language summary. Recorded so the detail view +/// states what happened rather than claiming generation is in progress for a +/// job that was never enqueued. +/// +/// WITHHELD deserves a note: the outbound safety screen tripped, so the model's +/// prose is discarded and NEVER shown. Only the fact of the refusal is kept, and +/// it is deliberately not terminal — the user can ask again, which is the whole +/// reason this is a state and not a stored refusal string. +enum DocumentSummaryState { + /// Never attempted — the opt-in was off at upload, or the document predates + /// the feature. The default, so every existing row is correct. + NONE + /// Enqueued or running. + PENDING + /// A summary was produced and is stored in `summaryEncrypted`. + READY + /// Produced, then blocked by the outbound safety screen. Text discarded. + WITHHELD + /// Attempted and could not produce one — no vision provider, spent budget, + /// withdrawn consent, an unreadable file, or a provider error. + UNAVAILABLE + + @@map("document_summary_state") +} + /// The FHIR resource an extracted fact maps to (STATED status only): a /// diagnosis → Condition, a measured value → Observation, a medication the /// patient is/was taking → MedicationStatement. @@ -6166,6 +6191,13 @@ model InboundDocument { /// When the background summary was generated (null until it runs). Surfaced /// as meta beside the summary in the document detail view. summaryGeneratedAt DateTime? @map("summary_generated_at") + /// What actually happened to this document's summary, so the detail view can + /// tell the four outcomes apart instead of reading a null column as "still + /// generating". `summaryEncrypted` alone cannot: a document nobody ever + /// enqueued, one whose provider is missing, one the safety screen withheld, + /// and one mid-flight all leave it null. NONE is the default, which keeps + /// every pre-existing row correct — none of them were ever attempted. + summaryState DocumentSummaryState @default(NONE) @map("summary_state") facts ExtractedFact[] conditionLinks DocumentConditionLink[] diff --git a/src/app/api/documents/inbound/[id]/summary/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/summary/__tests__/route.test.ts index 80607b9f1..9efa2b15a 100644 --- a/src/app/api/documents/inbound/[id]/summary/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/summary/__tests__/route.test.ts @@ -2,14 +2,23 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; /** - * On-demand summary / extracted text (Document vault P2). Pins P2-D4 / A3: - * session-only — the ONLY persistent side effects are the AI-budget ledger and - * the audit log; no row, index, or fact is written or mutated. + * On-demand summary / extracted text (Document vault P2). + * + * `mode=text` stays session-only (P2-D4 / A3): the only persistent side effects + * are the AI-budget ledger and the audit log. `mode=summary` additionally stores + * the summary on the document since v1.30.31 — the user asked for it, and it is + * the repair path for documents the background job never ran for. Nothing else + * (index, facts, rows) is ever written, and a WITHHELD summary is never stored + * as text. */ vi.mock("@/lib/db", () => ({ prisma: { - inboundDocument: { findFirst: vi.fn(), update: vi.fn() }, + inboundDocument: { + findFirst: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }, extractedFact: { create: vi.fn(), deleteMany: vi.fn() }, documentContentIndex: { upsert: vi.fn() }, user: { findUnique: vi.fn() }, @@ -18,6 +27,9 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/documents/store", () => ({ decryptDocumentContent: vi.fn(() => Buffer.from([1, 2, 3])), + encryptDocumentSummary: vi.fn((text: string) => + new TextEncoder().encode(`enc:${text}`), + ), })); vi.mock("@/lib/labs/ocr-upload", () => ({ detectOcrMimeType: vi.fn(() => "image/png"), @@ -25,6 +37,7 @@ vi.mock("@/lib/labs/ocr-upload", () => ({ vi.mock("@/lib/documents/describe", () => ({ runDocumentSummary: vi.fn(), transcribeDocument: vi.fn(), + documentSummaryBlockedCopy: () => "The summary was withheld.", DocumentDescribeError: class DocumentDescribeError extends Error {}, })); vi.mock("@/lib/documents/provider-order", () => ({ @@ -88,6 +101,11 @@ const req = (id: string, mode?: string) => { method: "POST" }, ); +/** + * Nothing beyond the summary column may be written. `mode=summary` DOES persist + * onto the document since v1.30.31 (see the route header) — that leg is asserted + * explicitly below; everything else stays session-only. + */ function assertNoPersistence(): void { expect(prisma.inboundDocument.update).not.toHaveBeenCalled(); expect(prisma.documentContentIndex.upsert).not.toHaveBeenCalled(); @@ -117,9 +135,10 @@ beforeEach(() => { }); describe("POST /api/documents/inbound/[id]/summary", () => { - it("mode=summary returns { summary } and persists only budget + audit", async () => { + it("mode=summary returns { summary } and STORES it, so a second open is free", async () => { vi.mocked(runDocumentSummary).mockResolvedValue({ summary: "A blood panel from a lab.", + blocked: null, } as never); const res = await POST( @@ -133,9 +152,76 @@ describe("POST /api/documents/inbound/[id]/summary", () => { "documents.inbound.summary", expect.anything(), ); + + // Persisted READY, owner-scoped, and only onto a document without one — + // an explicit re-run must not replace a summary already on screen. + expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith({ + where: { + id: "doc-1", + userId: "user-1", + deletedAt: null, + summaryEncrypted: null, + }, + data: expect.objectContaining({ summaryState: "READY" }), + }); assertNoPersistence(); }); + it("records WITHHELD without ever storing the blocked prose", async () => { + vi.mocked(runDocumentSummary).mockResolvedValue({ + summary: "raise the dose to 10 mg", + blocked: "dose_directive", + } as never); + + const res = await POST( + req("doc-1", "summary") as never, + ctx("doc-1") as never, + ); + expect(res.status).toBe(200); + + // The user gets the honest statement, never the blocked text. + const body = await res.json(); + expect(body.data.summary).toBe("The summary was withheld."); + expect(body.data.summary).not.toContain("10 mg"); + + // Only the state lands. No ciphertext column is touched on this path. + expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith({ + where: { + id: "doc-1", + userId: "user-1", + deletedAt: null, + summaryState: { not: "READY" }, + }, + data: { summaryState: "WITHHELD" }, + }); + const calls = vi.mocked(prisma.inboundDocument.updateMany).mock.calls; + for (const [arg] of calls) { + expect(arg.data).not.toHaveProperty("summaryEncrypted"); + } + assertNoPersistence(); + }); + + it("still answers when the summary write fails", async () => { + vi.mocked(runDocumentSummary).mockResolvedValue({ + summary: "A blood panel from a lab.", + blocked: null, + } as never); + vi.mocked(prisma.inboundDocument.updateMany).mockRejectedValueOnce( + new Error("db down"), + ); + + // The user is waiting on a summary they can already read; a failed write + // must not turn that into an error. + const res = await POST( + req("doc-1", "summary") as never, + ctx("doc-1") as never, + ); + expect(res.status).toBe(200); + expect((await res.json()).data).toEqual({ + summary: "A blood panel from a lab.", + }); + }); + it("mode=text returns { text } session-only", async () => { vi.mocked(transcribeDocument).mockResolvedValue({ text: "raw transcribed body", diff --git a/src/app/api/documents/inbound/[id]/summary/route.ts b/src/app/api/documents/inbound/[id]/summary/route.ts index 71cfd311d..480cc93c8 100644 --- a/src/app/api/documents/inbound/[id]/summary/route.ts +++ b/src/app/api/documents/inbound/[id]/summary/route.ts @@ -3,11 +3,17 @@ * extracted text. * * `?mode=summary` (default) returns a short plain-language summary of WHAT the - * document is; `?mode=text` returns its raw transcribed text. Both are transient - * (P2-D4): the result is returned once and NOTHING is persisted except the - * AI-budget ledger and the audit log — never coach memory, snapshots, the - * structured stores, or the search index. The summary is descriptive only and - * is forbidden from diagnosing (interpretation boundary G7). + * document is; `?mode=text` returns its raw transcribed text. The summary is + * descriptive only and is forbidden from diagnosing (interpretation boundary + * G7). + * + * `mode=text` stays transient (P2-D4) — a transcription is a read-through of the + * user's own file and there is nothing to keep. `mode=summary` PERSISTS onto the + * document row since v1.30.31: the user asked for it explicitly, it is the same + * artefact the background job stores, and keeping it means a second open shows + * the paragraph instead of buying it again. Beyond that the old rule holds — + * nothing reaches coach memory, snapshots, the structured stores, or the search + * index; a summary the safety screen blocked is never stored as text. * * Same VISION/TEXT dispatch and gauntlet as the extract route. The document is * UNTRUSTED (prompt-injection): the server never acts on an instruction inside @@ -47,6 +53,8 @@ import { transcribeDocument, type DescribeInput, } from "@/lib/documents/describe"; +import { encryptDocumentSummary } from "@/lib/documents/store"; +import type { OutboundReason } from "@/lib/ai/safety/outbound-screen"; import { resolveDocumentTextProvider, resolveDocumentVisionProvider, @@ -85,20 +93,90 @@ function resolveMode(request: NextRequest): DocumentSummaryMode { : "summary"; } -/** Run the requested describe call (summary or raw text) over the input. */ +/** + * Run the requested describe call (summary or raw text) over the input. + * + * `mode=summary` PERSISTS a clean result onto the document (v1.30.31). The + * route was session-only by design (P2-D4), and the transcription leg still is + * — but a summary that the user explicitly asked for and that passed the screen + * is exactly the artefact the background job stores, and re-deriving it on every + * open would mean paying a provider call to show the same paragraph twice. This + * is a deliberate user action, not a warm-on-mount: nothing generates because a + * view rendered. It is also the only repair path for the documents the + * background job never ran for — those uploaded before the auto-read opt-in + * went on, which no backfill ever reaches. + */ async function describe( mode: DocumentSummaryMode, input: DescribeInput, locale: Locale, + persist: { userId: string; documentId: string } | null, ): Promise<{ summary: string } | { text: string }> { if (mode === "text") return transcribeDocument(input); // REPLACE policy: the user clicked "summarise" and is waiting, so a screened // summary becomes a short honest statement rather than an empty panel. The // document itself and the extracted text remain reachable and unaltered. const { summary, blocked } = await runDocumentSummary({ ...input, locale }); + if (persist) await persistSummary(persist, summary, blocked); return { summary: blocked ? documentSummaryBlockedCopy(locale) : summary }; } +/** + * Store a screened-clean summary, or record the refusal as a state. + * + * A blocked summary NEVER lands as text — the honest statement the caller shows + * is generated copy for this response, not the model's prose, and persisting it + * would put a refusal where a summary belongs. Only the WITHHELD state is kept, + * and it is not terminal: the user can ask again. + * + * Storing is best-effort. The user is waiting on the summary they can already + * read in the response; a failed write must not turn that into an error. + */ +async function persistSummary( + target: { userId: string; documentId: string }, + summary: string, + blocked: OutboundReason | null, +): Promise { + try { + if (blocked) { + await prisma.inboundDocument.updateMany({ + where: { + id: target.documentId, + userId: target.userId, + deletedAt: null, + summaryState: { not: "READY" }, + }, + data: { summaryState: "WITHHELD" }, + }); + return; + } + // Scoped to a document without a stored summary: an explicit re-run must + // not silently replace a summary the user already has on screen. + const written = await prisma.inboundDocument.updateMany({ + where: { + id: target.documentId, + userId: target.userId, + deletedAt: null, + summaryEncrypted: null, + }, + data: { + summaryEncrypted: encryptDocumentSummary(summary), + summaryGeneratedAt: new Date(), + summaryState: "READY", + }, + }); + annotate({ + action: { name: "documents.summary.persisted" }, + meta: { documentId: target.documentId, stored: written.count > 0 }, + }); + } catch { + annotate({ + action: { name: "documents.summary.persistFailed" }, + meta: { documentId: target.documentId }, + }); + } +} + /** The budget the requested mode charges. */ function budgetFor(mode: DocumentSummaryMode): number { return mode === "text" @@ -237,6 +315,7 @@ async function handleTextSummary( ocrText: parsed.data.text, }, locale, + { userId, documentId: document.id }, ); await reconcileSpend( userId, @@ -322,6 +401,7 @@ async function handleVisionSummary( documents: vision.documents, }, locale, + { userId, documentId: document.id }, ); await reconcileSpend( userId, diff --git a/src/components/documents/__tests__/document-summary-block.test.tsx b/src/components/documents/__tests__/document-summary-block.test.tsx new file mode 100644 index 000000000..8a9b2143b --- /dev/null +++ b/src/components/documents/__tests__/document-summary-block.test.tsx @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { DocumentSummaryStateValue } from "@/lib/validations/inbound-documents"; +import { DocumentSummaryBlock } from "../document-summary-block"; + +/** + * The detail view used to read a null summary as "being generated" and say so + * forever — for a document nobody ever enqueued as readily as for one actually + * in flight. These pin the states apart: only PENDING may claim generation is + * running, a stored summary comes from storage, and every dead end offers a way + * out. + */ + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +const noop = () => {}; + +function base( + overrides: Partial> = {}, +) { + const props: React.ComponentProps = { + summary: null, + summaryState: "NONE", + generatedAtLabel: null, + aiEnabled: true, + isGenerating: false, + actionsDisabled: false, + onGenerate: noop, + ...overrides, + }; + return render(); +} + +/** Every state that has no summary to show. */ +const ABSENT_STATES: DocumentSummaryStateValue[] = [ + "NONE", + "WITHHELD", + "UNAVAILABLE", +]; + +describe("DocumentSummaryBlock — a stored summary", () => { + it("renders the stored summary from storage, with its date", () => { + const html = base({ + summary: "A discharge letter from a city hospital.", + summaryState: "READY", + generatedAtLabel: "14 Feb 2026", + }); + + expect(html).toContain("A discharge letter from a city hospital."); + expect(html).toContain("14 Feb 2026"); + expect(html).toContain('data-slot="document-detail-summary"'); + // Nothing claims it is being made — it already exists. + expect(html).not.toContain("being prepared"); + expect(html).not.toContain("document-detail-summary-generate"); + }); + + it("shows a stored summary even if the state disagrees", () => { + // The bytes are the truth. A stale state must never hide a real summary. + const html = base({ summary: "Stored prose.", summaryState: "PENDING" }); + + expect(html).toContain("Stored prose."); + expect(html).not.toContain("being prepared"); + }); +}); + +describe("DocumentSummaryBlock — honest absent states", () => { + it("claims generation is running ONLY for PENDING", () => { + const pending = base({ summaryState: "PENDING" }); + + expect(pending).toContain("being prepared"); + expect(pending).toContain('data-slot="document-detail-summary-pending"'); + // A job is running; offering a second one would double-spend. + expect(pending).not.toContain("document-detail-summary-generate"); + }); + + it.each(ABSENT_STATES)( + "never says 'being generated' for %s", + (summaryState) => { + const html = base({ summaryState }); + + expect(html).not.toContain("being prepared"); + expect(html).toContain('data-slot="document-detail-summary-absent"'); + }, + ); + + it("says a summary was never generated for NONE", () => { + expect(base({ summaryState: "NONE" })).toContain( + "No summary has been generated for this document yet.", + ); + }); + + it("says the summary was withheld, and never shows blocked prose", () => { + const html = base({ summaryState: "WITHHELD" }); + + expect(html).toContain("did not pass the safety check"); + // The withheld state carries no summary text — that is the whole contract. + expect(html).not.toContain("document-detail-summary""); + }); + + it("says none could be produced for UNAVAILABLE", () => { + expect(base({ summaryState: "UNAVAILABLE" })).toContain( + "No summary could be generated for this document.", + ); + }); + + it.each(ABSENT_STATES)("offers a way out of %s", (summaryState) => { + const html = base({ summaryState }); + + expect(html).toContain('data-slot="document-detail-summary-generate"'); + expect(html).toContain("Generate summary"); + }); +}); + +describe("DocumentSummaryBlock — the action", () => { + it("states the outcome but offers no action without a provider", () => { + const html = base({ summaryState: "UNAVAILABLE", aiEnabled: false }); + + // The state is still honest; only the button that would 422 is gone. + expect(html).toContain("No summary could be generated for this document."); + expect(html).not.toContain("document-detail-summary-generate"); + }); + + it("disables the action while generating, and says so", () => { + const html = base({ summaryState: "NONE", isGenerating: true }); + + expect(html).toContain("disabled"); + expect(html).toContain("Generating"); + }); + + it("disables the action while the capability probe resolves", () => { + expect(base({ summaryState: "NONE", actionsDisabled: true })).toContain( + "disabled", + ); + }); +}); diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index 8f963f3f0..0270d523e 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -66,13 +66,13 @@ import { invalidateKeys, queryKeys } from "@/lib/query-keys"; import { cn } from "@/lib/utils"; import { INBOUND_DOCUMENT_KINDS, - isAiReadSource, type DocumentSuggestionDto, type DocumentSummaryMode, type InboundDocumentDetailDto, type InboundDocumentKindValue, } from "@/lib/validations/inbound-documents"; import { DocumentAiSection } from "./document-ai-section"; +import { DocumentSummaryBlock } from "./document-summary-block"; import type { DocumentAiTarget } from "./document-ai-transport"; import { DocumentShareSheet } from "./document-share-sheet"; import { DOCUMENT_KIND_ICONS } from "./document-kind-meta"; @@ -399,6 +399,26 @@ export function DocumentDetailSheet({ summary.mutate({ mode: aiMode, target: aiTarget, output }); }; + // Generate the STORED summary from the detail block. Same endpoint as the + // action row, but the result is not routed into the transient panel: the + // route persists a screened-clean summary, so refetching the document shows + // it in place. This runs only on a click — nothing here generates on open. + const generateStoredSummary = () => { + if (!aiTarget || !documentId) return; + summary.reset(); + summary.mutate( + { mode: aiMode, target: aiTarget, output: "summary" }, + { + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: queryKeys.inboundDocument(documentId), + }); + }, + onError: (error) => toast.error(t(documentAiErrorKey(error))), + }, + ); + }; + const runIndex = () => { if (!aiTarget) return; indexDoc.mutate( @@ -440,12 +460,6 @@ export function DocumentDetailSheet({ const title = doc?.title ?? doc?.filename ?? t("documents.card.untitled"); const Icon = doc ? DOCUMENT_KIND_ICONS[doc.kind] : null; const originalHref = doc ? `/api/documents/inbound/${doc.id}/original` : "#"; - // AI-read provenance — surfaced here (the detail view) as calm muted meta - // since v1.28.38 dropped the vault-card glyph. Same condition the AI section - // uses: a content index exists AND its source is a provider vision/text read. - const aiRead = doc - ? doc.hasContentIndex && isAiReadSource(doc.contentIndexSource) - : false; return ( <> @@ -588,34 +602,19 @@ export function DocumentDetailSheet({ )} - {doc.summary ? ( - // Persisted background summary — generated once at upload when the - // auto-read opt-in is on. AI narrative body → foreground content; - // the generation date is muted meta (UI-STANDARDS §3). -
-

- {t("documents.detail.summary.title")} -

-

{doc.summary}

- {doc.summaryGeneratedAt ? ( -

- {format.date(doc.summaryGeneratedAt)} -

- ) : null} -
- ) : autoReadEnabled ? ( - // Auto-read is on but no summary yet (still generating, or the - // provider could not read this file) — one calm muted hint. -

- {t("documents.detail.summary.pending")} -

- ) : null} + - - {aiRead ? ( -

- {t("documents.ai.statusAiRead")} -

- ) : null} ) : null} diff --git a/src/components/documents/document-summary-block.tsx b/src/components/documents/document-summary-block.tsx new file mode 100644 index 000000000..f42b2be87 --- /dev/null +++ b/src/components/documents/document-summary-block.tsx @@ -0,0 +1,117 @@ +"use client"; + +/** + * The document detail sheet's summary block, lifted out of the sheet so its + * state machine renders without the dialog portal and is pinned by static-render + * tests. + * + * The block exists because a null summary is ambiguous and used to be read as + * one thing: "being generated". It was the fallback for every outcome — a + * document nobody ever enqueued (no backfill reaches uploads that predate the + * auto-read opt-in), a missing vision provider, a spent budget, a withdrawn + * consent, an unreadable file, a provider error, and a summary the outbound + * safety screen withheld. All of them left the column null, so the view told the + * user a job was running that in most cases never existed. + * + * It now branches on the stored `summaryState`: + * + * READY → the stored summary, served from storage, never regenerated + * PENDING → "being generated" — the ONLY state allowed to claim that, and + * it is set when a job was actually enqueued + * WITHHELD → says the summary was blocked, offers another attempt. The + * blocked prose is never shown or stored + * UNAVAILABLE → says none could be produced, offers another attempt + * NONE → says none exists yet, offers to generate one + * + * The action is a click, never an effect: opening a document must not spend a + * provider call. With no AI provider configured the block renders the state + * without an action rather than an button that would 422. + */ +import { FileText } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { useTranslations } from "@/lib/i18n/context"; +import type { DocumentSummaryStateValue } from "@/lib/validations/inbound-documents"; + +/** Copy key for a state with no summary to show. */ +function absentCopyKey(state: DocumentSummaryStateValue): string { + if (state === "WITHHELD") return "documents.detail.summary.withheld"; + if (state === "UNAVAILABLE") return "documents.detail.summary.unavailable"; + return "documents.detail.summary.none"; +} + +export function DocumentSummaryBlock({ + summary, + summaryState, + generatedAtLabel, + aiEnabled, + isGenerating, + actionsDisabled, + onGenerate, +}: { + /** The stored, decrypted summary, or null when there is none to show. */ + summary: string | null; + summaryState: DocumentSummaryStateValue; + /** Pre-formatted generation date — the sheet owns locale formatting. */ + generatedAtLabel: string | null; + /** Whether an AI provider is configured; false hides the action, not the state. */ + aiEnabled: boolean; + isGenerating: boolean; + /** Capability still resolving — the transport mode isn't known yet. */ + actionsDisabled: boolean; + onGenerate: () => void; +}) { + const { t } = useTranslations(); + + // A stored summary wins over every state: it is shown from storage. + if (summary) { + return ( +
+

+ {t("documents.detail.summary.title")} +

+

{summary}

+ {generatedAtLabel ? ( +

{generatedAtLabel}

+ ) : null} +
+ ); + } + + if (summaryState === "PENDING") { + return ( +

+ {t("documents.detail.summary.pending")} +

+ ); + } + + return ( +
+

+ {t("documents.detail.summary.title")} +

+

+ {t(absentCopyKey(summaryState))} +

+ {aiEnabled ? ( + + ) : null} +
+ ); +} diff --git a/src/lib/documents/store.ts b/src/lib/documents/store.ts index 7f063122a..3b999754b 100644 --- a/src/lib/documents/store.ts +++ b/src/lib/documents/store.ts @@ -20,6 +20,7 @@ import { servingClassFor } from "@/lib/documents/upload-policy"; import type { DocumentConditionLinkDto, DocumentContentIndexSourceValue, + DocumentSummaryStateValue, ExtractedFactDto, FactData, FactProvenance, @@ -241,12 +242,22 @@ export function serialiseDocumentDetail( // failing the whole detail load — the field is descriptive, not load-bearing, // and the ciphertext is never returned (fail-closed to null). let summary: string | null = null; + // The stored state is the truth EXCEPT when the ciphertext will not open: a + // READY row we cannot decrypt has no summary to show, and reporting READY + // would leave the view rendering a heading over nothing. Degrade to + // UNAVAILABLE so what the user is told matches what they can see. + let summaryState: DocumentSummaryStateValue = doc.summaryState; if (doc.summaryEncrypted && doc.summaryEncrypted.byteLength > 0) { try { summary = decryptDocumentSummary(doc.summaryEncrypted); } catch { summary = null; + summaryState = "UNAVAILABLE"; } + } else if (summaryState === "READY") { + // READY with no ciphertext should be unreachable; trust the bytes, not the + // flag, rather than promising a summary that is not there. + summaryState = "UNAVAILABLE"; } return { ...serialiseDocument( @@ -262,5 +273,6 @@ export function serialiseDocumentDetail( summaryGeneratedAt: doc.summaryGeneratedAt ? doc.summaryGeneratedAt.toISOString() : null, + summaryState, }; } diff --git a/src/lib/jobs/__tests__/document-summary.test.ts b/src/lib/jobs/__tests__/document-summary.test.ts index 2e0d66f8c..c6115bc19 100644 --- a/src/lib/jobs/__tests__/document-summary.test.ts +++ b/src/lib/jobs/__tests__/document-summary.test.ts @@ -182,16 +182,58 @@ describe("runDocumentSummaryJob — gating", () => { expect(reserveBudget).not.toHaveBeenCalled(); expect(runDocumentSummary).not.toHaveBeenCalled(); - expect(prisma.inboundDocument.updateMany).not.toHaveBeenCalled(); + // No summary is written — but the attempt is RECORDED. Leaving the row + // untouched is what made the detail view claim "being generated" forever. + expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ data: { summaryState: "UNAVAILABLE" } }), + ); }); - it("refunds the budget and persists nothing when the provider call throws", async () => { + it("refunds the budget and records UNAVAILABLE when the provider call throws", async () => { vi.mocked(runDocumentSummary).mockRejectedValueOnce(new Error("boom")); await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" }); - // Reservation refunded to zero spend; no write. + // Reservation refunded to zero spend; no summary written, state recorded. expect(reconcileSpend).toHaveBeenCalledWith("user-1", 5, 0, "2026-07-17"); - expect(prisma.inboundDocument.updateMany).not.toHaveBeenCalled(); + expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ data: { summaryState: "UNAVAILABLE" } }), + ); + }); + + it("records WITHHELD — not silence — when the safety screen blocks the summary", async () => { + vi.mocked(runDocumentSummary).mockResolvedValueOnce({ + summary: "", + blocked: "dose_directive", + } as never); + + await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" }); + + // The blocked prose NEVER lands. Only the state does, and it is not + // terminal — the guard refuses to overwrite READY, nothing else. + expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith({ + where: { + id: "doc-1", + userId: "user-1", + deletedAt: null, + summaryState: { not: "READY" }, + }, + data: { summaryState: "WITHHELD" }, + }); + const calls = vi.mocked(prisma.inboundDocument.updateMany).mock.calls; + for (const [arg] of calls) { + expect(arg.data).not.toHaveProperty("summaryEncrypted"); + } + }); + + it("marks a stored summary READY so the view serves it from storage", async () => { + await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" }); + + expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ summaryEncrypted: null }), + data: expect.objectContaining({ summaryState: "READY" }), + }), + ); }); }); diff --git a/src/lib/jobs/document-summary.ts b/src/lib/jobs/document-summary.ts index 602b561b0..83bd6e022 100644 --- a/src/lib/jobs/document-summary.ts +++ b/src/lib/jobs/document-summary.ts @@ -44,9 +44,31 @@ import { encryptDocumentSummary } from "@/lib/documents/store"; import { prisma } from "@/lib/db"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { annotate } from "@/lib/logging/context"; +import type { DocumentSummaryState } from "@/generated/prisma/client"; export const DOCUMENT_SUMMARY_QUEUE = "document-summary"; +/** + * Record what became of this document's summary. Never touches a READY row — + * a stored summary is the terminal state and a later run must not downgrade it. + * Owner-scoped like every other write here. + */ +async function markSummaryState( + userId: string, + documentId: string, + state: Exclude, +): Promise { + await prisma.inboundDocument.updateMany({ + where: { + id: documentId, + userId, + deletedAt: null, + summaryState: { not: "READY" }, + }, + data: { summaryState: state }, + }); +} + /** Serial concurrency — provider calls, kept off the request pool. */ export const DOCUMENT_SUMMARY_CONCURRENCY = 1; @@ -87,6 +109,8 @@ export async function runDocumentSummaryJob( // on-demand route stays the only way to summarise). Fail-closed on a missing // row. if (!(await documentAutoReadEnabled(userId))) { + // Opting out is not a failed attempt — leave the state alone so the view + // keeps offering the manual action rather than claiming we tried. annotate({ action: { name: "documents.summary.autoSkipped" }, meta: { documentId, reason: "opt-out" }, @@ -99,6 +123,7 @@ export async function runDocumentSummaryJob( // there is no local text-layer fallback for a descriptive summary). const { pick } = await resolveDocumentVisionProvider(userId); if (!pick) { + await markSummaryState(userId, documentId, "UNAVAILABLE"); annotate({ action: { name: "documents.summary.autoSkipped" }, meta: { documentId, reason: "no-provider" }, @@ -117,6 +142,7 @@ export async function runDocumentSummaryJob( surface: "insights", }); } catch (err) { + await markSummaryState(userId, documentId, "UNAVAILABLE"); annotate({ action: { name: "documents.summary.autoSkipped" }, meta: { @@ -135,6 +161,7 @@ export async function runDocumentSummaryJob( if (!vision.ok) { // Cannot read this file with the picked provider (decrypt miss, unsupported // type, or a PDF needing an Anthropic provider). Graceful no-op. + await markSummaryState(userId, documentId, "UNAVAILABLE"); annotate({ action: { name: "documents.summary.autoSkipped" }, meta: { documentId, reason: vision.reason }, @@ -151,6 +178,7 @@ export async function runDocumentSummaryJob( ); // Budget exhausted → skip (no local fallback for a summary). if (!reservation.allowed) { + await markSummaryState(userId, documentId, "UNAVAILABLE"); annotate({ action: { name: "documents.summary.autoSkipped" }, meta: { documentId, reason: "budget" }, @@ -185,12 +213,19 @@ export async function runDocumentSummaryJob( reservation.reserved, dateKey, ); - // WITHHOLD policy. Persisting the refusal copy would be worse than no - // summary: `updateMany` below is guarded on `summaryEncrypted: null`, so - // the first write wins and a stored refusal would stamp the document - // permanently with no path to regenerate. Leaving the column null keeps the - // on-demand route as the manual fallback, exactly as a provider miss does. + // WITHHOLD policy, v1.30.31. The model's prose still never lands — a + // summary that tripped the outbound screen must never be shown as if it + // were fine, and that rule is unchanged. + // + // What changed is the consequence. Withholding was chosen so a refusal + // could not stamp the document permanently, but leaving the column null + // achieved exactly that anyway: the detail view read null as "still + // generating" and said so forever, with no signal and no way out. The fact + // of the refusal is now recorded as a STATE (never the text), the view says + // so honestly, and WITHHELD is not terminal — `markSummaryState` only + // refuses to overwrite READY, so asking again re-runs the whole gauntlet. if (blocked) { + await markSummaryState(userId, documentId, "WITHHELD"); annotate({ action: { name: "documents.summary.outbound_blocked" }, meta: { documentId, reason: blocked, providerType: pick.providerType }, @@ -210,6 +245,7 @@ export async function runDocumentSummaryJob( data: { summaryEncrypted: encryptDocumentSummary(summary), summaryGeneratedAt: new Date(), + summaryState: "READY", }, }); annotate({ @@ -226,6 +262,7 @@ export async function runDocumentSummaryJob( // (the on-demand route remains the manual fallback). Never rethrow — a // transient provider error must not retry-loop or fail the queue. await reconcileSpend(userId, reservation.reserved, 0, dateKey); + await markSummaryState(userId, documentId, "UNAVAILABLE"); annotate({ action: { name: "documents.summary.autoFailed" }, meta: { documentId, reason: "provider_error" }, @@ -259,6 +296,11 @@ export async function enqueueDocumentSummary( retryBackoff: true, singletonKey: `document-summary|${documentId}`, }); + // PENDING is claimed only once a job genuinely exists. That is the whole + // point: the detail view may say "being generated" for this state, so + // nothing may set it speculatively — a dropped send (no boss, DB hiccup) + // leaves the state alone and the view offers the manual action instead. + if (jobId) await markSummaryState(userId, documentId, "PENDING"); return { enqueued: Boolean(jobId) }; } catch (err) { annotate({ diff --git a/src/lib/openapi/routes/documents.ts b/src/lib/openapi/routes/documents.ts index 2915f0170..06a4bc989 100644 --- a/src/lib/openapi/routes/documents.ts +++ b/src/lib/openapi/routes/documents.ts @@ -23,6 +23,7 @@ import { documentUpdateSchema, inboundConfirmSchema, inboundFactEditSchema, + DOCUMENT_SUMMARY_STATES, INBOUND_DOCUMENT_KINDS, INBOUND_DOCUMENT_STATUSES, } from "@/lib/validations/inbound-documents"; @@ -165,11 +166,12 @@ const inboundDocumentDetail = inboundDocument facts: z.array(extractedFact), summary: z.string().nullable(), summaryGeneratedAt: z.string().nullable(), + summaryState: z.enum(DOCUMENT_SUMMARY_STATES), }) .meta({ id: "InboundDocumentDetail", description: - "A stored document plus its staged facts. `summary` is a short (3-4 sentence) plain-language description of WHAT the document is, generated once in the background right after upload when the `documentsAutoAiRead` opt-in is ON (that flag is also the standing AI-egress consent); it is descriptive only, never a diagnosis, and is null when auto-read is OFF, no provider is configured, or the background summary has not run yet. `summaryGeneratedAt` is when it was generated (null until then).", + 'A stored document plus its staged facts. `summary` is a short (3-4 sentence) plain-language description of WHAT the document is, generated once and then served from storage; it is descriptive only, never a diagnosis. `summaryGeneratedAt` is when it was generated (null until then). `summaryState` says what became of it, because a null `summary` on its own is ambiguous: NONE = never attempted (the `documentsAutoAiRead` opt-in was off at upload, or the document predates it — no backfill reaches these), PENDING = a job is enqueued or running, READY = stored and returned in `summary`, WITHHELD = generated but blocked by the outbound safety screen and therefore never returned as text, UNAVAILABLE = attempted and could not produce one (no vision provider, spent budget, withdrawn consent, unreadable file, provider error). Only PENDING may be presented as "being generated"; WITHHELD and UNAVAILABLE are both re-attemptable via POST `/api/documents/inbound/{id}/summary`. Treat an unknown value as UNAVAILABLE when decoding.', }); const listResponse = z diff --git a/src/lib/validations/inbound-documents.ts b/src/lib/validations/inbound-documents.ts index 0b7d381da..db4c89c16 100644 --- a/src/lib/validations/inbound-documents.ts +++ b/src/lib/validations/inbound-documents.ts @@ -98,6 +98,22 @@ export function toContentIndexSource( : null; } +/** + * What became of a document's plain-language summary. Mirrors the Prisma + * `DocumentSummaryState` enum. The detail view branches on this rather than on + * a null summary, which cannot tell "never attempted" from "in flight" from + * "withheld" from "could not produce". + */ +export const DOCUMENT_SUMMARY_STATES = [ + "NONE", + "PENDING", + "READY", + "WITHHELD", + "UNAVAILABLE", +] as const; +export type DocumentSummaryStateValue = + (typeof DOCUMENT_SUMMARY_STATES)[number]; + /** The document lifecycle states. STORED is the library default. */ export const INBOUND_DOCUMENT_STATUSES = [ "STORED", @@ -301,6 +317,13 @@ export interface InboundDocumentDetailDto extends InboundDocumentDto { summary: string | null; /** When the background summary was generated (ISO 8601), or null. */ summaryGeneratedAt: string | null; + /** + * What became of the summary. A null `summary` is ambiguous on its own — + * never attempted, mid-flight, withheld by the safety screen and could-not- + * produce all look identical — so the view reads this instead of guessing + * "still generating". + */ + summaryState: DocumentSummaryStateValue; } // ─── Edit (correction before approval) ───────────────────────────────────── From 6f3d06c39b504d59e154af523648f443e1d5d004 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 10:06:33 +0200 Subject: [PATCH 6/8] Cover the summary DTO's decrypt-failure degrade A READY row whose ciphertext will not open reports UNAVAILABLE, so the view does not render a heading over nothing. Nothing asserted that, and removing the degrade left the suite green. --- .../document-summary-serialisation.test.ts | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 src/lib/documents/__tests__/document-summary-serialisation.test.ts diff --git a/src/lib/documents/__tests__/document-summary-serialisation.test.ts b/src/lib/documents/__tests__/document-summary-serialisation.test.ts new file mode 100644 index 000000000..a4be9e6b1 --- /dev/null +++ b/src/lib/documents/__tests__/document-summary-serialisation.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { _resetCryptoCacheForTests } from "@/lib/crypto"; +import { + encryptDocumentSummary, + serialiseDocumentDetail, + type SerialisableDocument, +} from "@/lib/documents/store"; + +/** + * What the detail DTO says about a summary has to match what the user can + * actually see. The state column is the source of truth for the four outcomes, + * with one exception the view depends on: a READY row whose ciphertext will not + * open has no summary to render, so reporting READY would leave a heading over + * nothing. + */ + +const KEY_V1 = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +beforeEach(() => { + vi.unstubAllEnvs(); + vi.stubEnv("ENCRYPTION_KEYS", ""); + vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", ""); + vi.stubEnv("ENCRYPTION_KEY", KEY_V1); + _resetCryptoCacheForTests(); +}); + +function row(overrides: Partial = {}) { + return { + id: "doc-1", + userId: "user-1", + kind: "OTHER", + title: "Discharge letter", + filename: "letter.pdf", + mimeType: "application/pdf", + byteSize: 1024, + contentCodec: "binary2", + contentSha256: null, + status: "STORED", + providerType: null, + reportDate: null, + documentDate: null, + errorReason: null, + summaryEncrypted: null, + summaryGeneratedAt: null, + summaryState: "NONE", + createdAt: new Date("2026-02-14T00:00:00Z"), + updatedAt: new Date("2026-02-14T00:00:00Z"), + deletedAt: null, + ...overrides, + } as unknown as SerialisableDocument; +} + +describe("serialiseDocumentDetail — summary state", () => { + it("returns the stored summary as READY", () => { + const dto = serialiseDocumentDetail( + row({ + summaryEncrypted: encryptDocumentSummary("A discharge letter."), + summaryGeneratedAt: new Date("2026-02-15T09:00:00Z"), + summaryState: "READY", + }), + [], + ); + + expect(dto.summary).toBe("A discharge letter."); + expect(dto.summaryState).toBe("READY"); + expect(dto.summaryGeneratedAt).toBe("2026-02-15T09:00:00.000Z"); + }); + + it.each(["NONE", "PENDING", "WITHHELD", "UNAVAILABLE"] as const)( + "passes %s through with no summary", + (summaryState) => { + const dto = serialiseDocumentDetail(row({ summaryState }), []); + + expect(dto.summary).toBeNull(); + expect(dto.summaryState).toBe(summaryState); + }, + ); + + it("degrades an undecryptable READY row to UNAVAILABLE", () => { + // A rotated-away key. There is no summary to show, so the DTO must not + // keep promising one — and the ciphertext is never returned either. + const dto = serialiseDocumentDetail( + row({ + summaryEncrypted: new TextEncoder().encode("not-ciphertext"), + summaryState: "READY", + }), + [], + ); + + expect(dto.summary).toBeNull(); + expect(dto.summaryState).toBe("UNAVAILABLE"); + }); + + it("degrades a READY row with no ciphertext at all to UNAVAILABLE", () => { + const dto = serialiseDocumentDetail( + row({ summaryEncrypted: null, summaryState: "READY" }), + [], + ); + + expect(dto.summary).toBeNull(); + expect(dto.summaryState).toBe("UNAVAILABLE"); + }); +}); From d85e7bf63c94f515c4bdf74087b5f6d095050610 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 10:14:35 +0200 Subject: [PATCH 7/8] fix(documents): tell the summary outcomes apart instead of promising one forever A summary is enqueued only when a document is uploaded while automatic reading is already on, and nothing backfills the ones uploaded before that. The detail view read a null summary as 'being generated' and said so permanently, while the manual action row was hidden precisely when automatic reading was on - so the promise could never be met and the repair was out of reach. Record what actually happened. Seven outcomes previously collapsed into the same null: never enqueued, opted out, no vision provider, spent budget, consent refused, unreadable file, provider error, plus the withhold. Each now names itself and offers a manual generation, and what that produces is kept. The withhold rule is unchanged - refused prose is still never shown or stored - but recording the refusal as a state makes it re-attemptable instead of a permanent dead end. Also removes the read-by-AI footer. --- CHANGELOG.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2abd0911e..6813d9451 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,8 +20,16 @@ - **The "read the full briefing" link is gone from Today.** Tapping the health-score ring already opens the same page. - **The close button on a document sits flush with the edge again.** +- **A document no longer says its summary is being generated forever.** + Summaries are only started when a document is uploaded while automatic + reading is on — there is no catch-up for documents you already had, so + the view promised a summary that was never coming, and hid the button + that would have made one. It now says what actually happened — not + attempted, unavailable, or held back — and offers to generate one in + every case. A summary you generate that way is kept. +- **Removed the "read by AI" note** at the bottom of a document. -No breaking changes. +One migration (0254). No breaking changes. ## [1.30.28] — 2026-07-19 From f05e91e1ef5dd018129b3feefebe0bd4ee362c28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 10:55:16 +0200 Subject: [PATCH 8/8] test(e2e): keep the extraction-provenance line out of the document detail view --- e2e/documents-ai.spec.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/e2e/documents-ai.spec.ts b/e2e/documents-ai.spec.ts index 1bb3b71d0..8952d314b 100644 --- a/e2e/documents-ai.spec.ts +++ b/e2e/documents-ai.spec.ts @@ -351,24 +351,24 @@ test.describe("document vault — AI assist + content search", () => { expect(indexHadJsonBody).toBe(false); }); - // ── (h) Provenance: a provider-read document is marked "Read by AI" ────── + // ── (h) The detail view does not label how the text was extracted ──────── - test("a vision-indexed document surfaces the AI-read provenance", async ({ + test("a vision-indexed document carries no extraction-provenance line", async ({ page, }) => { - // CONTENT_DOC_ID is seeded with source "vision" (an AI provider read it). - // No mocks: the real detail GET threads the provenance through. v1.28.38 — - // the vault card no longer carries the AI-read glyph (it looked wrong on - // the card face); the provenance now lives as a calm muted meta line in the - // document DETAIL view. Deep-link straight to the document so the sheet - // opens deterministically (the vault grid is virtualized). + // CONTENT_DOC_ID is seeded with source "vision". The detail view used to + // print a "Read by AI" meta line under the summary; it told the reader + // nothing they could act on — how the text came out of the page is our + // plumbing, not their business — so it is gone. This test keeps it gone: + // the sheet still opens on the deep link, and the line stays absent. await page.goto(`/documents?doc=${CONTENT_DOC_ID}`); const sheet = page.getByRole("dialog"); await expect(sheet).toBeVisible(); - const marker = sheet.locator('[data-slot="document-detail-ai-read"]'); - await expect(marker).toBeVisible(); - await expect(marker).toHaveText("Read by AI"); + await expect( + sheet.locator('[data-slot="document-detail-ai-read"]'), + ).toHaveCount(0); + await expect(sheet.getByText("Read by AI")).toHaveCount(0); }); // ── (f) Text-mode refuses a non-image before any OCR/upload ──────────────