From 29673c11552c564da7c843bfeb155527f04fffe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 15:48:57 +0200 Subject: [PATCH] fix(insights): register the arrival reaction line with the tone harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The harness landed in v1.30.35, after the slices that built this release were already cut — so the reaction line's prompt module was never registered, and its own docblock's warning against the two-locale collapse was untested. Registering it surfaced a real gap: the meaning-first instruction lived only in the system prompt's own words, never in the user prompt in the family's shared phrasing, which is where the rest of the surfaces carry it and where the tone contract checks for it. The German body carried no such instruction at all. Threads locale through the user-prompt builder so the fixed instruction line matches the reader's own reply language, and wires the per-user per-day opener-archetype rotation the milestone spec called for and the first implementation had not connected. --- package.json | 2 +- public/sw.js | 2 +- src/lib/ai/prompts/arrival-reaction.ts | 35 +++++++++++++++++++++++- src/lib/insights/eval/prompt-surfaces.ts | 17 ++++++++++++ src/lib/jobs/reaction-line.ts | 16 ++++++++--- 5 files changed, 65 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 7b01ae120..04626bf0b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.30.35", + "version": "1.31.0", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 1c48fd505..219a29024 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.30.35"; + /* @sw-version-fallback */ "v1.31.0"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/lib/ai/prompts/arrival-reaction.ts b/src/lib/ai/prompts/arrival-reaction.ts index 830a96aa9..e1f29b725 100644 --- a/src/lib/ai/prompts/arrival-reaction.ts +++ b/src/lib/ai/prompts/arrival-reaction.ts @@ -85,12 +85,45 @@ export interface ArrivalReactionPromptInput { * only to say what the block already establishes. */ evidence: string; + /** + * Rotating opener-archetype hint (`openerArchetypeHint`, seeded + * `${userId}:reaction:${kind}:${localDate}` at the call site) — the same + * per-user, per-day rotation every other assessment surface uses so this + * line does not open on the same shape every time. This sits ALONGSIDE the + * fixed meaning-first instruction below, not in place of it — the rotation + * varies the angle, the instruction is the invariant every surface owes. + */ + openerHint?: string; } +/** + * The fixed, always-present meaning-first instruction, phrased exactly the + * way every other assessment surface's user prompt phrases it. The system + * prompt's `REACTION_CONTRACT` states the same rule in its own words for the + * model to follow; this line exists so the INSTRUCTION SHAPE itself matches + * the family's shared contract, in the same prompt the harness inspects for + * it. Two locale bodies only — fr/es/it/pl ride the English one, exactly + * like the system prompt's own `instructionLocale` split. + */ +const OPENING_INSTRUCTION_EN = + "Open with what just landed MEANS in plain words — the overall read, not the number — then bring the figure in right after as support."; +const OPENING_INSTRUCTION_DE = + "Beginne mit dem, was gerade eingetroffen ist, in klaren Worten — nicht der Zahl — und bring sie danach als Beleg."; + export function getArrivalReactionUserPrompt( input: ArrivalReactionPromptInput, + locale: Locale, ): string { - return `WHAT JUST LANDED: ${KIND_SUBJECT[input.kind]}. + const openingInstruction = + instructionLocale(locale) === "en" + ? OPENING_INSTRUCTION_EN + : OPENING_INSTRUCTION_DE; + const openerLine = + input.openerHint && input.openerHint.trim().length > 0 + ? `\nOPENER HINT: ${input.openerHint}` + : ""; + + return `WHAT JUST LANDED: ${KIND_SUBJECT[input.kind]}. ${openingInstruction}${openerLine} 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} diff --git a/src/lib/insights/eval/prompt-surfaces.ts b/src/lib/insights/eval/prompt-surfaces.ts index 02b9e045b..df2b7e1b9 100644 --- a/src/lib/insights/eval/prompt-surfaces.ts +++ b/src/lib/insights/eval/prompt-surfaces.ts @@ -15,6 +15,10 @@ */ import type { Locale } from "@/lib/i18n/config"; +import { + getArrivalReactionSystemPrompt, + getArrivalReactionUserPrompt, +} from "@/lib/ai/prompts/arrival-reaction"; import { getBiomarkerSystemPrompt, getBiomarkerUserPrompt, @@ -182,4 +186,17 @@ export const ASSESSMENT_SURFACES: readonly AssessmentSurface[] = [ system: getWorkoutInsightSystemPrompt, user: (l) => getWorkoutInsightUserPrompt(SNAPSHOT, TODAY, l, OPENER_HINT), }, + { + // The one-sentence Today-hero reaction to a salient arrival. Smallest + // surface in the registry, and the one whose own module docblock warns + // most explicitly against the two-locale collapse — grading it here is + // what makes that warning enforced rather than just written down. + name: "arrival-reaction", + system: getArrivalReactionSystemPrompt, + user: (l) => + getArrivalReactionUserPrompt( + { kind: "sleep_night", evidence: SNAPSHOT, openerHint: OPENER_HINT }, + l, + ), + }, ]; diff --git a/src/lib/jobs/reaction-line.ts b/src/lib/jobs/reaction-line.ts index eef7925af..72e70c9c6 100644 --- a/src/lib/jobs/reaction-line.ts +++ b/src/lib/jobs/reaction-line.ts @@ -49,6 +49,7 @@ 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 { openerArchetypeHint } from "@/lib/ai/prompts/opener-archetype"; import { loadDailyDigest } from "@/lib/daily/load-digest"; import { getArrivalReactionSystemPrompt, @@ -231,10 +232,17 @@ export async function runReactionLine( const result = await chain[0].instance.generateCompletion( singleUserTurn({ system: getArrivalReactionSystemPrompt(locale), - user: getArrivalReactionUserPrompt({ - kind: job.kind, - evidence: buildEvidence(digest), - }), + user: getArrivalReactionUserPrompt( + { + kind: job.kind, + evidence: buildEvidence(digest), + openerHint: openerArchetypeHint( + `${job.userId}:reaction:${job.kind}:${job.localDate}`, + locale, + ), + }, + locale, + ), temperature: budget.temperature, maxTokens, timeoutMs: REACTION_LINE_TIMEOUT_MS,