chore(release): v1.28.40 — insight assessments lead with meaning, not a number

The per-metric insight assessments opened on the current value and read as
a data readout, while the overview texts lead with the day's verdict and
land the warm, motivating tone. Diagnosis (campaign insights-copy audit +
verification): identical warm persona/contract fragments, but the
per-metric USER prompt instructed number-first while the overview
instructed verdict-first, and the opener-hint rotation was wired into
briefing/scores/Coach but never the per-metric path (so the base prompt's
'do not open with the number' clause stayed dormant there).

Rewrote the seven per-metric assessment user prompts (generic archetype +
six bespoke: BP/weight/pulse/BMI/mood/compliance + overall) verdict-first
in EN+DE — meaning first, the number right after as support; grounding,
non-diagnostic framing, no-vendor and no-markdown all preserved (only
clause order changed). Wired openerArchetypeHint into metric-status and
the bespoke generators. Factored a shared openingShape contract into
shared-contracts so overview/per-metric/scores can't drift again, with a
coverage test. Lifted the three deterministic first-paint fallbacks to
verdict-led. Prompt-contract regression tests added. No new model calls,
token budget flat.
This commit is contained in:
Marc-André Bombeck committed 2026-07-16 02:14:46 +02:00
1 parent 273ead0304
commit 461c1cfe09
33 files changed
+501 -53

No files matched your search

@@ -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.