merge(COACHFINAL): coach-core finalization — safety/scope/outlook/illness/learn/perf

This commit is contained in:
Marc-André Bombeck committed 2026-06-26 13:41:19 +02:00
commit 21a5eade41
18 files changed
+1221 -89

No files matched your search

@@ -71,9 +71,10 @@ vi.mock("@/lib/ai/coach/types", () => ({
// thin stub keeps the import graph satisfied without pulling the real schemas.
vi.mock("@/lib/ai/coach/tools", () => ({
COACH_TOOL_DEFS: [],
MAX_ROUNDS: 2,
MAX_ROUNDS: 3,
buildCoachDataInventory: vi.fn(),
renderDataInventory: vi.fn(),
renderFocusHint: vi.fn(() => ""),
buildToolModeAddendum: vi.fn(),
runCoachToolLoop: vi.fn(),
}));
@@ -138,31 +138,36 @@ vi.mock("@/lib/ai/coach/snapshot", () => ({
}));
// Tool-module surface — stub the inventory + loop so we assert routing only.
const { buildCoachDataInventory, renderDataInventory, runCoachToolLoop } =
vi.hoisted(() => ({
buildCoachDataInventory: vi.fn(async () => ({
entries: [],
restMode: false,
cycleEnabled: false,
window: "last30days",
})),
renderDataInventory: vi.fn(
() => "DATA INVENTORY\n- blood pressure: present",
),
runCoachToolLoop: vi.fn(async () => ({
result: { content: "Your BP is steady.", tokensUsed: 80, model: "m" },
workingProviderType: "anthropic",
totalTokens: 80,
rounds: 2,
toolTrace: [{ name: "get_metric_series", present: true }],
toolResults: [],
})),
}));
vi.mock("@/lib/ai/coach/tools", () => ({
COACH_TOOL_DEFS: [{ name: "get_metric_series" }],
MAX_ROUNDS: 2,
const {
buildCoachDataInventory,
renderDataInventory,
renderFocusHint,
runCoachToolLoop,
} = vi.hoisted(() => ({
buildCoachDataInventory: vi.fn(async () => ({
entries: [],
restMode: false,
cycleEnabled: false,
window: "last30days",
probeScope: { sources: ["bp", "hrv"], window: "last30days" },
})),
renderDataInventory: vi.fn(() => "DATA INVENTORY\n- blood pressure: present"),
renderFocusHint: vi.fn(() => ""),
runCoachToolLoop: vi.fn(async () => ({
result: { content: "Your BP is steady.", tokensUsed: 80, model: "m" },
workingProviderType: "anthropic",
totalTokens: 80,
rounds: 2,
toolTrace: [{ name: "get_metric_series", present: true }],
toolResults: [],
})),
}));
vi.mock("@/lib/ai/coach/tools", () => ({
COACH_TOOL_DEFS: [{ name: "get_metric_series" }],
MAX_ROUNDS: 3,
buildCoachDataInventory,
renderDataInventory,
renderFocusHint,
buildToolModeAddendum: vi.fn(() => "TOOL ADDENDUM"),
runCoachToolLoop,
}));
@@ -246,11 +251,11 @@ describe("coach chat — tool-mode routing (F1)", () => {
{ providerType: "anthropic", instance: {} },
]);
await post(chatReq({ message: "How is my BP?" }));
// maxTokens (1500) × MAX_ROUNDS (2). The 4th arg is the provider-aware
// maxTokens (1500) × MAX_ROUNDS (3). The 4th arg is the provider-aware
// daily cap (F1) — mocked to the user-plan ceiling for this BYOK chain.
expect(reserveBudget).toHaveBeenCalledWith(
"u1",
3000,
4500,
"2026-06-21",
2_000_000,
);
+37 -1
View File
@@ -78,6 +78,7 @@ import {
COACH_TOOL_DEFS,
buildCoachDataInventory,
renderDataInventory,
renderFocusHint,
buildToolModeAddendum,
runCoachToolLoop,
MAX_ROUNDS,
@@ -89,6 +90,7 @@ import {
findUnverifiedCoachNumbers,
stripUnverifiedNumbers,
} from "@/lib/ai/coach/coach-prose-grounding";
import { scrubUnknownLearnLinks } from "@/lib/ai/coach/learn-link-guard";
import { parseSuggestReminder } from "@/lib/ai/coach/suggest-reminder";
import { gateSuggestion } from "@/lib/ai/coach/suggest-gate";
import {
@@ -547,10 +549,17 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
// that fire this turn share its reads.
const inventory = await buildCoachDataInventory(userId, effectiveScope);
const toolSystem = `${systemPrompt}\n\n${buildToolModeAddendum(locale)}`;
// v1.21.0 (D1) — when the Coach was opened from a metric page/card, thread
// the launch sources into a one-line FOCUS hint so tool mode honours the
// metric the user is looking at (the no-tools path already narrows the
// snapshot; the inventory probes the full set, so this is the tool-mode
// equivalent of that narrowing). Empty string on a generic open.
const focusHint = renderFocusHint(effectiveScope?.sources);
const focusBlock = focusHint ? `${focusHint}\n\n` : "";
const messages: AiMessage[] = [
{
role: "user",
content: `${renderDataInventory(inventory)}${guidedBlock}
content: `${focusBlock}${renderDataInventory(inventory)}${guidedBlock}
CONVERSATION
${transcript}
@@ -567,6 +576,11 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch
temperature: AI_BUDGETS.coach.temperature,
maxTokens: AI_BUDGETS.coach.maxTokens,
fallbackWindow: effectiveScope?.window,
// v1.21.0 (D5-1) — share the inventory's full-source snapshot across
// every tool so the turn builds ONE snapshot, not one per tool. The
// probe scope is the exact scope the inventory was built against, so the
// per-tool reads land its 60s LRU entry.
sharedScope: inventory.probeScope,
// v1.20.1 — thread the abort signal so a mid-generation disconnect tears
// down the per-round provider calls instead of paying the full cost.
signal: request.signal,
@@ -755,6 +769,28 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch
}
}
// v1.21.0 (NEW-C C-3) — Learn-link post-filter. The prompt instructs the
// model to only link a published `/learn/<slug>`, but that is guidance, not
// enforcement: a fabricated `/learn/<invented-slug>` would otherwise ship as
// a dead link. Scrub any reference whose slug is not in the catalog (a real
// one is kept verbatim). A blocked turn carries canned fallback prose with no
// links, so skip it.
if (!outbound.block && replyText.includes("/learn/")) {
const scrubbed = scrubUnknownLearnLinks(replyText);
if (scrubbed.dropped.length > 0) {
replyText = scrubbed.text;
annotate({
action: { name: "coach.learn.link_dropped" },
meta: {
dropped: scrubbed.dropped.length,
// Truncated slug tokens for ops triage — no user content.
slugs: scrubbed.dropped.slice(0, 6),
promptVersion: PROMPT_VERSION,
},
});
}
}
let surfacedSuggestion: CoachSuggestion | null = null;
if (!outbound.block && suggestParse.cadence) {
const decision = await gateSuggestion({
@@ -0,0 +1,180 @@
/**
* v1.21.0 (NEW-B B-2) — the Coach illness-scores block.
*
* The Coach illness CONTEXT block carries only labels + lifecycle + dates; this
* block surfaces the computed retrospective the illness card shows (recovery-
* gap, gap-driver, nadir, pre-onset, red flags) for the most relevant episode,
* read-only and coverage-gated. These guards assert the gating, the
* active-wins-over-resolved selection, the finding compaction, and that a
* withheld (insufficient) episode yields null — never a fabricated number.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
const isIllnessEnabled = vi.fn();
const computeEpisodeCorrelation = vi.fn();
const findUnique = vi.fn();
const findFirst = vi.fn();
vi.mock("@/lib/illness/gate", () => ({
isIllnessEnabled: (userId: string) => isIllnessEnabled(userId),
}));
vi.mock("@/lib/illness/correlation-read", () => ({
computeEpisodeCorrelation: (...args: unknown[]) =>
computeEpisodeCorrelation(...args),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: (a: unknown) => findUnique(a) },
illnessEpisode: { findFirst: (a: unknown) => findFirst(a) },
},
}));
import { buildIllnessScores } from "@/lib/ai/coach/illness-snapshot";
const ACTIVE = {
id: "ep-active",
label: "flu",
type: "INFECTION",
onsetAt: new Date("2026-06-10T00:00:00Z"),
resolvedAt: null,
lifecycle: "ACTIVE",
};
const RESOLVED = {
id: "ep-resolved",
label: "cold",
type: "INFECTION",
onsetAt: new Date("2026-05-01T00:00:00Z"),
resolvedAt: new Date("2026-05-08T00:00:00Z"),
lifecycle: "RESOLVED",
};
function okDerived() {
return {
status: "ok" as const,
value: {
episodeId: "ep-active",
recoveryGapDays: 4,
gapDriverType: "RESTING_HEART_RATE",
nadir: [
{
type: "HEART_RATE_VARIABILITY",
day: "2026-06-12",
value: 30,
baselineCenter: 55,
deviationSd: -2.43,
direction: "below" as const,
adverse: true,
},
{
type: "WEIGHT",
day: "2026-06-12",
value: 80,
baselineCenter: 81,
deviationSd: -0.5,
direction: "below" as const,
adverse: false,
},
],
preOnset: [],
redFlags: [
{
type: "BODY_TEMPERATURE",
reason: "sustained_fever" as const,
worstValue: 38.9,
days: 3,
},
],
},
};
}
describe("buildIllnessScores", () => {
beforeEach(() => {
isIllnessEnabled.mockReset();
computeEpisodeCorrelation.mockReset();
findUnique.mockReset();
findFirst.mockReset();
isIllnessEnabled.mockResolvedValue(true);
findUnique.mockResolvedValue({ timezone: "Europe/Berlin" });
});
it("returns null when the illness module is off (no read)", async () => {
isIllnessEnabled.mockResolvedValue(false);
const out = await buildIllnessScores("u1");
expect(out).toBeNull();
expect(findFirst).not.toHaveBeenCalled();
});
it("returns null when there is no episode to score", async () => {
findFirst.mockResolvedValue(null);
const out = await buildIllnessScores("u1");
expect(out).toBeNull();
expect(computeEpisodeCorrelation).not.toHaveBeenCalled();
});
it("prefers the active episode and surfaces the computed scores", async () => {
// First findFirst call = active, second = resolved. Active wins.
findFirst.mockResolvedValueOnce(ACTIVE).mockResolvedValueOnce(RESOLVED);
computeEpisodeCorrelation.mockResolvedValue(okDerived());
const out = await buildIllnessScores("u1");
expect(out).not.toBeNull();
// The active episode drove the read.
expect(computeEpisodeCorrelation).toHaveBeenCalledWith(
"u1",
expect.objectContaining({ id: "ep-active" }),
"Europe/Berlin",
expect.any(Date),
);
expect(out).toMatchObject({
episodeLabel: "flu",
state: "active",
recoveryGapDays: 4,
gapDriverType: "RESTING_HEART_RATE",
});
// Red flags carried verbatim.
expect(out?.redFlags).toEqual([
{
type: "BODY_TEMPERATURE",
reason: "sustained_fever",
worstValue: 38.9,
days: 3,
},
]);
});
it("compacts nadir findings adverse-first and rounds the deviation", async () => {
findFirst.mockResolvedValueOnce(ACTIVE).mockResolvedValueOnce(null);
computeEpisodeCorrelation.mockResolvedValue(okDerived());
const out = await buildIllnessScores("u1");
// Adverse HRV finding leads the neutral WEIGHT one; deviation rounded to .1.
expect(out?.nadir[0]).toEqual({
type: "HEART_RATE_VARIABILITY",
day: "2026-06-12",
deviationSd: -2.4,
direction: "below",
});
});
it("falls back to the most-recently-resolved episode when none active", async () => {
findFirst.mockResolvedValueOnce(null).mockResolvedValueOnce(RESOLVED);
computeEpisodeCorrelation.mockResolvedValue({
...okDerived(),
value: { ...okDerived().value, episodeId: "ep-resolved" },
});
const out = await buildIllnessScores("u1");
expect(computeEpisodeCorrelation).toHaveBeenCalledWith(
"u1",
expect.objectContaining({ id: "ep-resolved" }),
"Europe/Berlin",
expect.any(Date),
);
expect(out?.state).toBe("resolved");
});
it("returns null when the engine withholds (insufficient coverage)", async () => {
findFirst.mockResolvedValueOnce(ACTIVE).mockResolvedValueOnce(null);
computeEpisodeCorrelation.mockResolvedValue({ status: "insufficient" });
const out = await buildIllnessScores("u1");
expect(out).toBeNull();
});
});
@@ -0,0 +1,63 @@
/**
* v1.21.0 (NEW-C C-3) — the Coach Learn-link post-filter. A published
* `/learn/<slug>` is kept verbatim; a fabricated one (a slug not in the
* catalog) is neutralised, so the catalog's "impossible by construction" claim
* becomes an enforced guarantee rather than a prompt promise.
*/
import { describe, expect, it } from "vitest";
import { scrubUnknownLearnLinks } from "@/lib/ai/coach/learn-link-guard";
import { LEARN_GUIDES } from "@/lib/ai/coach/learn-catalog";
const REAL = LEARN_GUIDES.find((g) => g.slug === "resting-heart-rate")!;
describe("scrubUnknownLearnLinks", () => {
it("returns the input unchanged when there is no /learn reference", () => {
const reply = "Your resting heart rate is settling nicely this week.";
const out = scrubUnknownLearnLinks(reply);
expect(out.text).toBe(reply);
expect(out.dropped).toEqual([]);
});
it("keeps a published /learn link verbatim (absolute URL)", () => {
const reply = `Worth a read — more on this: ${REAL.url}.`;
const out = scrubUnknownLearnLinks(reply);
expect(out.text).toContain(REAL.url);
expect(out.dropped).toEqual([]);
});
it("drops a fabricated /learn slug and reports it", () => {
const reply =
"That can drift with stress. More on this: https://healthlog.dev/learn/lower-your-cortisol-fast — give it a look.";
const out = scrubUnknownLearnLinks(reply);
expect(out.dropped).toContain("lower-your-cortisol-fast");
expect(out.text).not.toContain("lower-your-cortisol-fast");
expect(out.text).not.toContain("/learn/");
// The surrounding sentence survives.
expect(out.text).toContain("That can drift with stress");
expect(out.text).toContain("give it a look");
});
it("drops a fabricated relative /learn link", () => {
const reply = "See /learn/made-up-topic for the details.";
const out = scrubUnknownLearnLinks(reply);
expect(out.dropped).toContain("made-up-topic");
expect(out.text).not.toContain("/learn/");
});
it("keeps the real link and drops the fake one in the same reply", () => {
const reply = `Two reads: ${REAL.url} and https://healthlog.dev/learn/fictional-guide.`;
const out = scrubUnknownLearnLinks(reply);
expect(out.text).toContain(REAL.url);
expect(out.text).not.toContain("fictional-guide");
expect(out.dropped).toEqual(["fictional-guide"]);
});
it("is case-insensitive on the slug match", () => {
// A real slug in mixed case still resolves as published (kept).
const reply = "Read: https://healthlog.dev/learn/Resting-Heart-Rate.";
const out = scrubUnknownLearnLinks(reply);
expect(out.dropped).toEqual([]);
expect(out.text).toContain("Resting-Heart-Rate");
});
});
@@ -21,6 +21,14 @@ vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({
readCoachCorrelations: (userId: string) => readCoachCorrelations(userId),
}));
// v1.21.0 (NEW-B B-2) — the illness-recovery tool augments its snapshot blocks
// with the computed retrospective scores. Mock the read-only engine wrapper so
// the executor test stays a pure dispatch test.
const buildIllnessScores = vi.fn();
vi.mock("@/lib/ai/coach/illness-snapshot", () => ({
buildIllnessScores: (userId: string) => buildIllnessScores(userId),
}));
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
function snapshot(
@@ -39,6 +47,8 @@ describe("executeCoachTool", () => {
beforeEach(() => {
buildCoachSnapshot.mockReset();
readCoachCorrelations.mockReset();
buildIllnessScores.mockReset();
buildIllnessScores.mockResolvedValue(null);
});
it("returns the matching section for get_metric_series when present", async () => {
@@ -188,6 +198,45 @@ describe("executeCoachTool", () => {
expect(miss.reason).toBe("analyte_not_found");
});
// v1.21.0 (NEW-A) — labs must reach the Coach with their value AND unit so it
// can cite the reading, not just name the biomarker. The snapshot labs block
// already carries { analyte, value, unit, rangeStatus, … }; the tool passes
// it through verbatim. This guards that the value + unit survive the tool.
it("get_labs carries each reading's value and unit (citation guarantee)", async () => {
buildCoachSnapshot.mockResolvedValue(
snapshot({
labs: {
recent: [
{
analyte: "LDL",
value: 120,
unit: "mg/dL",
rangeStatus: "above",
referenceLow: null,
referenceHigh: 116,
takenAt: "2026-06-01T00:00:00.000Z",
},
],
},
}),
);
const result = await executeCoachTool({
userId: "u1",
name: "get_labs",
rawArguments: "{}",
});
expect(result.present).toBe(true);
const data = result.data as {
recent: Array<{ value: number; unit: string; rangeStatus: string }>;
};
expect(data.recent[0]).toMatchObject({
analyte: "LDL",
value: 120,
unit: "mg/dL",
rangeStatus: "above",
});
});
it("returns the workouts section for get_workouts when present", async () => {
buildCoachSnapshot.mockResolvedValue(
snapshot({ workouts: { recent: [{ sport: "RUN" }], totalInWindow: 3 } }),
@@ -298,4 +347,154 @@ describe("executeCoachTool", () => {
expect(result.present).toBe(false);
expect(result.reason).toBe("invalid_arguments");
});
// v1.21.0 (NEW-B B-2) — get_illness_recovery now carries the computed
// retrospective scores the illness card shows (recovery-gap, gap-driver,
// nadir, pre-onset, red flags), not just labels + composites.
it("get_illness_recovery surfaces the computed illness scores", async () => {
buildCoachSnapshot.mockResolvedValue(
snapshot({ illness: { restMode: true, active: [], recentResolved: [] } }),
);
buildIllnessScores.mockResolvedValue({
episodeLabel: "flu",
episodeType: "INFECTION",
state: "resolved",
recoveryGapDays: 4,
gapDriverType: "RESTING_HEART_RATE",
nadir: [
{
type: "HEART_RATE_VARIABILITY",
day: "2026-06-10",
deviationSd: -2.4,
direction: "below",
},
],
preOnset: [],
redFlags: [
{
type: "BODY_TEMPERATURE",
reason: "sustained_fever",
worstValue: 38.9,
days: 3,
},
],
});
const result = await executeCoachTool({
userId: "u1",
name: "get_illness_recovery",
rawArguments: "{}",
});
expect(result.present).toBe(true);
const data = result.data as { illnessScores?: Record<string, unknown> };
expect(data.illnessScores).toMatchObject({
recoveryGapDays: 4,
gapDriverType: "RESTING_HEART_RATE",
});
expect(data.illnessScores?.redFlags).toHaveLength(1);
expect(data.illnessScores?.nadir).toHaveLength(1);
});
it("get_illness_recovery is present on scores alone (no derived blocks)", async () => {
// No illness/derived/strain/trajectory section, but the engine returned
// scores → the tool must still report present and carry them.
buildCoachSnapshot.mockResolvedValue(snapshot({}));
buildIllnessScores.mockResolvedValue({
episodeLabel: "cold",
episodeType: "INFECTION",
state: "active",
recoveryGapDays: null,
gapDriverType: null,
nadir: [],
preOnset: [],
redFlags: [],
});
const result = await executeCoachTool({
userId: "u1",
name: "get_illness_recovery",
rawArguments: "{}",
});
expect(result.present).toBe(true);
expect(
(result.data as { illnessScores?: unknown }).illnessScores,
).toBeTruthy();
});
});
// v1.21.0 (D5-1) — ONE snapshot per turn. When the route threads the shared
// full-source scope (the inventory's probe scope) and the tool's window
// matches, the tool must read under the SHARED scope key so every per-tool read
// lands the inventory's already-built 60s LRU entry — rather than rebuilding a
// distinct single-source snapshot per tool.
describe("executeCoachTool — shared snapshot scope (D5-1)", () => {
beforeEach(() => {
buildCoachSnapshot.mockReset();
});
const sharedScope = {
sources: ["bp", "hrv", "sleep", "glucose"] as never,
window: "last30days" as const,
};
it("reads under the shared scope when the tool window matches", async () => {
buildCoachSnapshot.mockResolvedValue(
snapshot({ bloodPressure: { aggregate: { avgSys30: 128 } } }),
);
const result = await executeCoachTool({
userId: "u1",
name: "get_metric_series",
rawArguments: JSON.stringify({ metric: "bp" }),
fallbackWindow: "last30days",
sharedScope,
});
expect(result.present).toBe(true);
// The read used the SHARED scope object, not a narrow single-source scope —
// so a second tool with the same shared scope hits the same cache key.
expect(buildCoachSnapshot).toHaveBeenCalledWith("u1", sharedScope);
});
it("two tools in a turn read under the SAME scope key (one build)", async () => {
buildCoachSnapshot.mockResolvedValue(
snapshot({
bloodPressure: { aggregate: {} },
heartRateVariability: { aggregate: {} },
}),
);
await executeCoachTool({
userId: "u1",
name: "get_metric_series",
rawArguments: JSON.stringify({ metric: "bp" }),
fallbackWindow: "last30days",
sharedScope,
});
await executeCoachTool({
userId: "u1",
name: "get_metric_series",
rawArguments: JSON.stringify({ metric: "hrv" }),
fallbackWindow: "last30days",
sharedScope,
});
// Both reads used the identical shared scope object → identical cache key.
const scopes = buildCoachSnapshot.mock.calls.map((c) => c[1]);
expect(scopes).toHaveLength(2);
expect(scopes[0]).toBe(sharedScope);
expect(scopes[1]).toBe(sharedScope);
});
it("falls back to a narrow scope when the tool overrides the window", async () => {
buildCoachSnapshot.mockResolvedValue(
snapshot({ bloodPressure: { aggregate: {} } }),
);
await executeCoachTool({
userId: "u1",
name: "get_metric_series",
// The model overrides the window → a distinct, correct build.
rawArguments: JSON.stringify({ metric: "bp", window: "last7days" }),
fallbackWindow: "last30days",
sharedScope,
});
expect(buildCoachSnapshot).toHaveBeenCalledWith("u1", {
sources: ["bp"],
window: "last7days",
});
});
});
@@ -21,6 +21,7 @@ vi.mock("@/lib/cycle/gate", () => ({
import {
buildCoachDataInventory,
renderDataInventory,
renderFocusHint,
} from "@/lib/ai/coach/tools/inventory";
function snapshot(
@@ -142,3 +143,30 @@ describe("renderDataInventory", () => {
expect(text.toLowerCase()).not.toContain("whoop");
});
});
// v1.21.0 (D1) — the launch FOCUS hint narrows tool mode to the metric the
// Coach was opened from. The no-tools path narrows the snapshot; this is the
// tool-mode equivalent so the metric-narrowing is not silently dropped.
describe("renderFocusHint", () => {
it("is empty on a generic open (no pinned sources)", () => {
expect(renderFocusHint(undefined)).toBe("");
expect(renderFocusHint([])).toBe("");
});
it("names the launched domain(s) by their natural-language label", () => {
const hint = renderFocusHint(["hrv", "resting_hr", "sleep"]);
expect(hint).toContain("FOCUS:");
expect(hint).toContain("heart-rate variability");
expect(hint).toContain("resting heart rate");
expect(hint).toContain("sleep");
// It instructs prioritisation, not exclusion (the user can still pivot).
expect(hint).toContain("prioritise");
expect(hint).toContain("only branch to other domains");
});
it("falls back to the raw source key for an unlabelled source", () => {
// `compliance` has no domain label in the map; the raw key is used.
const hint = renderFocusHint(["compliance"]);
expect(hint).toContain("compliance");
});
});
+79 -3
View File
@@ -7,9 +7,9 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
const executeCoachTool = vi.fn<() => Promise<CoachToolResult>>();
const executeCoachTool = vi.fn<(args?: unknown) => Promise<CoachToolResult>>();
vi.mock("@/lib/ai/coach/tools/executor", () => ({
executeCoachTool: () => executeCoachTool(),
executeCoachTool: (args: unknown) => executeCoachTool(args),
}));
// The loop calls the real fallback runner; stub it to a deterministic
@@ -20,7 +20,11 @@ vi.mock("@/lib/ai/provider-runner", () => ({
runRawCompletionWithFallback(args),
}));
import { runCoachToolLoop, HARD_CAP } from "@/lib/ai/coach/tools/loop";
import {
runCoachToolLoop,
HARD_CAP,
MAX_ROUNDS,
} from "@/lib/ai/coach/tools/loop";
import { COACH_TOOL_DEFS } from "@/lib/ai/coach/tools/definitions";
function completion(opts: {
@@ -199,3 +203,75 @@ describe("runCoachToolLoop", () => {
expect(msgs[2].toolCallId).toBe("c1");
});
});
// v1.21.0 (D5) — the round cap is 3 (sequential cross-metric "why" chains are
// no longer starved on a 2-round ceiling), and the absolute ceiling tracks one
// above so the final allowed round still forces prose.
describe("round bounds (D5)", () => {
it("MAX_ROUNDS is 3 and HARD_CAP tracks one above", () => {
expect(MAX_ROUNDS).toBe(3);
expect(HARD_CAP).toBe(MAX_ROUNDS + 1);
});
it("allows up to three tool-fetch rounds before forcing prose", async () => {
executeCoachTool.mockResolvedValue({ present: false });
let toolRounds = 0;
runRawCompletionWithFallback.mockImplementation(
(args: { params: { toolChoice?: string } }) => {
if (args.params.toolChoice === "none") {
return Promise.resolve(completion({ content: "Forced final." }));
}
toolRounds += 1;
return Promise.resolve(
completion({
content: "",
finishReason: "tool_calls",
toolCalls: [
{
id: "c",
name: "get_metric_series",
arguments: '{"metric":"bp"}',
},
],
}),
);
},
);
const out = await runCoachToolLoop(baseArgs);
// Three rounds offered tools, the fourth forced prose.
expect(toolRounds).toBe(MAX_ROUNDS);
expect(out.rounds).toBe(HARD_CAP);
expect(out.result.content).toBe("Forced final.");
});
});
// v1.21.0 (D5-1) — the loop threads the turn's shared snapshot scope to every
// tool so each per-tool read lands one cache key (one snapshot build per turn).
describe("shared snapshot scope threading (D5-1)", () => {
it("passes sharedScope through to executeCoachTool", async () => {
executeCoachTool.mockResolvedValue({ present: true, data: { x: 1 } });
const shared = {
sources: ["bp", "hrv"] as never,
window: "last30days" as const,
};
runRawCompletionWithFallback
.mockResolvedValueOnce(
completion({
content: "",
finishReason: "tool_calls",
toolCalls: [
{
id: "c1",
name: "get_metric_series",
arguments: '{"metric":"bp"}',
},
],
}),
)
.mockResolvedValueOnce(completion({ content: "done" }));
await runCoachToolLoop({ ...baseArgs, sharedScope: shared });
expect(executeCoachTool).toHaveBeenCalledWith(
expect.objectContaining({ sharedScope: shared }),
);
});
});
+173
View File
@@ -15,6 +15,8 @@
*/
import { prisma } from "@/lib/db";
import { isIllnessEnabled } from "@/lib/illness/gate";
import { computeEpisodeCorrelation } from "@/lib/illness/correlation-read";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
/** How many recently-resolved episodes to include as history context. */
const RESOLVED_HISTORY_LIMIT = 6;
@@ -108,3 +110,174 @@ export async function buildIllnessSnapshotBlock(
})),
};
}
/* ── v1.21.0 (NEW-B B-2) — illness SCORES for the Coach ────────────────────
*
* The Coach illness CONTEXT block above carries only labels / lifecycle /
* dates — it never reaches the computed retrospective the illness card shows
* (recovery-gap, the metric that dominated it, the nadir, and the red flags).
* So a user asking "how long did my body take to recover last time" got the
* recovery COMPOSITE, not the recovery-GAP the card renders, and a sustained-
* fever / low-SpO2 escalation was invisible in-conversation.
*
* This block surfaces those same numbers, READ-ONLY, by running the existing
* `computeEpisodeCorrelation` read-layer (the one the card + the notification
* path use) for the single most relevant episode — the active one, else the
* most-recently-resolved. The engine is coverage-gated: a thin signal returns
* `insufficient`, which yields `null` here (no fabricated number). It is
* RETROSPECTIVE and DESCRIPTIVE — never a prediction, never a diagnosis. The
* Coach reads these to restate what the card already shows.
*/
/** Cap on per-finding rows so the block never balloons the prompt. */
const MAX_FINDING_ROWS = 3;
/** One vital deviation finding, compacted for the prompt. */
export interface CoachVitalFinding {
type: string;
day: string;
deviationSd: number;
direction: "above" | "below";
}
/** One illness red-flag escalation, compacted for the prompt. */
export interface CoachIllnessRedFlag {
type: string;
reason: "sustained_low_spo2" | "sustained_fever";
worstValue: number;
days: number;
}
/** The computed retrospective scores for the most relevant episode. */
export interface CoachIllnessScores {
/** Which episode these scores describe (label + lifecycle, no free-text note). */
episodeLabel: string;
episodeType: string;
/** "active" | "resolved" — the lifecycle context for the gap. */
state: "active" | "resolved";
/**
* The headline recovery-gap in days (the body lagged the felt-better marker),
* or null when still active / no vital returned. Mirrors the card's headline.
*/
recoveryGapDays: number | null;
/** The metric whose physiological return dominated the gap (or null). */
gapDriverType: string | null;
/** "What dropped" — each vital's worst (nadir) deviation, capped + adverse-first. */
nadir: CoachVitalFinding[];
/** "How it announced itself" — notable pre-onset deviations, capped. */
preOnset: CoachVitalFinding[];
/** Sustained-fever / low-SpO2 escalations during the episode (empty when none). */
redFlags: CoachIllnessRedFlag[];
}
function compactFindings(
findings: ReadonlyArray<{
type: string;
day: string;
deviationSd: number;
direction: "above" | "below";
adverse: boolean;
}>,
): CoachVitalFinding[] {
// Adverse-direction findings lead (the illness-relevant moves), then the
// largest absolute deviation; cap the row count.
return [...findings]
.sort((a, b) => {
if (a.adverse !== b.adverse) return a.adverse ? -1 : 1;
return Math.abs(b.deviationSd) - Math.abs(a.deviationSd);
})
.slice(0, MAX_FINDING_ROWS)
.map((f) => ({
type: f.type,
day: f.day,
// Round to one decimal — the felt grain, never false precision.
deviationSd: Math.round(f.deviationSd * 10) / 10,
direction: f.direction,
}));
}
/**
* Build the computed illness-scores block for the Coach, or `null` when the
* module is off, there is no episode to score, or the engine withholds
* (insufficient coverage). Reads the active episode first, else the most-
* recently-resolved. Server-authoritative + coverage-gated; the engine is the
* SAME one the illness card + the red-flag notifier run.
*/
export async function buildIllnessScores(
userId: string,
now: Date = new Date(),
): Promise<CoachIllnessScores | null> {
if (!(await isIllnessEnabled(userId))) return null;
const [user, active, resolved] = await Promise.all([
prisma.user.findUnique({
where: { id: userId },
select: { timezone: true },
}),
prisma.illnessEpisode.findFirst({
where: {
userId,
deletedAt: null,
resolvedAt: null,
onsetAt: { lte: now },
},
orderBy: { onsetAt: "desc" },
select: {
id: true,
label: true,
type: true,
onsetAt: true,
resolvedAt: true,
lifecycle: true,
},
}),
prisma.illnessEpisode.findFirst({
where: { userId, deletedAt: null, resolvedAt: { not: null } },
orderBy: { resolvedAt: "desc" },
select: {
id: true,
label: true,
type: true,
onsetAt: true,
resolvedAt: true,
lifecycle: true,
},
}),
]);
// Active episode wins (it is the one a "how am I recovering" question is
// about); fall back to the most-recently-resolved for "last time" questions.
const episode = active ?? resolved;
if (!episode) return null;
const tz = user?.timezone ?? DEFAULT_TIMEZONE;
const derived = await computeEpisodeCorrelation(
userId,
{
id: episode.id,
onsetAt: episode.onsetAt,
resolvedAt: episode.resolvedAt,
lifecycle: episode.lifecycle,
},
tz,
now,
);
if (derived.status !== "ok") return null;
const v = derived.value;
return {
episodeLabel: sanitizeLabel(episode.label),
episodeType: episode.type,
state: episode.resolvedAt === null ? "active" : "resolved",
recoveryGapDays: v.recoveryGapDays,
gapDriverType: v.gapDriverType,
nadir: compactFindings(v.nadir),
preOnset: compactFindings(v.preOnset),
redFlags: v.redFlags.map((f) => ({
type: f.type,
reason: f.reason,
worstValue: f.worstValue,
days: f.days,
})),
};
}
+100
View File
@@ -0,0 +1,100 @@
/**
* v1.21.0 (NEW-C C-3) — Coach Learn-link post-filter.
*
* The Coach MAY point the user at a public `/learn/<slug>` guide, but only a
* slug that appears in `learn-catalog.ts`. The system prompt instructs this
* ("Only link a URL from this list — never invent a /learn URL"), but that is a
* prompt instruction, not enforcement: a model could still hallucinate
* `/learn/lower-your-cortisol` and ship a dead/wrong link.
*
* This deterministic post-filter runs on the assembled reply before
* persistence and streaming. It scans for every `/learn/<slug>` reference
* (relative or the absolute `healthlog.dev/learn/<slug>` form) and NEUTRALISES
* any whose slug is not a published catalog entry — stripping the fabricated
* URL out of the prose while leaving the surrounding sentence intact. A real
* slug is kept verbatim. This converts the catalog's "impossible by
* construction" claim from a prompt promise into an enforced guarantee.
*
* Cheap + read-only: a single regex pass over the reply, the slug set sourced
* from the read-only `LEARN_GUIDES` catalog. No allocation on the common path
* (a reply with no `/learn` mention returns the input unchanged).
*/
import { LEARN_GUIDES } from "./learn-catalog";
/** The set of published `/learn` slugs, lower-cased for case-insensitive match. */
const KNOWN_SLUGS: ReadonlySet<string> = new Set(
LEARN_GUIDES.map((g) => g.slug.toLowerCase()),
);
/**
* Match a `/learn/<slug>` reference, with or without the host. Captures the
* slug so the caller can validate it. The slug is the URL-path segment after
* `/learn/` — letters, digits, and hyphens (the catalog's slug grammar). A
* trailing slash or punctuation ends the slug.
*
* Examples matched:
* https://healthlog.dev/learn/resting-heart-rate
* healthlog.dev/learn/made-up-slug
* /learn/another-fabrication
*/
const LEARN_LINK_RE =
/(?:https?:\/\/)?(?:[a-z0-9.-]*healthlog\.dev)?\/learn\/([a-z0-9-]+)\/?/gi;
export interface LearnLinkScrubResult {
/** The reply with every unknown `/learn/<slug>` reference removed. */
text: string;
/** The unknown slugs that were neutralised (lower-cased, deduped). */
dropped: string[];
}
/**
* Strip every `/learn/<slug>` reference whose slug is not in the published
* catalog. A known slug is preserved verbatim; an unknown one (a fabrication)
* is removed along with a common lead-in fragment ("more on this: ", "see "),
* so the prose does not read as a broken link. Returns the input unchanged when
* the reply contains no `/learn` reference.
*/
export function scrubUnknownLearnLinks(reply: string): LearnLinkScrubResult {
if (!reply || !reply.includes("/learn/")) {
return { text: reply, dropped: [] };
}
const dropped = new Set<string>();
// First pass: collect the unknown slugs (so the result is reportable) and
// build the cleaned string in one replace.
const text = reply.replace(LEARN_LINK_RE, (match, slug: string) => {
if (KNOWN_SLUGS.has(slug.toLowerCase())) {
// Published guide — keep the link exactly as written.
return match;
}
dropped.add(slug.toLowerCase());
// Drop the fabricated URL. Leave the surrounding prose; a dangling
// "more on this:" lead-in is cleaned by the post-pass below.
return "";
});
if (dropped.size === 0) {
// Every /learn link was valid — return the original (the replace above is a
// no-op clone, but be explicit about the contract).
return { text: reply, dropped: [] };
}
// Tidy the prose around a removed link: collapse a now-empty "more on this:"
// / "see:" lead-in and any double spaces / stranded punctuation the removal
// left behind. Conservative — only the well-known lead-in phrases the prompt
// example seeds, plus whitespace normalisation.
const tidied = text
// Drop a now-empty "more on this:" / "read more:" / "see:" lead-in (EN+DE).
.replace(
/\b(?:more on this|read more|see|learn more|mehr dazu|mehr hier)\s*:?\s*(?=[\s).,;!?]|$)/gi,
"",
)
// Collapse a doubled space + tidy a space-before-punctuation the gap left.
.replace(/[ \t]{2,}/g, " ")
.replace(/\s+([).,;!?])/g, "$1")
// Drop an empty parenthesis pair left by a parenthesised link.
.replace(/\(\s*\)/g, "")
.trim();
return { text: tidied, dropped: [...dropped] };
}
+41 -12
View File
@@ -212,14 +212,18 @@ GROUND RULES
diabetes, the glucose line states a clinician-set management GOAL, not
a screening threshold. When no REFERENCE GROUNDING block is present,
do not invent a band — pivot to the user's own trend.
14. Connect the signals. On a "why" or pattern question, don't read each
metric in isolation. After you have the relevant signals, consult the
correlations available to you (the "get_correlations" retrieval tool
14. Connect the signals. On a "why", pattern, or conflict question, don't
read each metric in isolation. After you have the relevant signals, consult
the correlations available to you (the "get_correlations" retrieval tool
when offered, otherwise any "correlation"/driver field the SNAPSHOT
carries) and LINK them descriptively — "after your short-sleep nights
your next-morning HRV tends to read lower" — rather than listing
metrics side by side. Always frame the link as an association worth a
small experiment, never a cause, and only when the underlying figure
metrics side by side. When two signals seem to disagree (a "why is X off
while Y looks fine" question, or two fetched metrics pointing opposite
ways), CALL "get_correlations" and read its coincident-deviation flag
before you answer, so you name the tension the cross-metric layer found
instead of picking one side. Always frame the link as an association worth
a small experiment, never a cause, and only when the underlying figure
is actually present. If you have no correlation to lean on, say what
you observe across the series plainly and invite the user's read.
15. Confidence ruler on action turns. When the user asks "what should I
@@ -343,6 +347,15 @@ ISO-week means.
"recentResolved" is light history for "how often do I get sick"
questions. When no "illness" block is present, assume nothing about
the user's health status.
When a "get_illness_recovery" result (or the snapshot) carries an
"illnessScores" object, it holds the computed retrospective the illness
card shows for the most relevant episode: "recoveryGapDays" (how many
days the body lagged the felt-better marker), "gapDriverType" (the metric
whose return drove that gap), "nadir" / "preOnset" (the worst and earliest
deviations), and "redFlags" (sustained fever / low-SpO2 escalations). Read
these numbers verbatim — they are the SAME ones the card renders — and a
red flag escalates ("if that recurs, seek care"), never reassures. State
them only when the object is present; never invent a recovery gap.
- The SNAPSHOT MAY carry a "labs" block: { recent[] }, where each entry is
the user's MOST RECENT reading for one biomarker — { analyte, panel,
value, valueText, unit, referenceLow, referenceHigh, rangeStatus,
@@ -699,17 +712,22 @@ GRUNDREGELN
klinisch gesetztes Management-ZIEL, keinen Screening-Schwellwert.
Ist kein REFERENCE-GROUNDING-Block vorhanden, erfinde keinen Bereich
— wechsle zum eigenen Trend des Nutzers.
14. Verbinde die Signale. Bei einer "Warum"- oder Musterfrage lies nicht
jede Metrik für sich. Wenn du die relevanten Signale hast, ziehe die
dir verfügbaren Zusammenhänge heran (das Abfrage-Tool
14. Verbinde die Signale. Bei einer "Warum"-, Muster- oder Widerspruchsfrage
lies nicht jede Metrik für sich. Wenn du die relevanten Signale hast, ziehe
die dir verfügbaren Zusammenhänge heran (das Abfrage-Tool
"get_correlations", wenn es angeboten wird, sonst ein
"correlation"-/Treiber-Feld, das der SNAPSHOT trägt) und VERKNÜPFE
sie beschreibend — "nach deinen kurzen Nächten liest deine HRV am
nächsten Morgen tendenziell niedriger" — statt Metriken
nebeneinanderzustellen. Rahme die Verknüpfung immer als Zusammenhang,
der einen kleinen Versuch wert ist, nie als Ursache, und nur, wenn
die zugrunde liegende Zahl wirklich vorhanden ist. Hast du keinen
Zusammenhang, auf den du dich stützen kannst, benenne klar, was du
nebeneinanderzustellen. Wenn zwei Signale sich zu widersprechen scheinen
(eine "Warum ist X auffällig, während Y normal aussieht"-Frage, oder zwei
abgerufene Metriken zeigen in entgegengesetzte Richtungen), RUFE
"get_correlations" auf und lies das coincident-deviation-Flag, bevor du
antwortest, damit du die Spannung benennst, die die Querschnitts-Ebene
gefunden hat, statt dich auf eine Seite zu schlagen. Rahme die Verknüpfung
immer als Zusammenhang, der einen kleinen Versuch wert ist, nie als Ursache,
und nur, wenn die zugrunde liegende Zahl wirklich vorhanden ist. Hast du
keinen Zusammenhang, auf den du dich stützen kannst, benenne klar, was du
über die Reihe hinweg beobachtest, und lade die Einschätzung des
Nutzers ein.
15. Konfidenz-Skala bei Handlungs-Turns. Fragt der Nutzer "Was soll ich
@@ -781,6 +799,16 @@ ISO-Wochenmittel zusammen.
Labels als die EIGENE beschreibende Angabe des Nutzers, nie als Diagnose;
"recentResolved" ist leichte Historie für "wie oft bin ich krank".
Fehlt der "illness"-Block, nimm nichts über den Gesundheitszustand an.
Trägt ein "get_illness_recovery"-Ergebnis (oder der SNAPSHOT) ein
"illnessScores"-Objekt, enthält es die berechnete Retrospektive, die die
Krankheits-Karte für die relevanteste Episode zeigt: "recoveryGapDays" (wie
viele Tage der Körper dem Besser-Gefühl-Marker hinterherhinkte),
"gapDriverType" (die Metrik, deren Rückkehr den Abstand trieb), "nadir" /
"preOnset" (die stärksten und frühesten Abweichungen) und "redFlags"
(anhaltendes Fieber / niedriges SpO2). Lies diese Zahlen wörtlich — es sind
DIESELBEN, die die Karte rendert — und ein Red Flag eskaliert ("falls das
wiederkehrt, lass es abklären"), beruhigt nie. Nenne sie nur, wenn das
Objekt vorhanden ist; erfinde nie einen Recovery-Gap.
- Der SNAPSHOT KANN einen "labs"-Block tragen: { recent[] }, wobei jeder
Eintrag der NEUESTE Wert des Nutzers für einen Biomarker ist — { analyte,
panel, value, valueText, unit, referenceLow, referenceHigh, rangeStatus,
@@ -1000,6 +1028,7 @@ export function getCoachSystemPrompt(
"safetyAcute",
"metricIdentifierBan",
"forbiddenFiller",
"outlookContract",
])}`
: base;
const prefix = buildPrefsPrefix(locale, prefs);
+111 -35
View File
@@ -20,10 +20,17 @@
* mutation or egress surface.
*
* The builder result is memoised by the 60s snapshot LRU keyed on
* `(userId, window, sources)`. Each tool scopes to a different source set, so
* tool calls for distinct domains do NOT share a cache entry; sharing happens
* only when the SAME scope recurs (a repeated call, or a turn that re-enters
* the same domain) within the 60s window.
* `(userId, window, sources)`.
*
* v1.21.0 (D5-1) — ONE snapshot per turn. The route hands every tool the SAME
* `sharedScope` it built the DATA INVENTORY against (the full source set + the
* conversation window). A full-source build already contains every section, so
* each tool reads under that shared scope key and slices its own section out —
* landing the 60s LRU hit the inventory primed rather than rebuilding a
* distinct single-source snapshot per tool (the D5-1 N-builds-per-turn cost).
* A tool that overrides the window to something OTHER than the shared window
* still gets a correct, distinct build (the rare case); the common path
* collapses to one build.
*/
import { z } from "zod/v4";
@@ -48,6 +55,7 @@ import {
METRIC_SERIES_EXCLUDED_SOURCES,
} from "./source-keys";
import { readCoachCorrelations } from "./correlations-read";
import { buildIllnessScores } from "@/lib/ai/coach/illness-snapshot";
/** A read-only structured tool result. Serialised to a `role:"tool"` turn. */
export interface CoachToolResult {
@@ -79,18 +87,32 @@ function pickSection(
}
/**
* Resolve `args.window` into a scope. Falls back to the conversation's
* effective window so a tool call that omits `window` matches the user's
* chosen analysis window rather than the builder default.
* Resolve the scope a tool reads its snapshot under.
*
* v1.21.0 (D5-1) — when the route handed us a `sharedScope` (the full-source
* inventory build for this turn) AND the tool's effective window matches the
* shared window, read under the SHARED scope key so the per-tool read lands the
* 60s LRU hit the inventory already primed (the section it needs is present in
* the full-source build). Otherwise — no shared scope, or a window override —
* fall back to the tight per-source scope (a correct, distinct build).
*
* The window default chain is unchanged: `args.window` wins, else the
* conversation's `fallbackWindow`, else the builder default.
*/
function scopeFor(
sources: CoachScope["sources"],
window: CoachScopeWindow | undefined,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
): CoachScope {
const effectiveWindow = window ?? fallbackWindow;
if (sharedScope && sharedScope.window === effectiveWindow) {
// Same window as the inventory's full-source build → reuse its cache entry.
return sharedScope;
}
return {
sources,
window: window ?? fallbackWindow,
window: effectiveWindow,
};
}
@@ -107,8 +129,15 @@ export async function executeCoachTool(args: {
rawArguments: string;
/** The conversation's effective window, used when a call omits `window`. */
fallbackWindow?: CoachScopeWindow;
/**
* v1.21.0 (D5-1) — the turn's shared full-source snapshot scope (the same one
* the DATA INVENTORY was built against). When present and the tool's window
* matches, the tool reads under this scope key so it lands the inventory's
* already-built cache entry instead of rebuilding a single-source snapshot.
*/
sharedScope?: CoachScope;
}): Promise<CoachToolResult> {
const { userId, name, rawArguments, fallbackWindow } = args;
const { userId, name, rawArguments, fallbackWindow, sharedScope } = args;
if (!isCoachToolName(name)) {
annotate({
@@ -130,7 +159,13 @@ export async function executeCoachTool(args: {
}
try {
const result = await dispatch(name, userId, parsedArgs, fallbackWindow);
const result = await dispatch(
name,
userId,
parsedArgs,
fallbackWindow,
sharedScope,
);
annotate({
action: { name: "coach.tool.executed" },
meta: { tool: name, present: result.present },
@@ -155,24 +190,30 @@ async function dispatch(
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
): Promise<CoachToolResult> {
switch (name) {
case "get_metric_series":
return getMetricSeries(userId, rawArgs, fallbackWindow);
return getMetricSeries(userId, rawArgs, fallbackWindow, sharedScope);
case "get_glucose_panel":
return getGlucosePanel(userId, rawArgs, fallbackWindow);
return getGlucosePanel(userId, rawArgs, fallbackWindow, sharedScope);
case "get_sleep":
return getSleep(userId, rawArgs, fallbackWindow);
return getSleep(userId, rawArgs, fallbackWindow, sharedScope);
case "get_medication_compliance":
return getMedicationCompliance(userId, rawArgs, fallbackWindow);
return getMedicationCompliance(
userId,
rawArgs,
fallbackWindow,
sharedScope,
);
case "get_labs":
return getLabs(userId, rawArgs, fallbackWindow);
return getLabs(userId, rawArgs, fallbackWindow, sharedScope);
case "get_illness_recovery":
return getIllnessRecovery(userId, fallbackWindow);
return getIllnessRecovery(userId, fallbackWindow, sharedScope);
case "get_workouts":
return getWorkouts(userId, rawArgs, fallbackWindow);
return getWorkouts(userId, rawArgs, fallbackWindow, sharedScope);
case "get_cycle":
return getCycle(userId, rawArgs);
return getCycle(userId, rawArgs, sharedScope);
case "get_correlations":
return getCorrelations(userId, rawArgs);
}
@@ -190,6 +231,7 @@ async function getMetricSeries(
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
): Promise<CoachToolResult> {
const parsed = getMetricSeriesArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_metric_series", parsed.error);
@@ -217,7 +259,7 @@ async function getMetricSeries(
const snapshot = await buildCoachSnapshot(
userId,
scopeFor([metric], window, fallbackWindow),
scopeFor([metric], window, fallbackWindow, sharedScope),
);
const section = pickSection(snapshot.sections, sectionKey);
if (section === undefined) {
@@ -234,12 +276,13 @@ async function getGlucosePanel(
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
): Promise<CoachToolResult> {
const parsed = getGlucosePanelArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_glucose_panel", parsed.error);
const snapshot = await buildCoachSnapshot(
userId,
scopeFor(["glucose"], parsed.data.window, fallbackWindow),
scopeFor(["glucose"], parsed.data.window, fallbackWindow, sharedScope),
);
const section = pickSection(snapshot.sections, "glucose");
if (section === undefined) return { present: false, reason: "no_data" };
@@ -254,12 +297,13 @@ async function getSleep(
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
): Promise<CoachToolResult> {
const parsed = getSleepArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_sleep", parsed.error);
const snapshot = await buildCoachSnapshot(
userId,
scopeFor(["sleep"], parsed.data.window, fallbackWindow),
scopeFor(["sleep"], parsed.data.window, fallbackWindow, sharedScope),
);
const nights = pickSection(snapshot.sections, "sleep");
const rhythm = pickSection(snapshot.sections, "sleepRhythm");
@@ -280,6 +324,7 @@ async function getMedicationCompliance(
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
): Promise<CoachToolResult> {
const parsed = getMedicationComplianceArgsSchema.safeParse(rawArgs);
if (!parsed.success) {
@@ -287,7 +332,7 @@ async function getMedicationCompliance(
}
const snapshot = await buildCoachSnapshot(
userId,
scopeFor(["compliance"], parsed.data.window, fallbackWindow),
scopeFor(["compliance"], parsed.data.window, fallbackWindow, sharedScope),
);
const compliance = pickSection(snapshot.sections, "compliance");
// GLP-1 context rides the `weeklyContext` block.
@@ -311,22 +356,26 @@ async function getLabs(
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
): Promise<CoachToolResult> {
const parsed = getLabsArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_labs", parsed.error);
// Labs ride the snapshot regardless of `sources` (attached unconditionally),
// so a minimal scope still surfaces them. Pass an empty source set to keep
// the read tight.
// so a minimal scope still surfaces them.
//
// v1.21.0 (A5-F4) — the labs block itself is a "latest reading per biomarker
// over the last 12 months" snapshot and is intentionally window-AGNOSTIC
// (the read cutoff is fixed). We still thread the conversation's window onto
// the scope so the snapshot's `scope` block reports the right horizon for the
// turn; it does not move the labs read.
const snapshot = await buildCoachSnapshot(userId, {
sources: [],
window: fallbackWindow,
});
//
// v1.21.0 (D5-1) — prefer the turn's shared full-source scope so this read
// lands the inventory's cache entry; fall back to a tight empty-source scope
// (labs ride unconditionally) when no shared scope or a window mismatch.
const snapshot = await buildCoachSnapshot(
userId,
scopeFor([], fallbackWindow, fallbackWindow, sharedScope),
);
const labs = pickSection(snapshot.sections, "labs") as
| { recent?: Array<{ name?: string; analyte?: string }> }
| undefined;
@@ -350,6 +399,7 @@ async function getLabs(
async function getIllnessRecovery(
userId: string,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
): Promise<CoachToolResult> {
// Validate the (empty) args shape for consistency; an empty object always
// passes.
@@ -361,19 +411,35 @@ async function getIllnessRecovery(
// v1.21.0 (A5-F4) — honour the conversation's window so the recovery/strain/
// trajectory composites are computed over the horizon the caller asked for
// rather than the builder default.
const snapshot = await buildCoachSnapshot(userId, {
sources: ["hrv", "resting_hr", "vo2_max"],
window: fallbackWindow,
});
//
// v1.21.0 (D5-1) — prefer the turn's shared full-source scope (which already
// carries hrv/resting_hr/vo2_max + illness + derived) so this read lands the
// inventory's cache entry; the narrow scope is the window-mismatch fallback.
const snapshot = await buildCoachSnapshot(
userId,
scopeFor(
["hrv", "resting_hr", "vo2_max"],
fallbackWindow,
fallbackWindow,
sharedScope,
),
);
const illness = pickSection(snapshot.sections, "illness");
const derived = pickSection(snapshot.sections, "derived");
const dayStrain = pickSection(snapshot.sections, "dayStrain");
const trajectory = pickSection(snapshot.sections, "trajectory");
// v1.21.0 (NEW-B B-2) — the computed illness retrospective the card shows
// (recovery-gap, gap-driver, nadir, pre-onset, red flags) for the most
// relevant episode. Read-only, coverage-gated (null when the engine
// withholds), and the SAME engine the card + the red-flag notifier run, so
// the Coach restates the numbers the user sees rather than the composite.
const illnessScores = await buildIllnessScores(userId);
if (
illness === undefined &&
derived === undefined &&
dayStrain === undefined &&
trajectory === undefined
trajectory === undefined &&
illnessScores === null
) {
return { present: false, reason: "no_data" };
}
@@ -381,6 +447,7 @@ async function getIllnessRecovery(
present: true,
data: {
...(illness !== undefined ? { illness } : {}),
...(illnessScores !== null ? { illnessScores } : {}),
...(derived !== undefined ? { derived } : {}),
...(dayStrain !== undefined ? { dayStrain } : {}),
...(trajectory !== undefined ? { trajectory } : {}),
@@ -392,6 +459,7 @@ async function getWorkouts(
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
): Promise<CoachToolResult> {
const parsed = getWorkoutsArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_workouts", parsed.error);
@@ -399,7 +467,7 @@ async function getWorkouts(
// user has workout rows in the window. Scope the read to that single source.
const snapshot = await buildCoachSnapshot(
userId,
scopeFor(["workouts"], parsed.data.window, fallbackWindow),
scopeFor(["workouts"], parsed.data.window, fallbackWindow, sharedScope),
);
const workouts = pickSection(snapshot.sections, "workouts");
if (workouts === undefined) return { present: false, reason: "no_data" };
@@ -409,6 +477,7 @@ async function getWorkouts(
async function getCycle(
userId: string,
rawArgs: unknown,
sharedScope: CoachScope | undefined,
): Promise<CoachToolResult> {
const parsed = getCycleArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_cycle", parsed.error);
@@ -416,7 +485,14 @@ async function getCycle(
// (the per-user toggle AND the operator switch), independent of `sources` —
// so a minimal scope still surfaces it when the account tracks cycles, and a
// non-cycle account structurally produces no block (→ present:false).
const snapshot = await buildCoachSnapshot(userId, { sources: [] });
//
// v1.21.0 (D5-1) — prefer the turn's shared full-source scope so this read
// lands the inventory's cache entry; the empty-source scope is the fallback
// when no shared scope is threaded.
const snapshot = await buildCoachSnapshot(
userId,
sharedScope ?? { sources: [] },
);
const cycle = pickSection(snapshot.sections, "cycle");
if (cycle === undefined) return { present: false, reason: "no_data" };
return { present: true, data: cycle };
+1
View File
@@ -20,6 +20,7 @@ export {
export {
buildCoachDataInventory,
renderDataInventory,
renderFocusHint,
type CoachDataInventory,
type InventoryEntry,
} from "./inventory";
+44
View File
@@ -50,6 +50,14 @@ export interface CoachDataInventory {
cycleEnabled: boolean;
/** The window the inventory was built against. */
window: string;
/**
* v1.21.0 (D5-1) — the exact full-source scope this inventory's snapshot was
* built against. The route threads it to the tool loop so every per-tool read
* lands the SAME 60s LRU entry (one snapshot build per turn, not N). Returned
* here so the route reuses the identical scope object rather than
* re-deriving it and risking a key mismatch.
*/
probeScope: CoachScope;
}
/**
@@ -188,9 +196,45 @@ export async function buildCoachDataInventory(
restMode,
cycleEnabled,
window: scopeBlock?.window ?? scope?.window ?? "last30days",
probeScope,
};
}
/**
* v1.21.0 (D1) — render the launch FOCUS hint for the tool-mode user turn.
*
* When the Coach is opened from a metric page or card, the client sends
* `scope.sources` (the metric the user is looking at). The no-tools path
* narrows the snapshot to those sources, but the tool-mode inventory probes the
* FULL source set, so without this hint the metric-narrowing was silently
* dropped server-side (D1 — the only surviving signals were the window + seed
* question). This injects a single `FOCUS:` line naming the launched domain(s)
* so the model prioritises the metric the user actually opened the Coach about,
* while every other domain stays fetchable (the user can still pivot).
*
* Returns `""` when no explicit sources were pinned (a generic open), so the
* prompt prefix is byte-identical to before on the non-scoped path.
*/
export function renderFocusHint(
sources: readonly CoachScopeSource[] | undefined,
): string {
if (!sources || sources.length === 0) return "";
const labels = sources.map(
(s) => COACH_SOURCE_DOMAIN_LABEL[s] ?? (s as string),
);
// De-dup while preserving order (two sources can share a label only via the
// fallback, but keep it stable regardless).
const seen = new Set<string>();
const unique = labels.filter((l) => {
if (seen.has(l)) return false;
seen.add(l);
return true;
});
return `FOCUS: the user opened this conversation from ${unique.join(
", ",
)} — prioritise that, fetch its figures first, and only branch to other domains if the question leads there.`;
}
/**
* Render the inventory as the compact text block that rides the base context.
* Brand-free, deterministic, and stable across turns so the prompt-cache prefix
+29 -8
View File
@@ -7,10 +7,11 @@
* FINAL prose for the visible stream exactly as before.
*
* Bounds (the loop can never run away):
* - MAX_ROUNDS = 2: one tool-fetch round + the answer round.
* - HARD_CAP = 3: an absolute ceiling; on the last allowed round the loop
* re-calls with `toolChoice:"none"` so the model is FORCED to produce prose
* even if it would otherwise keep asking for tools.
* - MAX_ROUNDS = 3 (v1.21.0 D5): up to three tool-fetch rounds before the
* forced answer, so a sequential cross-metric "why" chain isn't starved.
* - HARD_CAP = MAX_ROUNDS + 1: an absolute ceiling; on the last allowed round
* the loop re-calls with `toolChoice:"none"` so the model is FORCED to
* produce prose even if it would otherwise keep asking for tools.
* - Tool calls within a round run in parallel (`Promise.all`).
*
* Token accounting: the caller reserves `maxTokens * MAX_ROUNDS` up front and
@@ -27,16 +28,28 @@ import { runRawCompletionWithFallback } from "@/lib/ai/provider-runner";
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
import type { AiMessage, AiToolDef, CompletionResult } from "@/lib/ai/types";
import type { ProviderHealthLedger } from "@/lib/ai/provider-health-ledger";
import type { CoachScopeWindow } from "@/lib/ai/coach/types";
import type { CoachScope, CoachScopeWindow } from "@/lib/ai/coach/types";
import {
executeCoachTool,
type CoachToolResult,
type CoachToolTrace,
} from "./executor";
export const MAX_ROUNDS = 2;
/** Absolute ceiling; the final allowed round forces prose (toolChoice none). */
export const HARD_CAP = 3;
/**
* v1.21.0 (D5) — MAX_ROUNDS raised 2 → 3 so a sequential cross-metric "why"
* chain (recovery low → which driver → pull that metric's series) is not
* starved on the 2-round ceiling. The per-round token budget is unchanged; the
* caller reserves `maxTokens * MAX_ROUNDS` and reconciles the summed actual
* tokens, so the bound scales with this constant and stays explicit. HARD_CAP
* tracks one above so the final allowed round still forces prose.
*/
export const MAX_ROUNDS = 3;
/**
* Absolute ceiling; the final allowed round forces prose (toolChoice none).
* Tracks one above MAX_ROUNDS so rounds 1..MAX_ROUNDS may fetch tools and the
* final round is always a forced answer.
*/
export const HARD_CAP = MAX_ROUNDS + 1;
export interface CoachToolLoopResult {
/** The final completion (prose). */
@@ -74,6 +87,12 @@ export async function runCoachToolLoop(args: {
temperature?: number;
maxTokens?: number;
fallbackWindow?: CoachScopeWindow;
/**
* v1.21.0 (D5-1) — the turn's shared full-source snapshot scope (the same one
* the DATA INVENTORY was built against). Threaded to every tool so they read
* under one cache key — one snapshot build per turn instead of N.
*/
sharedScope?: CoachScope;
ledger?: ProviderHealthLedger;
/** Aborts the per-round provider calls on client disconnect. */
signal?: AbortSignal;
@@ -86,6 +105,7 @@ export async function runCoachToolLoop(args: {
temperature,
maxTokens,
fallbackWindow,
sharedScope,
ledger,
signal,
} = args;
@@ -166,6 +186,7 @@ export async function runCoachToolLoop(args: {
name: call.name,
rawArguments: call.arguments,
fallbackWindow,
sharedScope,
});
toolTrace.push({ name: call.name, present: toolResult.present });
// v1.21.0 (P6) — retain the present results' payloads for the post-hoc
@@ -17,8 +17,17 @@ import {
safetyAcute,
metricIdentifierBan,
forbiddenFiller,
outlookContract,
type ContractLocale,
} from "../shared-contracts";
import {
BP_SYS_CRITICAL,
BP_DIA_CRITICAL,
GLUCOSE_HYPO_FLOOR,
GLUCOSE_HYPO_SEVERE_FLOOR,
GLUCOSE_HYPER_FLOOR,
FEVER_RED_FLAG_C,
} from "@/lib/clinical-floors";
import { getBaseSystemPrompt } from "../base-system";
import { getStrictInsightsSystemPrompt } from "../insight-generator";
import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt";
@@ -47,6 +56,9 @@ const SURFACES: Record<
safetyAcute,
metricIdentifierBan,
forbiddenFiller,
// v1.21.0 (QoL-B §3) — the briefing composes the forward-looking outlook
// contract beside the tone contract.
outlookContract,
],
},
"base-system (status cards)": {
@@ -69,6 +81,8 @@ const SURFACES: Record<
safetyAcute,
metricIdentifierBan,
forbiddenFiller,
// v1.21.0 (QoL-B §3) — the Coach composes the outlook contract too.
outlookContract,
],
},
// v1.21.0 (coach C1 MEDIUM-1) — the retrospective narrative now composes the
@@ -123,4 +137,44 @@ describe("shared-contract cross-surface coverage", () => {
expect(SYSTEM_PROMPTS_FOR_TEST[locale]).toContain(safetyAcute[locale]);
}
});
it("the outlook contract reaches the Coach + briefing surfaces", () => {
for (const locale of LOCALES) {
expect(getCoachSystemPrompt(locale)).toContain(outlookContract[locale]);
expect(getStrictInsightsSystemPrompt(locale)).toContain(
outlookContract[locale],
);
}
});
});
// v1.21.0 (D3-M1 / D3-L1) — the acute-safety clause's threshold numbers are
// composed from `clinical-floors.ts`, so the Coach's stated crisis thresholds
// can never drift from the dashboard hero / notification engine / status
// registry that read the same constants. Assert every floor is present in the
// clause for both hand-composed locales, and that the glucose + sustained-fever
// floors (previously absent) are now echoed.
describe("safetyAcute numbers are bound to clinical-floors", () => {
it("echoes the BP crisis floors verbatim", () => {
for (const locale of LOCALES) {
expect(safetyAcute[locale]).toContain(String(BP_SYS_CRITICAL));
expect(safetyAcute[locale]).toContain(String(BP_DIA_CRITICAL));
}
});
it("echoes the glucose floors verbatim", () => {
for (const locale of LOCALES) {
expect(safetyAcute[locale]).toContain(String(GLUCOSE_HYPO_FLOOR));
expect(safetyAcute[locale]).toContain(String(GLUCOSE_HYPO_SEVERE_FLOOR));
expect(safetyAcute[locale]).toContain(String(GLUCOSE_HYPER_FLOOR));
}
});
it("echoes the sustained-fever escalation floor verbatim", () => {
// EN keeps the dot decimal; DE renders the decimal comma.
expect(safetyAcute.en).toContain(String(FEVER_RED_FLAG_C));
expect(safetyAcute.de).toContain(
String(FEVER_RED_FLAG_C).replace(".", ","),
);
});
});
+9
View File
@@ -36,6 +36,7 @@ import {
safetyAcute,
metricIdentifierBan,
forbiddenFiller,
outlookContract,
} from "./shared-contracts";
/** Stable identifier for the active system prompt revision. */
@@ -771,6 +772,14 @@ neutralem Label + Detail. Höchstgrenze: 20 Einträge.`,
en: forbiddenFiller.en,
de: forbiddenFiller.de,
},
// v1.21.0 (QoL-B §3) — the forward-looking outlook contract, composed beside
// the tone contract on the briefing so its close can sharpen expectations
// within the same no-false-promise rails the Coach uses.
{
id: "sharedOutlook",
en: outlookContract.en,
de: outlookContract.de,
},
{
id: "language",
en: `LANGUAGE
+41 -4
View File
@@ -23,10 +23,32 @@
*
* The cross-surface coverage test (`shared-contracts-coverage.test.ts`)
* asserts each fragment appears verbatim in every surface that enforces it.
*
* v1.21.0 (D3-M1 / D3-L1) — the acute-safety clause's threshold numbers are no
* longer prose literals: they are composed from the canonical
* `clinical-floors.ts` constants so the Coach's stated crisis thresholds can
* never drift from the dashboard hero, the notification engine, or the status
* registry that read the same constants. The glucose floors and the
* sustained-fever escalation (previously absent from the clause) are echoed
* too, so every acute number the notification engine can alarm on has a Coach
* voice bound to the same source of truth.
*/
import {
BP_SYS_CRITICAL,
BP_DIA_CRITICAL,
GLUCOSE_HYPO_FLOOR,
GLUCOSE_HYPO_SEVERE_FLOOR,
GLUCOSE_HYPER_FLOOR,
FEVER_RED_FLAG_C,
} from "@/lib/clinical-floors";
export type ContractLocale = "de" | "en";
/** A German decimal-comma rendering of a clinical-floor number for prose. */
function deNum(value: number): string {
return String(value).replace(".", ",");
}
/** A named contract fragment with both hand-composed locale texts. */
export type SharedContract = Record<ContractLocale, string>;
@@ -49,9 +71,9 @@ Jede Aussage muss auf einer Zahl beruhen, die im übergebenen Snapshot sichtbar
*/
export const toneContract: SharedContract = {
en: `TONE — a warm, motivating advisor, never clinical or alarming
Write in the second person, warm and direct. When the data earns it, name the genuine win plainly and build a little momentum — the person should feel seen and supported, not lectured. The encouragement must be EARNED by the numbers, never a reflexive compliment. Be autonomy-supporting ("worth a try", "can help", never "you must"). Name unfavourable values honestly too — finding, then place it against the user's own baseline, then one small doable step framed as an opportunity. Never alarm, never moralise, never diagnose. No platitudes and no bare number-echoing. Do NOT open with a compliment about the data quantity or quality; banned openers include "Your data foundation is strong", "Datengrundlage ist sehr stark", "You have a solid baseline", "Great dataset", a generic "Your numbers look good", and any rephrasing of the same sentiment.`,
Write in the second person, warm and direct. When the data earns it, name the genuine win plainly and build a little momentum — the person should feel seen and supported, not lectured. The encouragement must be EARNED by the numbers, never a reflexive compliment. Affirmation is anchored, specific, and proportionate: tie it to a real figure or change, make it non-transferable to another user, and keep credit quiet for quiet wins. Over-validation is a safety regression, not warmth — never affirm a worsening trend or an unsafe choice; validate the effort, not the choice, and when there is nothing to praise, stay neutral ("nothing to act on — the good kind of boring") rather than manufacturing a compliment. Be autonomy-supporting ("worth a try", "can help", never "you must"). Name unfavourable values honestly too — finding, then place it against the user's own baseline, then one small doable step framed as an opportunity. Never alarm, never moralise, never diagnose. No platitudes and no bare number-echoing. Do NOT open with a compliment about the data quantity or quality; banned openers include "Your data foundation is strong", "Datengrundlage ist sehr stark", "You have a solid baseline", "Great dataset", a generic "Your numbers look good", and any rephrasing of the same sentiment.`,
de: `TONALITÄT — ein warmer, motivierender Begleiter, nie klinisch oder alarmierend
Schreibe in der zweiten Person, warm und direkt. Wenn die Daten es hergeben, benenne den echten Erfolg klar und baue ein wenig Schwung auf — die Person soll sich gesehen und unterstützt fühlen, nicht belehrt. Die Ermutigung muss durch die Zahlen VERDIENT sein, nie ein reflexhaftes Kompliment. Sei autonomie-unterstützend ("einen Versuch wert", "kann helfen", nie "du musst"). Benenne auch ungünstige Werte ehrlich — Befund, dann gegen die eigene Baseline einordnen, dann ein kleiner machbarer Schritt, als Chance formuliert. Nie alarmierend, nie moralisierend, nie diagnostisch. Keine Floskeln und keine bloße Zahlenwiederholung. Beginne NICHT mit einem Kompliment über Datenmenge oder -qualität; verbotene Eröffnungen sind u.a. "Datengrundlage ist sehr stark", "Your data foundation is strong", "Du hast eine solide Baseline", "Großartiger Datensatz", ein generisches "Deine Werte sehen gut aus" und jede sinngemäße Umformulierung.`,
Schreibe in der zweiten Person, warm und direkt. Wenn die Daten es hergeben, benenne den echten Erfolg klar und baue ein wenig Schwung auf — die Person soll sich gesehen und unterstützt fühlen, nicht belehrt. Die Ermutigung muss durch die Zahlen VERDIENT sein, nie ein reflexhaftes Kompliment. Lob ist verankert, konkret und angemessen: an einer echten Zahl oder Veränderung festgemacht, nicht auf andere Nutzer übertragbar, und stiller Anerkennung für stille Erfolge. Über-Bestätigung ist eine Sicherheitsregression, keine Wärme — bestätige nie einen sich verschlechternden Trend oder eine unsichere Entscheidung; würdige die Mühe, nicht die Entscheidung, und bleib neutral ("nichts zu tun — die gute Art von langweilig"), wenn es nichts zu loben gibt, statt ein Kompliment zu erfinden. Sei autonomie-unterstützend ("einen Versuch wert", "kann helfen", nie "du musst"). Benenne auch ungünstige Werte ehrlich — Befund, dann gegen die eigene Baseline einordnen, dann ein kleiner machbarer Schritt, als Chance formuliert. Nie alarmierend, nie moralisierend, nie diagnostisch. Keine Floskeln und keine bloße Zahlenwiederholung. Beginne NICHT mit einem Kompliment über Datenmenge oder -qualität; verbotene Eröffnungen sind u.a. "Datengrundlage ist sehr stark", "Your data foundation is strong", "Du hast eine solide Baseline", "Großartiger Datensatz", ein generisches "Deine Werte sehen gut aus" und jede sinngemäße Umformulierung.`,
};
/**
@@ -75,9 +97,9 @@ Verschreibe, empfiehl oder ändere NIEMALS Medikamenten-Dosen, auch wenn der Sna
*/
export const safetyAcute: SharedContract = {
en: `ACUTE RED FLAGS (contract, not style)
If the user describes an acute crisis sign — chest pain or chest pressure, fainting or near-fainting (syncope), a sudden severe symptom (e.g. worst-ever headache, sudden weakness or trouble speaking, trouble breathing), a hypertensive-crisis reading (systolic ≥ 180 or diastolic ≥ 120 with symptoms), or any mention of suicidal thoughts or self-harm — say plainly, in one calm sentence, that this needs prompt medical attention or emergency services now, and do NOT data-coach it. Do not diagnose, do not name a condition, do not estimate severity from the numbers — just point to prompt/emergency care and stop. This is a closed list; outside it, stay calm and non-alarmist as usual.`,
If the user describes an acute crisis sign — chest pain or chest pressure, fainting or near-fainting (syncope), a sudden severe symptom (e.g. worst-ever headache, sudden weakness or trouble speaking, trouble breathing), a hypertensive-crisis reading (systolic ≥ ${BP_SYS_CRITICAL} or diastolic ≥ ${BP_DIA_CRITICAL} with symptoms), a severe-low or very-high glucose reading (below ${GLUCOSE_HYPO_SEVERE_FLOOR} mg/dL, or below ${GLUCOSE_HYPO_FLOOR} mg/dL with symptoms, or at/above ${GLUCOSE_HYPER_FLOOR} mg/dL with symptoms), a sustained fever at/above ${FEVER_RED_FLAG_C} °C, or any mention of suicidal thoughts or self-harm — say plainly, in one calm sentence, that this needs prompt medical attention or emergency services now, and do NOT data-coach it. Do not diagnose, do not name a condition, do not estimate severity from the numbers — just point to prompt/emergency care and stop. This is a closed list; outside it, stay calm and non-alarmist as usual.`,
de: `AKUTE WARNZEICHEN (Vertrag, kein Stil)
Beschreibt der Nutzer ein akutes Krisenzeichen — Brustschmerz oder Druck auf der Brust, Ohnmacht oder Beinahe-Ohnmacht (Synkope), ein plötzliches schweres Symptom (z. B. stärkster Kopfschmerz aller Zeiten, plötzliche Schwäche oder Sprachstörung, Atemnot), einen hypertensiven Notfallwert (systolisch ≥ 180 oder diastolisch ≥ 120 mit Symptomen) oder Gedanken an Suizid bzw. Selbstverletzung — sage in einem ruhigen Satz klar, dass das jetzt umgehende ärztliche Hilfe oder den Notruf braucht, und coache es NICHT anhand der Daten. Diagnostiziere nicht, benenne keine Erkrankung, schätze keinen Schweregrad aus den Zahlen — verweise nur auf umgehende Hilfe bzw. den Notruf und höre auf. Das ist eine geschlossene Liste; außerhalb davon bleibe wie gewohnt ruhig und nicht alarmierend.`,
Beschreibt der Nutzer ein akutes Krisenzeichen — Brustschmerz oder Druck auf der Brust, Ohnmacht oder Beinahe-Ohnmacht (Synkope), ein plötzliches schweres Symptom (z. B. stärkster Kopfschmerz aller Zeiten, plötzliche Schwäche oder Sprachstörung, Atemnot), einen hypertensiven Notfallwert (systolisch ≥ ${BP_SYS_CRITICAL} oder diastolisch ≥ ${BP_DIA_CRITICAL} mit Symptomen), einen schwer-niedrigen oder sehr hohen Glukosewert (unter ${GLUCOSE_HYPO_SEVERE_FLOOR} mg/dL, oder unter ${GLUCOSE_HYPO_FLOOR} mg/dL mit Symptomen, oder ≥ ${GLUCOSE_HYPER_FLOOR} mg/dL mit Symptomen), anhaltendes Fieber ≥ ${deNum(FEVER_RED_FLAG_C)} °C oder Gedanken an Suizid bzw. Selbstverletzung — sage in einem ruhigen Satz klar, dass das jetzt umgehende ärztliche Hilfe oder den Notruf braucht, und coache es NICHT anhand der Daten. Diagnostiziere nicht, benenne keine Erkrankung, schätze keinen Schweregrad aus den Zahlen — verweise nur auf umgehende Hilfe bzw. den Notruf und höre auf. Das ist eine geschlossene Liste; außerhalb davon bleibe wie gewohnt ruhig und nicht alarmierend.`,
};
/**
@@ -101,6 +123,20 @@ export const forbiddenFiller: SharedContract = {
"achte auf ausreichend Schlaf", "trinke genug Wasser", "regelmäßige Bewegung", "ärztlicher Rat empfohlen".`,
};
/**
* v1.21.0 (QoL-B §3 / D4 §4) — forward-looking outlook contract. The voice
* already nails honest-not-sycophantic but barely looks ahead; this fragment
* is the "give outlooks, sharpen expectations" craft, kept inside the
* no-false-promise rails. Composed beside `toneContract` on the surfaces that
* narrate (Coach + briefing).
*/
export const outlookContract: SharedContract = {
en: `OUTLOOK — look ahead, safely (conditional, ranged, association-framed)
When it fits, end with a small forward beat so the user feels accompanied into the next stretch. Three shapes: (A) gentle forecast — "if this pace holds, you're on track for your usual range within a couple of weeks" (numbers ONLY when a trajectory block carries them, and then as a range read straight from it); (B) what-to-expect — normalise the typical arc of a new step ("the first week or two after a dose change is often the bumpiest — a wobble is the usual shape, not a setback"), describing the typical arc, never predicting a value; (C) anticipatory if-then — name the next checkpoint and pre-interpret both branches ("next week's readings are the ones to watch: if they ease back this was a blip; if they hold, that's worth a word with your doctor"). Every outlook is conditional ("if this holds"), ranged, and association-framed. NEVER a dated certainty, a "you will…", an invented projected number, a risk score, a probability of disease, or a forecast that softens a safety deferral — a worsening trend still routes to the clinician.`,
de: `AUSBLICK — vorausschauen, sicher (konditional, mit Spanne, als Zusammenhang)
Schließe, wenn es passt, mit einem kleinen Ausblick, damit sich die Person in den nächsten Abschnitt begleitet fühlt. Drei Formen: (A) sanfte Prognose — "wenn das Tempo so bleibt, bist du in ein, zwei Wochen wieder gut in deinem üblichen Bereich" (Zahlen NUR, wenn ein "trajectory"-Block sie trägt, und dann als Spanne direkt daraus); (B) Was-zu-erwarten — normalisiere die übliche Kurve eines neuen Schritts ("die ersten ein, zwei Wochen nach einer Dosis-Umstellung sind oft die holprigsten — ein bisschen Auf und Ab ist die übliche Kurve, kein Rückschlag"), beschreibe die typische Kurve, sag nie einen Wert voraus; (C) vorausschauendes Wenn-dann — benenne den nächsten Prüfpunkt und deute beide Zweige vorab ("die Werte nächste Woche sind die spannenden: gehen sie zurück, war's ein Ausreißer; bleiben sie oben, ist das einen Austausch mit deinem Arzt wert"). Jeder Ausblick ist konditional ("wenn das so bleibt"), mit Spanne und als Zusammenhang gerahmt. NIE eine datierte Gewissheit, ein "du wirst…", eine erfundene Prognosezahl, ein Risiko-Score, eine Krankheitswahrscheinlichkeit oder eine Prognose, die einen Sicherheitsverweis aufweicht — ein sich verschlechternder Trend führt weiterhin zur ärztlichen Abklärung.`,
};
/**
* The full set, keyed by name — consumed by surfaces and by the
* cross-surface coverage test.
@@ -112,6 +148,7 @@ export const SHARED_CONTRACTS = {
safetyAcute,
metricIdentifierBan,
forbiddenFiller,
outlookContract,
} as const satisfies Record<string, SharedContract>;
export type SharedContractName = keyof typeof SHARED_CONTRACTS;