diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1b8fc1dc1..4b7627433 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -31356,6 +31356,37 @@ components: items: $ref: "#/components/schemas/DailyPriorityItem" description: Bounded 0–3 rail items, never padded. + justIn: + anyOf: + - type: object + properties: + kind: + type: string + enum: + - sleep_night + - workout + - weight + - blood_pressure + - labs_panel + description: Closed arrival kind that landed. + at: + type: string + description: ISO-8601 instant of the newest sample. NEVER pre-formatted server-side — the client formats it in its own + locale and timezone. + required: + - kind + - at + additionalProperties: false + - type: "null" + description: The day's newest data arrival while it is still news (under three hours old), else null. Additive since + v1.31.0. + reactionLine: + anyOf: + - type: string + - type: "null" + description: One-sentence generated reaction to that arrival, standing for the rest of the local day. Null whenever no + line was generated (no provider, no consent, budget exhausted, or generation failed) — consumers fall back + to `briefingLead` / `line`. Additive since v1.31.0. required: - generatedAt - phase @@ -31365,6 +31396,8 @@ components: - briefingLead - line - worthALook + - justIn + - reactionLine additionalProperties: false description: The one data spine of the daily-value system — composed from already-cached data, never a fresh AI call. Consumed by the Today surface, the daily push, and a future iOS widget. diff --git a/messages/de.json b/messages/de.json index ad9268086..9f09092c1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8828,6 +8828,8 @@ "deltaVsBaseline": "{delta} ggü. deinem Ausgangswert", "deltaFlat": "Im Bereich deines Ausgangswerts", "sleepPending": "Der Schlaf von letzter Nacht fehlt noch – der heutige Wert ist vorläufig.", + "justIn": "Gerade eingetroffen", + "justInAt": "Gerade eingetroffen · {time}", "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 c78f6c78c..8daca269b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8828,6 +8828,8 @@ "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.", + "justIn": "Just in", + "justInAt": "Just in · {time}", "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 7e77e5ed2..bf95ccd5d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8828,6 +8828,8 @@ "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.", + "justIn": "Recién llegado", + "justInAt": "Recién llegado · {time}", "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 a390a4c5f..93299fe3c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8828,6 +8828,8 @@ "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.", + "justIn": "Vient d'arriver", + "justInAt": "Vient d'arriver · {time}", "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 827c43c5f..aa96c9100 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8828,6 +8828,8 @@ "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.", + "justIn": "Appena arrivato", + "justInAt": "Appena arrivato · {time}", "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 a43e94b7f..3ad6046d8 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8828,6 +8828,8 @@ "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.", + "justIn": "Właśnie dotarło", + "justInAt": "Właśnie dotarło · {time}", "worthALook": "Warto sprawdzić", "allClear": "Dziś nic nie wymaga Twojej uwagi.", "ringLink": "Otwórz szczegóły: {metric}" diff --git a/src/app/api/daily/digest/__tests__/route.test.ts b/src/app/api/daily/digest/__tests__/route.test.ts index cac7f1bd8..80a297833 100644 --- a/src/app/api/daily/digest/__tests__/route.test.ts +++ b/src/app/api/daily/digest/__tests__/route.test.ts @@ -63,6 +63,8 @@ const DIGEST: DailyDigest = { briefingLead: "Blood pressure is holding steady.", line: "Blood pressure is holding steady.", worthALook: [], + justIn: null, + reactionLine: null, }; const callGet = GET as unknown as (req: NextRequest) => Promise; diff --git a/src/components/daily/__tests__/today-hero.test.tsx b/src/components/daily/__tests__/today-hero.test.tsx index 5a7539b68..9d5949ab6 100644 --- a/src/components/daily/__tests__/today-hero.test.tsx +++ b/src/components/daily/__tests__/today-hero.test.tsx @@ -63,6 +63,8 @@ function digest(over: Partial = {}): DailyDigest { briefingLead: "Your week is trending steady.", line: "Your week is trending steady.", worthALook: [doseItem, syncItem], + justIn: null, + reactionLine: null, ...over, }; } diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index 9550dd18d..8a19e25f0 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -37,6 +37,7 @@ import Link from "next/link"; import { Moon } from "lucide-react"; +import { useMounted } from "@/hooks/use-mounted"; import { ScoreRing } from "@/components/insights/derived/score-ring"; import type { ScoreBand } from "@/components/insights/derived/band-tokens"; import { ProseBlocks } from "@/components/insights/prose-blocks"; @@ -62,8 +63,40 @@ function formatDelta( return t("daily.today.deltaVsBaseline", { delta: signed }); } +/** + * The "just in" chip's clock face. + * + * Formatted CLIENT-side, and only after mount. `justIn.at` crosses the wire as + * an ISO instant precisely so this stays a client concern: the server has no + * business rendering a local wall-clock time it would format under its OWN + * locale and timezone, and any such string would differ from the browser's on + * the hydration render — React #418, a class of bug this project has already + * paid for once. + * + * `undefined` locale lets the browser pick the user's own regional format + * (24-hour here, 12-hour there); the chip is a timestamp, not copy. + */ +function formatJustInTime(iso: string): string | null { + const at = new Date(iso); + if (Number.isNaN(at.getTime())) return null; + try { + return at.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return null; + } +} + export function TodayHero({ digest }: { digest: DailyDigest }) { const { t } = useTranslations(); + // Gates ONLY the chip's time string — never the chip's presence. The hero + // itself is deliberately NOT mount-gated (v1.30.9: it is the LCP element and + // paints from the server-dehydrated digest on both the SSR and the hydration + // render). So the slot renders on both passes and the formatted time fills in + // on the first client re-render, keeping those two passes byte-identical. + const mounted = useMounted(); const { keep, letGo } = useCoachCheckinAction(); const dismissItem = usePriorityItemDismiss(); @@ -83,17 +116,25 @@ export function TodayHero({ digest }: { digest: DailyDigest }) { const hasScore = digest.score !== null; const hasItems = digest.worthALook.length > 0; - // The briefing lead is the warmest read; the deterministic `line` is the - // floor a keyless self-hoster still gets. Prefer the lead for the hero. - const lead = digest.briefingLead ?? digest.line; + // Lead precedence, warmest first: the day's reaction line REPLACES the + // briefing lead once something landed — it never appends as a second + // paragraph, so the hero stays exactly one lead line tall and nothing below + // it shifts. The briefing lead is the standing read; the deterministic + // `line` is the floor a keyless self-hoster still gets. + const lead = digest.reactionLine ?? digest.briefingLead ?? digest.line; const topSignal = digest.topSignal; + const justInTime = digest.justIn ? formatJustInTime(digest.justIn.at) : null; // Calm degrade (plan §3): a genuinely empty account — no score, no rail // items, and no cached briefing lead — surfaces nothing here. The tile // strip below carries its own "add your first reading" empty state, so a // second alarming empty card on the hero would be noise. The all-clear // state (score present, nothing needs attention) is handled inline below. - if (!hasScore && !hasItems && !digest.briefingLead) { + // A freshly-generated reaction line counts as content in its own right: an + // otherwise-bare account whose first reading just landed has something + // honest to say, and swallowing it here would hide the one moment this + // surface exists for. + if (!hasScore && !hasItems && !digest.briefingLead && !digest.reactionLine) { return null; } @@ -184,17 +225,46 @@ export function TodayHero({ digest }: { digest: DailyDigest }) { - {/* Freshness note (plan §2.4) — provisional day, last night's sleep - not yet folded in. Muted, non-blocking, refreshes in place when - the morning job lands. */} - {digest.sleepPending ? ( -

-

+ {/* Meta row — the hero's quiet tier (UI-STANDARDS §text: `text-xs + text-muted-foreground` is the meta floor; never an accent, never an + opacity modifier). Holds the freshness note and the "just in" chip; + they can legitimately co-occur (a weight landed while last night's + sleep is still pending), so they share one wrapping row rather than + competing for the same slot. */} + {digest.sleepPending || digest.justIn ? ( +
+ {/* Freshness note (plan §2.4) — provisional day, last night's + sleep not yet folded in. Muted, non-blocking, refreshes in + place when the morning job lands. */} + {digest.sleepPending ? ( +

+

+ ) : null} + + {/* The "just in" chip — a state change, not a notification. No + badge count, no accent, no animation: the record simply reads + differently on the next refresh, and this names why. It is + present for as long as the arrival is news (the server applies + the window, so the chip expires on a poll rather than on a + client timer) and carries the arrival's local time once the + client has mounted and can format it honestly. */} + {digest.justIn ? ( +

+ {mounted && justInTime + ? t("daily.today.justInAt", { time: justInTime }) + : t("daily.today.justIn")} +

+ ) : null} +
) : null} {/* Worth-a-look rail — S1's `PriorityCard`s, bounded 0–3. When the diff --git a/src/lib/ai/ai-budgets.ts b/src/lib/ai/ai-budgets.ts index c12be7d72..f2757f975 100644 --- a/src/lib/ai/ai-budgets.ts +++ b/src/lib/ai/ai-budgets.ts @@ -123,6 +123,18 @@ export const AI_BUDGETS = { */ coachNudge: { temperature: 0.6, maxTokens: 160, timeoutMs: 9000 }, + /** + * v1.31.0 — the arrival reaction line. ONE sentence replacing the Today + * hero's lead for the rest of the day, written at most once per arrival kind + * per local day (the `ArrivalReaction` unique row is the throttle). 220 + * tokens is generous for a single sentence and leaves room for a model that + * warms up before it commits. Temperature between the reference surfaces and + * the conversational ones: the verdict must stay stable against the same + * evidence, but a line the user reads every day should not read as a + * template. + */ + arrivalReaction: { temperature: 0.45, maxTokens: 220, timeoutMs: 12_000 }, + /** * Coach rolling-conversation summary worker — a compact summary of the * elided older turns. 200 tokens. diff --git a/src/lib/ai/prompts/arrival-reaction.ts b/src/lib/ai/prompts/arrival-reaction.ts new file mode 100644 index 000000000..830a96aa9 --- /dev/null +++ b/src/lib/ai/prompts/arrival-reaction.ts @@ -0,0 +1,99 @@ +/** + * The arrival reaction line — one sentence, written once per arrival kind per + * local day, that replaces the Today hero's standing lead for the rest of the + * day. + * + * This is the smallest AI surface in the product and it is deliberately the + * most constrained. It says ONE thing: something landed, and here is what it + * means against this person's own history. It is not a summary, not a plan, + * and not an assessment card — those surfaces exist and this one must not + * drift into them. + * + * LOCALE — the load-bearing detail. The body composes through + * `getBaseSystemPromptBody(locale)` and closes with `withOutputLanguage`, + * which is the SIX-locale path: German readers get the German body, and + * fr/es/it/pl get the English body plus a directive, in their own language, + * naming the language to write in. Do NOT reintroduce a local + * `locale === "de" ? "German" : "English"` here — that two-locale collapse is + * exactly the defect the base-system path was fixed to remove, and it silently + * ships English prose to four locales. + */ +import type { Locale } from "@/lib/i18n/config"; +import type { ArrivalKind } from "@/lib/arrivals/types"; + +import { getBaseSystemPromptBody } from "./base-system"; +import { instructionLocale, withOutputLanguage } from "./output-language"; + +/** + * The reaction contract, appended to the shared assessment base. + * + * Every clause here is a bound on FORM, not on content — the base prompt + * already owns the safety contract (no diagnosis, no prescription, earned + * encouragement, associations never causal). What this adds is the shape that + * makes a one-line reaction readable in a hero: verdict first, the number as + * support rather than as the opener, and a hard stop at one sentence. + */ +const REACTION_CONTRACT_EN = `SURFACE — THE ARRIVAL REACTION LINE: +- Write EXACTLY ONE sentence. Not two. It renders as a single hero line and anything longer is truncated. +- Lead with the MEANING, not the measurement. "A solid night, deeper than your recent stretch — 7 h 12 min behind it" reads correctly; "Your sleep is 7 h 12 min" does not. The figure is support; it may appear, but never as the opening clause. +- Compare against THIS PERSON'S OWN recent baseline, which the evidence block carries already computed. State it; never recompute it, never invent a comparison the block does not contain. +- Never exclamatory. No exclamation marks, no "great job", no congratulation the numbers did not earn. An unfavourable reading is named plainly and without alarm. +- Never diagnostic and never prescriptive: no condition names, no risk levels, no dose or treatment suggestions, no training instructions. +- When the evidence block is too thin to support a verdict, say so plainly in that one sentence. A manufactured verdict is worse than an honest "not much to go on yet". +- Plain text only — no markdown, no quotation marks around the sentence, no emoji.`; + +const REACTION_CONTRACT_DE = `OBERFLÄCHE — DIE REAKTIONSZEILE ZUM DATENEINGANG: +- Schreibe GENAU EINEN Satz. Nicht zwei. Er erscheint als einzelne Hero-Zeile; alles Längere wird abgeschnitten. +- Führe mit der BEDEUTUNG, nicht mit dem Messwert. "Eine solide Nacht, tiefer als zuletzt — 7 h 12 min tragen sie" liest sich richtig; "Dein Schlaf beträgt 7 h 12 min" nicht. Die Zahl stützt; sie darf vorkommen, aber nie als einleitender Satzteil. +- Vergleiche gegen die EIGENE jüngste Baseline dieser Person, die der Evidenzblock bereits fertig berechnet mitführt. Nenne sie; rechne sie nicht neu und erfinde keinen Vergleich, den der Block nicht enthält. +- Nie ausrufend. Keine Ausrufezeichen, kein "super gemacht", kein Lob, das die Zahlen nicht hergeben. Ein ungünstiger Wert wird sachlich und ohne Alarm benannt. +- Nie diagnostisch und nie verordnend: keine Krankheitsnamen, keine Risikostufen, keine Dosis- oder Therapievorschläge, keine Trainingsanweisungen. +- Wenn der Evidenzblock zu dünn für ein Urteil ist, sage das in diesem einen Satz klar. Ein konstruiertes Urteil ist schlechter als ein ehrliches "dafür ist es noch zu wenig". +- Nur Fließtext — kein Markdown, keine Anführungszeichen um den Satz, keine Emojis.`; + +export function getArrivalReactionSystemPrompt(locale: Locale): string { + // fr/es/it/pl compose the ENGLISH body — the base prompt names their own + // language and `withOutputLanguage` appends their directive last, so the + // language instruction is the final thing the model reads. + const contract = + instructionLocale(locale) === "en" + ? REACTION_CONTRACT_EN + : REACTION_CONTRACT_DE; + + return withOutputLanguage( + `${getBaseSystemPromptBody(locale)} + +${contract}`, + locale, + ); +} + +/** What kind of arrival the line is reacting to, in the model's own terms. */ +const KIND_SUBJECT: Record = { + sleep_night: "last night's completed sleep", + workout: "a workout the person just finished", + weight: "the first weight reading of the day", + blood_pressure: "a fresh blood-pressure reading", + labs_panel: "a new lab panel", +}; + +export interface ArrivalReactionPromptInput { + kind: ArrivalKind; + /** + * The deterministic, already-grounded evidence the digest read path + * computed. Passed verbatim — the model is never asked to derive a figure, + * only to say what the block already establishes. + */ + evidence: string; +} + +export function getArrivalReactionUserPrompt( + input: ArrivalReactionPromptInput, +): string { + return `WHAT JUST LANDED: ${KIND_SUBJECT[input.kind]}. + +EVIDENCE BLOCK — everything below is already computed from this person's own record. Use only what is here; if it does not support a verdict, say so. +${input.evidence} + +Write the one-sentence reaction line. Return only the sentence.`; +} diff --git a/src/lib/arrivals/reaction-line-shared.ts b/src/lib/arrivals/reaction-line-shared.ts new file mode 100644 index 000000000..8c587a792 --- /dev/null +++ b/src/lib/arrivals/reaction-line-shared.ts @@ -0,0 +1,78 @@ +/** + * Generator-free enqueue contract for the `reaction-line-generate` queue. + * + * Same split as `emit-shared.ts`, and for the same reason: the spine's worker + * (`@/lib/jobs/data-arrival`) must be able to ENQUEUE a reaction line without + * importing the module that GENERATES one. The generator reaches a provider; + * the spine's central cost claim is that it structurally cannot. A shared + * queue-name-plus-payload module is what keeps both true at once, and the + * spine's module-graph guard (`data-arrival-provider-isolation.test.ts`) + * fails the moment this file grows an import that breaks it. + * + * Keep this module provider-free and worker-free. + */ +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { annotate } from "@/lib/logging/context"; + +import type { ArrivalKind } from "./types"; + +export const REACTION_LINE_QUEUE = "reaction-line-generate"; + +/** + * The generation job's payload. It carries the row's identity, not its + * content: the worker re-reads the `ArrivalReaction` row it names, so a job + * that sat in the queue while the day moved on can still decide honestly + * whether the line is still wanted. + */ +export interface ReactionLineJob { + userId: string; + kind: ArrivalKind; + /** The user's profile-tz YYYY-MM-DD the marker is filed under. */ + localDate: string; +} + +/** + * Enqueue the day's single reaction-line generation for one arrival kind. + * + * The CALLER owns the throttle, and it is not a timer: the spine enqueues this + * only on the pass that freshly INSERTED the day's `ArrivalReaction` row, so + * the row's `@@unique([userId, kind, localDate])` constraint is what bounds + * the spend to one call per kind per local day. There is deliberately no + * second entry point — a surface that wants a line asks the spine for an + * arrival, it does not call the generator. + * + * The singleton key restates that claim at the queue level so a retried spine + * job cannot stack a second generation behind the first. + * + * Never throws. A queue that is not there means no line, and no line is a + * fully-supported state. + */ +export async function enqueueReactionLine(job: ReactionLineJob): Promise { + try { + const boss = getGlobalBoss(); + if (!boss) { + annotate({ + action: { name: "arrival.reaction_line.skipped" }, + meta: { reason: "no_boss", kind: job.kind }, + }); + return; + } + + await boss.send(REACTION_LINE_QUEUE, job, { + singletonKey: `reaction-line:${job.userId}:${job.kind}:${job.localDate}`, + // The LLM retry policy, not the spine's: a provider hiccup is worth one + // patient retry, and a second failure leaves the row line-less — which + // is a first-class state, not an error to keep chasing. + retryLimit: 1, + retryDelay: 120, + retryBackoff: true, + }); + + annotate({ + action: { name: "arrival.reaction_line.enqueued" }, + meta: { kind: job.kind, local_date: job.localDate }, + }); + } catch { + // The data landed and the marker is written; the sentence is garnish. + } +} diff --git a/src/lib/daily/__tests__/daily-briefing-push.test.ts b/src/lib/daily/__tests__/daily-briefing-push.test.ts index 6aae8c602..8ef31e2b4 100644 --- a/src/lib/daily/__tests__/daily-briefing-push.test.ts +++ b/src/lib/daily/__tests__/daily-briefing-push.test.ts @@ -34,6 +34,8 @@ function makeDigest(over: Partial = {}): DailyDigest { briefingLead: "Sleep looked solid last night.", line: "Sleep looked solid last night.", worthALook: [], + justIn: null, + reactionLine: null, ...over, }; } diff --git a/src/lib/daily/__tests__/load-digest.test.ts b/src/lib/daily/__tests__/load-digest.test.ts index 5e775572c..f6c2df61a 100644 --- a/src/lib/daily/__tests__/load-digest.test.ts +++ b/src/lib/daily/__tests__/load-digest.test.ts @@ -19,6 +19,7 @@ vi.mock("@/lib/db", () => ({ ecgRecording: { findFirst: vi.fn().mockResolvedValue(null) }, dismissedPriorityItem: { findMany: vi.fn().mockResolvedValue([]) }, personalRecord: { findMany: vi.fn().mockResolvedValue([]) }, + arrivalReaction: { findMany: vi.fn().mockResolvedValue([]) }, }, })); diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index 8fe0ff7b4..7f93ee277 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -20,6 +20,7 @@ * populate the same two fields the DTO already carries, so no consumer changes. */ import type { DailyBriefing, DailyBriefingSignal } from "@/lib/ai/schema"; +import type { ArrivalKind } from "@/lib/arrivals/types"; import type { MedsTodayBlock } from "@/lib/dashboard/meds-today"; import type { ModuleKey } from "@/lib/modules/registry"; import type { ServerTranslator } from "@/lib/i18n/server-translator"; @@ -151,6 +152,32 @@ export interface DailyDigestCoachPlan { planText: string | null; } +/** + * A day's arrival marker, as the IO seam read it off `ArrivalReaction`. + * + * One row per (user, kind, local date) — the spine's unique claim. The builder + * picks the NEWEST of them for the "just in" chip and takes its reaction line; + * it never sees more than the day's own rows, and it never reads a row it did + * not ask for. + */ +export interface DailyDigestArrival { + kind: ArrivalKind; + /** Timestamp of the newest sample that triggered this arrival. */ + occurredAt: Date; + /** Decrypted reaction line, or null when none was ever generated. */ + line: string | null; +} + +/** + * How long an arrival stays NEWS. + * + * Past this the record is simply current — the reading is still today's, but + * announcing it is no longer telling the user anything they don't know. The + * chip disappears on its own; the reaction LINE does not (see below), because + * the sentence is the day's read, not a notification. + */ +export const JUST_IN_WINDOW_MS = 3 * 60 * 60 * 1000; + /** The fully-resolved, IO-free input the composer folds into a digest. */ export interface DailyDigestInput { now: Date; @@ -203,6 +230,12 @@ export interface DailyDigestInput { * never rendered with its actions suppressed. */ dismissedItemKeys: ReadonlySet; + /** + * Today's arrival markers (`ArrivalReaction` rows for `todayLocalDate`), in + * any order. Optional so a consumer that predates the arrival spine stays + * valid; the builder treats a missing value as "nothing landed today". + */ + arrivals?: readonly DailyDigestArrival[]; } export interface DailyDigest { @@ -226,6 +259,26 @@ export interface DailyDigest { line: string; /** Bounded 0–3 rail items, never padded. */ worthALook: PriorityItem[]; + /** + * The day's newest arrival while it is still news (< `JUST_IN_WINDOW_MS` + * old), else null. Drives the hero's calm "just in" chip. + * + * `at` crosses the wire as an ISO-8601 string and is formatted CLIENT-side — + * a server-formatted local time would differ from the client's render and + * produce a hydration mismatch (React #418). + */ + justIn: { kind: ArrivalKind; at: string } | null; + /** + * The generated reaction line for the day's newest arrival, or null. + * + * Deliberately NOT gated on the just-in window: the chip is the "this just + * landed" moment and expires, while the sentence is the day's read and + * stands for the rest of the local day. Null whenever no line was generated + * — no provider, no consent, budget exhausted, or the generation failed — + * in which case the surface falls back to the deterministic lead. The + * sentence is garnish; its absence degrades nothing. + */ + reactionLine: string | null; } /** First sentence of a paragraph, trimmed; null when empty. */ @@ -647,6 +700,33 @@ export function buildDailyDigest( const topSignal = input.briefing?.signalsOfDay?.[0] ?? null; const briefingLead = firstSentence(input.briefing?.paragraph); + // The day's newest arrival drives both reaction fields, but on two different + // clocks: the chip expires with the news, the sentence stands for the day. + // Reducing rather than sorting keeps the composer allocation-free and total + // over an empty list. + const newestArrival = ( + input.arrivals ?? [] + ).reduce( + (newest, candidate) => + newest === null || candidate.occurredAt > newest.occurredAt + ? candidate + : newest, + null, + ); + const arrivalAgeMs = newestArrival + ? input.now.getTime() - newestArrival.occurredAt.getTime() + : null; + const justIn = + newestArrival && arrivalAgeMs !== null && arrivalAgeMs < JUST_IN_WINDOW_MS + ? { + kind: newestArrival.kind, + at: newestArrival.occurredAt.toISOString(), + } + : null; + // An empty or whitespace-only line is treated as absent rather than shipped + // as a blank lead — a degraded generation must fall through to the floor. + const reactionLine = newestArrival?.line?.trim() || null; + // 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 @@ -717,5 +797,7 @@ export function buildDailyDigest( briefingLead, line: composeLine(briefingLead, topSignal, input.score, t), worthALook: visible.slice(0, MAX_WORTH_A_LOOK), + justIn, + reactionLine, }; } diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index 9464470e9..1c683d6e1 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -24,9 +24,11 @@ import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; import { userDayKey } from "@/lib/tz/format"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval"; +import { isArrivalKind } from "@/lib/arrivals/types"; import { buildDailyDigest, type DailyDigest, + type DailyDigestArrival, type DailyDigestCoachPlan, type DailyDigestEcg, type DailyDigestPreventiveDue, @@ -239,10 +241,50 @@ function resolveLocale(locale: string | null | undefined): Locale { : defaultLocale; } +/** + * Today's arrival markers, decrypted fault-isolated. + * + * One indexed read on `[userId, localDate]` — the exact shape of the model's + * `@@index`, and at most one row per arrival kind, so the result is bounded by + * the closed `ARRIVAL_KINDS` enum rather than by anything the user can grow. + * + * A line that fails to decrypt (a key rotated out from under the row) yields a + * null line, never a throw: the digest is a hot, must-not-fail path, and the + * marker itself — which is what actually drives the "just in" chip — survives + * a lost sentence intact. Same discipline as `toCoachPlanCandidate` above. + */ +function toDigestArrival(row: { + kind: string; + occurredAt: Date; + lineEncrypted: Uint8Array | null; + generatedAt: Date | null; +}): DailyDigestArrival | null { + // A kind this build does not know about (a row written by a newer version) + // is dropped rather than widened — the DTO's kind union is closed. + if (!isArrivalKind(row.kind)) return null; + + let line: string | null = null; + // The ciphertext rides only once the generation actually COMMITTED. A row + // mid-generation carries no `generatedAt`, and its line must not surface. + if (row.generatedAt !== null && row.lineEncrypted !== null) { + try { + line = decryptFromBytes(row.lineEncrypted); + } catch { + line = null; + } + } + + return { kind: row.kind, occurredAt: row.occurredAt, line }; +} + export async function loadDailyDigest( user: User, now: Date = new Date(), ): Promise { + // Computed before the fan-out because the arrival read is keyed on it. Pure + // (a tz format of `now`), so hoisting it costs nothing. + const todayLocalDate = userDayKey(now, user.timezone); + const [ { body: snapshot, locale }, modules, @@ -250,6 +292,7 @@ export async function loadDailyDigest( dueReminders, planRows, ecgRow, + arrivalRows, ] = await Promise.all([ readDashboardSnapshotCached(user), resolveModuleMap(user.id), @@ -303,6 +346,18 @@ export async function loadDailyDigest( orderBy: { recordedAt: "desc" }, select: { recordedAt: true, rhythmClassification: true }, }), + // The arrival spine's markers for the user's CURRENT local day. At most + // one row per arrival kind by the model's unique constraint, so this is a + // bounded indexed read, not a scan. + prisma.arrivalReaction.findMany({ + where: { userId: user.id, localDate: todayLocalDate }, + select: { + kind: true, + occurredAt: true, + lineEncrypted: true, + generatedAt: true, + }, + }), ]); const score: DailyDigestScore | null = snapshot.healthScore @@ -321,7 +376,6 @@ export async function loadDailyDigest( // local date (profile tz). Read fresh off the row here, so it flips the // instant the refresh job runs — ahead of the snapshot cache that feeds // `sleepLastSeenDaysAgo`. - const todayLocalDate = userDayKey(now, user.timezone); const morningRefreshedToday = user.morningDigestRefreshedOn !== null && user.morningDigestRefreshedOn === todayLocalDate; @@ -423,6 +477,9 @@ export async function loadDailyDigest( latestEcg, todayLocalDate, dismissedItemKeys, + arrivals: arrivalRows + .map(toDigestArrival) + .filter((a): a is DailyDigestArrival => a !== null), }, t, ); @@ -440,6 +497,8 @@ export async function loadDailyDigest( daily_digest_has_milestone: digest.worthALook.some( (i) => i.kind === "milestone", ), + daily_digest_just_in_kind: digest.justIn?.kind ?? null, + daily_digest_has_reaction_line: digest.reactionLine !== null, }, }); diff --git a/src/lib/jobs/data-arrival.ts b/src/lib/jobs/data-arrival.ts index 67582c091..c4f3d33b5 100644 --- a/src/lib/jobs/data-arrival.ts +++ b/src/lib/jobs/data-arrival.ts @@ -36,6 +36,7 @@ import { annotate } from "@/lib/logging/context"; import { withBackgroundEvent } from "@/lib/logging/background"; import { DATA_ARRIVAL_QUEUE } from "@/lib/arrivals/emit-shared"; +import { enqueueReactionLine } from "@/lib/arrivals/reaction-line-shared"; import { isArrivalKind, type DataArrival } from "@/lib/arrivals/types"; import { getWorkerPrisma, workerLog } from "./reminder/shared"; @@ -142,9 +143,16 @@ export async function runDataArrival( } if (claimed) { - // DISPATCH POINT — the day's single reaction line consumes this. - // Gated on `claimed` precisely BECAUSE the unique row is the throttle: - // there is no code path to a second generation for this kind today. + // The day's single reaction line. Gated on `claimed` precisely BECAUSE the + // unique row is the throttle: this is the only pass that can have won the + // claim, so there is no code path to a second generation for this kind + // today. The enqueue helper is provider-free by construction (see its + // docblock and the module-graph guard) — this worker still spends nothing. + await enqueueReactionLine({ + userId: arrival.userId, + kind: arrival.kind, + localDate: arrival.localDate, + }); actions.push("line_pending"); } diff --git a/src/lib/jobs/reaction-line.ts b/src/lib/jobs/reaction-line.ts new file mode 100644 index 000000000..eef7925af --- /dev/null +++ b/src/lib/jobs/reaction-line.ts @@ -0,0 +1,308 @@ +/** + * The `reaction-line-generate` worker — the day's single sentence about what + * just landed. + * + * This is the ONE provider call the arrival spine can cause, and the whole + * design of the surface is arranged so that call is bounded before it is made: + * + * - The THROTTLE is a durable unique row, not a timer. The spine enqueues + * this only on the pass that freshly INSERTED today's `ArrivalReaction` + * for the kind (`data-arrival.ts`, the `claimed` branch), and this worker + * re-checks `generatedAt` before spending anything. Both ends of that + * claim are the same `@@unique([userId, kind, localDate])` constraint, so + * there is no code path to a second call for a kind on a day — not a + * racing spine job, not a retry, not a hand-sent job. + * - The SPEND is reserved before the call and reconciled on EVERY exit, + * including each failure path. A provider that times out still burned + * upstream tokens; a reservation that is never reconciled is a silent + * over-charge against the user's own daily ceiling. + * - The FLOOR is complete without any of this. No provider, no consent, an + * exhausted budget, a refused output, a dead network — every one of them + * leaves the row line-less, and a line-less row still drives the "just in" + * chip and the provisional→final flip. The reaction is a state change; the + * sentence is garnish. `reaction-line-degradation.test.ts` holds that line. + * + * The grounding is deliberately the digest itself: it is already cached, + * already deterministic, already the thing the surface will render the line + * next to, and reading it costs no new query path. A line grounded in a + * DIFFERENT snapshot than the one on screen is how a surface starts + * contradicting itself. + */ +import type { Job } from "pg-boss"; + +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { resolveProviderChain } from "@/lib/ai/provider"; +import { + chainRequiresServerManagedConsent, + hasActiveConsentForSurface, +} from "@/lib/ai/consent-guard"; +import { + buildDateKey, + reconcileSpend, + reserveBudget, + resolveDailyCap, +} from "@/lib/ai/coach/budget"; +import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; +import { singleUserTurn } from "@/lib/ai/types"; +import { screenCoachReply } from "@/lib/ai/coach/outbound-guard"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; +import { loadDailyDigest } from "@/lib/daily/load-digest"; +import { + getArrivalReactionSystemPrompt, + getArrivalReactionUserPrompt, +} from "@/lib/ai/prompts/arrival-reaction"; +import { + REACTION_LINE_QUEUE, + type ReactionLineJob, +} from "@/lib/arrivals/reaction-line-shared"; + +import { workerLog } from "./reminder/shared"; + +export { REACTION_LINE_QUEUE }; + +/** Upstream timeout. A hero line is not worth holding a worker slot for long. */ +export const REACTION_LINE_TIMEOUT_MS = 12_000; + +/** + * Hard ceiling on a shippable line. The contract asks for one sentence; a + * model that returns a paragraph has not followed it, and clamping mid-thought + * would ship a truncated verdict. Reject and keep the deterministic lead. + */ +export const REACTION_LINE_MAX_CHARS = 240; + +/** + * Tokens reserved per call. + * + * Sized to the whole call, not the output ceiling: the base assessment system + * prompt plus a compact evidence block plus the 220-token output. Reserving + * only the output would let a day's reactions spend materially more than they + * booked against the user's daily cap — the reconcile corrects the number + * afterwards, but the CAP check happens at reservation time. + */ +export const ARRIVAL_REACTION_RESERVE_TOKENS = 1_400; + +export type ReactionLineOutcome = + { status: "skipped"; reason: string } | { status: "generated" }; + +function resolveLocale(locale: string | null | undefined): Locale { + return locales.includes(locale as Locale) + ? (locale as Locale) + : defaultLocale; +} + +/** + * Normalise the model's output into a shippable sentence, or null. + * + * Rejects rather than repairs: a line that broke the shape broke the contract, + * and the deterministic lead it would replace is already good. + */ +export function sanitiseReactionLine( + raw: string, + locale: Locale, +): string | null { + let text = (raw ?? "").trim(); + if (!text) return null; + text = text + .replace(/^["“”'`]+/, "") + .replace(/["“”'`]+$/, "") + .trim() + .replace(/\s+/g, " "); + if (!text) return null; + if (text.length > REACTION_LINE_MAX_CHARS) return null; + // The same outbound content fence the Coach reply path runs — a dose + // prescription or a fabricated risk score never reaches the hero. + if (screenCoachReply(text, locale).block) return null; + return text; +} + +/** + * The deterministic evidence block. Built from the ALREADY-COMPUTED digest — + * no figure here is derived in this module, which is what keeps the line + * grounded in the same numbers the surface is rendering. + */ +function buildEvidence(digest: { + score: { value: number; band: string; delta: number | null } | null; + topSignal: { headline: string; delta: string | null } | null; + briefingLead: string | null; +}): string { + const parts: string[] = []; + if (digest.score) { + const delta = + digest.score.delta === null + ? "no baseline comparison available" + : `${digest.score.delta > 0 ? "+" : ""}${Math.round(digest.score.delta)} vs their baseline`; + parts.push( + `- Health score: ${Math.round(digest.score.value)} (${digest.score.band}), ${delta}.`, + ); + } + if (digest.topSignal) { + parts.push( + `- Today's leading signal: ${digest.topSignal.headline}${ + digest.topSignal.delta ? ` (${digest.topSignal.delta})` : "" + }.`, + ); + } + if (digest.briefingLead) { + parts.push(`- The day's standing read: ${digest.briefingLead}`); + } + return parts.length > 0 + ? parts.join("\n") + : "- (No computed comparison is available for this person yet.)"; +} + +/** + * Generate and persist one reaction line. + * + * Every refusal RETURNS a status rather than throwing — the discipline the + * spine's worker documents at length. A ceiling that does not move until the + * local day rolls over must never be retried against. + */ +export async function runReactionLine( + job: ReactionLineJob, +): Promise { + const row = await prisma.arrivalReaction.findUnique({ + where: { + userId_kind_localDate: { + userId: job.userId, + kind: job.kind, + localDate: job.localDate, + }, + }, + select: { id: true, generatedAt: true }, + }); + + // The marker is gone (retention swept it, or the account was deleted) or a + // line already committed. Either way there is nothing left to write, and the + // unique row having a `generatedAt` IS the once-per-kind-per-day claim. + if (!row) return { status: "skipped", reason: "no_marker" }; + if (row.generatedAt !== null) { + return { status: "skipped", reason: "already_generated" }; + } + + // The whole row: the locale decides the prompt, and `loadDailyDigest` takes + // a `User` (it reads the timezone and the morning-refresh marker off it), so + // one read serves both rather than two. + const user = await prisma.user.findUnique({ where: { id: job.userId } }); + if (!user) return { status: "skipped", reason: "no_user" }; + + const locale = resolveLocale(user.locale); + + const chain = await resolveProviderChain(job.userId); + // A provider-less install is the DEFAULT self-hosted shape, not an error. + if (chain.length === 0) return { status: "skipped", reason: "no_provider" }; + + // Consent gate before the reservation, so a user without a receipt never + // spends a token. This tick is unattended, so it carries the same receipt + // requirement as the interactive surfaces. BYOK / local / ChatGPT-OAuth + // chains are the user's own egress and stay ungated. + if ( + chainRequiresServerManagedConsent(chain) && + !(await hasActiveConsentForSurface(job.userId, "insights")) + ) { + return { status: "skipped", reason: "consent_required" }; + } + + const budget = AI_BUDGETS.arrivalReaction; + const maxTokens = budget.maxTokens ?? 220; + const dateKey = buildDateKey(); + const reservation = await reserveBudget( + job.userId, + // Reserve the documented ceiling for this surface, not the output cap + // alone: the reservation has to cover the prompt the call actually sends, + // or a day of these under-reports its own spend against the user's cap. + ARRIVAL_REACTION_RESERVE_TOKENS, + dateKey, + resolveDailyCap(chain), + ); + if (!reservation.allowed) { + return { status: "skipped", reason: "budget_exceeded" }; + } + + // From here every exit MUST reconcile — the reservation is already on the + // user's ledger. + let line: string | null = null; + try { + // The digest is the grounding AND the thing the line will sit inside. + const digest = await loadDailyDigest(user); + + const result = await chain[0].instance.generateCompletion( + singleUserTurn({ + system: getArrivalReactionSystemPrompt(locale), + user: getArrivalReactionUserPrompt({ + kind: job.kind, + evidence: buildEvidence(digest), + }), + temperature: budget.temperature, + maxTokens, + timeoutMs: REACTION_LINE_TIMEOUT_MS, + signal: AbortSignal.timeout(REACTION_LINE_TIMEOUT_MS + 1_000), + }), + ); + + await reconcileSpend( + job.userId, + reservation.reserved, + result.tokensUsed ?? 0, + dateKey, + result.cachedInputTokens ?? 0, + ).catch(() => {}); + + line = sanitiseReactionLine(result.content, locale); + } catch (err) { + // Timeout / network / provider / grounding failure. Reconcile against a + // zero actual so the unspent reservation is returned, then degrade. + await reconcileSpend(job.userId, reservation.reserved, 0, dateKey).catch( + () => {}, + ); + workerLog("error", "[reaction-line] generation failed", err); + return { status: "skipped", reason: "provider_failed" }; + } + + if (!line) return { status: "skipped", reason: "unusable_output" }; + + // Commit. `generatedAt` and the ciphertext land together and are read + // together (`load-digest.ts` requires both), so a half-written row can never + // surface a line. + await prisma.arrivalReaction.update({ + where: { id: row.id }, + data: { lineEncrypted: encryptToBytes(line), generatedAt: new Date() }, + }); + + return { status: "generated" }; +} + +export async function handleReactionLine( + jobs: Job[], +): Promise { + await withBackgroundEvent("job.reaction_line", async (evt) => { + for (const job of jobs) { + try { + const outcome = await runReactionLine(job.data); + + if (outcome.status === "skipped") { + annotate({ + action: { name: "arrival.reaction_line.skipped" }, + meta: { reason: outcome.reason, kind: job.data.kind }, + }); + evt.addMeta("reaction_line", `skipped:${outcome.reason}`); + continue; + } + + annotate({ + action: { name: "arrival.reaction_line.generated" }, + meta: { kind: job.data.kind, local_date: job.data.localDate }, + }); + evt.addMeta("reaction_line", "generated"); + } catch (err) { + // Only a genuine transient fault reaches here — every business refusal + // returned a status. Let the queue's single retry apply; the + // `generatedAt` check makes the retry idempotent. + workerLog("error", "[reaction-line] pass failed", err); + throw err; + } + } + }); +} diff --git a/src/lib/jobs/reminder/register-status.ts b/src/lib/jobs/reminder/register-status.ts index c8cab6683..831b6f70a 100644 --- a/src/lib/jobs/reminder/register-status.ts +++ b/src/lib/jobs/reminder/register-status.ts @@ -30,6 +30,11 @@ import { type MorningDigestRefreshPayload, } from "@/lib/jobs/morning-digest-refresh"; import { DATA_ARRIVAL_QUEUE, handleDataArrival } from "@/lib/jobs/data-arrival"; +import { + REACTION_LINE_QUEUE, + handleReactionLine, +} from "@/lib/jobs/reaction-line"; +import type { ReactionLineJob } from "@/lib/arrivals/reaction-line-shared"; import type { DataArrival } from "@/lib/arrivals/types"; import { DAILY_BRIEFING_QUEUE, @@ -179,6 +184,10 @@ const allQueues = [ // this entry pg-boss never provisions the queue and every emit silently // drops (the v1.4.37 dead-queue class). DATA_ARRIVAL_QUEUE, + // v1.31.0 — the day's single arrival reaction line. Send-only, enqueued by + // the spine worker on the pass that claimed the day's marker. Without this + // entry pg-boss never provisions the queue and every enqueue silently drops. + REACTION_LINE_QUEUE, // v1.10.0 — computed scores (WX-C / WX-E). Nightly Recovery / Stress / // Strain compute + store. The queue MUST be registered here or pg-boss // never provisions it and the nightly schedule silently never fires. @@ -302,6 +311,16 @@ const queuePolicies: QueuePolicyTable = { // id and DOES pass `singletonSeconds`, so it is constrained by pg-boss's // `job_i4` index regardless of this policy. Both shapes coexist on one // queue safely: they populate different columns. + // The reaction line's key is `reaction-line:::` — the + // same day-scoped shape, and for the same reason: a wall-clock window cannot + // express a user's local date. `exclusive` is what makes the key real, and it + // is the queue-level restatement of the durable claim the worker re-checks + // (`generatedAt`) before it spends anything. + [REACTION_LINE_QUEUE]: { + policy: "exclusive", + reason: + "Day-scoped per-kind keys carry the user's local date; under standard they coalesce nothing and a retried spine job could stack a second provider call behind the first.", + }, [DATA_ARRIVAL_QUEUE]: { policy: "exclusive", reason: @@ -532,6 +551,14 @@ export async function registerStatusQueues( { localConcurrency: 1 }, handleDataArrival, ); + // v1.31.0 — the arrival reaction line. Single-flight: this is the spine's + // ONE provider call, and a second concurrent slot would only race the same + // durable `generatedAt` claim exactly one of them can win. + await boss.work( + REACTION_LINE_QUEUE, + { localConcurrency: 1 }, + handleReactionLine, + ); // S5 — daily-briefing fallback slot. Single-flight; the `push_attempts` // frequency cap makes an overlapping tick a no-op (a second same-day attempt // is suppressed), and the tick only loads a digest for opted-in users at diff --git a/src/lib/openapi/routes/daily.ts b/src/lib/openapi/routes/daily.ts index 06c791f99..72213df18 100644 --- a/src/lib/openapi/routes/daily.ts +++ b/src/lib/openapi/routes/daily.ts @@ -11,6 +11,7 @@ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; +import { ARRIVAL_KINDS } from "@/lib/arrivals/types"; import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item"; import { dismissPriorityItemSchema } from "@/lib/validations/daily"; import { dataEnvelope, moduleDisabledResponse, stdResponses } from "./shared"; @@ -95,6 +96,27 @@ const dailyDigestResponse = z worthALook: z .array(priorityItemSchema) .describe("Bounded 0–3 rail items, never padded."), + justIn: z + .object({ + kind: z + .enum([...ARRIVAL_KINDS]) + .describe("Closed arrival kind that landed."), + at: z + .string() + .describe( + "ISO-8601 instant of the newest sample. NEVER pre-formatted server-side — the client formats it in its own locale and timezone.", + ), + }) + .nullable() + .describe( + "The day's newest data arrival while it is still news (under three hours old), else null. Additive since v1.31.0.", + ), + reactionLine: z + .string() + .nullable() + .describe( + "One-sentence generated reaction to that arrival, standing for the rest of the local day. Null whenever no line was generated (no provider, no consent, budget exhausted, or generation failed) — consumers fall back to `briefingLead` / `line`. Additive since v1.31.0.", + ), }) .meta({ id: "DailyDigest",