mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge branch 'coach/wp6-method' into release/v1.39.4
This commit is contained in:
commit
dc085cff4d
9 files changed
+708
-44
No files matched your search
@@ -50,7 +50,7 @@ exports[`coach chat golden transcripts > 01a tool mode, two rounds, present data
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "30126acc9ad2:1419",
|
||||
"system": "f41c228cc70f:1881",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -251,7 +251,7 @@ exports[`coach chat golden transcripts > 01b tool mode, present data, one figure
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "30126acc9ad2:1419",
|
||||
"system": "f41c228cc70f:1881",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -464,7 +464,7 @@ exports[`coach chat golden transcripts > 02 tool mode, every tool missed, fabric
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "30126acc9ad2:1419",
|
||||
"system": "f41c228cc70f:1881",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -1128,7 +1128,7 @@ exports[`coach chat golden transcripts > 07a all providers failed (plain) 1`] =
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "30126acc9ad2:1419",
|
||||
"system": "f41c228cc70f:1881",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -1217,7 +1217,7 @@ exports[`coach chat golden transcripts > 07b all providers failed (every hop rat
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "30126acc9ad2:1419",
|
||||
"system": "f41c228cc70f:1881",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -1306,7 +1306,7 @@ exports[`coach chat golden transcripts > 07c all providers failed (primary crede
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "30126acc9ad2:1419",
|
||||
"system": "f41c228cc70f:1881",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -1395,7 +1395,7 @@ exports[`coach chat golden transcripts > 08 an unwrapped tagged provider error 1
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "30126acc9ad2:1419",
|
||||
"system": "f41c228cc70f:1881",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -1485,7 +1485,7 @@ exports[`coach chat golden transcripts > 09 an aborted request leaves a cancelle
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "30126acc9ad2:1419",
|
||||
"system": "f41c228cc70f:1881",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -1581,7 +1581,7 @@ exports[`coach chat golden transcripts > 10 outbound block replaces the reply an
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "30126acc9ad2:1419",
|
||||
"system": "f41c228cc70f:1881",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -1912,7 +1912,7 @@ exports[`coach chat golden transcripts > 11a KEYVALUES valid 1`] = `
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "30126acc9ad2:1419",
|
||||
"system": "f41c228cc70f:1881",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -2117,7 +2117,7 @@ exports[`coach chat golden transcripts > 11b KEYVALUES partial 1`] = `
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "30126acc9ad2:1419",
|
||||
"system": "f41c228cc70f:1881",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -2320,7 +2320,7 @@ exports[`coach chat golden transcripts > 11c KEYVALUES malformed (no closing mar
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "30126acc9ad2:1419",
|
||||
"system": "f41c228cc70f:1881",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -2507,7 +2507,7 @@ exports[`coach chat golden transcripts > 11d sentinel-only reply is an empty rep
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "30126acc9ad2:1419",
|
||||
"system": "f41c228cc70f:1881",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -2596,7 +2596,7 @@ exports[`coach chat golden transcripts > 11e empty provider reply 1`] = `
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "30126acc9ad2:1419",
|
||||
"system": "f41c228cc70f:1881",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -2676,7 +2676,7 @@ exports[`coach chat golden transcripts > 12a SUGGEST-REMINDER surfaced 1`] = `
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "30126acc9ad2:1419",
|
||||
"system": "f41c228cc70f:1881",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -2901,7 +2901,7 @@ exports[`coach chat golden transcripts > 12b SUGGEST-REMINDER suppressed 1`] = `
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "30126acc9ad2:1419",
|
||||
"system": "f41c228cc70f:1881",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -3090,7 +3090,7 @@ exports[`coach chat golden transcripts > 13 SUGGEST-ACTION 1`] = `
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "30126acc9ad2:1419",
|
||||
"system": "f41c228cc70f:1881",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -3319,7 +3319,7 @@ exports[`coach chat golden transcripts > 14a REMEMBER valid 1`] = `
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "30126acc9ad2:1419",
|
||||
"system": "f41c228cc70f:1881",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -3528,7 +3528,7 @@ exports[`coach chat golden transcripts > 14b REMEMBER malformed 1`] = `
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "30126acc9ad2:1419",
|
||||
"system": "f41c228cc70f:1881",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -3732,7 +3732,7 @@ exports[`coach chat golden transcripts > 15 an unknown learn link is scrubbed, a
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "30126acc9ad2:1419",
|
||||
"system": "f41c228cc70f:1881",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -4230,7 +4230,7 @@ exports[`coach chat golden transcripts > 17a a workout-scoped first turn 1`] = `
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "30126acc9ad2:1419",
|
||||
"system": "f41c228cc70f:1881",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -4440,7 +4440,7 @@ exports[`coach chat golden transcripts > 17b a workout-scoped first turn with th
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "30126acc9ad2:1419",
|
||||
"system": "f41c228cc70f:1881",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -4629,7 +4629,7 @@ exports[`coach chat golden transcripts > 18 a persistence throw after the provid
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "30126acc9ad2:1419",
|
||||
"system": "f41c228cc70f:1881",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -4722,7 +4722,7 @@ exports[`coach chat golden transcripts > 19 an existing conversation with prior
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "30126acc9ad2:1419",
|
||||
"system": "f41c228cc70f:1881",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -4935,7 +4935,7 @@ exports[`coach chat golden transcripts > 20 a guided-question turn with a scope
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "30126acc9ad2:1419",
|
||||
"system": "f41c228cc70f:1881",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 30000,
|
||||
"tools": 2,
|
||||
@@ -5104,7 +5104,7 @@ exports[`coach chat golden transcripts > 21 the German locale with a stored self
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "082a378c693f:1512",
|
||||
"system": "af8316f8b500:2065",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
|
||||
@@ -66,7 +66,8 @@ vi.mock("@/lib/ai/provider", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain: vi.fn() }));
|
||||
vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" }));
|
||||
vi.mock("@/lib/ai/coach/types", () => ({
|
||||
vi.mock("@/lib/ai/coach/types", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/ai/coach/types")>()),
|
||||
coachChatRequestSchema: {
|
||||
safeParse: () => ({ success: false, error: { issues: [] } }),
|
||||
},
|
||||
|
||||
@@ -53,7 +53,8 @@ vi.mock("@/lib/ai/provider", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain: vi.fn() }));
|
||||
vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" }));
|
||||
vi.mock("@/lib/ai/coach/types", () => ({
|
||||
vi.mock("@/lib/ai/coach/types", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/ai/coach/types")>()),
|
||||
coachChatRequestSchema: { safeParse: () => ({ success: false }) },
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/tools", () => ({
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { CoachMethodLine } from "../method-line";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<CoachMethodLine>", () => {
|
||||
it("renders nothing without a method", () => {
|
||||
expect(render(<CoachMethodLine method={null} />)).toBe("");
|
||||
expect(render(<CoachMethodLine method={{ entries: [], text: "" }} />)).toBe(
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the server text as a muted meta line", () => {
|
||||
const html = render(
|
||||
<CoachMethodLine
|
||||
method={{
|
||||
entries: [{ domain: "bp", window: "last90days", count: 142 }],
|
||||
text: "Blood pressure, last 90 days: 142 readings",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="coach-method-line"');
|
||||
expect(html).toContain("text-muted-foreground text-xs");
|
||||
expect(html).toContain("How this was worked out:");
|
||||
expect(html).toContain("Blood pressure, last 90 days: 142 readings");
|
||||
});
|
||||
|
||||
it("labels the line in the reader's locale", () => {
|
||||
const html = render(
|
||||
<CoachMethodLine method={{ entries: [], text: "Blutdruck" }} />,
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("Wie das ermittelt wurde:");
|
||||
});
|
||||
});
|
||||
@@ -3,16 +3,28 @@
|
||||
/**
|
||||
* v1.39.4 — how an answer was worked out, as one muted line inside the
|
||||
* evidence disclosure: sources, windows, counts and aggregation, rendered
|
||||
* on the server. Never a health value.
|
||||
*
|
||||
* Not built yet: renders nothing.
|
||||
* on the server in the request locale. Never a health value, so it sits in
|
||||
* the meta tier beside the key values it explains rather than repeating
|
||||
* them.
|
||||
*/
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { COACH_METHOD_KEYS } from "@/lib/ai/coach/dialog-keys";
|
||||
import type { CoachMethod } from "@/lib/ai/coach/types";
|
||||
|
||||
export interface CoachMethodLineProps {
|
||||
method: CoachMethod | null;
|
||||
}
|
||||
|
||||
export function CoachMethodLine(_props: CoachMethodLineProps) {
|
||||
return null;
|
||||
export function CoachMethodLine({ method }: CoachMethodLineProps) {
|
||||
const { t } = useTranslations();
|
||||
if (!method || !method.text) return null;
|
||||
return (
|
||||
<p
|
||||
data-slot="coach-method-line"
|
||||
className="text-muted-foreground text-xs leading-relaxed"
|
||||
>
|
||||
<span className="font-medium">{t(COACH_METHOD_KEYS.label)}:</span>{" "}
|
||||
{method.text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { locales } from "@/lib/i18n/config";
|
||||
import { buildMethod, METHOD_MAX_ENTRIES } from "@/lib/ai/coach/method";
|
||||
import type {
|
||||
CoachResultMeta,
|
||||
CoachStep,
|
||||
CoachStepDomain,
|
||||
} from "@/lib/ai/coach/types";
|
||||
|
||||
function step(partial: Partial<CoachStep> & Pick<CoachStep, "id">): CoachStep {
|
||||
return {
|
||||
tool: "get_metric_series",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "server label",
|
||||
status: "done",
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function table(
|
||||
ref: string,
|
||||
domain: CoachStepDomain,
|
||||
over: Partial<CoachResultMeta> = {},
|
||||
): CoachResultMeta {
|
||||
return {
|
||||
ref,
|
||||
source: {
|
||||
tool: "get_metric_series",
|
||||
domain,
|
||||
window: "last90days",
|
||||
period: "current",
|
||||
granularity: "week",
|
||||
},
|
||||
shape: "timeSeries",
|
||||
titleKey: "coach.result.title.byWeek",
|
||||
title: "t",
|
||||
rowCount: 13,
|
||||
chartKind: "line",
|
||||
displayed: true,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildMethod", () => {
|
||||
it("returns null when the turn read nothing", () => {
|
||||
expect(buildMethod({ steps: [], results: [], locale: "en" })).toBeNull();
|
||||
});
|
||||
|
||||
it("names source, window, count and aggregation", () => {
|
||||
const method = buildMethod({
|
||||
steps: [
|
||||
step({
|
||||
id: "s1",
|
||||
domain: "bp",
|
||||
window: "last90days",
|
||||
granularity: "week",
|
||||
count: 142,
|
||||
resultRef: "r1",
|
||||
}),
|
||||
step({
|
||||
id: "s2",
|
||||
tool: "get_sleep",
|
||||
domain: "sleep",
|
||||
window: "last30days",
|
||||
status: "empty",
|
||||
reason: "outside_window",
|
||||
count: 40,
|
||||
}),
|
||||
],
|
||||
results: [table("r1", "bp")],
|
||||
locale: "en",
|
||||
});
|
||||
expect(method?.text).toBe(
|
||||
"Blood pressure, last 90 days: 142 readings, weekly averages · Sleep, last 30 days: no readings in this window",
|
||||
);
|
||||
// An absent entry carries no count, even when the step counted the
|
||||
// readings the window could not reach.
|
||||
expect(method?.entries[1]).toEqual({
|
||||
domain: "sleep",
|
||||
window: "last30days",
|
||||
absent: "outside_window",
|
||||
});
|
||||
});
|
||||
|
||||
it("says totals for a summed metric and a rate for compliance", () => {
|
||||
const method = buildMethod({
|
||||
steps: [
|
||||
step({
|
||||
id: "s1",
|
||||
domain: "steps",
|
||||
window: "last30days",
|
||||
count: 30,
|
||||
resultRef: "r1",
|
||||
}),
|
||||
step({
|
||||
id: "s2",
|
||||
tool: "get_medication_compliance",
|
||||
domain: "compliance",
|
||||
window: "last30days",
|
||||
count: 60,
|
||||
resultRef: "r2",
|
||||
}),
|
||||
],
|
||||
results: [
|
||||
table("r1", "steps", {
|
||||
source: {
|
||||
tool: "get_metric_series",
|
||||
domain: "steps",
|
||||
window: "last30days",
|
||||
period: "current",
|
||||
granularity: "day",
|
||||
},
|
||||
}),
|
||||
table("r2", "compliance", {
|
||||
source: {
|
||||
tool: "get_medication_compliance",
|
||||
domain: "compliance",
|
||||
window: "last30days",
|
||||
period: "current",
|
||||
granularity: "day",
|
||||
},
|
||||
}),
|
||||
],
|
||||
locale: "en",
|
||||
});
|
||||
expect(method?.text).toBe(
|
||||
"Steps, last 30 days: 30 readings, daily totals · Adherence, last 30 days: 60 readings, rate",
|
||||
);
|
||||
expect(method?.entries.map((e) => e.aggregation)).toEqual(["sum", "rate"]);
|
||||
});
|
||||
|
||||
it("names the period when it is not the current one", () => {
|
||||
const method = buildMethod({
|
||||
steps: [
|
||||
step({
|
||||
id: "s1",
|
||||
domain: "pulse",
|
||||
window: "last90days",
|
||||
period: "yearAgo",
|
||||
count: 80,
|
||||
}),
|
||||
],
|
||||
results: [],
|
||||
locale: "en",
|
||||
});
|
||||
expect(method?.text).toBe(
|
||||
"Pulse, last 90 days, a year earlier: 80 readings",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the no-window form for a domain read as a whole", () => {
|
||||
const method = buildMethod({
|
||||
steps: [step({ id: "s1", tool: "get_labs", domain: "labs", count: 5 })],
|
||||
results: [],
|
||||
locale: "en",
|
||||
});
|
||||
expect(method?.text).toBe("Lab results: 5 readings");
|
||||
});
|
||||
|
||||
it("leaves out running, failed and snapshot steps", () => {
|
||||
const method = buildMethod({
|
||||
steps: [
|
||||
step({
|
||||
id: "s1",
|
||||
domain: "bp",
|
||||
window: "last30days",
|
||||
status: "running",
|
||||
}),
|
||||
step({
|
||||
id: "s2",
|
||||
domain: "weight",
|
||||
window: "last30days",
|
||||
status: "failed",
|
||||
reason: "retrieval_failed",
|
||||
}),
|
||||
step({ id: "s3", tool: "snapshot", domain: "snapshot", count: 12 }),
|
||||
step({
|
||||
id: "s4",
|
||||
domain: "hrv",
|
||||
window: "last30days",
|
||||
status: "empty",
|
||||
reason: "invalid_arguments",
|
||||
}),
|
||||
],
|
||||
results: [],
|
||||
locale: "en",
|
||||
});
|
||||
expect(method).toBeNull();
|
||||
});
|
||||
|
||||
it("merges the same read twice into one entry", () => {
|
||||
const method = buildMethod({
|
||||
steps: [
|
||||
step({
|
||||
id: "s1",
|
||||
domain: "bp",
|
||||
window: "last30days",
|
||||
status: "empty",
|
||||
reason: "no_data",
|
||||
}),
|
||||
step({ id: "s2", domain: "bp", window: "last30days", count: 20 }),
|
||||
step({ id: "s3", domain: "bp", window: "last30days", count: 18 }),
|
||||
],
|
||||
results: [],
|
||||
locale: "en",
|
||||
});
|
||||
expect(method?.entries).toEqual([
|
||||
{ domain: "bp", window: "last30days", count: 20 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("describes a table no step accounts for", () => {
|
||||
const method = buildMethod({
|
||||
steps: [],
|
||||
results: [
|
||||
table("r1", "weight", {
|
||||
source: {
|
||||
tool: "get_metric_series",
|
||||
domain: "weight",
|
||||
window: "lastYear",
|
||||
period: "current",
|
||||
granularity: "month",
|
||||
},
|
||||
}),
|
||||
],
|
||||
locale: "en",
|
||||
});
|
||||
expect(method?.text).toBe("Weight, last 12 months: monthly averages");
|
||||
});
|
||||
|
||||
it("caps the line", () => {
|
||||
const domains: CoachStepDomain[] = [
|
||||
"bp",
|
||||
"pulse",
|
||||
"weight",
|
||||
"hrv",
|
||||
"sleep",
|
||||
"steps",
|
||||
"mood",
|
||||
"glucose",
|
||||
];
|
||||
const method = buildMethod({
|
||||
steps: domains.map((domain, i) =>
|
||||
step({ id: `s${i + 1}`, domain, window: "last30days", count: i + 1 }),
|
||||
),
|
||||
results: [],
|
||||
locale: "en",
|
||||
});
|
||||
expect(method?.entries).toHaveLength(METHOD_MAX_ENTRIES);
|
||||
});
|
||||
|
||||
it("never carries a health value, whatever the step label says", () => {
|
||||
// Property: the only digits in the line are the counts it was given.
|
||||
const hostile = "142/88 mmHg, 7.4 mmol/L, eGFR 55";
|
||||
for (let seed = 1; seed < 60; seed++) {
|
||||
const count = (seed * 37) % 500;
|
||||
const method = buildMethod({
|
||||
steps: [
|
||||
step({
|
||||
id: "s1",
|
||||
domain: "bp",
|
||||
window: "last30days",
|
||||
count,
|
||||
label: hostile,
|
||||
labelKey: hostile,
|
||||
resultRef: "r1",
|
||||
}),
|
||||
step({
|
||||
id: "s2",
|
||||
tool: "get_labs",
|
||||
domain: "labs",
|
||||
count: seed,
|
||||
label: hostile,
|
||||
}),
|
||||
step({
|
||||
id: "s3",
|
||||
domain: "glucose",
|
||||
window: "last7days",
|
||||
status: "empty",
|
||||
reason: "no_data",
|
||||
count: 999,
|
||||
label: hostile,
|
||||
}),
|
||||
],
|
||||
results: [table("r1", "bp", { title: hostile, titleKey: hostile })],
|
||||
locale: "en",
|
||||
})!;
|
||||
expect(method.text).not.toContain("mmHg");
|
||||
expect(method.text).not.toContain("eGFR");
|
||||
const digits = (method.text.match(/\d+/g) ?? []).map(Number);
|
||||
// "last 30 days" and "last 7 days" are the window labels.
|
||||
const allowed = new Set([count, seed, 30, 7]);
|
||||
for (const n of digits) expect(allowed.has(n)).toBe(true);
|
||||
expect(digits).not.toContain(999);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(locales)("renders every string in %s", (locale) => {
|
||||
const method = buildMethod({
|
||||
steps: [
|
||||
step({
|
||||
id: "s1",
|
||||
domain: "bp",
|
||||
window: "last90days",
|
||||
granularity: "week",
|
||||
count: 2,
|
||||
resultRef: "r1",
|
||||
}),
|
||||
step({
|
||||
id: "s2",
|
||||
domain: "steps",
|
||||
window: "last30days",
|
||||
period: "previous",
|
||||
count: 1,
|
||||
resultRef: "r2",
|
||||
}),
|
||||
step({ id: "s3", tool: "get_labs", domain: "labs", count: 5 }),
|
||||
step({
|
||||
id: "s4",
|
||||
domain: "sleep",
|
||||
window: "last7days",
|
||||
status: "empty",
|
||||
reason: "no_data",
|
||||
}),
|
||||
step({
|
||||
id: "s5",
|
||||
domain: "mood",
|
||||
window: "last30days",
|
||||
status: "empty",
|
||||
reason: "module_disabled",
|
||||
}),
|
||||
step({
|
||||
id: "s6",
|
||||
tool: "get_workouts",
|
||||
domain: "workouts",
|
||||
window: "last30days",
|
||||
count: 9,
|
||||
resultRef: "r3",
|
||||
}),
|
||||
],
|
||||
results: [
|
||||
table("r1", "bp"),
|
||||
table("r2", "steps", {
|
||||
source: {
|
||||
tool: "get_metric_series",
|
||||
domain: "steps",
|
||||
window: "last30days",
|
||||
period: "previous",
|
||||
granularity: "month",
|
||||
},
|
||||
}),
|
||||
table("r3", "workouts", {
|
||||
shape: "categoryCounts",
|
||||
source: {
|
||||
tool: "get_workouts",
|
||||
domain: "workouts",
|
||||
window: "last30days",
|
||||
period: "current",
|
||||
},
|
||||
}),
|
||||
],
|
||||
locale,
|
||||
})!;
|
||||
expect(method.entries).toHaveLength(6);
|
||||
expect(method.text.split(" · ")).toHaveLength(6);
|
||||
// No raw key and no unfilled placeholder in any locale.
|
||||
expect(method.text).not.toMatch(/coach\.|insights\./);
|
||||
expect(method.text).not.toMatch(/[{}]/);
|
||||
});
|
||||
});
|
||||
+210
-5
@@ -1,22 +1,227 @@
|
||||
/**
|
||||
* The method line under an answer: which sources, windows, counts and
|
||||
* aggregation it rests on, rendered on the server in the request locale.
|
||||
* "Blood pressure, last 90 days: 142 readings, weekly means · Sleep: no
|
||||
* readings in this window". Never a health value.
|
||||
* "Blood pressure, last 90 days: 142 readings, weekly averages · Sleep, last
|
||||
* 30 days: no readings in this window".
|
||||
*
|
||||
* Not built yet: no method line.
|
||||
* Built from what the server recorded about the turn (the steps and the
|
||||
* result tables), never from model text. It carries catalog strings and
|
||||
* server-counted integers only, so a health value cannot reach it: a step's
|
||||
* `label` is never read, and a count is the number of readings, not a
|
||||
* reading. A domain the turn found nothing for says so; absence is part of
|
||||
* the method, not a gap in it.
|
||||
*/
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import type {
|
||||
CoachMethod,
|
||||
CoachMethodEntry,
|
||||
CoachResultMeta,
|
||||
CoachStep,
|
||||
CoachStepDomain,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import {
|
||||
COACH_METHOD_ABSENT_KEYS,
|
||||
COACH_METHOD_AGGREGATION_KEYS,
|
||||
COACH_METHOD_KEYS,
|
||||
coachDomainLabelKey,
|
||||
coachPeriodLabelKey,
|
||||
coachWindowLabelKey,
|
||||
methodAveragesKey,
|
||||
methodReadingsKey,
|
||||
methodTotalsKey,
|
||||
} from "@/lib/ai/coach/dialog-keys";
|
||||
|
||||
export function buildMethod(_args: {
|
||||
/** More than this and the line stops being a line. */
|
||||
export const METHOD_MAX_ENTRIES = 6;
|
||||
|
||||
type Aggregation = NonNullable<CoachMethodEntry["aggregation"]>;
|
||||
type Absent = NonNullable<CoachMethodEntry["absent"]>;
|
||||
|
||||
/**
|
||||
* Metrics whose buckets are totals (a week of steps is summed, not
|
||||
* averaged). Every other time series is a level, and its buckets are means.
|
||||
*/
|
||||
const TOTAL_METRICS: ReadonlySet<CoachStepDomain> = new Set<CoachStepDomain>([
|
||||
"steps",
|
||||
"active_energy",
|
||||
"flights",
|
||||
"distance",
|
||||
"daylight",
|
||||
]);
|
||||
|
||||
const ABSENT_REASONS: ReadonlySet<string> = new Set<Absent>([
|
||||
"no_data",
|
||||
"outside_window",
|
||||
"module_disabled",
|
||||
]);
|
||||
|
||||
function aggregationFor(result: CoachResultMeta): Aggregation | undefined {
|
||||
switch (result.shape) {
|
||||
case "categoryCounts":
|
||||
case "distribution":
|
||||
return "count";
|
||||
case "single":
|
||||
return "latest";
|
||||
case "timeSeries": {
|
||||
const domain = result.source.domain;
|
||||
if (domain === "compliance") return "rate";
|
||||
// One night per day: a day table of sleep is the nights themselves.
|
||||
if (domain === "sleep" && result.source.granularity === "day") {
|
||||
return undefined;
|
||||
}
|
||||
return TOTAL_METRICS.has(domain) ? "sum" : "mean";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function entryKey(e: CoachMethodEntry): string {
|
||||
return [e.domain, e.window ?? "", e.period ?? "", e.granularity ?? ""].join(
|
||||
"|",
|
||||
);
|
||||
}
|
||||
|
||||
/** The entries, in the order the turn read them, one per read. */
|
||||
export function methodEntries(
|
||||
steps: readonly CoachStep[],
|
||||
results: readonly CoachResultMeta[],
|
||||
): CoachMethodEntry[] {
|
||||
const byRef = new Map(results.map((r) => [r.ref, r]));
|
||||
const usedRefs = new Set<string>();
|
||||
const entries = new Map<string, CoachMethodEntry>();
|
||||
|
||||
const add = (entry: CoachMethodEntry) => {
|
||||
const key = entryKey(entry);
|
||||
const existing = entries.get(key);
|
||||
if (!existing) {
|
||||
entries.set(key, entry);
|
||||
return;
|
||||
}
|
||||
// The same read twice (a retry, a show_result of this turn's table):
|
||||
// one entry, the fullest one. A found read outranks an absent one.
|
||||
if (existing.absent && !entry.absent) {
|
||||
entries.set(key, entry);
|
||||
return;
|
||||
}
|
||||
if (!entry.absent) {
|
||||
entries.set(key, {
|
||||
...existing,
|
||||
...(entry.count !== undefined &&
|
||||
(existing.count === undefined || entry.count > existing.count)
|
||||
? { count: entry.count }
|
||||
: {}),
|
||||
...(existing.aggregation === undefined && entry.aggregation
|
||||
? { aggregation: entry.aggregation }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
for (const step of steps) {
|
||||
if (!step.domain || step.domain === "snapshot") continue;
|
||||
const result = step.resultRef ? byRef.get(step.resultRef) : undefined;
|
||||
if (result) usedRefs.add(result.ref);
|
||||
const window = step.window ?? result?.source.window;
|
||||
const period = step.period ?? result?.source.period;
|
||||
const granularity = step.granularity ?? result?.source.granularity;
|
||||
const base: CoachMethodEntry = {
|
||||
domain: step.domain,
|
||||
...(window ? { window } : {}),
|
||||
...(period ? { period } : {}),
|
||||
...(granularity ? { granularity } : {}),
|
||||
};
|
||||
if (step.status === "empty") {
|
||||
if (step.reason && ABSENT_REASONS.has(step.reason)) {
|
||||
add({ ...base, absent: step.reason as Absent });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A running step never finished; a failed one was not read, which is
|
||||
// not the same as nothing being there. The step list says which.
|
||||
if (step.status !== "done") continue;
|
||||
const aggregation = result ? aggregationFor(result) : undefined;
|
||||
add({
|
||||
...base,
|
||||
...(typeof step.count === "number" && step.count >= 0
|
||||
? { count: Math.floor(step.count) }
|
||||
: {}),
|
||||
...(aggregation ? { aggregation } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
// A table no step accounts for still says how it was built.
|
||||
for (const result of results) {
|
||||
if (usedRefs.has(result.ref)) continue;
|
||||
const aggregation = aggregationFor(result);
|
||||
add({
|
||||
domain: result.source.domain,
|
||||
window: result.source.window,
|
||||
period: result.source.period,
|
||||
...(result.source.granularity
|
||||
? { granularity: result.source.granularity }
|
||||
: {}),
|
||||
...(aggregation ? { aggregation } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return [...entries.values()];
|
||||
}
|
||||
|
||||
function renderEntry(
|
||||
entry: CoachMethodEntry,
|
||||
t: (key: string, params?: Record<string, string | number>) => string,
|
||||
locale: Locale,
|
||||
): string | null {
|
||||
const details: string[] = [];
|
||||
if (entry.absent) {
|
||||
details.push(t(COACH_METHOD_ABSENT_KEYS[entry.absent]));
|
||||
} else {
|
||||
if (entry.count !== undefined) {
|
||||
details.push(
|
||||
t(methodReadingsKey(entry.count, locale), { count: entry.count }),
|
||||
);
|
||||
}
|
||||
if (entry.aggregation === "mean" && entry.granularity) {
|
||||
details.push(t(methodAveragesKey(entry.granularity)));
|
||||
} else if (entry.aggregation === "sum" && entry.granularity) {
|
||||
details.push(t(methodTotalsKey(entry.granularity)));
|
||||
} else if (entry.aggregation) {
|
||||
details.push(t(COACH_METHOD_AGGREGATION_KEYS[entry.aggregation]));
|
||||
}
|
||||
}
|
||||
// Nothing to say about how it was worked out: the step list already
|
||||
// names the source.
|
||||
if (details.length === 0) return null;
|
||||
|
||||
const domain = t(coachDomainLabelKey(entry.domain));
|
||||
const when = [
|
||||
...(entry.window ? [t(coachWindowLabelKey(entry.window))] : []),
|
||||
...(entry.period && entry.period !== "current"
|
||||
? [t(coachPeriodLabelKey(entry.period))]
|
||||
: []),
|
||||
].join(", ");
|
||||
const detail = details.join(", ");
|
||||
return when
|
||||
? t(COACH_METHOD_KEYS.entry, { domain, window: when, detail })
|
||||
: t(COACH_METHOD_KEYS.entryNoWindow, { domain, detail });
|
||||
}
|
||||
|
||||
export function buildMethod(args: {
|
||||
steps: CoachStep[];
|
||||
results: CoachResultMeta[];
|
||||
locale: Locale;
|
||||
}): CoachMethod | null {
|
||||
return null;
|
||||
const { locale } = args;
|
||||
const { t } = getServerTranslator(locale);
|
||||
const rendered: Array<{ entry: CoachMethodEntry; text: string }> = [];
|
||||
for (const entry of methodEntries(args.steps, args.results)) {
|
||||
const text = renderEntry(entry, t, locale);
|
||||
if (text) rendered.push({ entry, text });
|
||||
if (rendered.length === METHOD_MAX_ENTRIES) break;
|
||||
}
|
||||
if (rendered.length === 0) return null;
|
||||
return {
|
||||
entries: rendered.map((r) => r.entry),
|
||||
text: rendered.map((r) => r.text).join(" · "),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { methodAddendum } from "../method";
|
||||
|
||||
describe("methodAddendum", () => {
|
||||
it.each(["en", "de"] as const)("carries the recheck rule (%s)", (locale) => {
|
||||
const text = methodAddendum(locale);
|
||||
expect(text).toContain("show_result");
|
||||
expect(text.length).toBeGreaterThan(200);
|
||||
});
|
||||
|
||||
it("tells the model to re-read and correct, not defend", () => {
|
||||
const text = methodAddendum("en");
|
||||
expect(text).toMatch(/fetch it again/);
|
||||
expect(text).toMatch(/correct your answer/);
|
||||
expect(text).toMatch(/name the source and the window/);
|
||||
});
|
||||
|
||||
it("uses English for the other locales", () => {
|
||||
expect(methodAddendum("pl")).toBe(methodAddendum("en"));
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,20 @@
|
||||
/**
|
||||
* Prompt rules for the method line and for rechecking a figure the person
|
||||
* questions.
|
||||
*
|
||||
* Not written yet: empty.
|
||||
* Prompt rules for rechecking a figure the person questions. The method line
|
||||
* itself is built on the server from the turn's reads (`method.ts`); the
|
||||
* model neither writes nor sees it, so the rule here is only about what to
|
||||
* do when a figure is challenged: name where it came from, read it again,
|
||||
* and correct the answer when the second read differs.
|
||||
*/
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
export function methodAddendum(_locale: Locale): string {
|
||||
return "";
|
||||
const EN = `RECHECKING A FIGURE
|
||||
|
||||
If the person questions a figure ("is that right?", "that seems high"): name the source and the window you used, fetch it again with the same tool and arguments (or call show_result for a table this conversation already holds), and compare. If the figures differ, correct your answer and say what changed. If they match, say so plainly. Never defend a figure you have not just re-read, and never adjust one to fit what the person expected.`;
|
||||
|
||||
const DE = `ZAHL ÜBERPRÜFEN
|
||||
|
||||
Wenn die Person eine Zahl anzweifelt („stimmt das?", „das kommt mir hoch vor"): nenne die Quelle und den Zeitraum, die du verwendet hast, rufe sie mit demselben Tool und denselben Argumenten erneut ab (oder nutze show_result für eine Tabelle, die diese Unterhaltung schon enthält) und vergleiche. Weichen die Zahlen ab, korrigiere deine Antwort und sage, was sich geändert hat. Stimmen sie überein, sage das schlicht. Verteidige keine Zahl, die du nicht gerade neu gelesen hast, und passe keine an das an, was die Person erwartet hat.`;
|
||||
|
||||
export function methodAddendum(locale: Locale): string {
|
||||
return locale === "de" ? DE : EN;
|
||||
}
|
||||
Reference in new issue
Block a user