mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(daily): react in place when new data lands
Today gains two additive-nullable digest fields. `justIn` names the day's newest arrival while it is still news — under three hours old, after which the record is simply current rather than new. `reactionLine` carries a single generated sentence about that arrival and replaces the hero's standing lead for the rest of the local day, never appending a second paragraph, so the hero stays one lead line tall and nothing below it shifts. Nothing here polls. The open dashboard picks the change up on the cadence it already runs — a state change, not a notification. The generated sentence is bounded before it is written: the arrival spine's unique row per kind per local day IS the throttle, the worker re-checks the same claim before spending, and the reservation is reconciled on every exit including each failure path. Without a provider, without consent, or against an exhausted budget the row stays line-less and the surface falls back to the deterministic lead — the chip and the provisional-to-final flip are unaffected. The chip's time string is formatted client-side after mount; `justIn.at` crosses the wire as an ISO instant so the server never renders a local wall-clock time the browser would disagree with.
This commit is contained in:
1 parent
f9112925d2
commit
ca170925dc
21 files changed
+836
-19
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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<Response>;
|
||||
|
||||
@@ -63,6 +63,8 @@ function digest(over: Partial<DailyDigest> = {}): DailyDigest {
|
||||
briefingLead: "Your week is trending steady.",
|
||||
line: "Your week is trending steady.",
|
||||
worthALook: [doseItem, syncItem],
|
||||
justIn: null,
|
||||
reactionLine: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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 ? (
|
||||
<p
|
||||
data-slot="today-hero-sleep-pending"
|
||||
className="text-muted-foreground flex items-center gap-1.5 text-xs"
|
||||
>
|
||||
<Moon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{t("daily.today.sleepPending")}
|
||||
</p>
|
||||
{/* 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 ? (
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
{/* 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 ? (
|
||||
<p
|
||||
data-slot="today-hero-sleep-pending"
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<Moon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{t("daily.today.sleepPending")}
|
||||
</p>
|
||||
) : 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 ? (
|
||||
<p
|
||||
data-slot="today-hero-just-in"
|
||||
data-just-in-kind={digest.justIn.kind}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
{mounted && justInTime
|
||||
? t("daily.today.justInAt", { time: justInTime })
|
||||
: t("daily.today.justIn")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Worth-a-look rail — S1's `PriorityCard`s, bounded 0–3. When the
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<ArrivalKind, string> = {
|
||||
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.`;
|
||||
}
|
||||
@@ -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<void> {
|
||||
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.
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,8 @@ function makeDigest(over: Partial<DailyDigest> = {}): DailyDigest {
|
||||
briefingLead: "Sleep looked solid last night.",
|
||||
line: "Sleep looked solid last night.",
|
||||
worthALook: [],
|
||||
justIn: null,
|
||||
reactionLine: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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([]) },
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -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<string>;
|
||||
/**
|
||||
* 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<DailyDigestArrival | null>(
|
||||
(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,
|
||||
};
|
||||
}
|
||||
@@ -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<DailyDigest> {
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ReactionLineOutcome> {
|
||||
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<ReactionLineJob>[],
|
||||
): Promise<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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:<user>:<kind>:<localDate>` — 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<ReactionLineJob>(
|
||||
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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user