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,