diff --git a/CHANGELOG.md b/CHANGELOG.md index 03dff1c5f..34dc4e025 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## [Unreleased] +## [1.28.40] — 2026-07-16 — Insight assessments lead with meaning, not a number + +The per-metric insight assessments read like a data readout — they opened on +the current value. The overview texts already lead with the day's verdict in +plain words and land the warm, motivating tone; the per-metric cards were told +the opposite (name the level with a number first) and never received the +opener-variation the other insight surfaces use. Now every metric assessment +opens with what the reading MEANS and brings the number in right after as +support, in the same warm register as the overview. A shared opening-shape +contract keeps the surfaces from drifting apart again, and the deterministic +first-paint fallbacks lead with the verdict too. No change to what the numbers +say — only how the sentence is built. + ## [1.28.39] — 2026-07-16 — Sync data-loss hardening Four fixes to sync paths that could lose or corrupt data in edge cases: diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 90c02bd51..01f0fd0ae 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.28.39 + version: 1.28.40 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index b636365ab..4b68b3f0e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.28.39", + "version": "1.28.40", "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 aab6ebc23..a5b91f84e 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.28.39"; + /* @sw-version-fallback */ "v1.28.40"; 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/__tests__/assessment-prompt-contract.test.ts b/src/lib/ai/prompts/__tests__/assessment-prompt-contract.test.ts index ade53e8e8..d809f9e62 100644 --- a/src/lib/ai/prompts/__tests__/assessment-prompt-contract.test.ts +++ b/src/lib/ai/prompts/__tests__/assessment-prompt-contract.test.ts @@ -4,6 +4,13 @@ import { getMetricArchetypeSystemPrompt, getMetricArchetypeUserPrompt, } from "../metric-archetypes"; +import { getBloodPressureUserPrompt } from "../blood-pressure"; +import { getWeightUserPrompt } from "../weight"; +import { getPulseUserPrompt } from "../pulse"; +import { getBmiUserPrompt } from "../bmi"; +import { getMoodUserPrompt } from "../mood"; +import { getMedicationComplianceUserPrompt } from "../medication-compliance"; +import { getGeneralStatusUserPrompt } from "../general-status"; import { getMetricStatusMeta } from "@/lib/insights/metric-status-registry"; /** @@ -148,6 +155,69 @@ describe("metric-archetype user prompt — context block plumbing", () => { }); }); +/** + * v1.28.40 — the verdict-first rewrite. Every per-metric USER prompt must now + * LEAD with meaning (verdict-first) and STOP instructing the model to open on a + * concrete number, and must carry the opener-hint rotation. These pin both the + * new opening posture and the removal of the old "name the current level with a + * concrete number" instruction, in EN and DE, for all seven builders + the + * overall assessment — the exact drift the audit isolated as the tone split. + */ +describe("v1.28.40 per-metric user prompts — verdict-first, number-as-support", () => { + const meta = getMetricStatusMeta("RESTING_HEART_RATE")!; + + // A builder normalised to `(locale, openerHint?) => prompt` so the parametric + // assertions below can drive every surface through one shape. + const BUILDERS: Record< + string, + (locale: "en" | "de", openerHint?: string) => string + > = { + "metric-archetype": (l, h) => + getMetricArchetypeUserPrompt(meta, "{}", "2026-06-05", l, "", "", "", h), + "blood-pressure": (l, h) => + getBloodPressureUserPrompt("{}", "2026-06-05", l, "", "", h), + weight: (l, h) => getWeightUserPrompt("{}", "2026-06-05", l, "", "", h), + pulse: (l, h) => getPulseUserPrompt("{}", "2026-06-05", l, "", "", h), + bmi: (l, h) => getBmiUserPrompt("{}", "2026-06-05", l, "", "", h), + mood: (l, h) => getMoodUserPrompt("{}", "2026-06-05", l, "", "", h), + "medication-compliance": (l, h) => + getMedicationComplianceUserPrompt("{}", "2026-06-05", l, "", "", h), + general: (l, h) => + getGeneralStatusUserPrompt("{}", "2026-06-05", l, "", "", h), + }; + + for (const [name, build] of Object.entries(BUILDERS)) { + it(`${name}: EN leads verdict-first and no longer instructs number-first`, () => { + const p = build("en"); + // The old number-first instruction is gone. + expect(p).not.toMatch(/name the current .* with a concrete number/i); + expect(p).not.toMatch(/name the current systolic\/diastolic level/i); + // Verdict-first: opens on meaning, explicitly NOT the number/value. + expect(p).toMatch(/open with/i); + expect(p).toMatch(/not (a|the) (number|value)/i); + // The number stays required as support (grounding preserved). + expect(p).toMatch(/as support|snapshot/i); + }); + + it(`${name}: DE leads verdict-first and no longer instructs number-first`, () => { + const p = build("de"); + expect(p).not.toMatch(/benenne das aktuelle/i); + expect(p).toMatch(/beginne mit/i); + expect(p).toMatch(/nicht (der|mit einer) Zahl/i); + expect(p).toMatch(/als Beleg|Snapshot/i); + }); + + it(`${name}: emits the OPENER HINT line only when a hint is passed`, () => { + expect( + build("en", "Open with the overall read in plain words."), + ).toContain("OPENER HINT: Open with the overall read in plain words."); + // Backward-compatible: no hint → no dangling OPENER HINT line. + expect(build("en")).not.toContain("OPENER HINT"); + expect(build("en")).not.toContain("undefined"); + }); + } +}); + describe("metric-archetype system prompt — archetype grounding intact", () => { it("still injects the archetype section + metadata", () => { const meta = getMetricStatusMeta("SLEEP_DURATION")!; diff --git a/src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts b/src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts index a03314091..252ff888f 100644 --- a/src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts +++ b/src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts @@ -15,6 +15,7 @@ import { toneContract, antiRecitation, interpretationDepth, + openingShape, safetyGlp1, safetyAcute, metricIdentifierBan, @@ -59,6 +60,9 @@ const SURFACES: Record< // overview + briefing text too. antiRecitation, interpretationDepth, + // v1.28.40 — the opening-shape contract pins the briefing's verdict-first + // opening to the same wording the per-metric cards now carry. + openingShape, safetyGlp1, safetyAcute, metricIdentifierBan, @@ -78,6 +82,9 @@ const SURFACES: Record< // v1.27.13 (Welle J) — the assessment cards enforce both new contracts. antiRecitation, interpretationDepth, + // v1.28.40 — the opening-shape contract lands here so the per-metric card + // (and the derived-score archetype built on it) opens verdict-first. + openingShape, safetyGlp1, safetyAcute, metricIdentifierBan, @@ -109,6 +116,9 @@ const SURFACES: Record< contracts: [ grounding, toneContract, + // v1.28.40 — the retrospective narrative composes the opening-shape + // contract too, matching its verdict-first SHAPE rule to the house voice. + openingShape, safetyGlp1, safetyAcute, metricIdentifierBan, @@ -164,6 +174,21 @@ describe("shared-contract cross-surface coverage", () => { ); } }); + + // v1.28.40 — the opening-shape ("lead with meaning, not the value") contract + // must be present on the overview, the per-metric cards, AND the period + // narrative at once — the exact drift that produced the "dry per-insight + // readout" complaint (verdict-first present on the overview, absent on the + // per-metric card) can then never recur silently. + it("the opening-shape contract reaches the overview, per-metric, and narrative surfaces", () => { + for (const locale of LOCALES) { + expect(getStrictInsightsSystemPrompt(locale)).toContain( + openingShape[locale], + ); + expect(getBaseSystemPrompt(locale)).toContain(openingShape[locale]); + expect(SYSTEM_PROMPTS_FOR_TEST[locale]).toContain(openingShape[locale]); + } + }); }); // v1.21.0 (D3-M1 / D3-L1) — the acute-safety clause's threshold numbers are diff --git a/src/lib/ai/prompts/base-system.ts b/src/lib/ai/prompts/base-system.ts index 29446c95a..8140acaa4 100644 --- a/src/lib/ai/prompts/base-system.ts +++ b/src/lib/ai/prompts/base-system.ts @@ -4,6 +4,7 @@ import { toneContract, antiRecitation, interpretationDepth, + openingShape, safetyGlp1, safetyAcute, metricIdentifierBan, @@ -35,8 +36,14 @@ import { * (synthesis over recitation), the score archetype gains a band→meaning * interpretation, and a shared FORMATTING contract asks for real paragraph * breaks. Grounding is unchanged — every number still traces to the snapshot. + * 6.1.0 — verdict-first opening: the shared `openingShape` contract lands on + * this surface (and, through it, the derived-score archetype), and the + * per-metric USER prompts now lead with meaning and receive the opener-hint + * rotation. This activates the dormant "do NOT open with the number" branch so + * the per-metric card reads as warm as the overview. Clause ORDER only — + * grounding, non-diagnostic framing, and every safety contract are unchanged. */ -export const PROMPT_VERSION = "6.0.0" as const; +export const PROMPT_VERSION = "6.1.0" as const; /** * Base system prompt for the per-metric Insights *assessment* cards. @@ -161,6 +168,11 @@ Synthese statt Aufzählung: die Geschichte dessen, was die Daten bedeuten, zähl - Auch ungünstige Werte ehrlich benennen: Befund → gegen die eigene Baseline einordnen → ein kleiner machbarer Schritt, als Chance formuliert, nicht als Versagen. Nicht verharmlosen, nicht dramatisieren. - Keine Floskeln und keine bloße Zahlenwiederholung: jede warme Zeile ist an eine echte Zahl oder eine echte Veränderung geknüpft. Ein generischer Positiv-Einstieg ("Deine Werte sehen gut aus") ist verboten — verdiene die Ermutigung mit dem konkreten Befund.`, }, + // v1.28.40 — the shared opening-shape contract: lead with meaning, number as + // support. Canonical wording so the per-metric card can never again drift + // number-first from the verdict-first overview. The derived-score archetype + // inherits it too (its system prompt is built on getBaseSystemPrompt). + { id: "sharedOpeningShape", en: openingShape.en, de: openingShape.de }, { id: "length", en: `LENGTH: 2-4 sentences, roughly 30-60 words — but when the metric is steady and there is nothing to act on, ONE tight sentence is better than padding it to length. Concise and high-quality. No bare number-echoing, no filler, no generic platitudes.`, diff --git a/src/lib/ai/prompts/blood-pressure.ts b/src/lib/ai/prompts/blood-pressure.ts index 910adae73..c8ee05c7f 100644 --- a/src/lib/ai/prompts/blood-pressure.ts +++ b/src/lib/ai/prompts/blood-pressure.ts @@ -44,6 +44,8 @@ export function getBloodPressureUserPrompt( * data; optional and may be empty. */ assessmentContextBlock?: string, + /** v1.28.40 — rotating opener-archetype hint; see metric-archetypes.ts. */ + openerHint?: string, ): string { const ctxBlock = previousContextBlock && previousContextBlock.trim().length > 0 @@ -53,14 +55,18 @@ export function getBloodPressureUserPrompt( assessmentContextBlock && assessmentContextBlock.trim().length > 0 ? `\n\n${assessmentContextBlock}\n` : ""; + const openerLine = + openerHint && openerHint.trim().length > 0 + ? `\nOPENER HINT: ${openerHint}` + : ""; if (locale === "en") { - return `Date: ${todayKey} (Europe/Berlin) -Write one short assessment of this person's blood pressure: name the current systolic/diastolic level, place the recent days against their own weekly/monthly baseline, and — when something is genuinely actionable — close with one doable step; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock} + return `Date: ${todayKey} (Europe/Berlin)${openerLine} +Write one short assessment of this person's blood pressure. Open with what the reading MEANS in plain words — the overall read, not the number (e.g. "calm and right where you want it", "running a touch higher than your usual this week") — then bring in the systolic/diastolic figures right after as support, judged as ONE pair and placed against their own weekly/monthly baseline; never lead with the numbers. Close with one doable step only when the finding genuinely implies one; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock} ${snapshotJson}`; } - return `Datum: ${todayKey} (Europe/Berlin) -Schreibe eine kurze Einschätzung zum Blutdruck dieser Person: benenne das aktuelle systolisch/diastolisch-Niveau, ordne die jüngsten Tage gegen die eigene Wochen-/Monats-Baseline ein und schließe — wenn etwas wirklich umsetzbar ist — mit einem machbaren Schritt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}${extraBlock} + return `Datum: ${todayKey} (Europe/Berlin)${openerLine} +Schreibe eine kurze Einschätzung zum Blutdruck dieser Person. Beginne mit der BEDEUTUNG in klaren Worten — dem Gesamteindruck, nicht der Zahl (z. B. "ruhig und genau da, wo du ihn haben willst", "diese Woche einen Tick höher als sonst") — und bring danach die systolischen/diastolischen Werte als Beleg, als EIN Paar beurteilt und gegen die eigene Wochen-/Monats-Baseline eingeordnet; führe nie mit den Werten. Schließe nur dann mit einem machbaren Schritt, wenn der Befund wirklich einen hergibt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}${extraBlock} ${snapshotJson}`; } diff --git a/src/lib/ai/prompts/bmi.ts b/src/lib/ai/prompts/bmi.ts index c0e4c1031..fb444ae32 100644 --- a/src/lib/ai/prompts/bmi.ts +++ b/src/lib/ai/prompts/bmi.ts @@ -31,6 +31,8 @@ export function getBmiUserPrompt( previousContextBlock?: string, /** v1.12.7 — diversity / anti-repetition context; see blood-pressure.ts. */ assessmentContextBlock?: string, + /** v1.28.40 — rotating opener-archetype hint; see metric-archetypes.ts. */ + openerHint?: string, ): string { const ctxBlock = previousContextBlock && previousContextBlock.trim().length > 0 @@ -40,14 +42,18 @@ export function getBmiUserPrompt( assessmentContextBlock && assessmentContextBlock.trim().length > 0 ? `\n\n${assessmentContextBlock}\n` : ""; + const openerLine = + openerHint && openerHint.trim().length > 0 + ? `\nOPENER HINT: ${openerHint}` + : ""; if (locale === "en") { - return `Date: ${todayKey} (Europe/Berlin) -Write one short assessment of this person's BMI: name the current value and WHO band, say whether it has crossed a band or is nearing a band boundary against their own weekly/monthly baseline, and — when something is genuinely actionable — close with one doable step; when nothing is, skip the step rather than manufacture filler. Leave the weight pace to the weight card. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock} + return `Date: ${todayKey} (Europe/Berlin)${openerLine} +Write one short assessment of this person's BMI. Open with what its band placement MEANS in plain words — where it sits and whether that's holding or shifting, not the number (e.g. "sitting comfortably in the healthy band", "edging toward the next band up") — then bring in the current value and WHO band right after as support, saying against their own weekly/monthly baseline whether it has crossed a band or is nearing a boundary; never lead with the value. Close with one doable step only when the finding genuinely implies one; when nothing is, skip the step rather than manufacture filler. Leave the weight pace to the weight card. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock} ${snapshotJson}`; } - return `Datum: ${todayKey} (Europe/Berlin) -Schreibe eine kurze Einschätzung zum BMI dieser Person: benenne den aktuellen Wert und das WHO-Band, sage gegen die eigene Wochen-/Monats-Baseline, ob er ein Band gewechselt hat oder sich einer Bandgrenze nähert, und schließe — wenn etwas wirklich umsetzbar ist — mit einem machbaren Schritt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Das Gewichtstempo bleibt der Gewichts-Karte überlassen. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}${extraBlock} + return `Datum: ${todayKey} (Europe/Berlin)${openerLine} +Schreibe eine kurze Einschätzung zum BMI dieser Person. Beginne mit der BEDEUTUNG der Bandlage in klaren Worten — wo er liegt und ob das hält oder sich verschiebt, nicht der Zahl (z. B. "liegt bequem im gesunden Band", "nähert sich dem nächsten Band") — und bring danach den aktuellen Wert und das WHO-Band als Beleg, samt Angabe gegen die eigene Wochen-/Monats-Baseline, ob er ein Band gewechselt hat oder sich einer Bandgrenze nähert; führe nie mit dem Wert. Schließe nur dann mit einem machbaren Schritt, wenn der Befund wirklich einen hergibt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Das Gewichtstempo bleibt der Gewichts-Karte überlassen. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}${extraBlock} ${snapshotJson}`; } diff --git a/src/lib/ai/prompts/general-status.ts b/src/lib/ai/prompts/general-status.ts index d6119b9ea..bc21486d4 100644 --- a/src/lib/ai/prompts/general-status.ts +++ b/src/lib/ai/prompts/general-status.ts @@ -33,6 +33,8 @@ export function getGeneralStatusUserPrompt( previousContextBlock?: string, /** v1.12.7 — diversity / anti-repetition context; see blood-pressure.ts. */ assessmentContextBlock?: string, + /** v1.28.40 — rotating opener-archetype hint; see metric-archetypes.ts. */ + openerHint?: string, ): string { // v1.4: when the previous-analysis context block is supplied, the // model is instructed to call out improvements / regressions @@ -46,14 +48,18 @@ export function getGeneralStatusUserPrompt( assessmentContextBlock && assessmentContextBlock.trim().length > 0 ? `\n\n${assessmentContextBlock}\n` : ""; + const openerLine = + openerHint && openerHint.trim().length > 0 + ? `\nOPENER HINT: ${openerHint}` + : ""; if (locale === "en") { - return `Date: ${todayKey} (Europe/Berlin) -Write one short overall assessment across the available health metrics: name what stands out, place the recent days against the person's own weekly/monthly baseline, and — when something is genuinely actionable — close with the single most important doable step; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count, density and recency.${ctxBlock}${extraBlock} + return `Date: ${todayKey} (Europe/Berlin)${openerLine} +Write one short overall assessment across the available health metrics. Open with the overall read in plain words — how things are looking taken together, not a number (e.g. "a steady stretch across the board", "one thing standing out this week") — then bring in the one or two metrics that stand out as support, each placed against the person's own weekly/monthly baseline; never lead with a value. Close with the single most important doable step only when the overall picture genuinely implies one; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count, density and recency.${ctxBlock}${extraBlock} ${snapshotJson}`; } - return `Datum: ${todayKey} (Europe/Berlin) -Schreibe eine kurze Gesamteinschätzung über die verfügbaren Gesundheitsdaten: benenne das Auffälligste, ordne die jüngsten Tage gegen die eigene Wochen-/Monats-Baseline ein und schließe — wenn etwas wirklich umsetzbar ist — mit dem einen wichtigsten machbaren Schritt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Messanzahl, Dichte und Aktualität ableiten.${ctxBlock}${extraBlock} + return `Datum: ${todayKey} (Europe/Berlin)${openerLine} +Schreibe eine kurze Gesamteinschätzung über die verfügbaren Gesundheitsdaten. Beginne mit dem Gesamteindruck in klaren Worten — wie es zusammengenommen aussieht, nicht mit einer Zahl (z. B. "über alles hinweg eine ruhige Phase", "diese Woche sticht eine Sache heraus") — und bring danach die ein bis zwei auffälligsten Metriken als Beleg, jeweils gegen die eigene Wochen-/Monats-Baseline eingeordnet; führe nie mit einem Wert. Schließe nur dann mit dem einen wichtigsten machbaren Schritt, wenn das Gesamtbild wirklich einen hergibt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Messanzahl, Dichte und Aktualität ableiten.${ctxBlock}${extraBlock} ${snapshotJson}`; } diff --git a/src/lib/ai/prompts/insight-generator.ts b/src/lib/ai/prompts/insight-generator.ts index 211640416..ca7fdbbfb 100644 --- a/src/lib/ai/prompts/insight-generator.ts +++ b/src/lib/ai/prompts/insight-generator.ts @@ -39,6 +39,7 @@ import { toneContract, antiRecitation, interpretationDepth, + openingShape, safetyGlp1, safetyAcute, metricIdentifierBan, @@ -55,8 +56,12 @@ import { * read, weave 2-3 signals into one associative story, state fewer numbers than * signals), and the shared paragraph FORMATTING contract joins so the briefing * renders as real paragraphs. Grounding is unchanged. + * 5.1.0 (v1.28.40) — the shared `openingShape` contract joins so the briefing's + * verdict-first opening is pinned to the same canonical wording the per-metric + * cards now carry, foreclosing the number-first/verdict-first drift between + * surfaces. Clause order only — grounding untouched. */ -export const PROMPT_VERSION = "5.0.0" as const; +export const PROMPT_VERSION = "5.1.0" as const; /** Europe/Berlin YYYY-MM-DD day key — the rotation boundary for the opener. */ function berlinDayKey(now: Date): string { @@ -843,6 +848,10 @@ neutralem Label + Detail. Höchstgrenze: 20 Einträge.`, // future safety edit is made once in `shared-contracts.ts`. { id: "sharedGrounding", en: grounding.en, de: grounding.de }, { id: "sharedTone", en: toneContract.en, de: toneContract.de }, + // v1.28.40 — the shared opening-shape contract, canonical wording. The + // briefing already leads verdict-first (rule 8); this pins it to the same + // fragment the per-metric cards carry so the two surfaces can't drift. + { id: "sharedOpeningShape", en: openingShape.en, de: openingShape.de }, // v1.27.13 (Welle J) — the overview + briefing text carried the same // recitation disease (counts, cadence). Both contracts apply here so the // summary and the daily briefing interpret rather than enumerate; the diff --git a/src/lib/ai/prompts/medication-compliance.ts b/src/lib/ai/prompts/medication-compliance.ts index 5a2bcffe0..d0f903dd6 100644 --- a/src/lib/ai/prompts/medication-compliance.ts +++ b/src/lib/ai/prompts/medication-compliance.ts @@ -33,6 +33,8 @@ export function getMedicationComplianceUserPrompt( previousContextBlock?: string, /** v1.12.7 — diversity / anti-repetition context; see blood-pressure.ts. */ assessmentContextBlock?: string, + /** v1.28.40 — rotating opener-archetype hint; see metric-archetypes.ts. */ + openerHint?: string, ): string { const ctxBlock = previousContextBlock && previousContextBlock.trim().length > 0 @@ -42,14 +44,18 @@ export function getMedicationComplianceUserPrompt( assessmentContextBlock && assessmentContextBlock.trim().length > 0 ? `\n\n${assessmentContextBlock}\n` : ""; + const openerLine = + openerHint && openerHint.trim().length > 0 + ? `\nOPENER HINT: ${openerHint}` + : ""; if (locale === "en") { - return `Date: ${todayKey} (Europe/Berlin) -Write one short assessment of this person's medication adherence: name the recent rate, place it against their own baseline (compliance7 vs compliance30), and — when something is genuinely actionable — close with one doable, blame-free step; when nothing is, skip the step rather than manufacture filler. Judge confidence from the event count and recency.${ctxBlock}${extraBlock} + return `Date: ${todayKey} (Europe/Berlin)${openerLine} +Write one short assessment of this person's medication adherence. Open with what it MEANS in plain words — how reliable the routine is looking, not the number (e.g. "your routine's been rock-solid", "a few doses have slipped lately") — then bring in the recent rate right after as support, placed against their own baseline (compliance7 vs compliance30); never lead with the value. Close with one doable, blame-free step only when the finding genuinely implies one; when nothing is, skip the step rather than manufacture filler. Judge confidence from the event count and recency.${ctxBlock}${extraBlock} ${snapshotJson}`; } - return `Datum: ${todayKey} (Europe/Berlin) -Schreibe eine kurze Einschätzung zur Einnahmetreue dieser Person: benenne die jüngste Rate, ordne sie gegen die eigene Baseline ein (compliance7 vs. compliance30) und schließe — wenn etwas wirklich umsetzbar ist — mit einem machbaren, wertfreien Schritt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Ereignisanzahl und Aktualität ableiten.${ctxBlock}${extraBlock} + return `Datum: ${todayKey} (Europe/Berlin)${openerLine} +Schreibe eine kurze Einschätzung zur Einnahmetreue dieser Person. Beginne mit der BEDEUTUNG in klaren Worten — wie verlässlich die Routine aussieht, nicht der Zahl (z. B. "deine Routine sitzt richtig gut", "zuletzt sind ein paar Dosen durchgerutscht") — und bring danach die jüngste Rate als Beleg, gegen die eigene Baseline eingeordnet (compliance7 vs. compliance30); führe nie mit dem Wert. Schließe nur dann mit einem machbaren, wertfreien Schritt, wenn der Befund wirklich einen hergibt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Ereignisanzahl und Aktualität ableiten.${ctxBlock}${extraBlock} ${snapshotJson}`; } diff --git a/src/lib/ai/prompts/metric-archetypes.ts b/src/lib/ai/prompts/metric-archetypes.ts index 902e1d8d7..23ea4bbab 100644 --- a/src/lib/ai/prompts/metric-archetypes.ts +++ b/src/lib/ai/prompts/metric-archetypes.ts @@ -134,6 +134,14 @@ export function getMetricArchetypeUserPrompt( * relative, exactly as before. */ interpretationBlock?: string, + /** + * v1.28.40 — the rotating opener-archetype hint (VERDICT / TREND / CONTRAST / + * CONTINUITY / MEANING). When present it is emitted as an `OPENER HINT:` line + * so the base prompt's "Lead per the OPENER HINT … do NOT open with the + * number" branch fires. Optional so tests can omit it; production always + * passes it from `metric-status.ts`. + */ + openerHint?: string, ): string { const ctxBlock = previousContextBlock && previousContextBlock.trim().length > 0 @@ -147,14 +155,18 @@ export function getMetricArchetypeUserPrompt( interpretationBlock && interpretationBlock.trim().length > 0 ? `\n\n${interpretationBlock}\n` : ""; + const openerLine = + openerHint && openerHint.trim().length > 0 + ? `\nOPENER HINT: ${openerHint}` + : ""; if (locale === "en") { - return `Date: ${todayKey} (Europe/Berlin) -Write one short assessment of this person's ${meta.displayName.toLowerCase()}: name the current level with a concrete number from the snapshot, place the recent window against their own weekly/monthly baseline, and — when something is genuinely actionable — close with one doable step; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock}${interpBlock} + return `Date: ${todayKey} (Europe/Berlin)${openerLine} +Write one short assessment of this person's ${meta.displayName.toLowerCase()}. Open with what it MEANS in plain words — the read, not the number (e.g. "running a little calmer than usual", "steady and right where it's been") — then bring in ONE concrete number from the snapshot right after as support, placed against their own weekly/monthly baseline; never lead with the value. Close with one doable step only when the finding genuinely implies one; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock}${interpBlock} ${snapshotJson}`; } - return `Datum: ${todayKey} (Europe/Berlin) -Schreibe eine kurze Einschätzung zu ${meta.displayName} dieser Person: benenne das aktuelle Niveau mit einer konkreten Zahl aus dem Snapshot, ordne das recent-Fenster gegen die eigene Wochen-/Monats-Baseline ein und schließe — wenn etwas wirklich umsetzbar ist — mit einem machbaren Schritt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}${extraBlock}${interpBlock} + return `Datum: ${todayKey} (Europe/Berlin)${openerLine} +Schreibe eine kurze Einschätzung zu ${meta.displayName} dieser Person. Beginne mit der BEDEUTUNG in klaren Worten — dem Eindruck, nicht der Zahl (z. B. "läuft gerade etwas ruhiger als sonst", "stabil und genau da, wo es war") — und bring danach EINE konkrete Zahl aus dem Snapshot als Beleg, gegen die eigene Wochen-/Monats-Baseline eingeordnet; führe nie mit dem Wert. Schließe nur dann mit einem machbaren Schritt, wenn der Befund wirklich einen hergibt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}${extraBlock}${interpBlock} ${snapshotJson}`; } diff --git a/src/lib/ai/prompts/mood.ts b/src/lib/ai/prompts/mood.ts index a8b14dccf..8419ec02d 100644 --- a/src/lib/ai/prompts/mood.ts +++ b/src/lib/ai/prompts/mood.ts @@ -33,6 +33,8 @@ export function getMoodUserPrompt( previousContextBlock?: string, /** v1.12.7 — diversity / anti-repetition context; see blood-pressure.ts. */ assessmentContextBlock?: string, + /** v1.28.40 — rotating opener-archetype hint; see metric-archetypes.ts. */ + openerHint?: string, ): string { const ctxBlock = previousContextBlock && previousContextBlock.trim().length > 0 @@ -42,14 +44,18 @@ export function getMoodUserPrompt( assessmentContextBlock && assessmentContextBlock.trim().length > 0 ? `\n\n${assessmentContextBlock}\n` : ""; + const openerLine = + openerHint && openerHint.trim().length > 0 + ? `\nOPENER HINT: ${openerHint}` + : ""; if (locale === "en") { - return `Date: ${todayKey} (Europe/Berlin) -Write one short assessment of this person's mood: name the recent level, place it against their own weekly/monthly baseline, and — when something is genuinely actionable — close with one kind, doable step; when nothing is, skip the step rather than manufacture filler. Judge confidence from the entry count and recency.${ctxBlock}${extraBlock} + return `Date: ${todayKey} (Europe/Berlin)${openerLine} +Write one short assessment of this person's mood. Open with what the recent pattern MEANS in plain words — the read, not the number (e.g. "a brighter stretch than usual", "steady, holding where it's been") — then bring in the recent level right after as support, placed against their own weekly/monthly baseline; never lead with the value. Close with one kind, doable step only when the finding genuinely implies one; when nothing is, skip the step rather than manufacture filler. Judge confidence from the entry count and recency.${ctxBlock}${extraBlock} ${snapshotJson}`; } - return `Datum: ${todayKey} (Europe/Berlin) -Schreibe eine kurze Einschätzung zur Stimmung dieser Person: benenne das jüngste Niveau, ordne es gegen die eigene Wochen-/Monats-Baseline ein und schließe — wenn etwas wirklich umsetzbar ist — mit einem freundlichen, machbaren Schritt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Eintragsanzahl und Aktualität ableiten.${ctxBlock}${extraBlock} + return `Datum: ${todayKey} (Europe/Berlin)${openerLine} +Schreibe eine kurze Einschätzung zur Stimmung dieser Person. Beginne mit der BEDEUTUNG des jüngsten Musters in klaren Worten — dem Eindruck, nicht der Zahl (z. B. "eine hellere Phase als sonst", "stabil, hält sich, wo sie war") — und bring danach das jüngste Niveau als Beleg, gegen die eigene Wochen-/Monats-Baseline eingeordnet; führe nie mit dem Wert. Schließe nur dann mit einem freundlichen, machbaren Schritt, wenn der Befund wirklich einen hergibt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Eintragsanzahl und Aktualität ableiten.${ctxBlock}${extraBlock} ${snapshotJson}`; } diff --git a/src/lib/ai/prompts/pulse.ts b/src/lib/ai/prompts/pulse.ts index 345392701..b558b3df3 100644 --- a/src/lib/ai/prompts/pulse.ts +++ b/src/lib/ai/prompts/pulse.ts @@ -31,6 +31,8 @@ export function getPulseUserPrompt( previousContextBlock?: string, /** v1.12.7 — diversity / anti-repetition context; see blood-pressure.ts. */ assessmentContextBlock?: string, + /** v1.28.40 — rotating opener-archetype hint; see metric-archetypes.ts. */ + openerHint?: string, ): string { const ctxBlock = previousContextBlock && previousContextBlock.trim().length > 0 @@ -40,14 +42,18 @@ export function getPulseUserPrompt( assessmentContextBlock && assessmentContextBlock.trim().length > 0 ? `\n\n${assessmentContextBlock}\n` : ""; + const openerLine = + openerHint && openerHint.trim().length > 0 + ? `\nOPENER HINT: ${openerHint}` + : ""; if (locale === "en") { - return `Date: ${todayKey} (Europe/Berlin) -Write one short assessment of this person's resting pulse: name the current level, place the recent days against their own weekly/monthly baseline, and — when something is genuinely actionable — close with one doable step; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock} + return `Date: ${todayKey} (Europe/Berlin)${openerLine} +Write one short assessment of this person's resting pulse. Open with what it MEANS in plain words — the read, not the number (e.g. "running a little calmer than usual", "steady, right where it's been") — then bring in ONE concrete number from the snapshot right after as support, placed against their own weekly/monthly baseline; never lead with the value. Close with one doable step only when the finding genuinely implies one; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock} ${snapshotJson}`; } - return `Datum: ${todayKey} (Europe/Berlin) -Schreibe eine kurze Einschätzung zum Ruhepuls dieser Person: benenne das aktuelle Niveau, ordne die jüngsten Tage gegen die eigene Wochen-/Monats-Baseline ein und schließe — wenn etwas wirklich umsetzbar ist — mit einem machbaren Schritt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}${extraBlock} + return `Datum: ${todayKey} (Europe/Berlin)${openerLine} +Schreibe eine kurze Einschätzung zum Ruhepuls dieser Person. Beginne mit der BEDEUTUNG in klaren Worten — dem Eindruck, nicht der Zahl (z. B. "läuft gerade etwas ruhiger als sonst", "stabil, genau da, wo er war") — und bring danach EINE konkrete Zahl aus dem Snapshot als Beleg, gegen die eigene Wochen-/Monats-Baseline eingeordnet; führe nie mit dem Wert. Schließe nur dann mit einem machbaren Schritt, wenn der Befund wirklich einen hergibt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}${extraBlock} ${snapshotJson}`; } diff --git a/src/lib/ai/prompts/shared-contracts.ts b/src/lib/ai/prompts/shared-contracts.ts index a88e29ffd..9dd2e0176 100644 --- a/src/lib/ai/prompts/shared-contracts.ts +++ b/src/lib/ai/prompts/shared-contracts.ts @@ -151,6 +151,22 @@ export const forbiddenFiller: SharedContract = { "achte auf ausreichend Schlaf", "trinke genug Wasser", "regelmäßige Bewegung", "ärztlicher Rat empfohlen".`, }; +/** + * v1.28.40 — opening-shape contract. The maintainer's complaint: the per-metric + * assessment cards read as a dry data readout while the overview reads warm. The + * root cause is a divergent OPENING instruction — the overview leads verdict- + * first, the per-metric card led number-first. This single fragment pins the + * "lead with meaning, number as support" rule on every narrative surface so the + * two can never drift apart again. It changes clause ORDER only — grounding is + * untouched, the number stays required as support. + */ +export const openingShape: SharedContract = { + en: `OPENING — lead with meaning, not the metric value +Open on the read in plain words — what this says about the person today — and bring the number in right after, as support, never as the first thing. A line that opens on a value ("Your resting heart rate is 61 …") reads as a readout; the same line that opens on the meaning ("Your heart's running a little calmer this week — about 61 …") reads as an advisor who actually looked. Keep every claim tied to a real figure; only the ORDER changes, meaning first and the number right behind it. This holds whether the surface covers one metric or many.`, + de: `ERÖFFNUNG — mit der Bedeutung führen, nicht mit dem Messwert +Beginne mit dem Eindruck in klaren Worten — was das heute über die Person sagt — und bring die Zahl direkt danach als Beleg, nie als Erstes. Eine Zeile, die mit einem Wert eröffnet ("Dein Ruhepuls ist 61 …"), liest sich wie ein Ablesen; dieselbe Zeile, die mit der Bedeutung eröffnet ("Dein Herz läuft diese Woche etwas ruhiger — rund 61 …"), liest sich wie ein Begleiter, der wirklich hingesehen hat. Jede Aussage bleibt an einer echten Zahl verankert; nur die REIHENFOLGE ändert sich, Bedeutung zuerst und die Zahl direkt dahinter. Das gilt, ob die Fläche eine Metrik abdeckt oder viele.`, +}; + /** * v1.21.0 (QoL-B §3 / D4 §4) — forward-looking outlook contract. The voice * already nails honest-not-sycophantic but barely looks ahead; this fragment @@ -193,6 +209,7 @@ export const SHARED_CONTRACTS = { toneContract, antiRecitation, interpretationDepth, + openingShape, safetyGlp1, safetyAcute, metricIdentifierBan, diff --git a/src/lib/ai/prompts/weight.ts b/src/lib/ai/prompts/weight.ts index 97bc1bf90..f0b3ec1b9 100644 --- a/src/lib/ai/prompts/weight.ts +++ b/src/lib/ai/prompts/weight.ts @@ -37,6 +37,8 @@ export function getWeightUserPrompt( previousContextBlock?: string, /** v1.12.7 — diversity / anti-repetition context; see blood-pressure.ts. */ assessmentContextBlock?: string, + /** v1.28.40 — rotating opener-archetype hint; see metric-archetypes.ts. */ + openerHint?: string, ): string { const ctxBlock = previousContextBlock && previousContextBlock.trim().length > 0 @@ -46,14 +48,18 @@ export function getWeightUserPrompt( assessmentContextBlock && assessmentContextBlock.trim().length > 0 ? `\n\n${assessmentContextBlock}\n` : ""; + const openerLine = + openerHint && openerHint.trim().length > 0 + ? `\nOPENER HINT: ${openerHint}` + : ""; if (locale === "en") { - return `Date: ${todayKey} (Europe/Berlin) -Write one short assessment of this person's weight: name the current level and direction, place the recent days against their own weekly/monthly baseline as a continuous trend and pace (kg/week, plateau, milestone — not the single value, and not the WHO band, which the BMI card covers), and — when something is genuinely actionable — close with one doable step; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock} + return `Date: ${todayKey} (Europe/Berlin)${openerLine} +Write one short assessment of this person's weight. Open with what the trend MEANS in plain words — the direction and momentum, not the number (e.g. "easing down steadily", "holding right where it's settled") — then bring in ONE concrete number from the snapshot right after as support, read as a continuous trend and pace against their own weekly/monthly baseline (kg/week, plateau, milestone — not the single value, and not the WHO band, which the BMI card covers); never lead with the value. Close with one doable step only when the finding genuinely implies one; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock} ${snapshotJson}`; } - return `Datum: ${todayKey} (Europe/Berlin) -Schreibe eine kurze Einschätzung zum Gewicht dieser Person: benenne Niveau und Richtung, ordne die jüngsten Tage gegen die eigene Wochen-/Monats-Baseline als kontinuierlichen Trend und Tempo ein (kg/Woche, Plateau, Meilenstein — nicht der Einzelwert und nicht das WHO-Band, das die BMI-Karte trägt) und schließe — wenn etwas wirklich umsetzbar ist — mit einem machbaren Schritt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}${extraBlock} + return `Datum: ${todayKey} (Europe/Berlin)${openerLine} +Schreibe eine kurze Einschätzung zum Gewicht dieser Person. Beginne mit der BEDEUTUNG in klaren Worten — Richtung und Tempo, nicht der Zahl (z. B. "geht ruhig nach unten", "hält sich genau da, wo es sich eingependelt hat") — und bring danach EINE konkrete Zahl aus dem Snapshot als Beleg, als kontinuierlichen Trend und Tempo gegen die eigene Wochen-/Monats-Baseline gelesen (kg/Woche, Plateau, Meilenstein — nicht der Einzelwert und nicht das WHO-Band, das die BMI-Karte trägt); führe nie mit dem Wert. Schließe nur dann mit einem machbaren Schritt, wenn der Befund wirklich einen hergibt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}${extraBlock} ${snapshotJson}`; } diff --git a/src/lib/insights/__tests__/no-key-fallbacks.test.ts b/src/lib/insights/__tests__/no-key-fallbacks.test.ts index a8f739362..58b93d395 100644 --- a/src/lib/insights/__tests__/no-key-fallbacks.test.ts +++ b/src/lib/insights/__tests__/no-key-fallbacks.test.ts @@ -133,4 +133,77 @@ describe("signal-grounded no-key fallbacks", () => { getNoKeyGeneralStatusText("en"), ); }); + + // v1.28.40 — the deterministic floor now leads verdict-first (meaning before + // the value), matching the warm AI voice a provider user sees on the next + // read. The value + baseline still follow, so grounding is unchanged. + it("leads with an unfavourable-direction verdict before the value (pulse, en)", () => { + const text = getNoKeyPulseStatusText( + "en", + signal({ + current: 72, + baseline: 64, + delta: 8, + outsideNormalSwing: true, + direction: "lower-better", + }), + ); + // Verdict-first: opens on meaning, not the number. + expect(text.startsWith("A little off your usual lately.")).toBe(true); + // The grounded value + baseline still follow the verdict. + expect(text).toContain("72 bpm"); + expect(text).toContain("64 bpm"); + expect(text.toLowerCase()).toContain("usual average"); + }); + + it("leads with a steady verdict when inside the usual swing (weight, en)", () => { + const text = getNoKeyWeightStatusText( + "en", + signal({ + metric: "weight", + current: 80.4, + baseline: 80.3, + delta: 0.1, + outsideNormalSwing: false, + direction: "target-band", + }), + ); + expect(text.startsWith("Steady and much as usual.")).toBe(true); + expect(text).toContain("80.4"); + expect(text.toLowerCase()).toContain("nothing you need to act on"); + }); + + it("leads with a favourable verdict when the move is in the good direction (de)", () => { + const text = getNoKeyMedicationComplianceStatusText( + "de", + signal({ + metric: "adherence", + current: 94, + baseline: 80, + delta: 14, + outsideNormalSwing: true, + direction: "higher-better", + }), + ); + expect(text.startsWith("Das geht in eine gute Richtung.")).toBe(true); + expect(text).toContain("94%"); + }); + + it("opens on the value (no verdict) when there is no usable baseline (bp, de)", () => { + const text = getNoKeyBloodPressureStatusText( + "de", + signal({ + metric: "blood pressure", + current: 128, + baseline: null, + delta: null, + outsideNormalSwing: null, + deltaPct: null, + spread: null, + }), + ); + // No confident verdict without a baseline → the honest value-first opener. + expect(text.startsWith("Dein Blutdruck liegt aktuell bei 128")).toBe(true); + expect(text).not.toContain("Schnitt von"); + }); }); diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index 991eba764..d2f60f0c1 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -3,6 +3,8 @@ import { getBloodPressureSystemPrompt, getBloodPressureUserPrompt, } from "@/lib/ai/prompts/blood-pressure"; +import { openerArchetypeHint } from "@/lib/ai/prompts/opener-archetype"; +import type { Locale } from "@/lib/i18n/config"; import { formatPreviousContextForPrompt, getPreviousInsightContext, @@ -705,6 +707,12 @@ export async function prepareBloodPressureStatusForUser( locale, previousContextBlock, assessmentContextBlock, + // v1.28.40 — rotating opener hint, per (user, metric, day); activates the + // base prompt's verdict-first opener branch on the per-metric card. + openerArchetypeHint( + `${userId}:blood-pressure:${todayKey}`, + locale as Locale, + ), ), snapshotHash, // v1.12.7 — match the archetype cards' 0.45: more cadence entropy while diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index ddbb0c5e2..24140a25d 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -1,5 +1,7 @@ import { prisma } from "@/lib/db"; import { getBmiSystemPrompt, getBmiUserPrompt } from "@/lib/ai/prompts/bmi"; +import { openerArchetypeHint } from "@/lib/ai/prompts/opener-archetype"; +import type { Locale } from "@/lib/i18n/config"; import { classifyBMI } from "@/lib/analytics/classifications"; import { getNoKeyBmiStatusText } from "@/lib/insights/no-key-fallbacks"; import { @@ -407,6 +409,8 @@ export async function prepareBmiStatusForUser( locale, previousContextBlock, assessmentContextBlock, + // v1.28.40 — rotating opener hint, per (user, metric, day). + openerArchetypeHint(`${userId}:bmi:${todayKey}`, locale as Locale), ), snapshotHash, // v1.12.7 — match the archetype cards' 0.45. diff --git a/src/lib/insights/derived/__tests__/derived-assessment.test.ts b/src/lib/insights/derived/__tests__/derived-assessment.test.ts index e20f0a46e..e1df67c5a 100644 --- a/src/lib/insights/derived/__tests__/derived-assessment.test.ts +++ b/src/lib/insights/derived/__tests__/derived-assessment.test.ts @@ -230,6 +230,23 @@ describe("band → meaning enrichment (deterministic fallback)", () => { expect(a!.text.toLowerCase()).toContain("carry you well"); }); + // v1.28.40 — the band-meaning read now OPENS the assessment (verdict-first) + // instead of closing it, so the score card leads with meaning, e.g. + // "A middling day … — your readiness is 64/100 …". + it("leads with the band-meaning verdict BEFORE the score standing (en)", () => { + const a = resolveDeterministicAssessment( + "READINESS", + okDerived(readiness), + "en", + NOW, + ); + // Yellow readiness meaning ("… usual load …") precedes the "64 out of 100". + expect(a!.text.indexOf("usual load")).toBeGreaterThanOrEqual(0); + expect(a!.text.indexOf("usual load")).toBeLessThan( + a!.text.indexOf("out of 100"), + ); + }); + it("names BOTH a carry (green) AND the forward read (de)", () => { const a = resolveDeterministicAssessment( "SLEEP_SCORE", diff --git a/src/lib/insights/derived/derived-assessment.ts b/src/lib/insights/derived/derived-assessment.ts index 4fd9ec7e1..3ba8f2f12 100644 --- a/src/lib/insights/derived/derived-assessment.ts +++ b/src/lib/insights/derived/derived-assessment.ts @@ -396,6 +396,15 @@ export function buildDeterministicScoreAssessment( const standing = bandPhrase(band, locale); const sentences: string[] = []; + + // v1.28.40 — verdict-first: open with the band → "what it means for today" + // read (a closed, non-numeric table) BEFORE the score, so the deterministic + // floor leads with meaning like the warm AI voice, e.g. "A good day to take + // on a little more — your readiness is 72/100 …". No number is introduced + // here, so the grounding posture is unchanged. + const meaning = scoreBandMeaning(metric, band, locale); + if (meaning) sentences.push(meaning); + sentences.push( locale === "de" ? `${capitalise(label)} liegt heute bei ${score} von 100 — ${standing}.` @@ -454,12 +463,11 @@ export function buildDeterministicScoreAssessment( ); } - // v1.22 (W6) — close with a band-conditioned "what it means for today" read - // from the closed deterministic table, so even the provider-less + demo score - // cards carry the forward interpretation the WHOOP/Oura voice does, not just - // the attribution. No number is introduced, so the grounding posture holds. - const meaning = scoreBandMeaning(metric, band, locale); - if (meaning) sentences.push(meaning); + // v1.28.40 — the band-conditioned "what it means for today" read now OPENS the + // assessment (above) instead of closing it, so the score card leads with + // meaning. When no meaning is mapped for this band the card falls back to its + // standing-first shape, which is already verdict-carrying ("… — in a good + // place"). return sentences.join(" "); } diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index b63164015..6cb4bc49b 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -3,6 +3,8 @@ import { getGeneralStatusSystemPrompt, getGeneralStatusUserPrompt, } from "@/lib/ai/prompts/general-status"; +import { openerArchetypeHint } from "@/lib/ai/prompts/opener-archetype"; +import type { Locale } from "@/lib/i18n/config"; import { getBpTargets } from "@/lib/analytics/bp-targets"; import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target"; import { getNoKeyGeneralStatusText } from "@/lib/insights/no-key-fallbacks"; @@ -483,6 +485,8 @@ export async function prepareGeneralStatusForUser( locale, previousContextBlock, assessmentContextBlock, + // v1.28.40 — rotating opener hint, per (user, metric, day). + openerArchetypeHint(`${userId}:general:${todayKey}`, locale as Locale), ), snapshotHash, // v1.12.7 — match the archetype cards' 0.45. diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index 03f4bf3f8..67ecd8ecd 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -3,6 +3,8 @@ import { getMedicationComplianceSystemPrompt, getMedicationComplianceUserPrompt, } from "@/lib/ai/prompts/medication-compliance"; +import { openerArchetypeHint } from "@/lib/ai/prompts/opener-archetype"; +import type { Locale } from "@/lib/i18n/config"; import { buildComplianceMedicationContext, buildMedicationComplianceBundle, @@ -531,6 +533,11 @@ export async function prepareMedicationComplianceStatusForUser( locale, previousContextBlock, assessmentContextBlock, + // v1.28.40 — rotating opener hint, per (user, metric, day). + openerArchetypeHint( + `${userId}:medication-compliance:${todayKey}`, + locale as Locale, + ), ), snapshotHash, // v1.12.7 — match the archetype cards' 0.45. diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index 2ea11308b..2afa69f64 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -47,6 +47,8 @@ import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; import { lookupNormalRange } from "@/lib/insights/derived/norms"; import { buildMetricSignal } from "@/lib/insights/metric-signal"; import { PROMPT_VERSION } from "@/lib/ai/prompts/base-system"; +import { openerArchetypeHint } from "@/lib/ai/prompts/opener-archetype"; +import type { Locale } from "@/lib/i18n/config"; import { getNoKeyGeneralStatusText, getNoKeyMetricStatusText, @@ -480,6 +482,16 @@ export async function generateMetricStatus(args: { }) : undefined; + // v1.28.40 — the rotating opener-archetype hint, keyed per (user, metric, + // day) exactly as `derived-assessment-ai.ts` does for the score cards. This + // activates the base prompt's dormant "Lead per the OPENER HINT … do NOT open + // with the number" branch so the per-metric card opens verdict-first and + // varies run-to-run. No new model call — one arg on the existing completion. + const openerHint = openerArchetypeHint( + `${args.userId}:${scope}:${todayKey}`, + locale as Locale, + ); + const outcome = await runStatusCompletion({ userId: args.userId, cacheAction, @@ -493,6 +505,7 @@ export async function generateMetricStatus(args: { previousContextBlock, contextBlock, interpretationBlock, + openerHint, ), // v1.12.1 (D1) — the phrasing task benefits from a touch more sampling // entropy while the FACTS stay pinned by the snapshot + the diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index 4a90e6c96..32327bc5b 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -1,5 +1,7 @@ import { prisma } from "@/lib/db"; import { getMoodSystemPrompt, getMoodUserPrompt } from "@/lib/ai/prompts/mood"; +import { openerArchetypeHint } from "@/lib/ai/prompts/opener-archetype"; +import type { Locale } from "@/lib/i18n/config"; import { getNoKeyMoodStatusText } from "@/lib/insights/no-key-fallbacks"; import { formatPreviousContextForPrompt, @@ -715,6 +717,8 @@ export async function prepareMoodStatusForUser( locale, previousContextBlock, assessmentContextBlock, + // v1.28.40 — rotating opener hint, per (user, metric, day). + openerArchetypeHint(`${userId}:mood:${todayKey}`, locale as Locale), ), snapshotHash, // v1.12.7 — match the archetype cards' 0.45. diff --git a/src/lib/insights/narrative/__tests__/period-narrative-deterministic.test.ts b/src/lib/insights/narrative/__tests__/period-narrative-deterministic.test.ts index 7747b53ea..3eae6875d 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative-deterministic.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative-deterministic.test.ts @@ -152,6 +152,32 @@ describe("buildDeterministicNarrative", () => { ); }); + // v1.28.40 — the deterministic floor opens with a one-line verdict before the + // delta list, matching the verdict-first AI narrative beside it. + it("opens with a movement verdict before the delta list (en/de)", () => { + const en = buildDeterministicNarrative( + ctx({ metricDeltas: [WEIGHT_DOWN] }), + "en", + ); + expect(en.startsWith("A week of real movement.")).toBe(true); + // The changes sentence + its figures still follow the verdict. + expect(en).toContain("your weight"); + const de = buildDeterministicNarrative( + ctx({ metricDeltas: [WEIGHT_DOWN] }), + "de", + ); + expect(de.startsWith("Eine Woche mit echter Bewegung.")).toBe(true); + }); + + it("opens with a calm verdict when nothing moved (en/de, month)", () => { + expect(buildDeterministicNarrative(ctx({ period: "month" }), "en")).toMatch( + /^A calm month\./, + ); + expect(buildDeterministicNarrative(ctx({ period: "month" }), "de")).toMatch( + /^Ein ruhiger Monat\./, + ); + }); + it("falls back to a prettified label for an unmapped metric type", () => { const text = buildDeterministicNarrative( ctx({ diff --git a/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts b/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts index 0d919d52e..04f0b6bcd 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts @@ -115,7 +115,7 @@ describe("buildNarrativeUserPrompt", () => { expect(prompt).toContain("WEIGHT"); expect(prompt).toContain("ACTIVITY_STEPS ~ SLEEP_DURATION"); expect(prompt).toContain("12 pairs tested"); - expect(NARRATIVE_PROMPT_VERSION).toBe("1.12.0"); + expect(NARRATIVE_PROMPT_VERSION).toBe("1.13.0"); }); }); diff --git a/src/lib/insights/narrative/period-narrative-deterministic.ts b/src/lib/insights/narrative/period-narrative-deterministic.ts index d51651c54..9f85bb259 100644 --- a/src/lib/insights/narrative/period-narrative-deterministic.ts +++ b/src/lib/insights/narrative/period-narrative-deterministic.ts @@ -143,6 +143,30 @@ export function buildDeterministicNarrative( const sentences: string[] = []; const ranked = rankDeltas(context.metricDeltas).slice(0, 3); + + // v1.28.40 — verdict-first: open on a one-line read of the period before the + // delta list, so the deterministic floor leads with meaning like the warm AI + // narrative beside it. No number is introduced — the movers and their figures + // follow in the next sentence. + const periodWordEn = context.period === "week" ? "week" : "month"; + if (ranked.length > 0) { + sentences.push( + locale === "de" + ? context.period === "week" + ? "Eine Woche mit echter Bewegung." + : "Ein Monat mit echter Bewegung." + : `A ${periodWordEn} of real movement.`, + ); + } else { + sentences.push( + locale === "de" + ? context.period === "week" + ? "Eine ruhige Woche." + : "Ein ruhiger Monat." + : `A calm ${periodWordEn}.`, + ); + } + if (ranked.length > 0) { const phrases = ranked.map((d) => deltaPhrase(d, locale)); const joined = diff --git a/src/lib/insights/narrative/period-narrative-generate.ts b/src/lib/insights/narrative/period-narrative-generate.ts index dcc1d09f5..3d7df2654 100644 --- a/src/lib/insights/narrative/period-narrative-generate.ts +++ b/src/lib/insights/narrative/period-narrative-generate.ts @@ -52,7 +52,7 @@ import { * prompt below changes so the cross-feature attribution aggregator can slice * quality per (provider × prompt) and a prompt change is observable. */ -export const NARRATIVE_PROMPT_VERSION = "1.12.0" as const; +export const NARRATIVE_PROMPT_VERSION = "1.13.0" as const; /** A narrative cached this recently is served without regenerating. */ const NARRATIVE_FRESH_MS = 20 * 60 * 60 * 1000; @@ -117,7 +117,7 @@ Hard rules: - 2 to 4 short sentences. Plain text only — no markdown, no headings, no bullet points, no emojis. - If the context is thin, say plainly that there is little to report this period rather than inventing detail. -${composeSharedContracts("en", ["toneContract", "grounding", "safetyGlp1", "safetyAcute", "metricIdentifierBan", "forbiddenFiller", "formattingContract"])}`; +${composeSharedContracts("en", ["toneContract", "grounding", "openingShape", "safetyGlp1", "safetyAcute", "metricIdentifierBan", "forbiddenFiller", "formattingContract"])}`; const SYSTEM_PROMPT_DE = `Du fasst den Gesundheits-Tracking-ZEITRAUM einer Person (eine Woche oder einen Monat) für diese Person zusammen. Prompt-Version: ${NARRATIVE_PROMPT_VERSION}. @@ -131,7 +131,7 @@ Feste Regeln: - 2 bis 4 kurze Sätze. Nur Klartext — kein Markdown, keine Überschriften, keine Aufzählungen, keine Emojis. - Wenn der Kontext dünn ist, sage klar, dass es in diesem Zeitraum wenig zu berichten gibt, statt Details zu erfinden. -${composeSharedContracts("de", ["toneContract", "grounding", "safetyGlp1", "safetyAcute", "metricIdentifierBan", "forbiddenFiller", "formattingContract"])}`; +${composeSharedContracts("de", ["toneContract", "grounding", "openingShape", "safetyGlp1", "safetyAcute", "metricIdentifierBan", "forbiddenFiller", "formattingContract"])}`; /** * Test-only view of the composed system prompts (incl. the appended shared diff --git a/src/lib/insights/no-key-fallbacks.ts b/src/lib/insights/no-key-fallbacks.ts index 323fba9d5..9a3cf8689 100644 --- a/src/lib/insights/no-key-fallbacks.ts +++ b/src/lib/insights/no-key-fallbacks.ts @@ -65,11 +65,58 @@ function capitalise(s: string): string { } /** - * Compose a warm, grounded deterministic line from a metric signal. Names - * the current value, places it against the user's own baseline, and ends - * with one pointer (when the value is outside their usual swing) or an - * honest "nothing to act on" (when it sits in range). Returns null when the - * signal carries no current value — the caller then uses the generic tip. + * v1.28.40 — the verdict-first opener for the deterministic floor. A tiny + * CLOSED vocabulary keyed by (direction, outsideNormalSwing) so the line leads + * with MEANING before the number, matching the warm AI voice above it. It + * introduces no new figure and no new claim — the numbers still come from the + * value sentence right after — so grounding is untouched. Returns null when the + * signal is too thin to state a confident read (no baseline / no delta), in + * which case the line simply opens on the value as before. + */ +function verdictLead( + signal: MetricSignal, + locale: InsightLocale, +): string | null { + // Inside the user's own usual swing → a steady, reassuring verdict. + if (signal.outsideNormalSwing === false) { + return locale === "de" + ? "Stabil und wie gewohnt" + : "Steady and much as usual"; + } + // Outside the usual swing → is the move in a favourable or unfavourable + // direction? For a target-band metric the direction alone can't say, so it + // stays a neutral "worth a look" verdict. + if ( + signal.outsideNormalSwing === true && + signal.delta !== null && + signal.delta !== 0 + ) { + const favourable = + signal.direction === "higher-better" + ? signal.delta > 0 + : signal.direction === "lower-better" + ? signal.delta < 0 + : null; + if (favourable === true) { + return locale === "de" + ? "Das geht in eine gute Richtung" + : "This is moving in a good direction"; + } + return locale === "de" + ? "Zuletzt etwas außerhalb deines üblichen Rahmens" + : "A little off your usual lately"; + } + // No baseline / no usable delta → no confident verdict; open on the value. + return null; +} + +/** + * Compose a warm, grounded deterministic line from a metric signal. Leads with + * a plain-words verdict (meaning first), then names the current value placed + * against the user's own baseline, and ends with one pointer (when the value is + * outside their usual swing) or an honest "nothing to act on" (when it sits in + * range). Returns null when the signal carries no current value — the caller + * then uses the generic tip. */ function composeGroundedFallback( signal: MetricSignal | null | undefined, @@ -83,7 +130,12 @@ function composeGroundedFallback( const sentences: string[] = []; - // 1) Lead with the current value, placed against the user's own baseline. + // 0) Verdict-first: lead with what it MEANS in plain words before the number, + // so the floor reads like the warm AI voice and never opens on a bare value. + const lead = verdictLead(signal, locale); + if (lead) sentences.push(`${lead}.`); + + // 1) Then the current value, placed against the user's own baseline. if ( signal.baseline !== null && signal.delta !== null && diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index b0862cdfd..bb776a1b8 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -3,6 +3,8 @@ import { getPulseSystemPrompt, getPulseUserPrompt, } from "@/lib/ai/prompts/pulse"; +import { openerArchetypeHint } from "@/lib/ai/prompts/opener-archetype"; +import type { Locale } from "@/lib/i18n/config"; import { formatPreviousContextForPrompt, getPreviousInsightContext, @@ -455,6 +457,8 @@ export async function preparePulseStatusForUser( locale, previousContextBlock, assessmentContextBlock, + // v1.28.40 — rotating opener hint, per (user, metric, day). + openerArchetypeHint(`${userId}:pulse:${todayKey}`, locale as Locale), ), snapshotHash, // v1.12.7 — match the archetype cards' 0.45. diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index 637abe119..023909b7e 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -3,6 +3,8 @@ import { getWeightSystemPrompt, getWeightUserPrompt, } from "@/lib/ai/prompts/weight"; +import { openerArchetypeHint } from "@/lib/ai/prompts/opener-archetype"; +import type { Locale } from "@/lib/i18n/config"; import { significantPearsonCorrelation, type PairedPoint, @@ -611,6 +613,8 @@ export async function prepareWeightStatusForUser( locale, previousContextBlock, assessmentContextBlock, + // v1.28.40 — rotating opener hint, per (user, metric, day). + openerArchetypeHint(`${userId}:weight:${todayKey}`, locale as Locale), ), snapshotHash, // v1.12.7 — match the archetype cards' 0.45.