Merge pull request #497 from MBombeck/release/v1.28.40

release/v1.28.40 — insight assessments lead with meaning, not a number
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-16 02:25:23 +02:00
commit 065ceb0383
33 files changed
+501 -53

No files matched your search

+13
View File
@@ -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:
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
@@ -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")!;
@@ -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
+13 -1
View File
@@ -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.`,
+10 -4
View File
@@ -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}`;
}
+10 -4
View File
@@ -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}`;
}
+10 -4
View File
@@ -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}`;
}
+10 -1
View File
@@ -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
+10 -4
View File
@@ -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}`;
}
+16 -4
View File
@@ -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}`;
}
+10 -4
View File
@@ -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}`;
}
+10 -4
View File
@@ -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}`;
}
+17
View File
@@ -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,
+10 -4
View File
@@ -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}`;
}
@@ -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");
});
});
@@ -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
+4
View File
@@ -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.
@@ -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",
+14 -6
View File
@@ -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(" ");
}
+4
View File
@@ -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.
@@ -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.
+13
View File
@@ -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
+4
View File
@@ -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.
@@ -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({
@@ -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");
});
});
@@ -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 =
@@ -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
+58 -6
View File
@@ -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 &&
+4
View File
@@ -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.
+4
View File
@@ -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.