mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
merge(FALLBACK): v1.21.0
# Conflicts: # src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts # src/lib/insights/narrative/period-narrative-generate.ts
This commit is contained in:
commit
490c9d240a
16 files changed
+567
-51
No files matched your search
@@ -71,12 +71,14 @@ const SURFACES: Record<
|
||||
forbiddenFiller,
|
||||
],
|
||||
},
|
||||
// The narrative is descriptive-only, so it enforces every contract EXCEPT
|
||||
// the motivating tone contract.
|
||||
// v1.21.0 (coach C1 MEDIUM-1) — the retrospective narrative now composes the
|
||||
// shared tone contract too, so it matches the warm house voice of the daily
|
||||
// briefing beside it. Its own descriptive-never-causal guards stay intact.
|
||||
"period-narrative": {
|
||||
prompt: (l) => SYSTEM_PROMPTS_FOR_TEST[l],
|
||||
contracts: [
|
||||
grounding,
|
||||
toneContract,
|
||||
safetyGlp1,
|
||||
safetyAcute,
|
||||
metricIdentifierBan,
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
getNoKeyBloodPressureStatusText,
|
||||
getNoKeyWeightStatusText,
|
||||
getNoKeyPulseStatusText,
|
||||
getNoKeyMoodStatusText,
|
||||
getNoKeyMedicationComplianceStatusText,
|
||||
getNoKeyMetricStatusText,
|
||||
getNoKeyGeneralStatusText,
|
||||
} from "@/lib/insights/no-key-fallbacks";
|
||||
import type { MetricSignal } from "@/lib/insights/metric-signal";
|
||||
|
||||
/** A minimal signal with the fields the grounded composer reads. */
|
||||
function signal(overrides: Partial<MetricSignal>): MetricSignal {
|
||||
return {
|
||||
metric: "your metric",
|
||||
current: 70,
|
||||
currentWindowDays: 7,
|
||||
baseline: 65,
|
||||
delta: 5,
|
||||
deltaPct: 7.7,
|
||||
spread: 2,
|
||||
outsideNormalSwing: true,
|
||||
direction: "lower-better",
|
||||
n: 14,
|
||||
newestDaysAgo: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("signal-grounded no-key fallbacks", () => {
|
||||
it("names the user's value and places it against their own baseline (pulse, en)", () => {
|
||||
const text = getNoKeyPulseStatusText(
|
||||
"en",
|
||||
signal({ current: 72, baseline: 64, delta: 8, outsideNormalSwing: true }),
|
||||
);
|
||||
// Names the actual value with the metric's unit.
|
||||
expect(text).toContain("72 bpm");
|
||||
// Places it against the user's OWN baseline, not a population norm.
|
||||
expect(text).toContain("64 bpm");
|
||||
expect(text.toLowerCase()).toContain("usual average");
|
||||
// Ends with one grounded pointer (an actionable close, not a platitude).
|
||||
expect(text.trim().endsWith(".")).toBe(true);
|
||||
expect(text.toLowerCase()).toContain("same time of day");
|
||||
// No banned generic clinical opener.
|
||||
expect(text).not.toContain("Measure resting pulse in a relaxed state");
|
||||
});
|
||||
|
||||
it("affirms 'nothing to act on' when the value sits inside the usual swing (weight, en)", () => {
|
||||
const text = getNoKeyWeightStatusText(
|
||||
"en",
|
||||
signal({
|
||||
metric: "weight",
|
||||
current: 80.4,
|
||||
baseline: 80.3,
|
||||
delta: 0.1,
|
||||
outsideNormalSwing: false,
|
||||
direction: "target-band",
|
||||
}),
|
||||
);
|
||||
expect(text).toContain("80.4");
|
||||
expect(text.toLowerCase()).toContain("nothing you need to act on");
|
||||
});
|
||||
|
||||
it("names the value without inventing a comparison when there is no baseline (bp, de)", () => {
|
||||
const text = getNoKeyBloodPressureStatusText(
|
||||
"de",
|
||||
signal({
|
||||
metric: "blood pressure",
|
||||
current: 128,
|
||||
baseline: null,
|
||||
delta: null,
|
||||
outsideNormalSwing: null,
|
||||
deltaPct: null,
|
||||
spread: null,
|
||||
}),
|
||||
);
|
||||
expect(text).toContain("128");
|
||||
// No fabricated baseline phrasing.
|
||||
expect(text).not.toContain("Schnitt von");
|
||||
});
|
||||
|
||||
it("falls back to the generic tip when no signal is given (mood, en)", () => {
|
||||
const text = getNoKeyMoodStatusText("en");
|
||||
expect(text).toContain("Evaluate mood trends over several weeks");
|
||||
});
|
||||
|
||||
it("falls back to the generic tip when the signal has no finite current (adherence, en)", () => {
|
||||
const text = getNoKeyMedicationComplianceStatusText(
|
||||
"en",
|
||||
signal({ current: Number.NaN }),
|
||||
);
|
||||
expect(text).toContain("Consistency in intake matters more");
|
||||
});
|
||||
|
||||
it("adherence grounds with a percent value and a routine pointer (en)", () => {
|
||||
const text = getNoKeyMedicationComplianceStatusText(
|
||||
"en",
|
||||
signal({
|
||||
metric: "adherence",
|
||||
current: 72,
|
||||
baseline: 88,
|
||||
delta: -16,
|
||||
outsideNormalSwing: true,
|
||||
direction: "higher-better",
|
||||
}),
|
||||
);
|
||||
expect(text).toContain("72%");
|
||||
expect(text).toContain("88%");
|
||||
expect(text.toLowerCase()).toContain("reminder");
|
||||
});
|
||||
|
||||
it("the generic per-metric fallback grounds against the signal's own label + unit (en)", () => {
|
||||
const text = getNoKeyMetricStatusText(
|
||||
"en",
|
||||
signal({
|
||||
metric: "your steps",
|
||||
unit: "steps",
|
||||
current: 4200,
|
||||
baseline: 7800,
|
||||
delta: -3600,
|
||||
outsideNormalSwing: true,
|
||||
direction: "higher-better",
|
||||
}),
|
||||
);
|
||||
expect(text).toContain("4200 steps");
|
||||
expect(text).toContain("7800 steps");
|
||||
expect(text.toLowerCase()).toContain("your steps");
|
||||
});
|
||||
|
||||
it("the generic per-metric fallback degrades to the general tip with no signal", () => {
|
||||
expect(getNoKeyMetricStatusText("en")).toBe(
|
||||
getNoKeyGeneralStatusText("en"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,10 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
* fallback AS AN ASSESSMENT (the pre-v1.4.28 stick-until-midnight bug);
|
||||
* `updatedAt` stays null and the served text is never a real assessment.
|
||||
*
|
||||
* v1.21.0 (coach C1 HIGH-1) — the fallback now reports `hasProvider:false`
|
||||
* (it is a deterministic, signal-grounded line, not a fresh AI assessment),
|
||||
* and the served text names the user's own value rather than a generic tip.
|
||||
*
|
||||
* v1.8.3 — the timeout path now writes a *short-TTL negative stub*
|
||||
* (`{ timeout:true, model:"timeout-stub", retryAt }`). It is explicitly
|
||||
* rejected by `readFreshStatusText`, so it can never hide the real
|
||||
@@ -65,10 +69,15 @@ describe("generatePulseStatusForUser — provider timeout fallback", () => {
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
expect(result.hasProvider).toBe(true);
|
||||
// Honest labelling — a deterministic fallback is NOT a provider
|
||||
// assessment, so the UI can render it as the computed summary it is.
|
||||
expect(result.hasProvider).toBe(false);
|
||||
expect(result.cached).toBe(true);
|
||||
expect(typeof result.text).toBe("string");
|
||||
expect(result.text?.length ?? 0).toBeGreaterThan(0);
|
||||
// Signal-grounded — names the user's own pulse value (72), not a generic
|
||||
// clinical platitude.
|
||||
expect(result.text).toContain("72 bpm");
|
||||
// No real assessment persisted — `updatedAt` stays null so the card
|
||||
// never mislabels the fallback as a fresh assessment.
|
||||
expect(result.updatedAt).toBeNull();
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
|
||||
|
||||
describe("returnTimeoutFallback", () => {
|
||||
it("reports hasProvider:false so a deterministic fallback is never mislabelled as a fresh AI assessment", () => {
|
||||
const result = returnTimeoutFallback({
|
||||
cacheAction: "insights.pulse-status.en",
|
||||
reason: "timeout",
|
||||
stubText: "Your resting pulse is at 72 bpm right now.",
|
||||
// No userId/todayKey → no negative-stub write, pure render path.
|
||||
});
|
||||
expect(result.hasProvider).toBe(false);
|
||||
expect(result.cached).toBe(true);
|
||||
expect(result.updatedAt).toBeNull();
|
||||
expect(result.text).toContain("72 bpm");
|
||||
});
|
||||
|
||||
it("passes the grounded stub text through verbatim on an error", () => {
|
||||
const stub = "Your blood pressure is at 128 right now.";
|
||||
const result = returnTimeoutFallback({
|
||||
cacheAction: "insights.blood-pressure-status.en",
|
||||
reason: "error",
|
||||
stubText: stub,
|
||||
});
|
||||
expect(result.text).toBe(stub);
|
||||
expect(result.hasProvider).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -696,7 +696,7 @@ export async function prepareBloodPressureStatusForUser(
|
||||
temperature: 0.45,
|
||||
noProvider: {
|
||||
hasProvider: false,
|
||||
text: getNoKeyBloodPressureStatusText(locale),
|
||||
text: getNoKeyBloodPressureStatusText(locale, sysSignal),
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
},
|
||||
@@ -706,7 +706,7 @@ export async function prepareBloodPressureStatusForUser(
|
||||
reason,
|
||||
userId,
|
||||
todayKey,
|
||||
stubText: getNoKeyBloodPressureStatusText(locale),
|
||||
stubText: getNoKeyBloodPressureStatusText(locale, sysSignal),
|
||||
}),
|
||||
finalize: async (outcome): Promise<StatusCardResult> => {
|
||||
const summary = normalizeSummaryText(
|
||||
|
||||
@@ -409,7 +409,7 @@ export async function prepareBmiStatusForUser(
|
||||
temperature: 0.45,
|
||||
noProvider: {
|
||||
hasProvider: false,
|
||||
text: getNoKeyBmiStatusText(locale),
|
||||
text: getNoKeyBmiStatusText(locale, bmiSignal),
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
},
|
||||
@@ -419,7 +419,7 @@ export async function prepareBmiStatusForUser(
|
||||
reason,
|
||||
userId,
|
||||
todayKey,
|
||||
stubText: getNoKeyBmiStatusText(locale),
|
||||
stubText: getNoKeyBmiStatusText(locale, bmiSignal),
|
||||
}),
|
||||
finalize: async (outcome): Promise<StatusCardResult> => {
|
||||
const summary = normalizeSummaryText(
|
||||
|
||||
@@ -158,6 +158,41 @@ describe("resolveDeterministicAssessment", () => {
|
||||
expect(a!.text).toContain("Schlafmenge");
|
||||
});
|
||||
|
||||
it("appends a grounded next step when the weakest contributor is addressable (en)", () => {
|
||||
// sleep (30) is the lowest present contributor → a behaviourally
|
||||
// addressable driver, so the assessment closes with a doable pointer.
|
||||
const sleepWeak: ReadinessValue = {
|
||||
score: 58,
|
||||
band: "yellow",
|
||||
components: [
|
||||
{ key: "rhr", value: 80, weight: 0.3 },
|
||||
{ key: "hrv", value: 75, weight: 0.3 },
|
||||
{ key: "sleep", value: 30, weight: 0.25 },
|
||||
{ key: "mood", value: 70, weight: 0.15 },
|
||||
],
|
||||
};
|
||||
const a = resolveDeterministicAssessment(
|
||||
"READINESS",
|
||||
okDerived(sleepWeak),
|
||||
"en",
|
||||
NOW,
|
||||
);
|
||||
expect(a!.text).toContain("Held back most by");
|
||||
expect(a!.text).toContain("An earlier night would lift this most.");
|
||||
});
|
||||
|
||||
it("does NOT manufacture a step when the weakest contributor is physiology-only (en)", () => {
|
||||
// rhr (40) is the lowest in the base readiness fixture → no pointer.
|
||||
const a = resolveDeterministicAssessment(
|
||||
"READINESS",
|
||||
okDerived(readiness),
|
||||
"en",
|
||||
NOW,
|
||||
);
|
||||
expect(a!.text).not.toContain("An earlier night");
|
||||
expect(a!.text).not.toContain("most effective lever");
|
||||
});
|
||||
|
||||
it("uses the trend for a contributor-less recovery score (en)", () => {
|
||||
const a = resolveDeterministicAssessment(
|
||||
"RECOVERY_SCORE",
|
||||
|
||||
@@ -247,6 +247,41 @@ function contributorLabel(
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.21.0 (coach C1 MEDIUM-2) — a single grounded next-step pointer keyed to
|
||||
* the weakest BEHAVIOURALLY ADDRESSABLE contributor. Only the contributors a
|
||||
* person can actually move (sleep, mood, consistency, timing) carry a
|
||||
* pointer; physiology-only contributors (rhr, hrv, respiratory) return null,
|
||||
* so the assessment affirms-and-watches rather than manufacturing a step —
|
||||
* matching the `base-system.ts` "do NOT manufacture a step" rule.
|
||||
*/
|
||||
const CONTRIBUTOR_POINTERS: Record<string, { de: string; en: string }> = {
|
||||
sleep: {
|
||||
de: "Eine etwas frühere Nacht würde das am ehesten anheben.",
|
||||
en: "An earlier night would lift this most.",
|
||||
},
|
||||
sufficiency: {
|
||||
de: "Etwas mehr Schlafzeit ist hier der wirksamste Hebel.",
|
||||
en: "A little more time asleep is the most effective lever here.",
|
||||
},
|
||||
consistency: {
|
||||
de: "Gleichmäßigere Schlafzeiten über die Woche helfen am meisten.",
|
||||
en: "More even sleep and wake times across the week help most.",
|
||||
},
|
||||
timing: {
|
||||
de: "Ein gleichmäßigerer Rhythmus zieht das am ehesten nach oben.",
|
||||
en: "A steadier sleep rhythm is the most likely thing to pull this up.",
|
||||
},
|
||||
mood: {
|
||||
de: "Ein kurzer Moment für etwas, das dir guttut, kann hier spürbar helfen.",
|
||||
en: "A small moment for something that does you good can noticeably help here.",
|
||||
},
|
||||
};
|
||||
|
||||
function contributorPointer(key: string, locale: Locale): string | null {
|
||||
return CONTRIBUTOR_POINTERS[key]?.[locale] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the deterministic per-score assessment from the score's signal +
|
||||
* its contributors. Always returns a non-empty, factual, non-causal text.
|
||||
@@ -294,6 +329,11 @@ export function buildDeterministicScoreAssessment(
|
||||
? `Am stärksten gedämpft durch ${joined}.`
|
||||
: `Held back most by ${joined}.`,
|
||||
);
|
||||
// v1.21.0 (MEDIUM-2) — close with ONE grounded next step drawn from the
|
||||
// weakest contributor, but only when it is behaviourally addressable.
|
||||
// When the weakest driver is physiology-only, no step is manufactured.
|
||||
const pointer = contributorPointer(ranked[0].key, locale);
|
||||
if (pointer) sentences.push(pointer);
|
||||
}
|
||||
} else if (signal.delta !== null && signal.delta !== 0) {
|
||||
// No contributor breakdown (recovery/stress/strain): use the trend.
|
||||
|
||||
@@ -46,7 +46,10 @@ 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 { getNoKeyGeneralStatusText } from "@/lib/insights/no-key-fallbacks";
|
||||
import {
|
||||
getNoKeyGeneralStatusText,
|
||||
getNoKeyMetricStatusText,
|
||||
} from "@/lib/insights/no-key-fallbacks";
|
||||
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
|
||||
import {
|
||||
buildGradedSeriesFromPoints,
|
||||
@@ -479,7 +482,7 @@ export async function generateMetricStatus(args: {
|
||||
if (outcome.kind === "none") {
|
||||
return {
|
||||
hasProvider: false,
|
||||
text: getNoKeyGeneralStatusText(locale),
|
||||
text: getNoKeyMetricStatusText(locale, signal),
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
};
|
||||
@@ -490,7 +493,7 @@ export async function generateMetricStatus(args: {
|
||||
reason: outcome.kind,
|
||||
userId: args.userId,
|
||||
todayKey,
|
||||
stubText: getNoKeyGeneralStatusText(locale),
|
||||
stubText: getNoKeyMetricStatusText(locale, signal),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -708,7 +708,7 @@ export async function prepareMoodStatusForUser(
|
||||
temperature: 0.45,
|
||||
noProvider: {
|
||||
hasProvider: false,
|
||||
text: getNoKeyMoodStatusText(locale),
|
||||
text: getNoKeyMoodStatusText(locale, moodSignal),
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
},
|
||||
@@ -718,7 +718,7 @@ export async function prepareMoodStatusForUser(
|
||||
reason,
|
||||
userId,
|
||||
todayKey,
|
||||
stubText: getNoKeyMoodStatusText(locale),
|
||||
stubText: getNoKeyMoodStatusText(locale, moodSignal),
|
||||
}),
|
||||
finalize: async (outcome): Promise<StatusCardResult> => {
|
||||
const summary = normalizeSummaryText(
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
PeriodNarrativeContext,
|
||||
PeriodNarrativeResult,
|
||||
} from "@/lib/insights/narrative/period-narrative";
|
||||
import { toneContract } from "@/lib/ai/prompts/shared-contracts";
|
||||
|
||||
function readyContext(
|
||||
over: Partial<PeriodNarrativeContext> = {},
|
||||
@@ -170,6 +171,9 @@ describe("generatePeriodNarrative — descriptive generation", () => {
|
||||
});
|
||||
expect(systemPrompt).toMatch(/never CAUSAL|DESCRIPTIVE/);
|
||||
expect(systemPrompt).toMatch(/no markdown/i);
|
||||
// v1.21.0 (coach C1 MEDIUM-1) — the retrospective now carries the shared
|
||||
// warm tone contract verbatim, matching the briefing's house voice.
|
||||
expect(systemPrompt).toContain(toneContract.en);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -104,11 +104,11 @@ Hard rules:
|
||||
- The supplied CONTEXT is the ONLY source of truth. Never state a number, trend, driver, or threshold that is not in it.
|
||||
- Be DESCRIPTIVE, never CAUSAL. Say "X moved with Y" or "X was associated with Y", never "X caused Y" or "because of X".
|
||||
- The listed drivers already survived statistical multiple-comparison control; restate them only as associations and keep their conservative meaning.
|
||||
- No diagnosis, no medical advice, no alarm. Calm, factual, second person ("your").
|
||||
- No diagnosis, no medical advice, no alarm. Calm, factual, second person ("your"). When the period earned it, name one genuine win plainly; keep it warm and second-person, never a reflexive compliment.
|
||||
- 2 to 4 short sentences. Plain text only — no markdown, no headings, no bullet points, no emojis.
|
||||
- If the context is thin, say plainly that there is little to report this period rather than inventing detail.
|
||||
|
||||
${composeSharedContracts("en", ["grounding", "safetyGlp1", "safetyAcute", "metricIdentifierBan", "forbiddenFiller"])}`;
|
||||
${composeSharedContracts("en", ["toneContract", "grounding", "safetyGlp1", "safetyAcute", "metricIdentifierBan", "forbiddenFiller"])}`;
|
||||
|
||||
const SYSTEM_PROMPT_DE = `Du fasst den Gesundheits-Tracking-ZEITRAUM einer Person (eine Woche oder einen Monat) für diese Person zusammen.
|
||||
Prompt-Version: ${NARRATIVE_PROMPT_VERSION}.
|
||||
@@ -117,11 +117,11 @@ Feste Regeln:
|
||||
- Der bereitgestellte KONTEXT ist die EINZIGE Wahrheitsquelle. Nenne nie eine Zahl, einen Trend, einen Zusammenhang oder einen Schwellenwert, der nicht darin steht.
|
||||
- Sei BESCHREIBEND, nie URSÄCHLICH. Sage "X bewegte sich mit Y" oder "X war mit Y assoziiert", nie "X verursachte Y" oder "wegen X".
|
||||
- Die genannten Zusammenhänge haben bereits die statistische Mehrfachvergleichskorrektur überstanden; gib sie nur als Assoziationen wieder und bewahre ihre vorsichtige Bedeutung.
|
||||
- Keine Diagnose, kein medizinischer Rat, keine Panik. Ruhig, sachlich, in der zweiten Person ("dein").
|
||||
- Keine Diagnose, kein medizinischer Rat, keine Panik. Ruhig, sachlich, in der zweiten Person ("dein"). Wenn der Zeitraum es hergibt, benenne einen echten Erfolg klar; bleib warm und in der zweiten Person, nie ein reflexhaftes Kompliment.
|
||||
- 2 bis 4 kurze Sätze. Nur Klartext — kein Markdown, keine Überschriften, keine Aufzählungen, keine Emojis.
|
||||
- Wenn der Kontext dünn ist, sage klar, dass es in diesem Zeitraum wenig zu berichten gibt, statt Details zu erfinden.
|
||||
|
||||
${composeSharedContracts("de", ["grounding", "safetyGlp1", "safetyAcute", "metricIdentifierBan", "forbiddenFiller"])}`;
|
||||
${composeSharedContracts("de", ["toneContract", "grounding", "safetyGlp1", "safetyAcute", "metricIdentifierBan", "forbiddenFiller"])}`;
|
||||
|
||||
/**
|
||||
* Test-only view of the composed system prompts (incl. the appended shared
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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
|
||||
@@ -7,6 +8,17 @@ import type { Locale } from "@/lib/i18n/config";
|
||||
* 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.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
|
||||
* timeout path rendered them as if they were a fresh AI assessment.
|
||||
* Each `getNoKey*StatusText` now accepts the per-metric `MetricSignal`
|
||||
* the card already built and, when present, composes a SIGNAL-GROUNDED
|
||||
* deterministic line: it names the user's current value, places it
|
||||
* against their own baseline, and ends with ONE plain-language pointer
|
||||
* — the same warm voice the LLM surfaces use, without an LLM call. The
|
||||
* generic best-practice tip stays only as the honest no-signal floor
|
||||
* (no fabricated numbers when a metric has no usable history).
|
||||
*/
|
||||
export type InsightLocale = Locale;
|
||||
|
||||
@@ -18,7 +30,212 @@ function getLocalizedText(
|
||||
return locale === "de" ? de : en;
|
||||
}
|
||||
|
||||
// ── signal-grounded composer ────────────────────────────────────────────────
|
||||
|
||||
/** 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 };
|
||||
/** Unit suffix appended to a value (" bpm"), empty when none. */
|
||||
unit?: string;
|
||||
/** Digits to round the rendered value to (0 for integers). */
|
||||
digits?: number;
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
|
||||
function fmtValue(value: number, copy: FallbackCopy): string {
|
||||
const digits = copy.digits ?? 0;
|
||||
// Round to the metric's precision, but never render a trailing ".0" — an
|
||||
// integer reading (steps, a whole-number BMI day) should read as "4200",
|
||||
// not "4200.0", while a genuine fractional value keeps its decimal.
|
||||
const factor = 10 ** digits;
|
||||
const rounded = Math.round(value * factor) / factor;
|
||||
const text = Number.isInteger(rounded)
|
||||
? String(rounded)
|
||||
: rounded.toFixed(digits);
|
||||
return copy.unit ? `${text}${copy.unit}` : text;
|
||||
}
|
||||
|
||||
function capitalise(s: string): string {
|
||||
return s.length === 0 ? s : s[0].toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose a warm, grounded deterministic line from a metric signal. Names
|
||||
* the current value, places it against the user's own baseline, and ends
|
||||
* with one pointer (when the value is outside their usual swing) or an
|
||||
* honest "nothing to act on" (when it sits in range). Returns null when the
|
||||
* signal carries no current value — the caller then uses the generic tip.
|
||||
*/
|
||||
function composeGroundedFallback(
|
||||
signal: MetricSignal | null | undefined,
|
||||
copy: FallbackCopy,
|
||||
locale: InsightLocale,
|
||||
): string | null {
|
||||
if (!signal || !Number.isFinite(signal.current)) return null;
|
||||
|
||||
const label = locale === "de" ? copy.label.de : copy.label.en;
|
||||
const current = fmtValue(signal.current, copy);
|
||||
|
||||
const sentences: string[] = [];
|
||||
|
||||
// 1) Lead with the current value, placed against the user's own baseline.
|
||||
if (
|
||||
signal.baseline !== null &&
|
||||
signal.delta !== null &&
|
||||
signal.delta !== 0 &&
|
||||
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}.`,
|
||||
);
|
||||
} 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.`,
|
||||
);
|
||||
} 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.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 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);
|
||||
} 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.",
|
||||
);
|
||||
} 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.",
|
||||
);
|
||||
}
|
||||
|
||||
return sentences.join(" ");
|
||||
}
|
||||
|
||||
// ── per-metric copy ─────────────────────────────────────────────────────────
|
||||
|
||||
const BLOOD_PRESSURE_COPY: FallbackCopy = {
|
||||
label: { de: "dein Blutdruck", en: "your blood pressure" },
|
||||
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.",
|
||||
},
|
||||
};
|
||||
|
||||
const WEIGHT_COPY: FallbackCopy = {
|
||||
label: { de: "dein Gewicht", en: "your weight" },
|
||||
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.",
|
||||
},
|
||||
};
|
||||
|
||||
const PULSE_COPY: FallbackCopy = {
|
||||
label: { de: "dein Ruhepuls", en: "your resting pulse" },
|
||||
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.",
|
||||
},
|
||||
};
|
||||
|
||||
const BMI_COPY: FallbackCopy = {
|
||||
label: { de: "dein BMI", en: "your 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.",
|
||||
},
|
||||
};
|
||||
|
||||
const MOOD_COPY: FallbackCopy = {
|
||||
label: { de: "deine Stimmung", en: "your mood" },
|
||||
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.",
|
||||
},
|
||||
};
|
||||
|
||||
const ADHERENCE_COPY: FallbackCopy = {
|
||||
label: { de: "deine Einnahmetreue", en: "your medication adherence" },
|
||||
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.",
|
||||
},
|
||||
};
|
||||
|
||||
// ── public fallbacks ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generic single-metric fallback for the dynamic per-metric card
|
||||
* (`metric-status.ts`), which carries a fully-formed signal but no
|
||||
* per-metric copy table. Grounds against the signal's OWN natural-language
|
||||
* label + unit; on a missing signal it degrades to the general tip.
|
||||
*/
|
||||
export function getNoKeyMetricStatusText(
|
||||
locale: InsightLocale,
|
||||
signal?: MetricSignal | null,
|
||||
): string {
|
||||
const grounded =
|
||||
signal && Number.isFinite(signal.current)
|
||||
? composeGroundedFallback(
|
||||
signal,
|
||||
{
|
||||
label: {
|
||||
de: signal.metric,
|
||||
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.",
|
||||
},
|
||||
},
|
||||
locale,
|
||||
)
|
||||
: null;
|
||||
return grounded ?? getNoKeyGeneralStatusText(locale);
|
||||
}
|
||||
|
||||
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(
|
||||
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.",
|
||||
@@ -26,52 +243,86 @@ export function getNoKeyGeneralStatusText(locale: InsightLocale): string {
|
||||
);
|
||||
}
|
||||
|
||||
export function getNoKeyBloodPressureStatusText(locale: InsightLocale): string {
|
||||
return getLocalizedText(
|
||||
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.",
|
||||
export function getNoKeyBloodPressureStatusText(
|
||||
locale: InsightLocale,
|
||||
signal?: MetricSignal | null,
|
||||
): string {
|
||||
return (
|
||||
composeGroundedFallback(signal, BLOOD_PRESSURE_COPY, locale) ??
|
||||
getLocalizedText(
|
||||
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.",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function getNoKeyWeightStatusText(locale: InsightLocale): string {
|
||||
return getLocalizedText(
|
||||
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.",
|
||||
export function getNoKeyWeightStatusText(
|
||||
locale: InsightLocale,
|
||||
signal?: MetricSignal | null,
|
||||
): string {
|
||||
return (
|
||||
composeGroundedFallback(signal, WEIGHT_COPY, locale) ??
|
||||
getLocalizedText(
|
||||
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.",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function getNoKeyPulseStatusText(locale: InsightLocale): string {
|
||||
return getLocalizedText(
|
||||
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.",
|
||||
export function getNoKeyPulseStatusText(
|
||||
locale: InsightLocale,
|
||||
signal?: MetricSignal | null,
|
||||
): string {
|
||||
return (
|
||||
composeGroundedFallback(signal, PULSE_COPY, locale) ??
|
||||
getLocalizedText(
|
||||
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.",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function getNoKeyBmiStatusText(locale: InsightLocale): string {
|
||||
return getLocalizedText(
|
||||
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.",
|
||||
export function getNoKeyBmiStatusText(
|
||||
locale: InsightLocale,
|
||||
signal?: MetricSignal | null,
|
||||
): string {
|
||||
return (
|
||||
composeGroundedFallback(signal, BMI_COPY, locale) ??
|
||||
getLocalizedText(
|
||||
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.",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function getNoKeyMedicationComplianceStatusText(
|
||||
locale: InsightLocale,
|
||||
signal?: MetricSignal | null,
|
||||
): string {
|
||||
return getLocalizedText(
|
||||
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.",
|
||||
return (
|
||||
composeGroundedFallback(signal, ADHERENCE_COPY, locale) ??
|
||||
getLocalizedText(
|
||||
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.",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function getNoKeyMoodStatusText(locale: InsightLocale): string {
|
||||
return getLocalizedText(
|
||||
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.",
|
||||
export function getNoKeyMoodStatusText(
|
||||
locale: InsightLocale,
|
||||
signal?: MetricSignal | null,
|
||||
): string {
|
||||
return (
|
||||
composeGroundedFallback(signal, MOOD_COPY, locale) ??
|
||||
getLocalizedText(
|
||||
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.",
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -461,7 +461,7 @@ export async function preparePulseStatusForUser(
|
||||
temperature: 0.45,
|
||||
noProvider: {
|
||||
hasProvider: false,
|
||||
text: getNoKeyPulseStatusText(locale),
|
||||
text: getNoKeyPulseStatusText(locale, pulseSignal),
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
},
|
||||
@@ -471,7 +471,7 @@ export async function preparePulseStatusForUser(
|
||||
reason,
|
||||
userId,
|
||||
todayKey,
|
||||
stubText: getNoKeyPulseStatusText(locale),
|
||||
stubText: getNoKeyPulseStatusText(locale, pulseSignal),
|
||||
}),
|
||||
finalize: async (outcome): Promise<StatusCardResult> => {
|
||||
const summary = normalizeSummaryText(
|
||||
|
||||
@@ -67,6 +67,14 @@ export async function persistTimeoutNegativeStub(args: {
|
||||
* signal "no persisted assessment". When `userId` + `todayKey` are present
|
||||
* (the worker-side generation path) a short-TTL negative stub is persisted
|
||||
* so a stalled provider doesn't trigger a re-enqueue storm.
|
||||
*
|
||||
* v1.21.0 (coach C1 HIGH-1) — the envelope now reports `hasProvider:false`.
|
||||
* The `stubText` is a DETERMINISTIC (signal-grounded or generic) line, not a
|
||||
* fresh provider assessment; reporting `hasProvider:true` let the UI render
|
||||
* it in the primary assessment slot indistinguishable from real AI prose on
|
||||
* any slow/errored call. `false` lets the UI surface it honestly as the
|
||||
* computed/offline summary it is. This is independent of whether the user
|
||||
* has a provider configured at all — it describes THIS text's authorship.
|
||||
*/
|
||||
export function returnTimeoutFallback(input: {
|
||||
cacheAction: string;
|
||||
@@ -75,7 +83,7 @@ export function returnTimeoutFallback(input: {
|
||||
userId?: string;
|
||||
todayKey?: string;
|
||||
}): {
|
||||
hasProvider: true;
|
||||
hasProvider: false;
|
||||
text: string;
|
||||
cached: true;
|
||||
updatedAt: null;
|
||||
@@ -94,7 +102,7 @@ export function returnTimeoutFallback(input: {
|
||||
});
|
||||
}
|
||||
return {
|
||||
hasProvider: true,
|
||||
hasProvider: false,
|
||||
text: input.stubText,
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
|
||||
@@ -604,7 +604,7 @@ export async function prepareWeightStatusForUser(
|
||||
temperature: 0.45,
|
||||
noProvider: {
|
||||
hasProvider: false,
|
||||
text: getNoKeyWeightStatusText(locale),
|
||||
text: getNoKeyWeightStatusText(locale, weightSignal),
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
},
|
||||
@@ -614,7 +614,7 @@ export async function prepareWeightStatusForUser(
|
||||
reason,
|
||||
userId,
|
||||
todayKey,
|
||||
stubText: getNoKeyWeightStatusText(locale),
|
||||
stubText: getNoKeyWeightStatusText(locale, weightSignal),
|
||||
}),
|
||||
finalize: async (outcome): Promise<StatusCardResult> => {
|
||||
const summary = normalizeSummaryText(
|
||||
|
||||
Reference in new issue
Block a user