mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
feat(insights): bring the biomarker card onto the shared assessment contract
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.
This commit is contained in:
1 parent
571e415eaa
commit
ed049661b7
8 files changed
+1040
-81
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>): 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
@@ -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 <metric> is <number>…". */
|
||||
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");
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user