From ed049661b7a0ff2e34e42e4e06b40515ec59f2d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 13:14:23 +0200 Subject: [PATCH 1/5] feat(insights): bring the biomarker card onto the shared assessment contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-biomarker assessment was the last surface in the family building its prompt inline: a five-line scaffold that skipped the base system prompt, took no opener hint, and instructed the model to "state the current value" — so the one card where a bare number reads most like a verdict was value-first by instruction while every sibling had moved to meaning-first. It now composes exactly as the seven bespoke cards do. The new prompt module carries a METRIC section that states the one thing this card needs and the others do not: its snapshot has no signal block, so the read comes from the pre-computed range placement plus the direction across draws. Also point both archetype-driven cards at a registered budget. The generic metric card and the biomarker card each carried temperature/maxTokens inline at 0.45/1000, so the registry that documents every surface's ceiling was silently wrong about the two widest assessment surfaces. The single-marker no-key fallback replaces the multi-metric overview tip the card used to render — text about watching several metrics at once, on a card showing one lab value. --- src/lib/ai/ai-budgets.ts | 17 + src/lib/ai/prompts/biomarker.ts | 90 +++++ src/lib/insights/biomarker-status.ts | 81 ++--- .../eval/__tests__/tone-harness.test.ts | 317 ++++++++++++++++ src/lib/insights/eval/prompt-surfaces.ts | 173 +++++++++ src/lib/insights/eval/tone-rules.ts | 341 ++++++++++++++++++ src/lib/insights/metric-status.ts | 9 +- src/lib/insights/no-key-fallbacks.ts | 93 +++-- 8 files changed, 1040 insertions(+), 81 deletions(-) create mode 100644 src/lib/ai/prompts/biomarker.ts create mode 100644 src/lib/insights/eval/__tests__/tone-harness.test.ts create mode 100644 src/lib/insights/eval/prompt-surfaces.ts create mode 100644 src/lib/insights/eval/tone-rules.ts diff --git a/src/lib/ai/ai-budgets.ts b/src/lib/ai/ai-budgets.ts index c12be7d72..221bfb654 100644 --- a/src/lib/ai/ai-budgets.ts +++ b/src/lib/ai/ai-budgets.ts @@ -88,6 +88,23 @@ export const AI_BUDGETS = { */ status: { temperature: 0.3, maxTokens: 250 }, + /** + * Archetype-driven per-metric assessment — the generic metric card + * (`metric-status.ts`) and the per-biomarker card (`biomarker-status.ts`). + * Same `{ "summary": "..." }` output contract as `status`, but these two + * surfaces compose a longer prompt (interpretation block, opener hint, + * previous-assessment context) and were pinned at temperature 0.45 to buy + * cadence variety while the snapshot keeps the facts fixed. + * + * Both call sites carried those numbers inline — 0.45 / 1000 — so the + * registry claimed to be the single source of truth while the two widest + * assessment surfaces quietly ran at 4x the nominal ceiling. This entry + * makes that shape explicit and auditable. 700 tokens is still generous + * headroom over the 30-60-word contract; the old 1000 was never sized to + * anything. + */ + statusArchetype: { temperature: 0.45, maxTokens: 700 }, + /** * Batched per-metric status assessment (v1.18.7 HIGH-1) — ONE call that * returns a `{ perMetric: { bp, weight, pulse, bmi, mood, compliance, diff --git a/src/lib/ai/prompts/biomarker.ts b/src/lib/ai/prompts/biomarker.ts new file mode 100644 index 000000000..364ff1ed7 --- /dev/null +++ b/src/lib/ai/prompts/biomarker.ts @@ -0,0 +1,90 @@ +import type { Locale } from "@/lib/i18n/config"; +import { getBaseSystemPromptBody } from "./base-system"; +import { instructionLocale, withOutputLanguage } from "./output-language"; + +/** + * Per-biomarker assessment prompt. + * + * This surface was the last one in the assessment family to build its prompt + * inline: a self-contained five-line scaffold that skipped the base system + * prompt entirely, took no opener hint, and instructed the model to "state the + * current value" — value-first by instruction, on the one card where a number + * out of context reads most like a verdict. Everything the sibling cards get + * from the shared base — the opening shape, the earned-warmth tone contract, + * the non-diagnostic framing, the acute/GLP-1 safety contracts, the forbidden + * filler list, the JSON output clause — simply did not apply here. + * + * It now composes exactly as the seven bespoke cards do: the shared base body, + * one METRIC section describing this card's snapshot, and the output-language + * directive appended last by `withOutputLanguage`. + * + * The METRIC section carries one job the other cards do not need: this + * snapshot has NO `signal` block. The base DATA section is written around that + * block, so the section below states the substitution explicitly rather than + * leaving the model to reconcile the two. `latest.rangeStatus` plus the + * ascending `series` are what this card reads its comparison from. + */ + +const BIOMARKER_SECTION_DE = `METRIK — LABORWERT: +- ABWEICHENDE DATENFORM: Dieser Snapshot trägt KEINEN signal-Block. Die Felder der Basisanweisung, die auf signal verweisen, gelten hier nicht. Stattdessen: marker (name, unit, referenceRange), dataCoverage, latest (value, takenAt, rangeStatus) und series (aufsteigend, je { day, value }). +- Führe aus latest.rangeStatus — der vorab berechneten Einordnung gegen den Referenzbereich (in/unter/über dem Bereich, oder ohne Bereich) — und aus der Tendenz über series. Beides ist bereits bestimmt; nenne es, rechne es NICHT neu. +- Die eigene Historie führt: Wie der Wert gegenüber den letzten Abnahmen dieser Person steht, ist die Aussage. Der Referenzbereich ist ein grober sekundärer Anker, kein Urteil — ein Wert knapp außerhalb des Bereichs, der über Jahre stabil ist, ist etwas anderes als derselbe Wert nach einem klaren Sprung. +- Stabilität ist selbst ein Befund: Bewegt sich der Wert über die Abnahmen kaum, benenne die Konstanz — das ist bei einem Laborwert die häufigste und nützlichste Aussage. +- Einzelne Abnahmen schwanken (Labor, Tageszeit, Nüchternheit). Aus ZWEI Punkten keinen Trend bauen; ist die Historie kurz, ehrlich sagen, dass es für eine Tendenz noch zu wenig ist. +- KEINE Diagnose, keine Ursachenzuschreibung, keine Medikamenten- oder Supplement-Empfehlung. Bei deutlich auffälligen Werten neutral auf die ärztliche Einordnung verweisen — das ist der Abschluss, nicht ein erfundener Selbsthilfe-Schritt. +- Eine Botschaft: Schließe nur dann mit EINEM machbaren Schritt, wenn der Befund wirklich einen hergibt (z. B. den Wert bei der nächsten Kontrolle mitnehmen). Ist der Wert stabil und im Rahmen, erkenne das an, statt einen Schritt zu erzwingen.`; + +const BIOMARKER_SECTION_EN = `METRIC — LAB MARKER: +- DIFFERENT DATA SHAPE: this snapshot carries NO signal block. The base instruction's fields that reference signal do not apply here. Instead: marker (name, unit, referenceRange), dataCoverage, latest (value, takenAt, rangeStatus) and series (ascending, each { day, value }). +- Lead from latest.rangeStatus — the pre-computed placement against the reference range (inside/below/above it, or no range on file) — and from the direction across series. Both are already determined; STATE them, do NOT recompute them. +- Their own history leads: how this value sits against this person's own recent draws is the message. The reference range is a coarse secondary anchor, not a verdict — a value slightly outside the band that has held steady for years is a different story from the same value after a clear step. +- Steadiness is itself a finding: when the value barely moves across draws, name that constancy — for a lab marker it is the most common and most useful thing to say. +- Single draws vary (lab, time of day, fasting state). Do not build a trend out of TWO points; when the history is short, say honestly that it is too little for a direction yet. +- NO diagnosis, no attribution of cause, no medication or supplement recommendation. For clearly notable values, refer neutrally to a clinician for interpretation — that is the close, not an invented self-help step. +- One message: close with ONE doable step only when the finding genuinely implies one (e.g. take the value to the next check-up). When it is steady and in range, acknowledge that rather than manufacturing a step.`; + +export function getBiomarkerSystemPrompt( + markerName: string, + locale: Locale, +): string { + // fr/es/it/pl compose the ENGLISH body (the base prompt names their + // language and appends their own directive); only de takes the German one. + const section = + instructionLocale(locale) === "en" + ? BIOMARKER_SECTION_EN + : BIOMARKER_SECTION_DE; + const marker = + instructionLocale(locale) === "en" + ? `The marker under assessment is "${markerName}".` + : `Der zu beurteilende Marker ist „${markerName}".`; + return withOutputLanguage( + `${getBaseSystemPromptBody(locale)} + +${section} +- ${marker}`, + locale, + ); +} + +export function getBiomarkerUserPrompt( + snapshotJson: string, + todayKey: string, + locale: Locale, + /** v1.28.40 — rotating opener-archetype hint; see metric-archetypes.ts. */ + openerHint?: string, +): string { + const openerLine = + openerHint && openerHint.trim().length > 0 + ? `\nOPENER HINT: ${openerHint}` + : ""; + if (instructionLocale(locale) === "en") { + return `Date: ${todayKey}${openerLine} +Write one short assessment of this person's lab marker. Open with how the marker SITS in plain words — the read, not the number (e.g. "holding steady just inside the band", "sitting a step above where it usually runs") — then bring in ONE concrete figure from the snapshot right after as support; never lead with the value. Place it against this person's own recent draws first and the reference range only as a coarse anchor. 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 number of draws and their recency. + +${snapshotJson}`; + } + return `Datum: ${todayKey}${openerLine} +Schreibe eine kurze Einschätzung zu diesem Laborwert. Beginne mit der EINORDNUNG in klaren Worten — wie der Marker steht, nicht der Zahl (z. B. "hält sich stabil knapp innerhalb des Bereichs", "liegt einen Schritt über dem, wo er sonst läuft") — und bring danach EINE konkrete Zahl aus dem Snapshot als Beleg; führe nie mit dem Wert. Ordne ihn zuerst gegen die eigenen letzten Abnahmen dieser Person ein, den Referenzbereich nur als groben Anker. 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 Anzahl und Aktualität der Abnahmen ableiten. + +${snapshotJson}`; +} diff --git a/src/lib/insights/biomarker-status.ts b/src/lib/insights/biomarker-status.ts index 0822b69a8..29f2214ce 100644 --- a/src/lib/insights/biomarker-status.ts +++ b/src/lib/insights/biomarker-status.ts @@ -22,13 +22,15 @@ */ import { prisma } from "@/lib/db"; import { PROMPT_VERSION } from "@/lib/ai/prompts/base-system"; +import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { - instructionLocale, - withOutputLanguage, -} from "@/lib/ai/prompts/output-language"; + getBiomarkerSystemPrompt, + getBiomarkerUserPrompt, +} from "@/lib/ai/prompts/biomarker"; +import { openerArchetypeHint } from "@/lib/ai/prompts/opener-archetype"; import type { Locale } from "@/lib/i18n/config"; import { classifyReferenceRange } from "@/lib/labs/reference-range"; -import { getNoKeyGeneralStatusText } from "@/lib/insights/no-key-fallbacks"; +import { getNoKeyBiomarkerStatusText } from "@/lib/insights/no-key-fallbacks"; import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { @@ -85,51 +87,6 @@ function insufficient(): BiomarkerStatusResult { }; } -/** - * Unlike every other assessment prompt in the family, this one does NOT - * compose `getBaseSystemPrompt` — it is a self-contained inline scaffold. So - * the reader's output-language directive, which the base prompt would have - * carried, has to be appended here. When this prompt moves onto the shared - * base scaffold the append collapses into the shared path and this call goes - * away; until then it is the only thing making a non-de/en reader's language - * explicit on this surface. - * - * The parameter is the full `Locale` rather than `SupportedLocale` so the - * routing is correct the moment the pipeline stops collapsing fr/es/it/pl on - * the way in; today's callers still pass a narrowed locale. - */ -function getSystemPrompt(locale: Locale, markerName: string): string { - if (instructionLocale(locale) === "de") { - return [ - `Du bist ein vorsichtiger, faktenbasierter Gesundheitsbegleiter und beurteilst den Laborwert „${markerName}" einer Person.`, - "Beziehe dich AUSSCHLIESSLICH auf die im Snapshot gelieferten Zahlen. Erfinde keine Werte, keine Diagnosen und keine Medikamente.", - "Schreibe 2 bis 4 ruhige Sätze in klarer Alltagssprache: nenne den aktuellen Wert und seinen Bezug zum Referenzbereich, ordne die Tendenz über die letzten Messungen ein und gib höchstens einen sachlichen Hinweis.", - "Keine Panikmache, keine Marketing-Sprache, keine Befehle. Bei auffälligen Werten verweise neutral auf eine ärztliche Einordnung.", - 'Antworte als JSON-Objekt der Form { "summary": "…" } ohne weiteren Text.', - ].join(" "); - } - return withOutputLanguage( - [ - `You are a careful, fact-based health companion assessing a person's lab marker "${markerName}".`, - "Use ONLY the figures provided in the snapshot. Do not invent values, diagnoses, or medications.", - "Write 2 to 4 calm sentences in plain language: state the current value and how it sits against the reference range, place the trend across recent readings, and add at most one factual pointer.", - "No alarmism, no marketing tone, no commands. For notably out-of-range values, refer neutrally to a clinician for interpretation.", - 'Respond as a JSON object of the form { "summary": "…" } with no other text.', - ].join(" "), - locale, - ); -} - -function getUserPrompt( - snapshotJson: string, - todayKey: string, - locale: Locale, -): string { - return instructionLocale(locale) === "de" - ? `Heutiges Datum: ${todayKey}. Beurteile den folgenden Laborwert-Snapshot:\n${snapshotJson}` - : `Today's date: ${todayKey}. Assess the following lab-marker snapshot:\n${snapshotJson}`; -} - /** * Generate (or read from cache) the assessment for one biomarker. Mirrors * the metric generator's contract: @@ -235,7 +192,7 @@ export async function generateBiomarkerStatus(args: { if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { return { hasProvider: false, - text: getNoKeyGeneralStatusText(locale), + text: getNoKeyBiomarkerStatusText(locale, marker.name), cached: true, updatedAt: null, }; @@ -341,16 +298,26 @@ export async function generateBiomarkerStatus(args: { userId: args.userId, cacheAction, consentSurface: "insights", - systemPrompt: getSystemPrompt(locale, marker.name), - userPrompt: getUserPrompt(snapshotJson, todayKey, locale), - temperature: 0.45, - maxTokens: 1000, + systemPrompt: getBiomarkerSystemPrompt(marker.name, locale as Locale), + userPrompt: getBiomarkerUserPrompt( + snapshotJson, + todayKey, + locale as Locale, + // The rotating opener hint the sibling cards carry, keyed per + // (user, marker, day) so consecutive markers do not all open alike. + openerArchetypeHint( + `${args.userId}:biomarker:${marker.id}:${todayKey}`, + locale as Locale, + ), + ), + temperature: AI_BUDGETS.statusArchetype.temperature, + maxTokens: AI_BUDGETS.statusArchetype.maxTokens, }); if (outcome.kind === "none") { return { hasProvider: false, - text: getNoKeyGeneralStatusText(locale), + text: getNoKeyBiomarkerStatusText(locale, marker.name), cached: true, updatedAt: null, }; @@ -361,7 +328,7 @@ export async function generateBiomarkerStatus(args: { reason: outcome.kind, userId: args.userId, todayKey, - stubText: getNoKeyGeneralStatusText(locale), + stubText: getNoKeyBiomarkerStatusText(locale, marker.name), }); } @@ -377,7 +344,7 @@ export async function generateBiomarkerStatus(args: { reason: "screened", userId: args.userId, todayKey, - stubText: getNoKeyGeneralStatusText(locale), + stubText: getNoKeyBiomarkerStatusText(locale, marker.name), }); } const text = screened.text; diff --git a/src/lib/insights/eval/__tests__/tone-harness.test.ts b/src/lib/insights/eval/__tests__/tone-harness.test.ts new file mode 100644 index 000000000..b07e9033a --- /dev/null +++ b/src/lib/insights/eval/__tests__/tone-harness.test.ts @@ -0,0 +1,317 @@ +import { describe, expect, it } from "vitest"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { locales, type Locale } from "@/lib/i18n/config"; +import { openingShape } from "@/lib/ai/prompts/shared-contracts"; +import { + instructionLocale, + outputLanguageDirective, + targetLanguageName, +} from "@/lib/ai/prompts/output-language"; +import type { MetricSignal } from "@/lib/insights/metric-signal"; +import { + getNoKeyBiomarkerStatusText, + getNoKeyBloodPressureStatusText, + getNoKeyBmiStatusText, + getNoKeyGeneralStatusText, + getNoKeyMedicationComplianceStatusText, + getNoKeyMetricStatusText, + getNoKeyMoodStatusText, + getNoKeyPulseStatusText, + getNoKeyWeightStatusText, +} from "@/lib/insights/no-key-fallbacks"; + +import { ASSESSMENT_SURFACES } from "../prompt-surfaces"; +import { + checkFallbackTone, + checkLocaleIntegrity, + checkPromptToneContract, + findUnnegatedMatch, + formatViolations, + hasUnnegatedMatch, +} from "../tone-rules"; + +/** + * The per-metric tone harness. + * + * Runs in the normal suite with no provider, no network and no database: it + * grades the assembled prompt text and the deterministic fallback text, which + * is where every tone regression this repo has shipped was first visible. + * + * Three regression classes are blocking here: + * + * 1. a surface stops leading with meaning (it drops the shared opening + * contract, or its user prompt loses the meaning-first instruction), + * 2. a locale collapses (fr/es/it/pl fall back to the German body, or lose + * the reply-language directive), + * 3. a prompt reintroduces a value-first instruction. + * + * A prompt change that legitimately shifts tone updates the rules in the SAME + * change — which is the review moment the harness exists to force. + */ + +/** The four locales that ride the English body plus their own directive. */ +const RIDERS: readonly Locale[] = ["fr", "es", "it", "pl"]; + +describe("tone harness — prompt surfaces", () => { + describe.each(ASSESSMENT_SURFACES)("$name", (surface) => { + it.each(locales)( + "%s leads with meaning and never value-first", + (locale) => { + const body = instructionLocale(locale); + const violations = checkPromptToneContract({ + systemPrompt: surface.system(locale), + userPrompt: surface.user(locale), + instructionBody: body, + openingShapeFragment: openingShape[body], + }); + expect( + violations, + formatViolations(`${surface.name}/${locale}`, violations), + ).toEqual([]); + }, + ); + + it.each(RIDERS)("%s composes its own language, not German", (locale) => { + const violations = checkLocaleIntegrity({ + systemPrompt: surface.system(locale), + directive: outputLanguageDirective(locale), + languageName: targetLanguageName(locale), + }); + expect( + violations, + formatViolations(`${surface.name}/${locale}`, violations), + ).toEqual([]); + }); + }); + + /** + * Coverage guard. A new `get*SystemPrompt` under `src/lib/ai/prompts/` that + * is not registered in `ASSESSMENT_SURFACES` is a surface nothing above + * grades — exactly how the biomarker card stayed uncovered while every + * sibling was pinned. Modules that are not per-metric assessment surfaces + * are named here explicitly, so exempting one is a visible edit. + */ + it("every assessment prompt module is registered in the harness", () => { + const dir = join(process.cwd(), "src/lib/ai/prompts"); + const NON_ASSESSMENT = new Set([ + // Composed INTO the assessment prompts rather than being one. + "base-system.ts", + "shared-contracts.ts", + "output-language.ts", + "opener-archetype.ts", + "safety-contracts.ts", + "interpretation-block.ts", + "compact-sections.ts", + // Other AI surfaces with their own contracts and their own coverage. + "insight-generator.ts", + "insight-system-prompt.ts", + "native-prompts.ts", + "status-batch.ts", + ]); + const modules = readdirSync(dir).filter( + (f) => + f.endsWith(".ts") && + !f.endsWith(".d.ts") && + !NON_ASSESSMENT.has(f) && + /export function get\w*SystemPrompt/.test( + readFileSync(join(dir, f), "utf8"), + ), + ); + const registered = new Set( + ASSESSMENT_SURFACES.map((s) => + `${s.name}.ts`.replace("metric-archetype.ts", "metric-archetypes.ts"), + ), + ); + const unregistered = modules.filter((m) => !registered.has(m)); + expect( + unregistered, + `assessment prompt modules with no harness entry: ${unregistered.join(", ")}`, + ).toEqual([]); + }); +}); + +describe("tone harness — deterministic fallbacks", () => { + function signal(over: Partial): MetricSignal { + return { + metric: "steps", + unit: "steps", + current: 8200, + currentWindowDays: 7, + baseline: 7800, + baselineLabel: "your 30-day average", + delta: 400, + deltaPct: 5.1, + spread: 900, + outsideNormalSwing: false, + direction: "higher-better", + n: 21, + newestDaysAgo: 0, + } as unknown as MetricSignal; + } + + /** + * `expectMeaningFirst: false` on the last case is deliberate and load-bearing: + * with no baseline and no delta there is NO read the data supports, so the + * line honestly opens on the value instead of manufacturing a verdict. + */ + const GROUNDED = [ + { + name: "steady in range", + expectMeaningFirst: true, + build: (l: Locale) => + getNoKeyPulseStatusText(l, signal({ outsideNormalSwing: false })), + }, + { + name: "drifting the unfavourable way", + expectMeaningFirst: true, + build: (l: Locale) => + getNoKeyBloodPressureStatusText( + l, + signal({ + current: 138, + baseline: 132, + delta: 6, + outsideNormalSwing: true, + direction: "lower-better", + }), + ), + }, + { + name: "earned win", + expectMeaningFirst: true, + build: (l: Locale) => + getNoKeyMetricStatusText( + l, + signal({ delta: 2400, outsideNormalSwing: true }), + ), + }, + { + name: "no baseline — no verdict the data supports", + expectMeaningFirst: false, + build: (l: Locale) => + getNoKeyWeightStatusText( + l, + signal({ baseline: null, delta: null, outsideNormalSwing: null }), + ), + }, + ] as const; + + describe.each(GROUNDED)("$name", ({ build, expectMeaningFirst }) => { + it.each(locales)("%s stays in voice", (locale) => { + const text = build(locale); + const violations = checkFallbackTone({ text, expectMeaningFirst }); + expect(violations, formatViolations(locale, violations)).toEqual([]); + }); + }); + + /** + * The no-signal floors. These run when there is nothing to ground against, + * so they may not imply any read at all — but they still have to open on + * meaning rather than on a clinical instruction list, which is what they + * were before this pass. + */ + const FLOORS: readonly { + name: string; + build: (l: Locale) => string; + }[] = [ + { name: "general", build: (l) => getNoKeyGeneralStatusText(l) }, + { + name: "biomarker", + build: (l) => getNoKeyBiomarkerStatusText(l, "Marker"), + }, + { + name: "blood-pressure", + build: (l) => getNoKeyBloodPressureStatusText(l), + }, + { name: "weight", build: (l) => getNoKeyWeightStatusText(l) }, + { name: "pulse", build: (l) => getNoKeyPulseStatusText(l) }, + { name: "bmi", build: (l) => getNoKeyBmiStatusText(l) }, + { name: "mood", build: (l) => getNoKeyMoodStatusText(l) }, + { + name: "medication-compliance", + build: (l) => getNoKeyMedicationComplianceStatusText(l), + }, + ]; + + describe.each(FLOORS)("$name floor", ({ build }) => { + it.each(locales)("%s opens on the read, not on an order", (locale) => { + const text = build(locale); + const violations = checkFallbackTone({ text, expectMeaningFirst: true }); + expect(violations, formatViolations(locale, violations)).toEqual([]); + + // The floor states plainly that no assessment is available — it must not + // dress the absence up as one. + expect(text).toMatch( + locale === "de" ? /keine Einschätzung vor/ : /No assessment/, + ); + }); + }); +}); + +describe("tone harness — the rules themselves", () => { + it("catches an un-negated value-first instruction", () => { + expect( + hasUnnegatedMatch( + "Write 2 to 4 calm sentences: state the current value and how it sits.", + /state the current value/i, + ), + ).toBe(true); + }); + + it("does not fire on the prompts that BAN the value-first opener", () => { + expect( + hasUnnegatedMatch( + "Lead per the OPENER HINT — do NOT always open with the number.", + /(?:open|lead|start|begin)(?:s|ing)? with the (?:number|value|reading|figure)/i, + ), + ).toBe(false); + expect( + hasUnnegatedMatch( + "bring in ONE concrete number right after as support; never lead with the value.", + /(?:open|lead|start|begin)(?:s|ing)? with the (?:number|value|reading|figure)/i, + ), + ).toBe(false); + }); + + it("reports the offending excerpt so a red run reads as a diff", () => { + const hit = findUnnegatedMatch( + "…in plain language: state the current value and how it sits against…", + /state the current value/i, + ); + expect(hit).toContain("state the current value"); + }); + + it("flags a value-led fallback opener", () => { + const violations = checkFallbackTone({ + text: "Your resting pulse is 61 bpm right now. That is your usual range.", + expectMeaningFirst: true, + }); + expect(violations.map((v) => v.rule)).toContain( + "fallback-leads-with-meaning", + ); + }); + + it("flags false cheer and exclamation in a fallback", () => { + const violations = checkFallbackTone({ + text: "Steady and much as usual. Great job, keep it up!", + expectMeaningFirst: true, + }); + const rules = violations.map((v) => v.rule); + expect(rules).toContain("fallback-no-false-cheer"); + expect(rules).toContain("fallback-no-exclamation"); + }); + + it("flags a German body leak on a rider locale", () => { + const violations = checkLocaleIntegrity({ + systemPrompt: + "DEINE DATENGRUNDLAGE (graded snapshot): … AUSGABEFORMAT: Antworte ausschließlich mit JSON.", + directive: "OUTPUT LANGUAGE: Réponds en français.", + languageName: "French", + }); + expect(violations.map((v) => v.rule)).toContain( + "locale-no-german-body-leak", + ); + }); +}); diff --git a/src/lib/insights/eval/prompt-surfaces.ts b/src/lib/insights/eval/prompt-surfaces.ts new file mode 100644 index 000000000..b26cc61ba --- /dev/null +++ b/src/lib/insights/eval/prompt-surfaces.ts @@ -0,0 +1,173 @@ +/** + * The assessment surfaces the tone harness grades, and the committed fixture + * arguments it builds them from. + * + * One registry, so a NEW assessment surface is one entry away from being + * covered — and so a surface that is added without an entry is visible as a + * gap rather than as silence. The count assertion in the harness test is the + * thing that makes that true: adding a `get*SystemPrompt` module without + * registering it here fails the suite. + * + * The fixture arguments are deliberately inert placeholders. These rules grade + * the INSTRUCTION text, not the snapshot, so a realistic snapshot would only + * add noise (and a real-looking one would put fabricated health figures in the + * repo). The snapshot token is a literal. + */ +import type { Locale } from "@/lib/i18n/config"; + +import { + getBiomarkerSystemPrompt, + getBiomarkerUserPrompt, +} from "@/lib/ai/prompts/biomarker"; +import { + getBloodPressureSystemPrompt, + getBloodPressureUserPrompt, +} from "@/lib/ai/prompts/blood-pressure"; +import { getBmiSystemPrompt, getBmiUserPrompt } from "@/lib/ai/prompts/bmi"; +import { + getGeneralStatusSystemPrompt, + getGeneralStatusUserPrompt, +} from "@/lib/ai/prompts/general-status"; +import { + getMedicationComplianceSystemPrompt, + getMedicationComplianceUserPrompt, +} from "@/lib/ai/prompts/medication-compliance"; +import { + getMetricArchetypeSystemPrompt, + getMetricArchetypeUserPrompt, +} from "@/lib/ai/prompts/metric-archetypes"; +import { getMoodSystemPrompt, getMoodUserPrompt } from "@/lib/ai/prompts/mood"; +import { + getPulseSystemPrompt, + getPulseUserPrompt, +} from "@/lib/ai/prompts/pulse"; +import { + getWeightSystemPrompt, + getWeightUserPrompt, +} from "@/lib/ai/prompts/weight"; +import type { MetricStatusMeta } from "@/lib/insights/metric-status-registry"; + +/** Inert snapshot placeholder — the rules never read it. */ +const SNAPSHOT = "{SNAPSHOT}"; +const TODAY = "2026-07-18"; + +/** + * A representative opener hint. Passed to every surface that accepts one so + * the harness grades the prompt in the shape production actually sends, hint + * included — a surface that silently drops the hint arg still has to satisfy + * the meaning-first rules on its own instruction text. + */ +const OPENER_HINT = + "Open with the overall read in plain words, then bring in the number as support — not number-first."; + +const META: MetricStatusMeta = { + key: "STEP_COUNT", + displayName: "Steps", + unit: "steps", + direction: "higher-better", + archetype: "activity-fitness", + normalRange: { low: 7000, high: 12000 }, +} as unknown as MetricStatusMeta; + +export interface AssessmentSurface { + /** Stable id — appears in the failure message. */ + name: string; + system: (locale: Locale) => string; + user: (locale: Locale) => string; +} + +export const ASSESSMENT_SURFACES: readonly AssessmentSurface[] = [ + { + name: "blood-pressure", + system: getBloodPressureSystemPrompt, + user: (l) => + getBloodPressureUserPrompt( + SNAPSHOT, + TODAY, + l, + undefined, + undefined, + OPENER_HINT, + ), + }, + { + name: "weight", + system: getWeightSystemPrompt, + user: (l) => + getWeightUserPrompt( + SNAPSHOT, + TODAY, + l, + undefined, + undefined, + OPENER_HINT, + ), + }, + { + name: "bmi", + system: getBmiSystemPrompt, + user: (l) => + getBmiUserPrompt(SNAPSHOT, TODAY, l, undefined, undefined, OPENER_HINT), + }, + { + name: "pulse", + system: getPulseSystemPrompt, + user: (l) => + getPulseUserPrompt(SNAPSHOT, TODAY, l, undefined, undefined, OPENER_HINT), + }, + { + name: "mood", + system: getMoodSystemPrompt, + user: (l) => + getMoodUserPrompt(SNAPSHOT, TODAY, l, undefined, undefined, OPENER_HINT), + }, + { + name: "medication-compliance", + system: getMedicationComplianceSystemPrompt, + user: (l) => + getMedicationComplianceUserPrompt( + SNAPSHOT, + TODAY, + l, + undefined, + undefined, + OPENER_HINT, + ), + }, + { + name: "general-status", + system: getGeneralStatusSystemPrompt, + user: (l) => + getGeneralStatusUserPrompt( + SNAPSHOT, + TODAY, + l, + undefined, + undefined, + OPENER_HINT, + ), + }, + { + name: "metric-archetype", + system: (l) => getMetricArchetypeSystemPrompt(META, l), + user: (l) => + getMetricArchetypeUserPrompt( + META, + SNAPSHOT, + TODAY, + l, + undefined, + undefined, + undefined, + OPENER_HINT, + ), + }, + { + // The surface this harness was built around: it composed its prompt inline, + // skipped the shared base, took no opener hint, and instructed the model to + // state the value first. + name: "biomarker", + system: (l) => getBiomarkerSystemPrompt("Marker", l), + user: (l) => getBiomarkerUserPrompt(SNAPSHOT, TODAY, l, OPENER_HINT), + }, +]; diff --git a/src/lib/insights/eval/tone-rules.ts b/src/lib/insights/eval/tone-rules.ts new file mode 100644 index 000000000..1c1d34653 --- /dev/null +++ b/src/lib/insights/eval/tone-rules.ts @@ -0,0 +1,341 @@ +/** + * Deterministic tone rules for the assessment surfaces. + * + * The live-judge eval under `src/lib/ai/coach/eval/` grades MODEL OUTPUT and + * needs a provider key, so it can only run conditionally. This module grades + * the two things that are available with no provider at all: + * + * 1. the assembled PROMPT text every assessment surface sends, and + * 2. the DETERMINISTIC FALLBACK text those surfaces render when no provider + * is configured or the call stalls. + * + * That is enough to catch the regression classes that actually happened. Every + * tone regression this repo has shipped was visible in the prompt before it was + * ever visible in the prose: a surface built its prompt inline and skipped the + * shared opening contract; a locale silently composed the German body; an + * instruction told the model to state the value first. Those are string facts, + * so they are checkable in the normal suite in milliseconds and can block a + * merge — which is the whole point, since a judge run that needs a key cannot. + * + * The rules are deliberately narrow. Each one names a regression that has a + * concrete failure story, and each returns the offending excerpt so a red run + * reads as a diff rather than as "tone check failed". + */ + +/** One rule violation, with enough context to fix it without re-deriving. */ +export interface ToneViolation { + /** Stable rule id — the test reports it, so keep it greppable. */ + rule: string; + /** What went wrong, in one line. */ + detail: string; + /** The offending excerpt, when the rule found one. */ + excerpt?: string; +} + +/** + * Words that flip an instruction's polarity. + * + * The shared prompts talk ABOUT the value-first failure mode in order to ban + * it — "do NOT always open with the number", "never lead with the value", + * "führe nie mit dem Wert". A naive substring scan for the banned instruction + * would fire on the ban itself, so every candidate match is tested for a + * negator immediately before it. + */ +const NEGATORS = + /\b(not|never|no|non|rather than|instead of|nicht|nie|niemals|kein(e|en|em|er)?|statt|anstatt)\b/i; + +/** How far back a negator still counts as governing the match. */ +const NEGATION_WINDOW = 48; + +/** + * True when `pattern` matches somewhere it is NOT governed by a preceding + * negator. Exported because the mutation-check and the fixture tests both + * assert on the negation handling itself — it is the load-bearing part of + * every banned-instruction rule. + */ +export function hasUnnegatedMatch(text: string, pattern: RegExp): boolean { + return findUnnegatedMatch(text, pattern) !== null; +} + +export function findUnnegatedMatch( + text: string, + pattern: RegExp, +): string | null { + const scan = new RegExp( + pattern.source, + pattern.flags.replace(/g/g, "") + "g", + ); + for (const m of text.matchAll(scan)) { + const start = m.index ?? 0; + const before = text.slice(Math.max(0, start - NEGATION_WINDOW), start); + if (NEGATORS.test(before)) continue; + return text.slice(Math.max(0, start - 24), start + m[0].length + 24); + } + return null; +} + +/** + * Instructions that put the NUMBER first. + * + * This is the exact class the biomarker card shipped for eight releases + * ("state the current value and how it sits against the reference range"), + * while every sibling card had already been moved to meaning-first. A prompt + * edit that reintroduces any of these — in either instruction body — is the + * regression this list exists to stop. + */ +export const VALUE_FIRST_INSTRUCTIONS: readonly { + id: string; + pattern: RegExp; +}[] = [ + { id: "en/state-the-current-value", pattern: /state the current value/i }, + { + id: "en/name-the-value-first", + pattern: /name the (?:current )?value first/i, + }, + { + id: "en/open-with-the-number", + pattern: + /(?:open|lead|start|begin)(?:s|ing)? with the (?:number|value|reading|figure)/i, + }, + { id: "de/nenne-den-aktuellen-wert", pattern: /nenne den aktuellen Wert/i }, + { + id: "de/mit-der-zahl-eroeffnen", + pattern: + /(?:eröffne|führe|beginne|starte)(?:st)? mit (?:der Zahl|dem Wert|dem Messwert)/i, + }, +]; + +/** + * The meaning-first opening every assessment USER prompt has to carry. + * + * Two halves, both required, because either alone is satisfiable by accident: + * the surface must tell the model to open on the read in plain words, AND it + * must say the number is not what opens. The family phrases both consistently + * ("Open with what it MEANS in plain words — the read, not the number"), so + * matching the shared shape also keeps a new surface from inventing its own + * dialect for the same instruction. + */ +const MEANING_FIRST_OPENER = { + en: /\bOpen with\b[^.]{0,200}\bin plain words\b/, + de: /\bBeginne mit\b[^.]{0,200}\bin klaren Worten\b/, +} as const; + +const NUMBER_DEFERRED = { + en: /\bnot (?:the|a) number\b/i, + de: /\bnicht (?:der Zahl|mit einer Zahl|dem Wert)\b/i, +} as const; + +/** German-only vocabulary — a leak here means a locale collapsed to the DE body. */ +export const GERMAN_BODY_SENTINEL = + /AUSGABEFORMAT|Antworte ausschließlich|Schreibe eine kurze Einschätzung|DEINE DATENGRUNDLAGE|SO BAUST DU|TONALITÄT|ERÖFFNUNG —/; + +/** + * Grade one assembled surface. + * + * `instructionBody` is which reviewed body the locale composes (de for German + * readers, en for everyone else) — the meaning-first assertions key off that, + * not off the reader's locale, because a French prompt carries the ENGLISH + * instruction text plus a French output directive. + */ +export function checkPromptToneContract(input: { + systemPrompt: string; + userPrompt: string; + instructionBody: "en" | "de"; + /** The shared opening-shape fragment the system prompt must carry. */ + openingShapeFragment: string; +}): ToneViolation[] { + const violations: ToneViolation[] = []; + const { systemPrompt, userPrompt, instructionBody } = input; + const both = `${systemPrompt}\n${userPrompt}`; + + // 1. The shared opening-shape contract has to be present verbatim. A surface + // that builds its system prompt inline instead of composing the shared base + // loses it silently — which is exactly how the biomarker card drifted. + if (!systemPrompt.includes(input.openingShapeFragment)) { + violations.push({ + rule: "opening-shape-contract-present", + detail: + "system prompt does not carry the shared openingShape contract — it is not composing the shared base body", + }); + } + + // 2. The user prompt has to instruct a meaning-first opening. + if (!MEANING_FIRST_OPENER[instructionBody].test(userPrompt)) { + violations.push({ + rule: "user-prompt-leads-with-meaning", + detail: `user prompt has no meaning-first opener instruction (expected ${MEANING_FIRST_OPENER[instructionBody]})`, + }); + } + if (!NUMBER_DEFERRED[instructionBody].test(userPrompt)) { + violations.push({ + rule: "user-prompt-defers-the-number", + detail: `user prompt does not say the number is not what opens (expected ${NUMBER_DEFERRED[instructionBody]})`, + }); + } + + // 3. No value-first instruction anywhere in the assembled pair. + for (const banned of VALUE_FIRST_INSTRUCTIONS) { + const hit = findUnnegatedMatch(both, banned.pattern); + if (hit) { + violations.push({ + rule: `no-value-first-instruction:${banned.id}`, + detail: "an un-negated value-first instruction is back in the prompt", + excerpt: hit, + }); + } + } + + return violations; +} + +/** + * Locale integrity for one system prompt. + * + * The failure this pins: for four releases the assessment path composed + * `locale === "en" ? "en" : "de"`, so a French, Spanish, Italian or Polish + * reader received the GERMAN instruction body — whose output clause asks for + * German prose — with nothing anywhere telling the model to reply in the + * reader's language. It is invisible to every English and German test. + */ +export function checkLocaleIntegrity(input: { + systemPrompt: string; + /** Non-empty for the four locales that ride the English body. */ + directive: string; + /** English name of the reader's language, e.g. "French". */ + languageName: string; +}): ToneViolation[] { + const violations: ToneViolation[] = []; + + if (input.directive.length === 0) { + violations.push({ + rule: "locale-directive-resolves", + detail: "no output-language directive resolved for a rider locale", + }); + } else if (!input.systemPrompt.includes(input.directive)) { + violations.push({ + rule: "locale-directive-present", + detail: + "system prompt does not carry the reader's output-language directive", + }); + } + + if (!input.systemPrompt.includes(input.languageName)) { + violations.push({ + rule: "locale-language-named", + detail: `system prompt never names the reply language (${input.languageName}) — the output clause collapsed`, + }); + } + + const german = GERMAN_BODY_SENTINEL.exec(input.systemPrompt); + if (german) { + violations.push({ + rule: "locale-no-german-body-leak", + detail: + "a rider locale composed the GERMAN instruction body — the six-locale collapse is back", + excerpt: german[0], + }); + } + + return violations; +} + +// ── deterministic fallback tone ───────────────────────────────────────────── + +/** Praise that the numbers did not earn. */ +const FALSE_CHEER = + /\b(great job|well done|amazing|fantastic|excellent|awesome|congratulations|keep it up|don't worry)\b|\b(super gemacht|toll gemacht|großartig|fantastisch|hervorragend|weiter so|keine sorge)\b/i; + +/** Verdict vocabulary a non-diagnostic surface may never use. */ +const CLINICAL_VERDICT = + /\b(diagnos\w*|abnormal|patholog\w*|you (?:have|suffer from)|risk score|clinically significant)\b|\b(Diagnose|krankhaft|pathologisch|du (?:hast|leidest)|behandlungsbedürftig)\b/i; + +/** The value-led opener §10 bans: "Your is …". */ +const VALUE_LED_OPENER = + /^(Your|Dein|Deine|Ihr|Ihre)\s+\S+(\s+\S+)?\s+(is|ist|liegt)\s+\d/i; + +function firstSentence(text: string): string { + const m = /^[^.!?]*[.!?]/.exec(text.trim()); + return (m ? m[0] : text.trim()).trim(); +} + +/** + * Grade one rendered deterministic fallback. + * + * `expectMeaningFirst` is false for exactly one honest case: a signal with no + * baseline and no usable delta supports NO confident read, so the line opens + * on the value rather than manufacturing a verdict. Pinning that asymmetry is + * the point — "always lead with meaning" would push this surface into claiming + * something the data does not support, which is the failure mode on the other + * side of the same rule. + */ +export function checkFallbackTone(input: { + text: string; + expectMeaningFirst: boolean; +}): ToneViolation[] { + const violations: ToneViolation[] = []; + const text = input.text.trim(); + const opener = firstSentence(text); + + if (text.length === 0) { + return [{ rule: "fallback-non-empty", detail: "fallback rendered empty" }]; + } + + if (input.expectMeaningFirst) { + if (/\d/.test(opener)) { + violations.push({ + rule: "fallback-leads-with-meaning", + detail: + "the opening sentence carries a figure — the deterministic line opens on a value instead of the read", + excerpt: opener, + }); + } + if (VALUE_LED_OPENER.test(opener)) { + violations.push({ + rule: "fallback-no-value-led-opener", + detail: "opening matches the banned value-led opener shape", + excerpt: opener, + }); + } + } + + const cheer = FALSE_CHEER.exec(text); + if (cheer) { + violations.push({ + rule: "fallback-no-false-cheer", + detail: "praise the numbers did not earn", + excerpt: cheer[0], + }); + } + + const verdict = CLINICAL_VERDICT.exec(text); + if (verdict) { + violations.push({ + rule: "fallback-no-clinical-verdict", + detail: "a non-diagnostic surface used verdict vocabulary", + excerpt: verdict[0], + }); + } + + if (/!/.test(text)) { + violations.push({ + rule: "fallback-no-exclamation", + detail: "exclamation mark — the assessment voice never exclaims", + }); + } + + return violations; +} + +/** Render violations as a readable multi-line failure message. */ +export function formatViolations( + label: string, + violations: readonly ToneViolation[], +): string { + return [ + `${label} — ${violations.length} tone violation(s):`, + ...violations.map( + (v) => + ` · [${v.rule}] ${v.detail}${v.excerpt ? `\n …${v.excerpt}…` : ""}`, + ), + ].join("\n"); +} diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index 018e63d75..393563889 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -47,6 +47,7 @@ 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 { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { openerArchetypeHint } from "@/lib/ai/prompts/opener-archetype"; import type { Locale } from "@/lib/i18n/config"; import { @@ -513,9 +514,11 @@ export async function generateMetricStatus(args: { // v1.12.1 (D1) — the phrasing task benefits from a touch more sampling // entropy while the FACTS stay pinned by the snapshot + the // forbidden-phrase guards. 0.3 was conservative for a 2-4 sentence prose - // task; 0.45 varies cadence without loosening grounding. - temperature: 0.45, - maxTokens: 1000, + // task; 0.45 varies cadence without loosening grounding. The pair used to + // sit inline here, bypassing the registry that documents every surface's + // ceiling; it now reads from `AI_BUDGETS.statusArchetype`. + temperature: AI_BUDGETS.statusArchetype.temperature, + maxTokens: AI_BUDGETS.statusArchetype.maxTokens, }); if (outcome.kind === "none") { diff --git a/src/lib/insights/no-key-fallbacks.ts b/src/lib/insights/no-key-fallbacks.ts index 9a3cf8689..c1a812113 100644 --- a/src/lib/insights/no-key-fallbacks.ts +++ b/src/lib/insights/no-key-fallbacks.ts @@ -285,26 +285,77 @@ export function getNoKeyMetricStatusText( return grounded ?? getNoKeyGeneralStatusText(locale); } +/** + * The no-signal floor's opening move. + * + * Every floor below runs when there is no usable signal to ground against — + * so the honest thing to lead with is not a clinical instruction ("Measure + * blood pressure at rest…") but the situation itself: no assessment is + * available right now. That IS the meaning-first opening for this case. It + * claims nothing about the person's numbers, which is exactly the point — the + * floor must never imply a read the data does not support, and a warm-sounding + * verdict here would be manufactured. + * + * What follows the lead is what makes the metric readable, phrased as how the + * measure behaves rather than as an order to the reader. + */ +function noReadLead(locale: InsightLocale): string { + return locale === "de" + ? "Für diese Karte liegt gerade keine Einschätzung vor." + : "No assessment on this one right now."; +} + +function floor(locale: InsightLocale, de: string, en: string): string { + return `${noReadLead(locale)} ${getLocalizedText(locale, de, en)}`; +} + export function getNoKeyGeneralStatusText(locale: InsightLocale): string { // The overview spans many metrics with no single headline value to ground // against, so it keeps the honest, generic multi-metric pointer. - return getLocalizedText( + return floor( locale, - "Beobachte Entwicklungen über mehrere Wochen statt einzelne Tageswerte isoliert zu bewerten. Achte auf konsistente Messzeitpunkte, damit Trends belastbar vergleichbar bleiben. Reagiere früh, wenn sich mehrere Kennzahlen gleichzeitig in eine ungünstige Richtung bewegen.", - "Track developments over several weeks instead of judging single daily values in isolation. Keep measurement timing consistent so trends remain comparable and reliable. React early when multiple metrics move in an unfavorable direction at the same time.", + "Aussagekräftig werden diese Zahlen über mehrere Wochen, nicht über einzelne Tage — und zu konstanten Zeitpunkten erfasst, damit sie vergleichbar bleiben. Das Signal, das zählt, ist mehrere Kennzahlen, die sich gleichzeitig in dieselbe ungünstige Richtung bewegen, nicht ein Wert an einem Tag.", + "These numbers become readable across several weeks rather than single days, and taken at consistent times so they stay comparable. The signal that counts is several metrics drifting the same unfavourable way at once, not one value on one day.", ); } +/** + * Single lab-marker floor. The biomarker card used to fall back to the + * multi-metric overview tip — text about watching several metrics at once, on + * a card showing exactly one lab value. This says something true about a lab + * marker instead. + */ +export function getNoKeyBiomarkerStatusText( + locale: InsightLocale, + markerName?: string | null, +): string { + const named = + markerName && markerName.trim().length > 0 + ? locale === "de" + ? ` zu „${markerName.trim()}"` + : ` for "${markerName.trim()}"` + : ""; + const lead = + locale === "de" + ? `Für diesen Laborwert${named} liegt gerade keine Einschätzung vor.` + : `No assessment${named} right now.`; + return `${lead} ${getLocalizedText( + locale, + "Ein Laborwert liest sich zuerst gegen die eigenen vorherigen Abnahmen — ein Referenzbereich ist ein grober Anker, kein Urteil. Ein Wert, der sich über mehrere Abnahmen kaum bewegt, erzählt etwas anderes als einer, der gerade gesprungen ist. Die nächste ärztliche Kontrolle ist der richtige Ort, ihn einzuordnen.", + "A lab value reads first against your own previous draws — a reference range is a coarse anchor, not a verdict. One that has barely moved across several draws tells a different story from one that has just stepped. Your next check-up is the right place to put it in context.", + )}`; +} + export function getNoKeyBloodPressureStatusText( locale: InsightLocale, signal?: MetricSignal | null, ): string { return ( composeGroundedFallback(signal, BLOOD_PRESSURE_COPY, locale) ?? - getLocalizedText( + floor( locale, - "Miss den Blutdruck möglichst in Ruhe und unter vergleichbaren Bedingungen. Entscheidend ist die Tendenz über mehrere Tage, nicht ein einzelner Ausreißer. Beurteile systolische und diastolische Werte immer gemeinsam im zeitlichen Verlauf.", - "Measure blood pressure at rest and under comparable conditions whenever possible. The multi-day trend matters more than a single outlier. Always evaluate systolic and diastolic values together over time.", + "Blutdruck wird lesbar über eine Reihe ruhiger Messungen unter vergleichbaren Bedingungen — die Richtung über mehrere Tage sagt weit mehr als ein einzelner Ausreißer, und systolisch und diastolisch gehören dabei zusammen gelesen.", + "Blood pressure becomes readable over a run of calm readings under similar conditions — the direction across several days says far more than any single outlier, and systolic and diastolic are read together rather than one at a time.", ) ); } @@ -315,10 +366,10 @@ export function getNoKeyWeightStatusText( ): string { return ( composeGroundedFallback(signal, WEIGHT_COPY, locale) ?? - getLocalizedText( + floor( locale, - "Bewerte Gewicht vor allem im Verlauf und nicht anhand einzelner Tage. Nutze möglichst konstante Messbedingungen, um normale Schwankungen besser einzuordnen. Wichtig ist die langfristige Richtung im Zusammenspiel mit Blutdruck und BMI.", - "Evaluate weight mainly as a trend rather than by isolated daily readings. Use consistent measurement conditions to interpret normal fluctuations more reliably. What matters most is the long-term direction together with blood pressure and BMI.", + "Gewicht liest sich als Verlauf, nicht als Tageszahl — unter konstanten Bedingungen gewogen, pendelt sich die normale Tagesschwankung von selbst ein. Die Richtung über Wochen, zusammen mit Blutdruck und BMI gelesen, ist das, was trägt.", + "Weight reads as a trend rather than a daily number — weighed under consistent conditions, the normal day-to-day swing settles out on its own. The direction over weeks, read alongside blood pressure and BMI, is what carries.", ) ); } @@ -329,10 +380,10 @@ export function getNoKeyPulseStatusText( ): string { return ( composeGroundedFallback(signal, PULSE_COPY, locale) ?? - getLocalizedText( + floor( locale, - "Miss den Ruhepuls in einer entspannten Situation und möglichst zur gleichen Tageszeit. Kurzfristige Ausschläge sind normal, wichtiger ist die Entwicklung über mehrere Tage. Achte auf wiederkehrende Abweichungen vom persönlichen Zielbereich.", - "Measure resting pulse in a relaxed state and ideally at the same time of day. Short-term spikes are normal, while the multi-day pattern is more important. Watch for repeated deviations from your personal target range.", + "Der Ruhepuls liest sich am besten entspannt und zur gleichen Tageszeit gemessen. Kurze Ausschläge sind gewöhnlich; was auffällt, ist ein Muster, das über mehrere Tage hält, oder wiederholtes Abdriften vom eigenen üblichen Bereich.", + "Resting pulse reads best taken relaxed and at the same time of day. Short spikes are ordinary; what stands out is a pattern holding across several days, or repeated drift away from your usual range.", ) ); } @@ -343,10 +394,10 @@ export function getNoKeyBmiStatusText( ): string { return ( composeGroundedFallback(signal, BMI_COPY, locale) ?? - getLocalizedText( + floor( locale, - "Der BMI ist eine Orientierungsgröße und sollte immer zusammen mit Gewichtstrend und Körperfett betrachtet werden. Einzelwerte sind weniger wichtig als die Entwicklung über Wochen. Aussagekräftig sind vor allem stabile Verbesserungen oder dauerhafte Abweichungen.", - "BMI is a directional metric and should always be viewed together with weight trend and body-fat context. Single values are less important than changes across weeks. The most meaningful signals are sustained improvements or persistent deviations.", + "Der BMI ist eine grobe Orientierung, kein Urteil, und bedeutet am meisten zusammen mit Gewichtstrend und Körperzusammensetzung gelesen. Eine anhaltende Bewegung über Wochen trägt, wo ein Einzelwert es nicht tut.", + "BMI is a rough orientation rather than a verdict, and it means most read alongside your weight trend and body composition. Sustained movement over weeks carries where a single value does not.", ) ); } @@ -357,10 +408,10 @@ export function getNoKeyMedicationComplianceStatusText( ): string { return ( composeGroundedFallback(signal, ADHERENCE_COPY, locale) ?? - getLocalizedText( + floor( locale, - "Konstanz bei der Einnahme ist wichtiger als einzelne perfekte Tage. Beurteile die Treue pro Medikament und zusätzlich im Gesamtbild über mehrere Wochen. Achte besonders auf wiederkehrende Auslassungen und stabilisiere dafür feste Zeitfenster-Routinen.", - "Consistency in intake matters more than isolated perfect days. Evaluate adherence per medication and also in the overall multi-week picture. Pay special attention to repeated misses and stabilize fixed time-window routines.", + "Einnahmetreue liest sich als Konstanz über Wochen, nicht als eine Reihe perfekter Tage — je Medikament und im Gesamtbild. Wiederkehrende Auslassungen zur selben Tageszeit sind das Muster, auf das es ankommt, und eine feste Routine fängt sie am ehesten ab.", + "Adherence reads as consistency over weeks rather than a run of perfect days — per medication and in the overall picture. Repeated misses at the same time of day are the pattern that counts, and a fixed routine is what usually catches them.", ) ); } @@ -371,10 +422,10 @@ export function getNoKeyMoodStatusText( ): string { return ( composeGroundedFallback(signal, MOOD_COPY, locale) ?? - getLocalizedText( + floor( locale, - "Bewerte die Stimmung im Verlauf über mehrere Wochen statt einzelne Tage isoliert zu betrachten. Achte auf wiederkehrende Muster und Zusammenhänge mit anderen Gesundheitswerten. Anhaltende Phasen niedriger Stimmung verdienen besondere Aufmerksamkeit.", - "Evaluate mood trends over several weeks rather than isolated daily readings. Watch for recurring patterns and correlations with other health metrics. Sustained periods of low mood deserve special attention.", + "Stimmung liest sich über Wochen, nicht über einzelne Tage — wiederkehrende Muster und wie sie zu den anderen Werten passen, sind das, was trägt. Eine anhaltende Phase niedriger Stimmung ist die, die Aufmerksamkeit verdient.", + "Mood reads over weeks rather than single days — recurring patterns, and how they line up with your other metrics, are what carry. A sustained low stretch is the one that deserves attention.", ) ); } From 1b50dc0909c9a234e3b94d548c7dd8da4cba8c33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 13:16:41 +0200 Subject: [PATCH 2/5] test(insights): add the per-metric tone harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deterministic grader over the assembled assessment prompts and the deterministic fallback text. No provider, no network, no database — it runs in the normal suite in under half a second, which is the point: the live-judge eval needs a key and can only advise, so the regressions that actually shipped need a check that can block. Three classes are blocking. A surface stops leading with meaning (it drops the shared opening contract, or its user prompt loses the meaning-first instruction). A locale collapses (fr/es/it/pl fall back to the German body or lose the reply-language directive). A prompt reintroduces a value-first instruction — matched with negation handling, since the shared prompts quote that failure mode in order to ban it. The deterministic fallbacks are graded on the same axis, including the asymmetry that matters: a signal with no baseline supports no confident read, so that one line honestly opens on the value. Pinning it stops a future "always lead with meaning" edit from pushing the surface into claiming something the data does not support. A coverage guard fails when an assessment prompt module has no harness entry, so the next surface cannot stay uncovered the way the biomarker card did. --- src/lib/insights/eval/tone-rules.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/lib/insights/eval/tone-rules.ts b/src/lib/insights/eval/tone-rules.ts index 1c1d34653..35bfb724f 100644 --- a/src/lib/insights/eval/tone-rules.ts +++ b/src/lib/insights/eval/tone-rules.ts @@ -253,6 +253,20 @@ const CLINICAL_VERDICT = const VALUE_LED_OPENER = /^(Your|Dein|Deine|Ihr|Ihre)\s+\S+(\s+\S+)?\s+(is|ist|liegt)\s+\d/i; +/** + * The OTHER way a deterministic line stops leading with meaning: it opens on a + * bare order. + * + * This is what the no-signal floors were — "Measure blood pressure at rest and + * under comparable conditions", "Bewerte Gewicht vor allem im Verlauf". Not a + * value-led opener, so the number rules never saw it, but it is the same + * failure: the surface tells the reader what to do before it says anything + * about what it can and cannot see. A pointer is fine LATER in the line; it + * may not be the opening move. + */ +const IMPERATIVE_OPENER = + /^(Measure|Track|Evaluate|Monitor|Check|Watch|Log|React|Use|Keep|Pay|Consider|Make sure)\b|^(Miss|Bewerte|Beobachte|Achte|Nutze|Halte|Reagiere|Prüfe|Vergleiche|Erfasse)\b/; + function firstSentence(text: string): string { const m = /^[^.!?]*[.!?]/.exec(text.trim()); return (m ? m[0] : text.trim()).trim(); @@ -289,6 +303,14 @@ export function checkFallbackTone(input: { excerpt: opener, }); } + if (IMPERATIVE_OPENER.test(opener)) { + violations.push({ + rule: "fallback-no-imperative-opener", + detail: + "the line opens on an order rather than on what the surface can honestly say", + excerpt: opener, + }); + } if (VALUE_LED_OPENER.test(opener)) { violations.push({ rule: "fallback-no-value-led-opener", From 80e88cfb23b5dd7046ee8b73ab05347da67cac1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 13:25:32 +0200 Subject: [PATCH 3/5] fix(insights): warm the deterministic fallbacks and re-fingerprint the cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The no-signal floors were the coldest text the product renders. They run when there is nothing to ground against, and they opened on a bare order — "Measure blood pressure at rest and under comparable conditions", "Evaluate mood trends over several weeks" — so the one moment the surface has nothing to say was also the one moment it told the reader what to do. They now open on what the surface can honestly say: no assessment is available right now. That is the meaning-first opening for this case, and it is the only one available, because a floor with no signal may not imply a read. What follows describes how the measure behaves rather than ordering the reader around. The signal-grounded lines above them are unchanged. The prompt version bump re-fingerprints the assessments cached under the old biomarker prompt; the per-card input hash folds the constant in, so the regeneration is one-time and paced by the nightly warm. --- src/lib/ai/prompts/base-system.ts | 9 ++++++++- src/lib/insights/__tests__/no-key-fallbacks.test.ts | 10 +++++++--- src/lib/insights/eval/__tests__/tone-harness.test.ts | 1 + 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/lib/ai/prompts/base-system.ts b/src/lib/ai/prompts/base-system.ts index 687bf4e03..631347eb0 100644 --- a/src/lib/ai/prompts/base-system.ts +++ b/src/lib/ai/prompts/base-system.ts @@ -60,8 +60,15 @@ const OUTPUT_LANGUAGE_TOKEN = "{{OUTPUT_LANGUAGE}}"; * prose. They now compose the English body with their language named in the * output clause and the locale's own reply-language directive appended last. * The German and English prompts are byte-identical to 6.1.0 (test-pinned). + * 6.3.0 — the biomarker card joins this prompt. It was the last surface in the + * family composing its own inline scaffold, so it carried none of the contracts + * below and instructed the model to state the value first. The base sections + * are unchanged; the bump exists to re-fingerprint the cached assessments that + * were generated under the old inline prompt, since the per-card input hash + * folds this constant in. The regeneration is one-time and rides the nightly + * warm's existing per-user pacing. */ -export const PROMPT_VERSION = "6.2.0" as const; +export const PROMPT_VERSION = "6.3.0" as const; /** * Base system prompt for the per-metric Insights *assessment* cards. diff --git a/src/lib/insights/__tests__/no-key-fallbacks.test.ts b/src/lib/insights/__tests__/no-key-fallbacks.test.ts index 58b93d395..9f6446cf9 100644 --- a/src/lib/insights/__tests__/no-key-fallbacks.test.ts +++ b/src/lib/insights/__tests__/no-key-fallbacks.test.ts @@ -80,9 +80,12 @@ describe("signal-grounded no-key fallbacks", () => { expect(text).not.toContain("Schnitt von"); }); - it("falls back to the generic tip when no signal is given (mood, en)", () => { + it("falls back to the no-signal floor when no signal is given (mood, en)", () => { const text = getNoKeyMoodStatusText("en"); - expect(text).toContain("Evaluate mood trends over several weeks"); + // The floor states the absence plainly before saying anything else — it + // has no signal to ground against, so it may not imply a read. + expect(text).toContain("No assessment on this one right now"); + expect(text).toContain("Mood reads over weeks"); }); it("falls back to the generic tip when the signal has no finite current (adherence, en)", () => { @@ -90,7 +93,8 @@ describe("signal-grounded no-key fallbacks", () => { "en", signal({ current: Number.NaN }), ); - expect(text).toContain("Consistency in intake matters more"); + expect(text).toContain("No assessment on this one right now"); + expect(text).toContain("Adherence reads as consistency over weeks"); }); it("adherence grounds with a percent value and a routine pointer (en)", () => { diff --git a/src/lib/insights/eval/__tests__/tone-harness.test.ts b/src/lib/insights/eval/__tests__/tone-harness.test.ts index b07e9033a..90ebacf18 100644 --- a/src/lib/insights/eval/__tests__/tone-harness.test.ts +++ b/src/lib/insights/eval/__tests__/tone-harness.test.ts @@ -148,6 +148,7 @@ describe("tone harness — deterministic fallbacks", () => { direction: "higher-better", n: 21, newestDaysAgo: 0, + ...over, } as unknown as MetricSignal; } From 8b13b46ac799f11f5d95553ea901950e87c748b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 13:42:09 +0200 Subject: [PATCH 4/5] feat(insights): write the no-key fallbacks in every shipped locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deterministic fallbacks nine metric surfaces render when no provider is reachable carried German and English bodies only. `getLocalizedText` returned `locale === "de" ? de : en`, so French, Spanish, Italian and Polish readers got English — on the no-key path, an outage, a spent budget or a timeout, which is precisely when this text is the whole assessment. Every string now has a body per locale: the verdict leads and value sentences of the signal-grounded composer, the per-metric labels and pointers, and the eight no-signal floors. Selection reads the reader's locale; English stays as the structural floor for a locale that is ever genuinely missing a body. The bodies are written per language, not translated word-for-word. fr/es/it replace the comparative adjective ("higher than your usual average") with a preposition — au-dessus de / por encima de / sopra — because a comparative would have to agree in gender with the metric noun, and the labels do not share one. Polish restructures further: the sentence takes `wynosi`, which is invariant for the subject's gender, and the comparison uses powyżej / poniżej with the genitive; the labels span all three genders, so nothing that inflects can sit in a shared template. The tone harness grades the four locales by the same rules rather than passing them by default: false cheer, clinical-verdict and imperative-opener vocabulary extended to all six, the no-assessment sentinel keyed per locale instead of a de/en binary, and a byte-identity guard that fails if any locale silently falls back to the English text again. The value-led-opener rule was rewritten as well — it assumed the copula sits against the figure, which under-matched its own second locale (German "liegt aktuell bei 24") and would have missed all four new ones. --- .../eval/__tests__/tone-harness.test.ts | 43 +- src/lib/insights/eval/tone-rules.ts | 47 +- src/lib/insights/no-key-fallbacks.ts | 457 +++++++++++++----- 3 files changed, 419 insertions(+), 128 deletions(-) diff --git a/src/lib/insights/eval/__tests__/tone-harness.test.ts b/src/lib/insights/eval/__tests__/tone-harness.test.ts index 90ebacf18..0bcffd3bb 100644 --- a/src/lib/insights/eval/__tests__/tone-harness.test.ts +++ b/src/lib/insights/eval/__tests__/tone-harness.test.ts @@ -236,6 +236,21 @@ describe("tone harness — deterministic fallbacks", () => { }, ]; + /** + * "No assessment is available" in each locale's own words. Keyed per locale + * rather than `de ? … : /No assessment/`, which is the same de/en binary that + * let four locales render English bodies unnoticed — under it, a French floor + * passed this assertion precisely BECAUSE it was still English. + */ + const NO_READ_SENTINEL: Record = { + de: /keine Einschätzung vor/, + en: /No assessment/, + fr: /Aucune évaluation/, + es: /no hay ninguna valoración/, + it: /non è disponibile alcuna valutazione/, + pl: /nie ma (?:tu )?żadnej oceny/, + }; + describe.each(FLOORS)("$name floor", ({ build }) => { it.each(locales)("%s opens on the read, not on an order", (locale) => { const text = build(locale); @@ -244,11 +259,33 @@ describe("tone harness — deterministic fallbacks", () => { // The floor states plainly that no assessment is available — it must not // dress the absence up as one. - expect(text).toMatch( - locale === "de" ? /keine Einschätzung vor/ : /No assessment/, - ); + expect(text).toMatch(NO_READ_SENTINEL[locale]); }); }); + + /** + * The regression this whole pass exists to close: `getLocalizedText` used to + * return `locale === "de" ? de : en`, so fr/es/it/pl rendered the ENGLISH + * body on nine metric surfaces. Every tone rule above passed on that text — + * it was in voice, it just was not the reader's language. + * + * Byte-identity against English is therefore the assertion that actually + * pins it. It holds for both the no-signal floors and the signal-grounded + * composer, because the composer has its own per-locale sentence templates + * (the value sentence and the verdict lead), not only per-locale nouns. + */ + describe.each(RIDERS)("%s renders its own body", (locale) => { + it.each(FLOORS)("$name floor is not the English text", ({ build }) => { + expect(build(locale)).not.toEqual(build("en")); + }); + + it.each(GROUNDED)( + "$name grounded line is not the English text", + ({ build }) => { + expect(build(locale)).not.toEqual(build("en")); + }, + ); + }); }); describe("tone harness — the rules themselves", () => { diff --git a/src/lib/insights/eval/tone-rules.ts b/src/lib/insights/eval/tone-rules.ts index 35bfb724f..b0db66819 100644 --- a/src/lib/insights/eval/tone-rules.ts +++ b/src/lib/insights/eval/tone-rules.ts @@ -241,17 +241,45 @@ export function checkLocaleIntegrity(input: { // ── deterministic fallback tone ───────────────────────────────────────────── -/** Praise that the numbers did not earn. */ +/** + * Praise that the numbers did not earn. + * + * Six locales, because the deterministic fallbacks now carry a written body in + * each: a rule that only knows English and German praise would grade four + * locales as clean no matter what they said. Vocabulary only — the rule shape + * is language-independent. + */ const FALSE_CHEER = - /\b(great job|well done|amazing|fantastic|excellent|awesome|congratulations|keep it up|don't worry)\b|\b(super gemacht|toll gemacht|großartig|fantastisch|hervorragend|weiter so|keine sorge)\b/i; + /\b(great job|well done|amazing|fantastic|excellent|awesome|congratulations|keep it up|don't worry)\b|\b(super gemacht|toll gemacht|großartig|fantastisch|hervorragend|weiter so|keine sorge)\b|\b(bravo|félicitations|continue comme ça|génial|formidable|ne t['’]inquiète pas)\b|\b(enhorabuena|felicidades|sigue así|genial|estupendo|no te preocupes)\b|\b(complimenti|continua così|ottimo lavoro|fantastico|eccellente|non preoccuparti)\b|\b(gratulacje|brawo|tak trzymaj|świetnie|wspaniale|nie martw się)\b/i; -/** Verdict vocabulary a non-diagnostic surface may never use. */ +/** + * Verdict vocabulary a non-diagnostic surface may never use. + * + * Note what is deliberately NOT here: the plain noun for "verdict" in each + * language (verdict / veredicto / verdetto / wyrok / Urteil). Several floors + * use it in the negated form that carries the whole non-diagnostic stance — "a + * reference range is a coarse anchor, not a verdict" — so banning the noun + * would flag the sentence that states the rule. The ban targets diagnosis, + * pathology, attribution of a condition to the reader, and treatment + * direction, which is what a non-diagnostic surface actually may not do. + */ const CLINICAL_VERDICT = - /\b(diagnos\w*|abnormal|patholog\w*|you (?:have|suffer from)|risk score|clinically significant)\b|\b(Diagnose|krankhaft|pathologisch|du (?:hast|leidest)|behandlungsbedürftig)\b/i; + /\b(diagnos\w*|abnormal|patholog\w*|you (?:have|suffer from)|risk score|clinically significant)\b|\b(Diagnose|krankhaft|pathologisch|du (?:hast|leidest)|behandlungsbedürftig)\b|\b(diagnostiqu\w*|pathologique|tu souffres de|nécessite un traitement)\b|\b(diagnóstic\w*|patológic\w*|sufres de|requiere (?:un )?tratamiento)\b|\b(diagnosi|diagnostic\w*|patologic\w*|soffri di|richiede (?:un )?trattamento)\b|\b(diagnoz\w*|patologiczn\w*|cierpisz na|wymaga leczenia)\b/i; -/** The value-led opener §10 bans: "Your is …". */ +/** + * The value-led opener §10 bans: "Your is …". + * + * Rewritten from the de/en-only shape, which assumed the copula sits directly + * against the figure. That is true for "Your BMI is 24" and false for almost + * everything else the surfaces actually render — German "liegt aktuell bei + * 24", French "est à 24", Spanish "está en 24", Polish "wynosi obecnie 24". + * The old pattern therefore under-matched German, its own second locale, and + * would have missed all four new ones. The shape is now: possessive determiner, + * up to three words of metric name, the copula, then up to two positional + * fillers before the figure. + */ const VALUE_LED_OPENER = - /^(Your|Dein|Deine|Ihr|Ihre)\s+\S+(\s+\S+)?\s+(is|ist|liegt)\s+\d/i; + /^(?:Your|Dein(?:e)?|Ihr(?:e)?|Ta|Ton|Tes|Tu|Tus|La tua|Le tue|Il tuo|I tuoi|Twój|Twoja|Twoje)\b(?:\s+\S+){0,3}\s+(?:is|ist|liegt|est|está|è|wynosi)\b(?:\s+(?:at|bei|à|a|en|aktuell|obecnie|right now)){0,2}\s+\d/i; /** * The OTHER way a deterministic line stops leading with meaning: it opens on a @@ -263,9 +291,14 @@ const VALUE_LED_OPENER = * failure: the surface tells the reader what to do before it says anything * about what it can and cannot see. A pointer is fine LATER in the line; it * may not be the opening move. + * + * Extended to the four locales whose bodies used to be English. Romance and + * Polish imperatives are a closed enough set here because the pointers these + * floors could regress into are drawn from the same small verb family the + * de/en list already names — measure, weigh, track, compare, read. */ const IMPERATIVE_OPENER = - /^(Measure|Track|Evaluate|Monitor|Check|Watch|Log|React|Use|Keep|Pay|Consider|Make sure)\b|^(Miss|Bewerte|Beobachte|Achte|Nutze|Halte|Reagiere|Prüfe|Vergleiche|Erfasse)\b/; + /^(Measure|Track|Evaluate|Monitor|Check|Watch|Log|React|Use|Keep|Pay|Consider|Make sure)\b|^(Miss|Bewerte|Beobachte|Achte|Nutze|Halte|Reagiere|Prüfe|Vergleiche|Erfasse)\b|^(Mesure|Suis|Évalue|Surveille|Vérifie|Note|Utilise|Garde|Compare|Pèse|Prends|Lis)\b|^(Mide|Evalúa|Vigila|Comprueba|Registra|Usa|Mantén|Compara|Pésate|Toma|Lee)\b|^(Misura|Valuta|Monitora|Controlla|Registra|Usa|Mantieni|Confronta|Pesati|Prendi|Leggi)\b|^(Mierz|Oceń|Obserwuj|Sprawdź|Zapisuj|Używaj|Utrzymuj|Porównaj|Waż|Czytaj)\b/; function firstSentence(text: string): string { const m = /^[^.!?]*[.!?]/.exec(text.trim()); diff --git a/src/lib/insights/no-key-fallbacks.ts b/src/lib/insights/no-key-fallbacks.ts index c1a812113..fa3fcee0d 100644 --- a/src/lib/insights/no-key-fallbacks.ts +++ b/src/lib/insights/no-key-fallbacks.ts @@ -2,12 +2,19 @@ import type { Locale } from "@/lib/i18n/config"; import type { MetricSignal } from "@/lib/insights/metric-signal"; /** - * v1.4.25 W9e — these no-key fallbacks ship DE + EN bodies only. The - * type accepts every shipped locale (fr/es/it/pl too) and routes - * non-DE locales through the EN body, mirroring the same fallback - * chain the JSON message bundles use. When DE/EN-only fallbacks get - * replaced with proper FR/ES/IT/PL bodies, expand `getLocalizedText` - * to read the matching argument. + * v1.4.25 W9e / v1.31.0 — these no-key fallbacks now carry a written body for + * every shipped locale (de/en/fr/es/it/pl). They used to ship DE + EN only and + * route the other four through the English body, which meant a French, Spanish, + * Italian or Polish self-hoster read English on nine metric surfaces whenever + * no provider was reachable. `pick` selects the reader's own body; English + * stays as the structural floor for a locale that is ever genuinely missing + * one, not as the shipped answer for four of six. + * + * The bodies are written per language rather than translated token-for-token. + * Where a sentence cannot carry the English shape — a comparative adjective + * that would have to agree with the metric noun's gender, a locative "is at" + * with no natural equivalent — that language's sentence is restructured. The + * per-language notes sit at the templates themselves. * * v1.21.0 (coach C1 HIGH-1/2) — these surfaces used to emit static * clinical tips with ZERO reference to the user's own numbers, and the @@ -22,12 +29,24 @@ import type { MetricSignal } from "@/lib/insights/metric-signal"; */ export type InsightLocale = Locale; +/** + * A per-locale table with English required. + * + * English is required so the fallback arm can never itself be undefined; every + * other locale is optional so a genuinely missing body degrades to English + * rather than to `undefined`. Every table in this file fills all six. + */ +type Localized = { en: T } & Partial>; + +function pick(locale: InsightLocale, table: Localized): T { + return table[locale] ?? table.en; +} + function getLocalizedText( locale: InsightLocale, - de: string, - en: string, + text: Localized, ): string { - return locale === "de" ? de : en; + return pick(locale, text); } // ── signal-grounded composer ──────────────────────────────────────────────── @@ -35,7 +54,7 @@ function getLocalizedText( /** A natural-language metric label + the pointer phrasing for one metric. */ interface FallbackCopy { /** Possessive natural-language label ("your blood pressure"). */ - label: { de: string; en: string }; + label: Localized; /** Unit suffix appended to a value (" bpm"), empty when none. */ unit?: string; /** Digits to round the rendered value to (0 for integers). */ @@ -44,7 +63,7 @@ interface FallbackCopy { * One grounded pointer used when the value sits outside the user's own * usual swing — framed as an opportunity, never an order. */ - pointer: { de: string; en: string }; + pointer: Localized; } function fmtValue(value: number, copy: FallbackCopy): string { @@ -73,15 +92,40 @@ function capitalise(s: string): string { * 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. */ +const VERDICT_STEADY: Localized = { + de: "Stabil und wie gewohnt", + en: "Steady and much as usual", + fr: "Stable, comme d’habitude", + es: "Estable, como de costumbre", + it: "Stabile, come al solito", + pl: "Stabilnie, tak jak zwykle", +}; + +const VERDICT_FAVOURABLE: Localized = { + de: "Das geht in eine gute Richtung", + en: "This is moving in a good direction", + fr: "Cela évolue dans le bon sens", + es: "Esto va en buena dirección", + it: "Sta andando nella direzione giusta", + pl: "To zmierza w dobrym kierunku", +}; + +const VERDICT_OFF_USUAL: Localized = { + de: "Zuletzt etwas außerhalb deines üblichen Rahmens", + en: "A little off your usual lately", + fr: "Un peu en dehors de tes repères habituels ces derniers temps", + es: "Últimamente algo fuera de tu rango habitual", + it: "Ultimamente un po’ fuori dal tuo intervallo abituale", + pl: "Ostatnio nieco poza twoim zwykłym zakresem", +}; + 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"; + return pick(locale, VERDICT_STEADY); } // 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 @@ -98,18 +142,93 @@ function verdictLead( ? signal.delta < 0 : null; if (favourable === true) { - return locale === "de" - ? "Das geht in eine gute Richtung" - : "This is moving in a good direction"; + return pick(locale, VERDICT_FAVOURABLE); } - return locale === "de" - ? "Zuletzt etwas außerhalb deines üblichen Rahmens" - : "A little off your usual lately"; + return pick(locale, VERDICT_OFF_USUAL); } // No baseline / no usable delta → no confident verdict; open on the value. return null; } +/** + * The value sentence, placed against the user's own baseline. + * + * Three shapes, one per grounding level. Each is a per-locale template rather + * than one template with swapped words, because the languages do not share a + * sentence shape here: + * + * · de/en keep the locative "liegt bei" / "is at" plus a comparative adjective. + * · fr/es/it drop the comparative ("plus haut", "más alto", "più alto") for a + * PREPOSITION — "au-dessus de", "por encima de", "sopra". The comparative + * would have to agree in gender with the metric noun, which differs across + * the labels (ta tension / ton poids, tu presión / tu peso, la tua pressione + * / il tuo peso), so a single template carrying an adjective cannot be + * correct for all of them. The preposition governs the noun without agreeing + * with it, which makes one template safe for every metric. + * · pl restructures further: there is no natural locative "is at" for a + * measurement, so the sentence takes the verb `wynosi` ("amounts to"), which + * is invariant for the subject's gender — Polish adjectives and past-tense + * verbs are not, and the labels span all three genders (twój nastrój m., + * twoja waga f., twoje tętno n.). The comparison likewise uses the + * prepositions `powyżej` / `poniżej` with the genitive rather than a + * comparative adjective. + */ +const VALUE_VS_BASELINE: Localized< + (label: string, current: string, baseline: string, higher: boolean) => string +> = { + de: (l, c, b, higher) => + `${l} liegt aktuell bei ${c} — ${higher ? "höher" : "niedriger"} als dein üblicher Schnitt von ${b}.`, + en: (l, c, b, higher) => + `${l} is at ${c} right now — ${higher ? "higher" : "lower"} than your usual average of ${b}.`, + fr: (l, c, b, higher) => + `${l} est à ${c} en ce moment — ${higher ? "au-dessus" : "en dessous"} de ta moyenne habituelle de ${b}.`, + es: (l, c, b, higher) => + `${l} está en ${c} ahora mismo — ${higher ? "por encima" : "por debajo"} de tu promedio habitual de ${b}.`, + it: (l, c, b, higher) => + `${l} è a ${c} in questo momento — ${higher ? "sopra" : "sotto"} la tua media abituale di ${b}.`, + pl: (l, c, b, higher) => + `${l} wynosi obecnie ${c} — ${higher ? "powyżej" : "poniżej"} twojej zwykłej średniej ${b}.`, +}; + +const VALUE_AT_BASELINE: Localized<(label: string, current: string) => string> = + { + de: (l, c) => + `${l} liegt aktuell bei ${c}, im Bereich deines üblichen Schnitts.`, + en: (l, c) => `${l} is at ${c}, right around your usual average.`, + fr: (l, c) => `${l} est à ${c}, tout près de ta moyenne habituelle.`, + es: (l, c) => `${l} está en ${c}, justo en torno a tu promedio habitual.`, + it: (l, c) => `${l} è a ${c}, più o meno sulla tua media abituale.`, + pl: (l, c) => + `${l} wynosi obecnie ${c}, czyli mniej więcej tyle co twoja zwykła średnia.`, + }; + +const VALUE_ALONE: Localized<(label: string, current: string) => string> = { + de: (l, c) => `${l} liegt aktuell bei ${c}.`, + en: (l, c) => `${l} is at ${c} right now.`, + fr: (l, c) => `${l} est à ${c} en ce moment.`, + es: (l, c) => `${l} está en ${c} ahora mismo.`, + it: (l, c) => `${l} è a ${c} in questo momento.`, + pl: (l, c) => `${l} wynosi obecnie ${c}.`, +}; + +const CLOSER_IN_RANGE: Localized = { + de: "Das ist in deinem gewohnten Rahmen — nichts, worauf du jetzt reagieren musst.", + en: "That sits in your usual range — nothing you need to act on right now.", + fr: "C’est dans tes repères habituels — rien qui appelle une réaction maintenant.", + es: "Eso queda dentro de tu rango habitual — nada que pida una reacción ahora.", + it: "Rientra nel tuo intervallo abituale — niente su cui intervenire adesso.", + pl: "Mieści się to w twoim zwykłym zakresie — nic, na co trzeba teraz reagować.", +}; + +const CLOSER_UNKNOWN_SWING: Localized = { + de: "Ein paar Messungen mehr machen die Tendenz belastbar — beobachte sie über die nächsten Tage.", + en: "A few more readings will make the trend dependable — keep an eye on it over the coming days.", + fr: "Quelques mesures de plus rendront la tendance fiable — garde un œil dessus les prochains jours.", + es: "Unas pocas mediciones más harán fiable la tendencia — obsérvala durante los próximos días.", + it: "Qualche misurazione in più renderà la tendenza affidabile — tienila d’occhio nei prossimi giorni.", + pl: "Kilka kolejnych pomiarów sprawi, że tendencja stanie się wiarygodna — obserwuj ją przez najbliższe dni.", +}; + /** * Compose a warm, grounded deterministic line from a metric signal. Leads with * a plain-words verdict (meaning first), then names the current value placed @@ -125,7 +244,7 @@ function composeGroundedFallback( ): string | null { if (!signal || !Number.isFinite(signal.current)) return null; - const label = locale === "de" ? copy.label.de : copy.label.en; + const label = capitalise(pick(locale, copy.label)); const current = fmtValue(signal.current, copy); const sentences: string[] = []; @@ -143,47 +262,29 @@ function composeGroundedFallback( Number.isFinite(signal.baseline) ) { const baseline = fmtValue(signal.baseline, copy); - const higher = signal.delta > 0; sentences.push( - locale === "de" - ? `${capitalise(label)} liegt aktuell bei ${current} — ${ - higher ? "höher" : "niedriger" - } als dein üblicher Schnitt von ${baseline}.` - : `${capitalise(label)} is at ${current} right now — ${ - higher ? "higher" : "lower" - } than your usual average of ${baseline}.`, + pick(locale, VALUE_VS_BASELINE)( + label, + current, + baseline, + signal.delta > 0, + ), ); } else if (signal.baseline !== null && Number.isFinite(signal.baseline)) { - sentences.push( - locale === "de" - ? `${capitalise(label)} liegt aktuell bei ${current}, im Bereich deines üblichen Schnitts.` - : `${capitalise(label)} is at ${current}, right around your usual average.`, - ); + sentences.push(pick(locale, VALUE_AT_BASELINE)(label, current)); } else { // No baseline yet — name the value honestly without inventing a comparison. - sentences.push( - locale === "de" - ? `${capitalise(label)} liegt aktuell bei ${current}.` - : `${capitalise(label)} is at ${current} right now.`, - ); + sentences.push(pick(locale, VALUE_ALONE)(label, current)); } // 2) One grounded pointer when outside the usual swing; otherwise affirm. if (signal.outsideNormalSwing === true) { - sentences.push(locale === "de" ? copy.pointer.de : copy.pointer.en); + sentences.push(pick(locale, copy.pointer)); } else if (signal.outsideNormalSwing === false) { - sentences.push( - locale === "de" - ? "Das ist in deinem gewohnten Rahmen — nichts, worauf du jetzt reagieren musst." - : "That sits in your usual range — nothing you need to act on right now.", - ); + sentences.push(pick(locale, CLOSER_IN_RANGE)); } else { // Unknown swing (no baseline): keep one steady, non-alarming pointer. - sentences.push( - locale === "de" - ? "Ein paar Messungen mehr machen die Tendenz belastbar — beobachte sie über die nächsten Tage." - : "A few more readings will make the trend dependable — keep an eye on it over the coming days.", - ); + sentences.push(pick(locale, CLOSER_UNKNOWN_SWING)); } return sentences.join(" "); @@ -192,65 +293,141 @@ function composeGroundedFallback( // ── per-metric copy ───────────────────────────────────────────────────────── const BLOOD_PRESSURE_COPY: FallbackCopy = { - label: { de: "dein Blutdruck", en: "your blood pressure" }, + label: { + de: "dein Blutdruck", + en: "your blood pressure", + fr: "ta tension artérielle", + es: "tu presión arterial", + it: "la tua pressione sanguigna", + pl: "twoje ciśnienie krwi", + }, unit: "", digits: 0, pointer: { de: "Ein paar ruhige Messungen unter gleichen Bedingungen zeigen, ob das die Tendenz ist oder ein Ausreißer.", en: "A few calm readings under the same conditions will show whether this is the trend or a single outlier.", + fr: "Quelques mesures au calme dans les mêmes conditions montreront si c’est la tendance ou une valeur isolée.", + es: "Unas cuantas mediciones en calma y en las mismas condiciones mostrarán si es la tendencia o un valor aislado.", + it: "Qualche misurazione a riposo nelle stesse condizioni mostrerà se è la tendenza o un valore isolato.", + pl: "Kilka spokojnych pomiarów w tych samych warunkach pokaże, czy to tendencja, czy pojedynczy wyskok.", }, }; const WEIGHT_COPY: FallbackCopy = { - label: { de: "dein Gewicht", en: "your weight" }, + label: { + de: "dein Gewicht", + en: "your weight", + fr: "ton poids", + es: "tu peso", + it: "il tuo peso", + pl: "twoja waga", + }, unit: "", digits: 1, pointer: { de: "Wäge dich ein paar Tage zur gleichen Zeit, dann ordnet sich die normale Schwankung von selbst ein.", en: "Weigh in at the same time for a few days and the normal day-to-day swing sorts itself out.", + fr: "Pèse-toi à la même heure pendant quelques jours et la fluctuation quotidienne normale se lisse d’elle-même.", + es: "Pésate a la misma hora durante unos días y la fluctuación diaria normal se compensa sola.", + it: "Pesati alla stessa ora per qualche giorno e la normale oscillazione quotidiana si compensa da sé.", + pl: "Waż się przez kilka dni o tej samej porze, a normalne dobowe wahania same się wyrównają.", }, }; const PULSE_COPY: FallbackCopy = { - label: { de: "dein Ruhepuls", en: "your resting pulse" }, + label: { + de: "dein Ruhepuls", + en: "your resting pulse", + fr: "ton pouls au repos", + es: "tu pulso en reposo", + it: "il tuo battito a riposo", + pl: "twoje tętno spoczynkowe", + }, unit: " bpm", digits: 0, pointer: { de: "Miss ihn entspannt und zur gleichen Tageszeit, dann siehst du, ob die Richtung anhält.", en: "Take it relaxed and at the same time of day to see whether the direction holds.", + fr: "Prends-le détendu et à la même heure de la journée pour voir si la direction se maintient.", + es: "Tómatelo relajado y a la misma hora del día para ver si la dirección se mantiene.", + it: "Misuralo da rilassato e alla stessa ora del giorno per vedere se la direzione tiene.", + pl: "Mierz je na spokojnie i o tej samej porze dnia, żeby zobaczyć, czy kierunek się utrzyma.", }, }; const BMI_COPY: FallbackCopy = { - label: { de: "dein BMI", en: "your BMI" }, + label: { + de: "dein BMI", + en: "your BMI", + fr: "ton IMC", + es: "tu IMC", + it: "il tuo IMC", + pl: "twoje BMI", + }, unit: "", digits: 1, pointer: { de: "Schau ihn dir zusammen mit Gewichtstrend und Körperfett über ein paar Wochen an.", en: "Read it alongside your weight trend and body-fat over a few weeks rather than day by day.", + fr: "Lis-le avec la tendance de ton poids et ta masse grasse sur quelques semaines plutôt qu’au jour le jour.", + es: "Léelo junto con la tendencia de tu peso y tu grasa corporal a lo largo de unas semanas, no día a día.", + it: "Leggilo insieme all’andamento del tuo peso e alla massa grassa nell’arco di qualche settimana, non giorno per giorno.", + pl: "Czytaj je razem z tendencją wagi i poziomem tkanki tłuszczowej w skali kilku tygodni, a nie dzień po dniu.", }, }; const MOOD_COPY: FallbackCopy = { - label: { de: "deine Stimmung", en: "your mood" }, + label: { + de: "deine Stimmung", + en: "your mood", + fr: "ton humeur", + es: "tu estado de ánimo", + it: "il tuo umore", + pl: "twój nastrój", + }, unit: "", digits: 1, pointer: { de: "Halte sie ein paar Tage fest — wiederkehrende Muster sagen mehr als ein einzelner Tag.", en: "Log it for a few days — a recurring pattern says more than any single day.", + fr: "Note-la pendant quelques jours — un motif qui revient en dit plus qu’une journée isolée.", + es: "Regístralo unos días — un patrón que se repite dice más que un día suelto.", + it: "Registralo per qualche giorno — uno schema che si ripete dice più di un singolo giorno.", + pl: "Zapisuj go przez kilka dni — powtarzający się wzorzec mówi więcej niż pojedynczy dzień.", }, }; const ADHERENCE_COPY: FallbackCopy = { - label: { de: "deine Einnahmetreue", en: "your medication adherence" }, + label: { + de: "deine Einnahmetreue", + en: "your medication adherence", + fr: "ton observance du traitement", + es: "tu adherencia a la medicación", + it: "la tua aderenza alla terapia", + pl: "twoja regularność przyjmowania leków", + }, unit: "%", digits: 0, pointer: { de: "Eine feste Zeit oder ein kurzer Reminder fängt die wiederkehrenden Auslassungen am ehesten ab.", en: "A fixed time or a quick reminder is the most reliable way to catch the repeat misses.", + fr: "Une heure fixe ou un rappel court est ce qui rattrape le mieux les oublis répétés.", + es: "Una hora fija o un recordatorio breve es lo que mejor recupera los olvidos repetidos.", + it: "Un orario fisso o un promemoria breve è ciò che intercetta meglio le dimenticanze ripetute.", + pl: "Stała pora albo krótkie przypomnienie najlepiej wyłapuje powtarzające się pominięcia.", }, }; +/** The generic pointer for the dynamic per-metric card, which has no copy table. */ +const GENERIC_METRIC_POINTER: Localized = { + de: "Ein paar konsistente Messungen zeigen, ob das die Richtung ist oder ein einzelner Tag.", + en: "A few consistent readings will show whether this is the direction or a single day.", + fr: "Quelques mesures régulières montreront si c’est la direction prise ou une seule journée.", + es: "Unas cuantas mediciones constantes mostrarán si es la dirección o un solo día.", + it: "Qualche misurazione costante mostrerà se è la direzione o un singolo giorno.", + pl: "Kilka konsekwentnych pomiarów pokaże, czy to kierunek, czy pojedynczy dzień.", +}; + // ── public fallbacks ──────────────────────────────────────────────────────── /** @@ -268,16 +445,12 @@ export function getNoKeyMetricStatusText( ? composeGroundedFallback( signal, { - label: { - de: signal.metric, - en: signal.metric, - }, + // The signal's own label is already natural language and is not + // localised upstream, so it stands for every locale. + label: { en: signal.metric }, ...(signal.unit ? { unit: ` ${signal.unit}` } : {}), digits: 1, - pointer: { - de: "Ein paar konsistente Messungen zeigen, ob das die Richtung ist oder ein einzelner Tag.", - en: "A few consistent readings will show whether this is the direction or a single day.", - }, + pointer: GENERIC_METRIC_POINTER, }, locale, ) @@ -299,24 +472,34 @@ export function getNoKeyMetricStatusText( * What follows the lead is what makes the metric readable, phrased as how the * measure behaves rather than as an order to the reader. */ +const NO_READ_LEAD: Localized = { + de: "Für diese Karte liegt gerade keine Einschätzung vor.", + en: "No assessment on this one right now.", + fr: "Aucune évaluation disponible pour le moment.", + es: "Ahora mismo no hay ninguna valoración disponible.", + it: "Al momento non è disponibile alcuna valutazione.", + pl: "Na razie nie ma tu żadnej oceny.", +}; + function noReadLead(locale: InsightLocale): string { - return locale === "de" - ? "Für diese Karte liegt gerade keine Einschätzung vor." - : "No assessment on this one right now."; + return pick(locale, NO_READ_LEAD); } -function floor(locale: InsightLocale, de: string, en: string): string { - return `${noReadLead(locale)} ${getLocalizedText(locale, de, en)}`; +function floor(locale: InsightLocale, body: Localized): string { + return `${noReadLead(locale)} ${getLocalizedText(locale, body)}`; } export function getNoKeyGeneralStatusText(locale: InsightLocale): string { // The overview spans many metrics with no single headline value to ground // against, so it keeps the honest, generic multi-metric pointer. - return floor( - locale, - "Aussagekräftig werden diese Zahlen über mehrere Wochen, nicht über einzelne Tage — und zu konstanten Zeitpunkten erfasst, damit sie vergleichbar bleiben. Das Signal, das zählt, ist mehrere Kennzahlen, die sich gleichzeitig in dieselbe ungünstige Richtung bewegen, nicht ein Wert an einem Tag.", - "These numbers become readable across several weeks rather than single days, and taken at consistent times so they stay comparable. The signal that counts is several metrics drifting the same unfavourable way at once, not one value on one day.", - ); + return floor(locale, { + de: "Aussagekräftig werden diese Zahlen über mehrere Wochen, nicht über einzelne Tage — und zu konstanten Zeitpunkten erfasst, damit sie vergleichbar bleiben. Das Signal, das zählt, ist mehrere Kennzahlen, die sich gleichzeitig in dieselbe ungünstige Richtung bewegen, nicht ein Wert an einem Tag.", + en: "These numbers become readable across several weeks rather than single days, and taken at consistent times so they stay comparable. The signal that counts is several metrics drifting the same unfavourable way at once, not one value on one day.", + fr: "Ces chiffres deviennent lisibles sur plusieurs semaines plutôt que sur des journées isolées, et relevés à des moments constants pour rester comparables. Le signal qui compte, c’est plusieurs indicateurs qui dérivent en même temps dans le même sens défavorable, pas une valeur un jour donné.", + es: "Estos números se vuelven legibles a lo largo de varias semanas y no en días sueltos, y tomados a horas constantes para que sigan siendo comparables. La señal que cuenta son varios indicadores derivando a la vez en la misma dirección desfavorable, no un valor en un día.", + it: "Questi numeri diventano leggibili nell’arco di più settimane anziché di singoli giorni, e rilevati a orari costanti per restare confrontabili. Il segnale che conta è più indicatori che scivolano insieme nella stessa direzione sfavorevole, non un valore in un giorno.", + pl: "Te liczby stają się czytelne w skali kilku tygodni, a nie pojedynczych dni, i tylko wtedy, gdy są zbierane o stałych porach, żeby pozostały porównywalne. Sygnałem, który się liczy, jest kilka wskaźników dryfujących jednocześnie w tę samą niekorzystną stronę, a nie jedna wartość jednego dnia.", + }); } /** @@ -324,26 +507,46 @@ export function getNoKeyGeneralStatusText(locale: InsightLocale): string { * multi-metric overview tip — text about watching several metrics at once, on * a card showing exactly one lab value. This says something true about a lab * marker instead. + * + * The named lead is built per locale rather than by splicing a shared clause: + * the quoting convention differs (German low-high quotes, French and Italian + * guillemets, Polish low-high) and so does where the marker name attaches to + * the sentence. */ +const BIOMARKER_NAMED_LEAD: Localized<(named: string) => string> = { + de: (n) => `Für diesen Laborwert${n} liegt gerade keine Einschätzung vor.`, + en: (n) => `No assessment${n} right now.`, + fr: (n) => `Aucune évaluation${n} pour le moment.`, + es: (n) => `Ahora mismo no hay ninguna valoración${n}.`, + it: (n) => `Al momento non è disponibile alcuna valutazione${n}.`, + pl: (n) => `Na razie nie ma żadnej oceny${n}.`, +}; + +const BIOMARKER_NAMED_CLAUSE: Localized<(marker: string) => string> = { + de: (m) => ` zu „${m}“`, + en: (m) => ` for "${m}"`, + fr: (m) => ` pour « ${m} »`, + es: (m) => ` de «${m}»`, + it: (m) => ` per «${m}»`, + pl: (m) => ` dla „${m}”`, +}; + export function getNoKeyBiomarkerStatusText( locale: InsightLocale, markerName?: string | null, ): string { + const trimmed = markerName?.trim() ?? ""; const named = - markerName && markerName.trim().length > 0 - ? locale === "de" - ? ` zu „${markerName.trim()}"` - : ` for "${markerName.trim()}"` - : ""; - const lead = - locale === "de" - ? `Für diesen Laborwert${named} liegt gerade keine Einschätzung vor.` - : `No assessment${named} right now.`; - return `${lead} ${getLocalizedText( - locale, - "Ein Laborwert liest sich zuerst gegen die eigenen vorherigen Abnahmen — ein Referenzbereich ist ein grober Anker, kein Urteil. Ein Wert, der sich über mehrere Abnahmen kaum bewegt, erzählt etwas anderes als einer, der gerade gesprungen ist. Die nächste ärztliche Kontrolle ist der richtige Ort, ihn einzuordnen.", - "A lab value reads first against your own previous draws — a reference range is a coarse anchor, not a verdict. One that has barely moved across several draws tells a different story from one that has just stepped. Your next check-up is the right place to put it in context.", - )}`; + trimmed.length > 0 ? pick(locale, BIOMARKER_NAMED_CLAUSE)(trimmed) : ""; + const lead = pick(locale, BIOMARKER_NAMED_LEAD)(named); + return `${lead} ${getLocalizedText(locale, { + de: "Ein Laborwert liest sich zuerst gegen die eigenen vorherigen Abnahmen — ein Referenzbereich ist ein grober Anker, kein Urteil. Ein Wert, der sich über mehrere Abnahmen kaum bewegt, erzählt etwas anderes als einer, der gerade gesprungen ist. Die nächste ärztliche Kontrolle ist der richtige Ort, ihn einzuordnen.", + en: "A lab value reads first against your own previous draws — a reference range is a coarse anchor, not a verdict. One that has barely moved across several draws tells a different story from one that has just stepped. Your next check-up is the right place to put it in context.", + fr: "Une valeur de laboratoire se lit d’abord contre tes propres prélèvements précédents — un intervalle de référence est un repère grossier, pas un verdict. Une valeur qui a à peine bougé sur plusieurs prélèvements raconte autre chose qu’une valeur qui vient de faire un saut. Ton prochain contrôle médical est le bon endroit pour la remettre en contexte.", + es: "Un valor de laboratorio se lee primero frente a tus propias extracciones anteriores — un intervalo de referencia es un ancla aproximada, no un veredicto. Uno que apenas se ha movido a lo largo de varias extracciones cuenta algo distinto de uno que acaba de dar un salto. Tu próxima revisión médica es el lugar adecuado para ponerlo en contexto.", + it: "Un valore di laboratorio si legge prima di tutto rispetto ai tuoi prelievi precedenti — un intervallo di riferimento è un ancoraggio grossolano, non un verdetto. Uno che si è mosso appena nell’arco di più prelievi racconta una storia diversa da uno che ha appena fatto un salto. Il tuo prossimo controllo medico è il posto giusto per contestualizzarlo.", + pl: "Wynik laboratoryjny czyta się najpierw na tle twoich wcześniejszych oznaczeń — zakres referencyjny to zgrubna kotwica, a nie wyrok. Wynik, który przez kilka oznaczeń niemal się nie ruszył, mówi co innego niż taki, który właśnie podskoczył. Najbliższa kontrola lekarska to właściwe miejsce, żeby go osadzić w kontekście.", + })}`; } export function getNoKeyBloodPressureStatusText( @@ -352,11 +555,14 @@ export function getNoKeyBloodPressureStatusText( ): string { return ( composeGroundedFallback(signal, BLOOD_PRESSURE_COPY, locale) ?? - floor( - locale, - "Blutdruck wird lesbar über eine Reihe ruhiger Messungen unter vergleichbaren Bedingungen — die Richtung über mehrere Tage sagt weit mehr als ein einzelner Ausreißer, und systolisch und diastolisch gehören dabei zusammen gelesen.", - "Blood pressure becomes readable over a run of calm readings under similar conditions — the direction across several days says far more than any single outlier, and systolic and diastolic are read together rather than one at a time.", - ) + floor(locale, { + de: "Blutdruck wird lesbar über eine Reihe ruhiger Messungen unter vergleichbaren Bedingungen — die Richtung über mehrere Tage sagt weit mehr als ein einzelner Ausreißer, und systolisch und diastolisch gehören dabei zusammen gelesen.", + en: "Blood pressure becomes readable over a run of calm readings under similar conditions — the direction across several days says far more than any single outlier, and systolic and diastolic are read together rather than one at a time.", + fr: "La tension se lit sur une série de mesures au calme dans des conditions comparables — la direction sur plusieurs jours dit bien plus qu’une valeur isolée, et la systolique et la diastolique se lisent ensemble plutôt que l’une après l’autre.", + es: "La tensión se lee a lo largo de una serie de mediciones en calma y en condiciones comparables — la dirección a lo largo de varios días dice mucho más que cualquier valor aislado, y la sistólica y la diastólica se leen juntas y no una por una.", + it: "La pressione si legge su una serie di misurazioni a riposo in condizioni confrontabili — la direzione nell’arco di più giorni dice molto più di un singolo valore isolato, e sistolica e diastolica si leggono insieme anziché una alla volta.", + pl: "Ciśnienie staje się czytelne dopiero w serii spokojnych pomiarów w porównywalnych warunkach — kierunek z kilku dni mówi znacznie więcej niż pojedynczy wyskok, a wartość skurczowa i rozkurczowa czyta się razem, a nie osobno.", + }) ); } @@ -366,11 +572,14 @@ export function getNoKeyWeightStatusText( ): string { return ( composeGroundedFallback(signal, WEIGHT_COPY, locale) ?? - floor( - locale, - "Gewicht liest sich als Verlauf, nicht als Tageszahl — unter konstanten Bedingungen gewogen, pendelt sich die normale Tagesschwankung von selbst ein. Die Richtung über Wochen, zusammen mit Blutdruck und BMI gelesen, ist das, was trägt.", - "Weight reads as a trend rather than a daily number — weighed under consistent conditions, the normal day-to-day swing settles out on its own. The direction over weeks, read alongside blood pressure and BMI, is what carries.", - ) + floor(locale, { + de: "Gewicht liest sich als Verlauf, nicht als Tageszahl — unter konstanten Bedingungen gewogen, pendelt sich die normale Tagesschwankung von selbst ein. Die Richtung über Wochen, zusammen mit Blutdruck und BMI gelesen, ist das, was trägt.", + en: "Weight reads as a trend rather than a daily number — weighed under consistent conditions, the normal day-to-day swing settles out on its own. The direction over weeks, read alongside blood pressure and BMI, is what carries.", + fr: "Le poids se lit comme une tendance et non comme un chiffre du jour — pesé dans des conditions constantes, la fluctuation quotidienne normale se lisse d’elle-même. C’est la direction sur des semaines, lue avec la tension et l’IMC, qui porte.", + es: "El peso se lee como una tendencia y no como una cifra diaria — pesado en condiciones constantes, la fluctuación normal del día a día se compensa sola. Lo que sostiene es la dirección a lo largo de semanas, leída junto a la tensión y el IMC.", + it: "Il peso si legge come andamento e non come numero del giorno — pesato in condizioni costanti, la normale oscillazione quotidiana si compensa da sé. Quello che regge è la direzione nell’arco di settimane, letta insieme alla pressione e all’IMC.", + pl: "Wagę czyta się jako przebieg, a nie jako liczbę z jednego dnia — przy ważeniu w stałych warunkach normalne dobowe wahania same się wyrównają. Nośny jest kierunek z kilku tygodni, czytany razem z ciśnieniem i BMI.", + }) ); } @@ -380,11 +589,14 @@ export function getNoKeyPulseStatusText( ): string { return ( composeGroundedFallback(signal, PULSE_COPY, locale) ?? - floor( - locale, - "Der Ruhepuls liest sich am besten entspannt und zur gleichen Tageszeit gemessen. Kurze Ausschläge sind gewöhnlich; was auffällt, ist ein Muster, das über mehrere Tage hält, oder wiederholtes Abdriften vom eigenen üblichen Bereich.", - "Resting pulse reads best taken relaxed and at the same time of day. Short spikes are ordinary; what stands out is a pattern holding across several days, or repeated drift away from your usual range.", - ) + floor(locale, { + de: "Der Ruhepuls liest sich am besten entspannt und zur gleichen Tageszeit gemessen. Kurze Ausschläge sind gewöhnlich; was auffällt, ist ein Muster, das über mehrere Tage hält, oder wiederholtes Abdriften vom eigenen üblichen Bereich.", + en: "Resting pulse reads best taken relaxed and at the same time of day. Short spikes are ordinary; what stands out is a pattern holding across several days, or repeated drift away from your usual range.", + fr: "Le pouls au repos se lit au mieux pris détendu et à la même heure de la journée. Les à-coups brefs sont ordinaires ; ce qui ressort, c’est un motif qui tient sur plusieurs jours, ou une dérive répétée hors de tes repères habituels.", + es: "El pulso en reposo se lee mejor tomado relajado y a la misma hora del día. Los picos breves son corrientes; lo que destaca es un patrón que se mantiene varios días, o una deriva repetida fuera de tu rango habitual.", + it: "Il battito a riposo si legge meglio se misurato da rilassato e alla stessa ora del giorno. I picchi brevi sono ordinari; quello che spicca è uno schema che tiene per più giorni, o uno scostamento ripetuto dal tuo intervallo abituale.", + pl: "Tętno spoczynkowe najlepiej czytać zmierzone na spokojnie i o tej samej porze dnia. Krótkie skoki są czymś zwyczajnym; uwagę zwraca wzorzec utrzymujący się przez kilka dni albo powtarzające się oddalanie od twojego zwykłego zakresu.", + }) ); } @@ -394,11 +606,14 @@ export function getNoKeyBmiStatusText( ): string { return ( composeGroundedFallback(signal, BMI_COPY, locale) ?? - floor( - locale, - "Der BMI ist eine grobe Orientierung, kein Urteil, und bedeutet am meisten zusammen mit Gewichtstrend und Körperzusammensetzung gelesen. Eine anhaltende Bewegung über Wochen trägt, wo ein Einzelwert es nicht tut.", - "BMI is a rough orientation rather than a verdict, and it means most read alongside your weight trend and body composition. Sustained movement over weeks carries where a single value does not.", - ) + floor(locale, { + de: "Der BMI ist eine grobe Orientierung, kein Urteil, und bedeutet am meisten zusammen mit Gewichtstrend und Körperzusammensetzung gelesen. Eine anhaltende Bewegung über Wochen trägt, wo ein Einzelwert es nicht tut.", + en: "BMI is a rough orientation rather than a verdict, and it means most read alongside your weight trend and body composition. Sustained movement over weeks carries where a single value does not.", + fr: "L’IMC est une orientation grossière plutôt qu’un verdict, et il prend son sens lu avec la tendance de ton poids et ta composition corporelle. Un mouvement soutenu sur des semaines porte là où une valeur isolée ne porte pas.", + es: "El IMC es una orientación aproximada más que un veredicto, y cobra sentido leído junto a la tendencia de tu peso y tu composición corporal. Un movimiento sostenido a lo largo de semanas sostiene donde un valor aislado no lo hace.", + it: "L’IMC è un orientamento grossolano più che un verdetto, e assume senso letto insieme all’andamento del tuo peso e alla composizione corporea. Un movimento sostenuto nell’arco di settimane regge dove un valore singolo non regge.", + pl: "BMI to zgrubna orientacja, a nie wyrok, i nabiera sensu dopiero czytane razem z tendencją wagi i składem ciała. Utrzymujący się ruch w skali tygodni jest nośny tam, gdzie pojedyncza wartość nie jest.", + }) ); } @@ -408,11 +623,14 @@ export function getNoKeyMedicationComplianceStatusText( ): string { return ( composeGroundedFallback(signal, ADHERENCE_COPY, locale) ?? - floor( - locale, - "Einnahmetreue liest sich als Konstanz über Wochen, nicht als eine Reihe perfekter Tage — je Medikament und im Gesamtbild. Wiederkehrende Auslassungen zur selben Tageszeit sind das Muster, auf das es ankommt, und eine feste Routine fängt sie am ehesten ab.", - "Adherence reads as consistency over weeks rather than a run of perfect days — per medication and in the overall picture. Repeated misses at the same time of day are the pattern that counts, and a fixed routine is what usually catches them.", - ) + floor(locale, { + de: "Einnahmetreue liest sich als Konstanz über Wochen, nicht als eine Reihe perfekter Tage — je Medikament und im Gesamtbild. Wiederkehrende Auslassungen zur selben Tageszeit sind das Muster, auf das es ankommt, und eine feste Routine fängt sie am ehesten ab.", + en: "Adherence reads as consistency over weeks rather than a run of perfect days — per medication and in the overall picture. Repeated misses at the same time of day are the pattern that counts, and a fixed routine is what usually catches them.", + fr: "L’observance se lit comme une régularité sur des semaines plutôt que comme une série de journées parfaites — par médicament et dans l’ensemble. Les oublis répétés à la même heure sont le motif qui compte, et c’est une routine fixe qui les rattrape d’ordinaire.", + es: "La adherencia se lee como constancia a lo largo de semanas y no como una racha de días perfectos — por medicamento y en el conjunto. Los olvidos repetidos a la misma hora son el patrón que cuenta, y lo que suele recuperarlos es una rutina fija.", + it: "L’aderenza si legge come costanza nell’arco di settimane più che come una serie di giornate perfette — per singolo farmaco e nel quadro complessivo. Le dimenticanze ripetute alla stessa ora sono lo schema che conta, ed è una routine fissa a intercettarle di solito.", + pl: "Regularność przyjmowania leków czyta się jako stałość w skali tygodni, a nie jako serię idealnych dni — osobno dla każdego leku i w całym obrazie. Powtarzające się pominięcia o tej samej porze to wzorzec, który się liczy, a wyłapuje je zwykle stały rytm dnia.", + }) ); } @@ -422,10 +640,13 @@ export function getNoKeyMoodStatusText( ): string { return ( composeGroundedFallback(signal, MOOD_COPY, locale) ?? - floor( - locale, - "Stimmung liest sich über Wochen, nicht über einzelne Tage — wiederkehrende Muster und wie sie zu den anderen Werten passen, sind das, was trägt. Eine anhaltende Phase niedriger Stimmung ist die, die Aufmerksamkeit verdient.", - "Mood reads over weeks rather than single days — recurring patterns, and how they line up with your other metrics, are what carry. A sustained low stretch is the one that deserves attention.", - ) + floor(locale, { + de: "Stimmung liest sich über Wochen, nicht über einzelne Tage — wiederkehrende Muster und wie sie zu den anderen Werten passen, sind das, was trägt. Eine anhaltende Phase niedriger Stimmung ist die, die Aufmerksamkeit verdient.", + en: "Mood reads over weeks rather than single days — recurring patterns, and how they line up with your other metrics, are what carry. A sustained low stretch is the one that deserves attention.", + fr: "L’humeur se lit sur des semaines plutôt que sur des journées isolées — ce qui porte, ce sont les motifs qui reviennent et la façon dont ils s’alignent avec tes autres valeurs. C’est une phase basse qui dure qui mérite l’attention.", + es: "El estado de ánimo se lee a lo largo de semanas y no en días sueltos — lo que sostiene son los patrones que se repiten y cómo encajan con tus otros valores. La que merece atención es una fase baja que se prolonga.", + it: "L’umore si legge nell’arco di settimane più che nei singoli giorni — quello che regge sono gli schemi ricorrenti e come si allineano con gli altri tuoi valori. È una fase bassa che si protrae quella che merita attenzione.", + pl: "Nastrój czyta się w skali tygodni, a nie pojedynczych dni — nośne są powtarzające się wzorce i to, jak układają się względem twoich pozostałych wartości. Uwagi wymaga przede wszystkim dłużej utrzymujący się spadek.", + }) ); } From a0b7dae8eb056388e7daf01287191c1e1dd1c93c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 13:56:39 +0200 Subject: [PATCH 5/5] =?UTF-8?q?chore(release):=20v1.30.35=20=E2=80=94=20th?= =?UTF-8?q?e=20offline=20metric=20texts=20speak=20six=20languages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 21 +++++++++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9af17af71..758857887 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ ## [Unreleased] +## [1.30.35] — 2026-07-19 + +- **The metric texts you see without an AI provider now speak your + language.** They shipped in German and English only; French, Spanish, + Italian and Polish fell through to English. These are what nine metric + cards render whenever no provider is reachable — no key configured, an + outage, a spent budget — so for those readers this text was the app's + voice, in the wrong language. Written per language rather than + translated: where a sentence could not be formed naturally, that + language got its own sentence. +- **The lab-marker card leads with what a value means**, like every other + card already did. It was the last one still instructed to state the + number first. +- **The response length for a metric assessment is set in one place** + again, instead of one card carrying its own inline pair. +- **The fallback texts open on something honest** rather than on an + instruction, and the lab-marker card no longer shows the multi-metric + overview tip on a single value. + +No migrations. No breaking changes. + ## [1.30.34] — 2026-07-19 - **Rearranging your dashboard no longer wipes the comparison baseline.** diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1adc19f7a..e85bd0c36 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.30.34 + version: 1.30.35 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 89ff910c7..7b01ae120 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.30.34", + "version": "1.30.35", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 5fc13aa0e..1c48fd505 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.30.34"; + /* @sw-version-fallback */ "v1.30.35"; 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/*`