mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
fix(daily): surface a due dose on Today and keep the last grounded briefing
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.
This commit is contained in:
1 parent
e6e2210314
commit
f8bb8f4b9b
10 files changed
+252
-228
No files matched your search
+5
-2
@@ -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",
|
||||
|
||||
+5
-2
@@ -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",
|
||||
|
||||
+5
-2
@@ -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",
|
||||
|
||||
+5
-2
@@ -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",
|
||||
|
||||
+5
-2
@@ -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",
|
||||
|
||||
+5
-2
@@ -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",
|
||||
|
||||
@@ -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<string, unknown>).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<string, unknown>).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",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
|
||||
+80
-20
@@ -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
|
||||
|
||||
@@ -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<string, unknown>).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",
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user