mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Surface the computed illness retrospective to the Coach
The illness-recovery tool and the illness context block carried only labels, lifecycle, and dates plus the recovery composite — never the recovery-gap, the metric that drove it, the nadir, the pre-onset deviations, or the red flags the illness card renders. So a recovery question got the composite, not the gap the user sees, and a sustained-fever or low-SpO2 escalation was invisible in-conversation. Run the existing episode-correlation read-layer (the same engine the card and the red-flag notifier use) for the most relevant episode — active first, else the most-recently-resolved — and attach the computed scores to get_illness_recovery, coverage-gated so a thin signal yields nothing rather than a fabricated number. Document the illnessScores shape in the Coach prompt so it restates those numbers verbatim and escalates a red flag rather than reassuring.
This commit is contained in:
1 parent
abceff04c7
commit
976ecf8a75
5 files changed
+463
-1
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
@@ -298,6 +308,77 @@ 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
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -347,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,
|
||||
@@ -790,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,
|
||||
|
||||
@@ -55,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 {
|
||||
@@ -427,11 +428,18 @@ async function getIllnessRecovery(
|
||||
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" };
|
||||
}
|
||||
@@ -439,6 +447,7 @@ async function getIllnessRecovery(
|
||||
present: true,
|
||||
data: {
|
||||
...(illness !== undefined ? { illness } : {}),
|
||||
...(illnessScores !== null ? { illnessScores } : {}),
|
||||
...(derived !== undefined ? { derived } : {}),
|
||||
...(dayStrain !== undefined ? { dayStrain } : {}),
|
||||
...(trajectory !== undefined ? { trajectory } : {}),
|
||||
|
||||
Reference in new issue
Block a user