mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
33 files changed
+501
-53
No files matched your search
@@ -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,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
@@ -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
@@ -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
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(" ");
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user