mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge branch 'coach/wp1-steps' into release/v1.39.4
# Conflicts: # src/app/api/insights/chat/__tests__/route-capability-gate.test.ts # src/app/api/insights/chat/__tests__/route-list-query.test.ts
This commit is contained in:
commit
b84ea0604f
10 files changed
+1824
-20
No files matched your search
+2
-2
@@ -9485,8 +9485,8 @@
|
||||
},
|
||||
"noRecordedData": "I don't have any recorded readings for what you asked about, so there is nothing I can give you a figure from. Once you log some, ask me again and I'll work from your own numbers.",
|
||||
"step": {
|
||||
"read": "Reading {domain}",
|
||||
"readWindow": "Reading {domain}, {window}",
|
||||
"read": "Checking: {domain}",
|
||||
"readWindow": "Checking: {domain}, {window}",
|
||||
"snapshot": "Reading your record summary",
|
||||
"showResult": "Opening an earlier table",
|
||||
"reuse": "Using the table from before",
|
||||
|
||||
@@ -816,6 +816,17 @@ exports[`coach chat golden transcripts > 03 no-tools path with the full snapshot
|
||||
"metrics": [
|
||||
"bp",
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"count": 1,
|
||||
"domain": "snapshot",
|
||||
"id": "s1",
|
||||
"label": "Reading your record summary",
|
||||
"labelKey": "coach.step.snapshot",
|
||||
"status": "done",
|
||||
"tool": "snapshot",
|
||||
},
|
||||
],
|
||||
"windows": [
|
||||
"last30days",
|
||||
],
|
||||
@@ -844,6 +855,18 @@ exports[`coach chat golden transcripts > 03 no-tools path with the full snapshot
|
||||
],
|
||||
"contentType": "text/event-stream; charset=utf-8",
|
||||
"frames": [
|
||||
{
|
||||
"step": {
|
||||
"count": 1,
|
||||
"domain": "snapshot",
|
||||
"id": "s1",
|
||||
"label": "Reading your record summary",
|
||||
"labelKey": "coach.step.snapshot",
|
||||
"status": "done",
|
||||
"tool": "snapshot",
|
||||
},
|
||||
"type": "step",
|
||||
},
|
||||
{
|
||||
"token": "Your ",
|
||||
"type": "token",
|
||||
@@ -889,6 +912,17 @@ exports[`coach chat golden transcripts > 03 no-tools path with the full snapshot
|
||||
"metrics": [
|
||||
"bp",
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"count": 1,
|
||||
"domain": "snapshot",
|
||||
"id": "s1",
|
||||
"label": "Reading your record summary",
|
||||
"labelKey": "coach.step.snapshot",
|
||||
"status": "done",
|
||||
"tool": "snapshot",
|
||||
},
|
||||
],
|
||||
"windows": [
|
||||
"last30days",
|
||||
],
|
||||
@@ -4004,6 +4038,17 @@ exports[`coach chat golden transcripts > 16b an empty chain served by the legacy
|
||||
"metrics": [
|
||||
"bp",
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"count": 1,
|
||||
"domain": "snapshot",
|
||||
"id": "s1",
|
||||
"label": "Reading your record summary",
|
||||
"labelKey": "coach.step.snapshot",
|
||||
"status": "done",
|
||||
"tool": "snapshot",
|
||||
},
|
||||
],
|
||||
"windows": [
|
||||
"last30days",
|
||||
],
|
||||
@@ -4032,6 +4077,18 @@ exports[`coach chat golden transcripts > 16b an empty chain served by the legacy
|
||||
],
|
||||
"contentType": "text/event-stream; charset=utf-8",
|
||||
"frames": [
|
||||
{
|
||||
"step": {
|
||||
"count": 1,
|
||||
"domain": "snapshot",
|
||||
"id": "s1",
|
||||
"label": "Reading your record summary",
|
||||
"labelKey": "coach.step.snapshot",
|
||||
"status": "done",
|
||||
"tool": "snapshot",
|
||||
},
|
||||
"type": "step",
|
||||
},
|
||||
{
|
||||
"token": "Your ",
|
||||
"type": "token",
|
||||
@@ -4077,6 +4134,17 @@ exports[`coach chat golden transcripts > 16b an empty chain served by the legacy
|
||||
"metrics": [
|
||||
"bp",
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"count": 1,
|
||||
"domain": "snapshot",
|
||||
"id": "s1",
|
||||
"label": "Reading your record summary",
|
||||
"labelKey": "coach.step.snapshot",
|
||||
"status": "done",
|
||||
"tool": "snapshot",
|
||||
},
|
||||
],
|
||||
"windows": [
|
||||
"last30days",
|
||||
],
|
||||
@@ -5259,6 +5327,352 @@ exports[`coach chat golden transcripts > 21 the German locale with a stored self
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`coach chat golden transcripts > 22 tool mode with live steps: found, empty and failed, all before the first token 1`] = `
|
||||
{
|
||||
"body": null,
|
||||
"calls": [
|
||||
{
|
||||
"call": "createConversation",
|
||||
},
|
||||
{
|
||||
"call": "appendMessage",
|
||||
"content": "How is my blood pressure?",
|
||||
"metricSource": null,
|
||||
"model": null,
|
||||
"providerType": null,
|
||||
"role": "user",
|
||||
"tokensUsed": null,
|
||||
},
|
||||
{
|
||||
"call": "storeDeterministicFacts",
|
||||
},
|
||||
{
|
||||
"call": "assertConsentForChain",
|
||||
"chain": [
|
||||
"anthropic",
|
||||
],
|
||||
},
|
||||
{
|
||||
"amount": 1800,
|
||||
"call": "reserveBudget",
|
||||
"cap": 2000000,
|
||||
"dateKey": "2026-09-26",
|
||||
"owner": "user",
|
||||
"surface": "coach",
|
||||
},
|
||||
{
|
||||
"call": "runCoachToolLoop",
|
||||
"fallbackWindow": "allTime",
|
||||
"maxTokens": 600,
|
||||
"messages": [
|
||||
"user:0c52b5bc28a6:239",
|
||||
],
|
||||
"providers": [
|
||||
"anthropic",
|
||||
],
|
||||
"sharedScope": {
|
||||
"sources": [
|
||||
"bp",
|
||||
],
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "c7cb63b1723d:2954",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
},
|
||||
{
|
||||
"actual": 80,
|
||||
"attribution": {
|
||||
"reservedOwner": "user",
|
||||
"servedBy": "anthropic",
|
||||
},
|
||||
"cached": 0,
|
||||
"call": "reconcileSpend",
|
||||
"dateKey": "2026-09-26",
|
||||
"reserved": 1800,
|
||||
},
|
||||
{
|
||||
"call": "appendMessage",
|
||||
"content": "Your systolic averaged 128 mmHg.",
|
||||
"metricSource": {
|
||||
"groundedFigures": [
|
||||
128,
|
||||
82,
|
||||
46,
|
||||
105,
|
||||
35.9375,
|
||||
56.09756097560976,
|
||||
42,
|
||||
],
|
||||
"method": {
|
||||
"entries": [
|
||||
{
|
||||
"count": 142,
|
||||
"domain": "bp",
|
||||
"window": "last90days",
|
||||
},
|
||||
{
|
||||
"absent": "no_data",
|
||||
"domain": "sleep",
|
||||
"window": "allTime",
|
||||
},
|
||||
],
|
||||
"text": "Blood pressure, last 90 days: 142 readings · Sleep, all time: no readings",
|
||||
},
|
||||
"metrics": [
|
||||
"bp",
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"count": 142,
|
||||
"domain": "bp",
|
||||
"id": "s1",
|
||||
"label": "Checking: Blood pressure, last 90 days",
|
||||
"labelKey": "coach.step.readWindow",
|
||||
"status": "done",
|
||||
"tool": "get_metric_series",
|
||||
"window": "last90days",
|
||||
},
|
||||
{
|
||||
"domain": "sleep",
|
||||
"id": "s2",
|
||||
"label": "Checking: Sleep, all time",
|
||||
"labelKey": "coach.step.readWindow",
|
||||
"reason": "no_data",
|
||||
"status": "empty",
|
||||
"tool": "get_sleep",
|
||||
"window": "allTime",
|
||||
},
|
||||
{
|
||||
"domain": "labs",
|
||||
"id": "s3",
|
||||
"label": "Checking: Lab results, last 12 months",
|
||||
"labelKey": "coach.step.readWindow",
|
||||
"reason": "retrieval_failed",
|
||||
"status": "failed",
|
||||
"tool": "get_labs",
|
||||
"window": "lastYear",
|
||||
},
|
||||
],
|
||||
"toolCalls": [
|
||||
{
|
||||
"name": "get_metric_series",
|
||||
"present": true,
|
||||
},
|
||||
],
|
||||
"windows": [
|
||||
"last30days",
|
||||
],
|
||||
},
|
||||
"model": "m-tool",
|
||||
"providerType": "anthropic",
|
||||
"role": "assistant",
|
||||
"tokensUsed": 80,
|
||||
},
|
||||
{
|
||||
"call": "annotate",
|
||||
"metaKeys": [
|
||||
"clusterCount",
|
||||
"conversationId",
|
||||
"historyTurns",
|
||||
"promptChars",
|
||||
"promptVersion",
|
||||
"provider",
|
||||
"snapshotSent",
|
||||
"tokens",
|
||||
"toolMode",
|
||||
"toolsCalled",
|
||||
],
|
||||
"name": "insights.coach.replied",
|
||||
},
|
||||
],
|
||||
"contentType": "text/event-stream; charset=utf-8",
|
||||
"frames": [
|
||||
{
|
||||
"step": {
|
||||
"domain": "bp",
|
||||
"id": "s1",
|
||||
"label": "Checking: Blood pressure, last 90 days",
|
||||
"labelKey": "coach.step.readWindow",
|
||||
"status": "running",
|
||||
"tool": "get_metric_series",
|
||||
"window": "last90days",
|
||||
},
|
||||
"type": "step",
|
||||
},
|
||||
{
|
||||
"step": {
|
||||
"domain": "sleep",
|
||||
"id": "s2",
|
||||
"label": "Checking: Sleep, all time",
|
||||
"labelKey": "coach.step.readWindow",
|
||||
"status": "running",
|
||||
"tool": "get_sleep",
|
||||
"window": "allTime",
|
||||
},
|
||||
"type": "step",
|
||||
},
|
||||
{
|
||||
"step": {
|
||||
"domain": "labs",
|
||||
"id": "s3",
|
||||
"label": "Checking: Lab results, last 12 months",
|
||||
"labelKey": "coach.step.readWindow",
|
||||
"status": "running",
|
||||
"tool": "get_labs",
|
||||
"window": "lastYear",
|
||||
},
|
||||
"type": "step",
|
||||
},
|
||||
{
|
||||
"step": {
|
||||
"count": 142,
|
||||
"domain": "bp",
|
||||
"id": "s1",
|
||||
"label": "Checking: Blood pressure, last 90 days",
|
||||
"labelKey": "coach.step.readWindow",
|
||||
"status": "done",
|
||||
"tool": "get_metric_series",
|
||||
"window": "last90days",
|
||||
},
|
||||
"type": "step",
|
||||
},
|
||||
{
|
||||
"step": {
|
||||
"domain": "sleep",
|
||||
"id": "s2",
|
||||
"label": "Checking: Sleep, all time",
|
||||
"labelKey": "coach.step.readWindow",
|
||||
"reason": "no_data",
|
||||
"status": "empty",
|
||||
"tool": "get_sleep",
|
||||
"window": "allTime",
|
||||
},
|
||||
"type": "step",
|
||||
},
|
||||
{
|
||||
"step": {
|
||||
"domain": "labs",
|
||||
"id": "s3",
|
||||
"label": "Checking: Lab results, last 12 months",
|
||||
"labelKey": "coach.step.readWindow",
|
||||
"reason": "retrieval_failed",
|
||||
"status": "failed",
|
||||
"tool": "get_labs",
|
||||
"window": "lastYear",
|
||||
},
|
||||
"type": "step",
|
||||
},
|
||||
{
|
||||
"token": "Your ",
|
||||
"type": "token",
|
||||
},
|
||||
{
|
||||
"token": "systolic ",
|
||||
"type": "token",
|
||||
},
|
||||
{
|
||||
"token": "averaged ",
|
||||
"type": "token",
|
||||
},
|
||||
{
|
||||
"token": "128 ",
|
||||
"type": "token",
|
||||
},
|
||||
{
|
||||
"token": "mmHg.",
|
||||
"type": "token",
|
||||
},
|
||||
{
|
||||
"metricSource": {
|
||||
"groundedFigures": [
|
||||
128,
|
||||
82,
|
||||
46,
|
||||
105,
|
||||
35.9375,
|
||||
56.09756097560976,
|
||||
42,
|
||||
],
|
||||
"method": {
|
||||
"entries": [
|
||||
{
|
||||
"count": 142,
|
||||
"domain": "bp",
|
||||
"window": "last90days",
|
||||
},
|
||||
{
|
||||
"absent": "no_data",
|
||||
"domain": "sleep",
|
||||
"window": "allTime",
|
||||
},
|
||||
],
|
||||
"text": "Blood pressure, last 90 days: 142 readings · Sleep, all time: no readings",
|
||||
},
|
||||
"metrics": [
|
||||
"bp",
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"count": 142,
|
||||
"domain": "bp",
|
||||
"id": "s1",
|
||||
"label": "Checking: Blood pressure, last 90 days",
|
||||
"labelKey": "coach.step.readWindow",
|
||||
"status": "done",
|
||||
"tool": "get_metric_series",
|
||||
"window": "last90days",
|
||||
},
|
||||
{
|
||||
"domain": "sleep",
|
||||
"id": "s2",
|
||||
"label": "Checking: Sleep, all time",
|
||||
"labelKey": "coach.step.readWindow",
|
||||
"reason": "no_data",
|
||||
"status": "empty",
|
||||
"tool": "get_sleep",
|
||||
"window": "allTime",
|
||||
},
|
||||
{
|
||||
"domain": "labs",
|
||||
"id": "s3",
|
||||
"label": "Checking: Lab results, last 12 months",
|
||||
"labelKey": "coach.step.readWindow",
|
||||
"reason": "retrieval_failed",
|
||||
"status": "failed",
|
||||
"tool": "get_labs",
|
||||
"window": "lastYear",
|
||||
},
|
||||
],
|
||||
"toolCalls": [
|
||||
{
|
||||
"name": "get_metric_series",
|
||||
"present": true,
|
||||
},
|
||||
],
|
||||
"windows": [
|
||||
"last30days",
|
||||
],
|
||||
},
|
||||
"type": "provenance",
|
||||
},
|
||||
{
|
||||
"conversationId": "c-new",
|
||||
"messageId": "m2",
|
||||
"type": "done",
|
||||
"usage": {
|
||||
"model": "m-tool",
|
||||
"totalTokens": 80,
|
||||
},
|
||||
},
|
||||
],
|
||||
"status": 200,
|
||||
"thrown": null,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`coach chat golden transcripts — refusals before the stream > R1 a fence-drift conversation 404s with an audit row 1`] = `
|
||||
{
|
||||
"body": null,
|
||||
|
||||
@@ -120,8 +120,13 @@ vi.mock("@/lib/medications/scheduled-doses", () => ({
|
||||
vi.mock("@/lib/ai/coach/workout-evidence-builder", () => ({
|
||||
buildWorkoutEvidenceSection: h.m.buildWorkoutEvidenceSection,
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/tools", () => ({
|
||||
vi.mock("@/lib/ai/coach/tools", async () => ({
|
||||
COACH_TOOL_DEFS: [{ name: "get_metric_series" }, { name: "get_sleep" }],
|
||||
parseCoachToolArgs: (
|
||||
await vi.importActual<typeof import("@/lib/ai/coach/tools/definitions")>(
|
||||
"@/lib/ai/coach/tools/definitions",
|
||||
)
|
||||
).parseCoachToolArgs,
|
||||
MAX_ROUNDS: 3,
|
||||
buildCoachDataInventory: h.m.buildCoachDataInventory,
|
||||
renderDataInventory: h.m.renderDataInventory,
|
||||
@@ -521,6 +526,54 @@ describe("coach chat golden transcripts", () => {
|
||||
reply("Du läufst 30 km pro Woche, dein Blutdruck liegt bei 128 mmHg.");
|
||||
await golden({ message: "Wie ist mein Blutdruck?", locale: "de" });
|
||||
});
|
||||
|
||||
it("22 tool mode with live steps: found, empty and failed, all before the first token", async () => {
|
||||
type Call = { id: string; name: string; arguments: string };
|
||||
type Settled = { present: boolean; reason?: string; data?: unknown };
|
||||
const calls: Array<[Call, Settled]> = [
|
||||
[
|
||||
{
|
||||
id: "a",
|
||||
name: "get_metric_series",
|
||||
arguments: '{"metric":"bp","window":"last90days"}',
|
||||
},
|
||||
{
|
||||
present: true,
|
||||
data: {
|
||||
metric: "bp",
|
||||
section: { aggregate: { coverage: { count: 142 } } },
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{ id: "b", name: "get_sleep", arguments: "{}" },
|
||||
{ present: false, reason: "no_data" },
|
||||
],
|
||||
[
|
||||
{ id: "c", name: "get_labs", arguments: '{"analyte":"Ferritin"}' },
|
||||
{ present: false, reason: "retrieval_failed" },
|
||||
],
|
||||
];
|
||||
m.runCoachToolLoop.mockImplementation(
|
||||
async (args: {
|
||||
onCallStart?: (call: Call, index: number) => void;
|
||||
onCallSettled?: (call: Call, result: Settled, index: number) => void;
|
||||
}) => {
|
||||
calls.forEach(([call], i) => args.onCallStart?.(call, i));
|
||||
calls.forEach(([call, result], i) =>
|
||||
args.onCallSettled?.(call, result, i),
|
||||
);
|
||||
return toolLoopResult("Your systolic averaged 128 mmHg.");
|
||||
},
|
||||
);
|
||||
const transcript = await golden({ message: "How is my blood pressure?" });
|
||||
const types = transcript.frames.map((f) => (f as { type: string }).type);
|
||||
const firstToken = types.indexOf("token");
|
||||
expect(firstToken).toBeGreaterThan(0);
|
||||
expect(types.lastIndexOf("step")).toBeLessThan(firstToken);
|
||||
expect(types.filter((t) => t === "step")).toHaveLength(6);
|
||||
expect(JSON.stringify(transcript)).not.toContain("Ferritin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("coach chat golden transcripts — refusals before the stream", () => {
|
||||
|
||||
@@ -66,6 +66,7 @@ vi.mock("@/lib/ai/provider", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain: vi.fn() }));
|
||||
vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" }));
|
||||
// The rest of the module stays real: the step labels read its enums at load.
|
||||
vi.mock("@/lib/ai/coach/types", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/ai/coach/types")>()),
|
||||
coachChatRequestSchema: {
|
||||
|
||||
@@ -53,6 +53,7 @@ vi.mock("@/lib/ai/provider", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain: vi.fn() }));
|
||||
vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" }));
|
||||
// The rest of the module stays real: the step labels read its enums at load.
|
||||
vi.mock("@/lib/ai/coach/types", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/ai/coach/types")>()),
|
||||
coachChatRequestSchema: { safeParse: () => ({ success: false }) },
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { pluralKey } from "@/lib/i18n/plural";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import type { CoachStep } from "@/lib/ai/coach/types";
|
||||
|
||||
import {
|
||||
CoachTurnStepList,
|
||||
CoachTurnSteps,
|
||||
currentStep,
|
||||
describeStep,
|
||||
nextAnnouncement,
|
||||
stepLabel,
|
||||
} from "../turn-steps";
|
||||
|
||||
function render(node: React.ReactNode, locale: Locale = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function translators(locale: Locale) {
|
||||
const { t } = getServerTranslator(locale);
|
||||
const tCount = (base: string, count: number) =>
|
||||
t(pluralKey(base, count, locale), { count });
|
||||
return { t, tCount };
|
||||
}
|
||||
|
||||
const BP: CoachStep = {
|
||||
id: "s1",
|
||||
tool: "get_metric_series",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "Checking: Blood pressure, last 90 days",
|
||||
domain: "bp",
|
||||
window: "last90days",
|
||||
status: "done",
|
||||
count: 142,
|
||||
};
|
||||
const SLEEP: CoachStep = {
|
||||
id: "s2",
|
||||
tool: "get_sleep",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "Checking: Sleep, last 30 days",
|
||||
domain: "sleep",
|
||||
window: "last30days",
|
||||
status: "empty",
|
||||
reason: "no_data",
|
||||
};
|
||||
const LABS_RUNNING: CoachStep = {
|
||||
id: "s3",
|
||||
tool: "get_labs",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "Checking: Lab results, last 12 months",
|
||||
domain: "labs",
|
||||
window: "lastYear",
|
||||
status: "running",
|
||||
};
|
||||
|
||||
describe("describeStep", () => {
|
||||
const { t, tCount } = translators("en");
|
||||
|
||||
it("reads domain · window · count on a found step", () => {
|
||||
expect(describeStep(BP, t, tCount)).toEqual({
|
||||
title: "Blood pressure",
|
||||
meta: ["last 90 days", "142 readings"],
|
||||
});
|
||||
});
|
||||
|
||||
it("says why nothing was found on an empty step", () => {
|
||||
expect(describeStep(SLEEP, t, tCount)).toEqual({
|
||||
title: "Sleep",
|
||||
meta: ["last 30 days", "no readings"],
|
||||
});
|
||||
});
|
||||
|
||||
it("shows no count on a miss, even when the record holds rows elsewhere", () => {
|
||||
const outside: CoachStep = {
|
||||
...SLEEP,
|
||||
reason: "outside_window",
|
||||
count: 40,
|
||||
};
|
||||
expect(describeStep(outside, t, tCount).meta).toEqual([
|
||||
"last 30 days",
|
||||
"no readings in this window",
|
||||
]);
|
||||
});
|
||||
|
||||
it("counts metrics on the snapshot step and rows on labs", () => {
|
||||
const snapshot: CoachStep = {
|
||||
id: "s1",
|
||||
tool: "snapshot",
|
||||
labelKey: "coach.step.snapshot",
|
||||
label: "Reading your record summary",
|
||||
domain: "snapshot",
|
||||
status: "done",
|
||||
count: 1,
|
||||
};
|
||||
expect(describeStep(snapshot, t, tCount)).toEqual({
|
||||
title: "Record summary",
|
||||
meta: ["1 metric"],
|
||||
});
|
||||
expect(
|
||||
describeStep({ ...LABS_RUNNING, status: "done", count: 3 }, t, tCount)
|
||||
.meta,
|
||||
).toEqual(["last 12 months", "3 rows"]);
|
||||
});
|
||||
|
||||
it("renders in the reader's language from the keys, not the server label", () => {
|
||||
const de = translators("de");
|
||||
expect(stepLabel(BP, de.t)).toBe("Lese Blutdruck, letzte 90 Tage");
|
||||
expect(describeStep(BP, de.t, de.tCount).meta).toEqual([
|
||||
"letzte 90 Tage",
|
||||
"142 Messwerte",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to the server label for a key the bundle lacks", () => {
|
||||
expect(stepLabel({ ...BP, labelKey: "coach.step.fromTheFuture" }, t)).toBe(
|
||||
BP.label,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("currentStep / nextAnnouncement", () => {
|
||||
it("names the latest running step, else the latest step", () => {
|
||||
expect(currentStep([BP, LABS_RUNNING, SLEEP])?.id).toBe("s3");
|
||||
expect(currentStep([BP, SLEEP])?.id).toBe("s2");
|
||||
expect(currentStep([])).toBeNull();
|
||||
});
|
||||
|
||||
it("announces only settled steps not yet told, the newest one", () => {
|
||||
expect(nextAnnouncement([BP, SLEEP, LABS_RUNNING], new Set())?.id).toBe(
|
||||
"s2",
|
||||
);
|
||||
expect(nextAnnouncement([BP, SLEEP], new Set(["s1", "s2"]))).toBeNull();
|
||||
expect(nextAnnouncement([LABS_RUNNING], new Set())).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("<CoachTurnSteps>", () => {
|
||||
it("renders nothing without steps", () => {
|
||||
expect(render(<CoachTurnSteps steps={[]} active />)).not.toContain(
|
||||
"coach-turn-steps",
|
||||
);
|
||||
});
|
||||
|
||||
it("while active: the running step in the header, with a stable accessible name", () => {
|
||||
const html = render(<CoachTurnSteps steps={[BP, LABS_RUNNING]} active />);
|
||||
expect(html).toContain('data-slot="coach-turn-steps-active"');
|
||||
expect(html).toContain("Checking: Lab results, last 12 months…");
|
||||
expect(html).toContain('aria-label="Show what the Coach looked at"');
|
||||
expect(html).toContain('aria-expanded="false"');
|
||||
// The shimmer comes from the class that stops under reduced motion.
|
||||
expect(html).toContain("skeleton-shimmer");
|
||||
// Collapsed by default.
|
||||
expect(html).not.toContain('data-slot="coach-turn-steps-list"');
|
||||
});
|
||||
|
||||
it("when done: folds into a count of sources, closed", () => {
|
||||
const html = render(<CoachTurnSteps steps={[BP, SLEEP]} active={false} />);
|
||||
expect(html).toContain("Looked at 2 sources");
|
||||
expect(html).not.toContain("skeleton-shimmer");
|
||||
expect(html).not.toContain("aria-label=");
|
||||
expect(html).not.toContain('data-slot="coach-turn-steps-list"');
|
||||
});
|
||||
|
||||
it("carries a polite status region that starts empty", () => {
|
||||
const html = render(<CoachTurnSteps steps={[BP]} active={false} />);
|
||||
expect(html).toMatch(
|
||||
/<span role="status" aria-live="polite" aria-atomic="true" class="sr-only"><\/span>/,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses theme tokens only, and every motion stops under reduced motion", () => {
|
||||
const html =
|
||||
render(<CoachTurnSteps steps={[BP, LABS_RUNNING]} active />) +
|
||||
render(
|
||||
<CoachTurnStepList
|
||||
steps={[
|
||||
BP,
|
||||
SLEEP,
|
||||
LABS_RUNNING,
|
||||
{
|
||||
...SLEEP,
|
||||
id: "s4",
|
||||
status: "failed",
|
||||
reason: "retrieval_failed",
|
||||
},
|
||||
]}
|
||||
active
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toMatch(/#[0-9a-f]{3,8}\b|dracula-|\[#|oklch\(/i);
|
||||
expect(html).not.toMatch(/text-muted-foreground\/\d/);
|
||||
const classes = [...html.matchAll(/class="([^"]*)"/g)].flatMap((m) =>
|
||||
m[1].split(/\s+/),
|
||||
);
|
||||
for (const cls of classes) {
|
||||
if (cls.startsWith("transition")) {
|
||||
throw new Error(`unguarded transition: ${cls}`);
|
||||
}
|
||||
}
|
||||
// The spinner stops under reduced motion (the repo-wide convention).
|
||||
expect(html).toContain("animate-spin motion-reduce:animate-none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<CoachTurnStepList>", () => {
|
||||
it("one row per step: domain · window · count or reason, with its status", () => {
|
||||
const html = render(
|
||||
<CoachTurnStepList steps={[BP, SLEEP, LABS_RUNNING]} active />,
|
||||
);
|
||||
expect(html).toContain('aria-label="What the Coach looked at"');
|
||||
expect(html.match(/data-slot="coach-turn-step"/g)).toHaveLength(3);
|
||||
expect(html).toContain('data-status="done"');
|
||||
expect(html).toContain('data-status="empty"');
|
||||
expect(html).toContain('data-status="running"');
|
||||
const text = html
|
||||
.replace(/<span class="sr-only">, <\/span>/g, "")
|
||||
.replace(/<[^>]+>/g, "");
|
||||
expect(text).toContain("Blood pressure · last 90 days · 142 readings");
|
||||
expect(text).toContain("Sleep · last 30 days · no readings");
|
||||
expect(text).toContain("Lab results · last 12 months");
|
||||
});
|
||||
|
||||
it("a reloaded list shows the same rows", () => {
|
||||
const live = render(<CoachTurnStepList steps={[BP, SLEEP]} active />);
|
||||
const stored = render(
|
||||
<CoachTurnStepList
|
||||
steps={JSON.parse(JSON.stringify([BP, SLEEP]))}
|
||||
active={false}
|
||||
/>,
|
||||
);
|
||||
const text = (h: string) => h.replace(/<svg[\s\S]*?<\/svg>/g, "");
|
||||
expect(text(stored)).toBe(text(live));
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,41 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.39.4 — what the Coach read on a turn: a compact list of steps under
|
||||
* the assistant avatar, live while the turn runs (from the `step` frames)
|
||||
* and restored from `metricSource.steps` on reload.
|
||||
* v1.39.4 — what the Coach read on a turn: a compact list of steps above
|
||||
* the answer, live while the turn runs (from the `step` frames) and
|
||||
* restored from `metricSource.steps` on reload.
|
||||
*
|
||||
* Not built yet: renders nothing.
|
||||
* While the turn runs the header names the step in progress ("Reading
|
||||
* Blood pressure, last 90 days…") with a soft shimmer; once it ends the
|
||||
* header folds into "Looked at 3 sources" and the list closes. The list
|
||||
* itself is one row per step: domain, window, and a count or the reason
|
||||
* nothing was found. Everything shown comes from catalog keys and the
|
||||
* server's enums and counts; a row never shows a value, a tool argument or
|
||||
* model text.
|
||||
*
|
||||
* Screen readers hear each completed step once, through a polite status
|
||||
* region, at most one announcement per `ANNOUNCE_THROTTLE_MS` (the latest
|
||||
* wins). A reloaded conversation announces nothing.
|
||||
*/
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import {
|
||||
Check,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
Minus,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
COACH_STEP_REASON_KEYS,
|
||||
COACH_STEP_UI_KEYS,
|
||||
coachDomainLabelKey,
|
||||
coachGranularityLabelKey,
|
||||
coachPeriodLabelKey,
|
||||
coachWindowLabelKey,
|
||||
} from "@/lib/ai/coach/dialog-keys";
|
||||
import type { CoachStep } from "@/lib/ai/coach/types";
|
||||
|
||||
export interface CoachTurnStepsProps {
|
||||
@@ -15,6 +44,311 @@ export interface CoachTurnStepsProps {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export function CoachTurnSteps(_props: CoachTurnStepsProps) {
|
||||
return null;
|
||||
/** The shortest gap between two screen-reader announcements. */
|
||||
export const ANNOUNCE_THROTTLE_MS = 1500;
|
||||
|
||||
type Translate = (
|
||||
key: string,
|
||||
params?: Record<string, string | number>,
|
||||
) => string;
|
||||
type TranslateCount = (
|
||||
baseKey: string,
|
||||
count: number,
|
||||
params?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
/** `t(key)`, or `fallback` when the bundle does not carry the key. */
|
||||
function tOr(
|
||||
t: Translate,
|
||||
key: string,
|
||||
fallback: string,
|
||||
params?: Record<string, string | number>,
|
||||
) {
|
||||
const value = t(key, params);
|
||||
return value === key ? fallback : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The step's label in the reader's language: the catalog key rendered with
|
||||
* the domain and window names, or the server's label when the key is not
|
||||
* in the bundle.
|
||||
*/
|
||||
export function stepLabel(step: CoachStep, t: Translate): string {
|
||||
return tOr(t, step.labelKey, step.label, {
|
||||
...(step.domain ? { domain: t(coachDomainLabelKey(step.domain)) } : {}),
|
||||
...(step.window ? { window: t(coachWindowLabelKey(step.window)) } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One row's text: the domain as the title, then the window, period,
|
||||
* granularity, and either how much was read or why nothing was.
|
||||
*/
|
||||
export function describeStep(
|
||||
step: CoachStep,
|
||||
t: Translate,
|
||||
tCount: TranslateCount,
|
||||
): { title: string; meta: string[] } {
|
||||
const title = step.domain
|
||||
? t(coachDomainLabelKey(step.domain))
|
||||
: stepLabel(step, t);
|
||||
const meta: string[] = [];
|
||||
if (step.window) meta.push(t(coachWindowLabelKey(step.window)));
|
||||
if (step.period && step.period !== "current") {
|
||||
meta.push(t(coachPeriodLabelKey(step.period)));
|
||||
}
|
||||
if (step.granularity)
|
||||
meta.push(t(coachGranularityLabelKey(step.granularity)));
|
||||
if (step.status === "done" && step.count !== undefined) {
|
||||
const base =
|
||||
step.tool === "snapshot"
|
||||
? "coach.step.metrics"
|
||||
: step.tool === "get_labs"
|
||||
? "coach.step.rows"
|
||||
: "coach.step.readings";
|
||||
meta.push(tCount(base, step.count));
|
||||
} else if (step.status !== "running" && step.reason) {
|
||||
meta.push(t(COACH_STEP_REASON_KEYS[step.reason]));
|
||||
}
|
||||
return { title, meta };
|
||||
}
|
||||
|
||||
function rowText(step: CoachStep, t: Translate, tCount: TranslateCount) {
|
||||
const { title, meta } = describeStep(step, t, tCount);
|
||||
return [title, ...meta].join(" · ");
|
||||
}
|
||||
|
||||
/**
|
||||
* The step the active header names: the latest one still running, else the
|
||||
* latest one overall (between two rounds nothing is running).
|
||||
*/
|
||||
export function currentStep(steps: CoachStep[]): CoachStep | null {
|
||||
for (let i = steps.length - 1; i >= 0; i -= 1) {
|
||||
if (steps[i].status === "running") return steps[i];
|
||||
}
|
||||
return steps.at(-1) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest settled step the reader has not been told about yet, given the
|
||||
* ids already announced (or present when the list mounted). Null when there
|
||||
* is nothing new.
|
||||
*/
|
||||
export function nextAnnouncement(
|
||||
steps: CoachStep[],
|
||||
told: ReadonlySet<string>,
|
||||
): CoachStep | null {
|
||||
let latest: CoachStep | null = null;
|
||||
for (const step of steps) {
|
||||
if (step.status !== "running" && !told.has(step.id)) latest = step;
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
/**
|
||||
* The status region's text: each newly settled step, throttled. Steps that
|
||||
* were already settled when the list mounted are never announced, so a
|
||||
* reloaded conversation stays quiet.
|
||||
*/
|
||||
function useStepAnnouncement(
|
||||
steps: CoachStep[],
|
||||
describe: (step: CoachStep) => string,
|
||||
): string {
|
||||
// Mutable on purpose: the ids the reader has been told about, seeded with
|
||||
// whatever was already settled at mount.
|
||||
const [told] = useState(
|
||||
() => new Set(steps.filter((s) => s.status !== "running").map((s) => s.id)),
|
||||
);
|
||||
const lastAt = useRef(0);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [text, setText] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const next = nextAnnouncement(steps, told);
|
||||
if (!next) return;
|
||||
for (const step of steps) {
|
||||
if (step.status !== "running") told.add(step.id);
|
||||
}
|
||||
const nextText = describe(next);
|
||||
const wait = Math.max(
|
||||
0,
|
||||
lastAt.current + ANNOUNCE_THROTTLE_MS - Date.now(),
|
||||
);
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => {
|
||||
timer.current = null;
|
||||
lastAt.current = Date.now();
|
||||
setText(nextText);
|
||||
}, wait);
|
||||
}, [steps, describe, told]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
function StepIcon({ step, active }: { step: CoachStep; active: boolean }) {
|
||||
const className = "mt-0.5 size-3 shrink-0";
|
||||
if (step.status === "running" && active) {
|
||||
return (
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
className,
|
||||
"text-muted-foreground animate-spin motion-reduce:animate-none",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (step.status === "done") {
|
||||
return (
|
||||
<Check
|
||||
aria-hidden="true"
|
||||
className={cn(className, "text-muted-foreground")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (step.status === "failed") {
|
||||
return (
|
||||
<TriangleAlert
|
||||
aria-hidden="true"
|
||||
className={cn(className, "text-warning")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Minus
|
||||
aria-hidden="true"
|
||||
className={cn(className, "text-muted-foreground")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The rows under the header: one per step, in the order they started. */
|
||||
export function CoachTurnStepList({
|
||||
id,
|
||||
steps,
|
||||
active,
|
||||
}: CoachTurnStepsProps & { id?: string }) {
|
||||
const { t, tCount } = useTranslations();
|
||||
return (
|
||||
<ol
|
||||
id={id}
|
||||
data-slot="coach-turn-steps-list"
|
||||
aria-label={t(COACH_STEP_UI_KEYS.listLabel)}
|
||||
className="border-border/60 mt-1.5 ml-1.5 flex flex-col gap-1 border-l pl-2.5"
|
||||
>
|
||||
{steps.map((step) => {
|
||||
const { title, meta } = describeStep(step, t, tCount);
|
||||
return (
|
||||
<li
|
||||
key={step.id}
|
||||
data-slot="coach-turn-step"
|
||||
data-status={step.status}
|
||||
className="flex items-start gap-1.5 leading-relaxed"
|
||||
>
|
||||
<StepIcon step={step} active={active} />
|
||||
<span className="text-muted-foreground min-w-0">
|
||||
<span className="text-foreground/80">{title}</span>
|
||||
{meta.map((part, i) => (
|
||||
<span key={i}>
|
||||
<span aria-hidden="true"> · </span>
|
||||
<span className="sr-only">, </span>
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
export function CoachTurnSteps({ steps, active }: CoachTurnStepsProps) {
|
||||
const { t, tCount } = useTranslations();
|
||||
const listId = useId();
|
||||
// Closed by default; the reader's toggle holds until the turn ends, and
|
||||
// the list folds away again when it does.
|
||||
const [openOverride, setOpenOverride] = useState<boolean | null>(null);
|
||||
const [wasActive, setWasActive] = useState(active);
|
||||
if (wasActive !== active) {
|
||||
setWasActive(active);
|
||||
setOpenOverride(null);
|
||||
}
|
||||
const announcement = useStepAnnouncement(steps, (step) =>
|
||||
t(COACH_STEP_UI_KEYS.announceDone, { label: rowText(step, t, tCount) }),
|
||||
);
|
||||
|
||||
if (steps.length === 0) return null;
|
||||
const open = openOverride ?? false;
|
||||
const current = active ? currentStep(steps) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-turn-steps"
|
||||
className="flex max-w-full flex-col text-xs"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-turn-steps-toggle"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? listId : undefined}
|
||||
// While the turn runs the visible text changes with every step; the
|
||||
// status region below announces completions, so the button keeps a
|
||||
// stable name instead of re-announcing each label.
|
||||
{...(current
|
||||
? {
|
||||
"aria-label": t(
|
||||
open
|
||||
? COACH_STEP_UI_KEYS.toggleHide
|
||||
: COACH_STEP_UI_KEYS.toggleShow,
|
||||
),
|
||||
}
|
||||
: {})}
|
||||
onClick={() => setOpenOverride(!open)}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground flex w-fit max-w-full items-center gap-1.5",
|
||||
"focus-visible:ring-ring/50 rounded text-left leading-relaxed outline-none focus-visible:ring-2",
|
||||
)}
|
||||
>
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"size-3 shrink-0 motion-safe:transition-transform",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
{current ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-slot="coach-turn-steps-active"
|
||||
className="skeleton-shimmer min-w-0 truncate rounded"
|
||||
>
|
||||
{t(COACH_STEP_UI_KEYS.headerActive, {
|
||||
label: stepLabel(current, t),
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span data-slot="coach-turn-steps-done" className="min-w-0 truncate">
|
||||
{tCount("coach.step.headerDone", steps.length)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{open && <CoachTurnStepList id={listId} steps={steps} active={active} />}
|
||||
<span
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
>
|
||||
{announcement}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* v1.39.4 — live steps carry catalog labels, closed-enum domains and windows,
|
||||
* and server-counted integers. Nothing else: not a value, not an argument
|
||||
* the model wrote, not an analyte name.
|
||||
*
|
||||
* The property test drives every tool with fuzzed arguments and fuzzed
|
||||
* results (seeded, so a failure reproduces) and checks each step against the
|
||||
* full set of strings the catalog can render.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import {
|
||||
COACH_STEP_LABEL_KEYS,
|
||||
coachDomainLabelKey,
|
||||
coachWindowLabelKey,
|
||||
} from "@/lib/ai/coach/dialog-keys";
|
||||
import {
|
||||
coachScopeSourceSchema,
|
||||
coachScopeWindowSchema,
|
||||
type CoachStep,
|
||||
type CoachStepDomain,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import {
|
||||
COACH_TOOL_NAMES,
|
||||
parseCoachToolArgs,
|
||||
} from "@/lib/ai/coach/tools/definitions";
|
||||
import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
|
||||
|
||||
import { MAX_TURN_STEPS, snapshotStep, toStep } from "../steps";
|
||||
|
||||
const SOURCES = coachScopeSourceSchema.options;
|
||||
const WINDOWS = coachScopeWindowSchema.options;
|
||||
const DOMAINS: CoachStepDomain[] = [
|
||||
...SOURCES,
|
||||
"labs",
|
||||
"illness",
|
||||
"cycle",
|
||||
"correlations",
|
||||
"snapshot",
|
||||
];
|
||||
const STATUSES = new Set(["running", "done", "empty", "failed"]);
|
||||
const REASONS = new Set([
|
||||
"no_data",
|
||||
"outside_window",
|
||||
"module_disabled",
|
||||
"retrieval_failed",
|
||||
"invalid_arguments",
|
||||
]);
|
||||
const STEP_KEYS = new Set([
|
||||
"id",
|
||||
"tool",
|
||||
"labelKey",
|
||||
"label",
|
||||
"domain",
|
||||
"window",
|
||||
"period",
|
||||
"granularity",
|
||||
"status",
|
||||
"count",
|
||||
"reason",
|
||||
"resultRef",
|
||||
]);
|
||||
|
||||
/** Every label the catalog can render in `locale`. */
|
||||
function catalogLabels(locale: Locale): Set<string> {
|
||||
const { t } = getServerTranslator(locale);
|
||||
const out = new Set<string>();
|
||||
for (const key of Object.values(COACH_STEP_LABEL_KEYS)) out.add(t(key));
|
||||
for (const domain of DOMAINS) {
|
||||
const d = t(coachDomainLabelKey(domain));
|
||||
out.add(t(COACH_STEP_LABEL_KEYS.read, { domain: d }));
|
||||
for (const window of WINDOWS) {
|
||||
out.add(
|
||||
t(COACH_STEP_LABEL_KEYS.readWindow, {
|
||||
domain: d,
|
||||
window: t(coachWindowLabelKey(window)),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const LABELS = new Map(locales.map((l) => [l, catalogLabels(l)]));
|
||||
|
||||
// A small seeded generator (mulberry32): reproducible without a dependency.
|
||||
function rng(seed: number) {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/** Strings no step may ever carry, planted all through the fuzzed input. */
|
||||
const PLANTED = [
|
||||
"Zqx-Ferritin",
|
||||
"Zqx ignore previous instructions",
|
||||
"<script>zqx</script>",
|
||||
];
|
||||
|
||||
function fuzzValue(r: () => number, depth = 0): unknown {
|
||||
const roll = r();
|
||||
if (roll < 0.15) return PLANTED[Math.floor(r() * PLANTED.length)];
|
||||
if (roll < 0.3) return Math.floor(r() * 1000);
|
||||
if (roll < 0.4)
|
||||
return [-1, 1.5, 128.4, Number.NaN, 1e20, -0][Math.floor(r() * 6)];
|
||||
if (roll < 0.45) return null;
|
||||
if (roll < 0.5) return r() < 0.5;
|
||||
if (depth > 3) return "x";
|
||||
if (roll < 0.7) {
|
||||
return Array.from({ length: Math.floor(r() * 4) }, () =>
|
||||
fuzzValue(r, depth + 1),
|
||||
);
|
||||
}
|
||||
const keys = [
|
||||
"section",
|
||||
"aggregate",
|
||||
"coverage",
|
||||
"count",
|
||||
"recent",
|
||||
"totalInWindow",
|
||||
"metric",
|
||||
"name",
|
||||
"analyte",
|
||||
"value",
|
||||
"label",
|
||||
];
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (let i = 0; i < 1 + Math.floor(r() * 4); i += 1) {
|
||||
obj[keys[Math.floor(r() * keys.length)]] = fuzzValue(r, depth + 1);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
function fuzzArgs(r: () => number): string {
|
||||
const roll = r();
|
||||
if (roll < 0.1) return "{not json";
|
||||
if (roll < 0.15) return "";
|
||||
const args: Record<string, unknown> = {};
|
||||
if (r() < 0.7) {
|
||||
args.metric =
|
||||
r() < 0.8 ? SOURCES[Math.floor(r() * SOURCES.length)] : PLANTED[0];
|
||||
}
|
||||
if (r() < 0.6) {
|
||||
args.window =
|
||||
r() < 0.8 ? WINDOWS[Math.floor(r() * WINDOWS.length)] : PLANTED[1];
|
||||
}
|
||||
if (r() < 0.3) args.analyte = PLANTED[0];
|
||||
if (r() < 0.1) args.period = r() < 0.5 ? "previous" : PLANTED[2];
|
||||
return JSON.stringify(args);
|
||||
}
|
||||
|
||||
function fuzzResult(r: () => number): CoachToolResult | undefined {
|
||||
const roll = r();
|
||||
if (roll < 0.15) return undefined;
|
||||
if (roll < 0.55) {
|
||||
return {
|
||||
present: true,
|
||||
data: fuzzValue(r),
|
||||
...(r() < 0.3 ? { grounding: PLANTED[1] } : {}),
|
||||
};
|
||||
}
|
||||
const reasons = [
|
||||
"no_data",
|
||||
"outside_window",
|
||||
"unavailable_in_scope",
|
||||
"no_data_unconfirmed",
|
||||
"retrieval_failed",
|
||||
"invalid_arguments",
|
||||
"unknown_tool",
|
||||
"analyte_not_found",
|
||||
"no_significant_pattern",
|
||||
"module_disabled",
|
||||
PLANTED[1],
|
||||
"__proto__",
|
||||
"toString",
|
||||
];
|
||||
return {
|
||||
present: false,
|
||||
reason: reasons[Math.floor(r() * reasons.length)],
|
||||
...(r() < 0.4
|
||||
? {
|
||||
searchedWindow:
|
||||
r() < 0.7 ? WINDOWS[Math.floor(r() * WINDOWS.length)] : PLANTED[0],
|
||||
}
|
||||
: {}),
|
||||
...(r() < 0.4
|
||||
? {
|
||||
available: {
|
||||
count: fuzzValue(r) as number,
|
||||
firstDate: PLANTED[0],
|
||||
lastDate: "2024-01-01",
|
||||
reachableWithWindow: null,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function assertClean(step: CoachStep, locale: Locale) {
|
||||
for (const key of Object.keys(step)) expect(STEP_KEYS.has(key)).toBe(true);
|
||||
expect(step.id).toMatch(/^s([1-9]|1[0-2])$/);
|
||||
expect(
|
||||
[...COACH_TOOL_NAMES, "snapshot", "show_result"].includes(step.tool),
|
||||
).toBe(true);
|
||||
expect(Object.values(COACH_STEP_LABEL_KEYS)).toContain(step.labelKey);
|
||||
expect(LABELS.get(locale)!.has(step.label)).toBe(true);
|
||||
if (step.domain !== undefined) expect(DOMAINS).toContain(step.domain);
|
||||
if (step.window !== undefined) expect(WINDOWS).toContain(step.window);
|
||||
if (step.period !== undefined) {
|
||||
expect(["current", "previous", "yearAgo"]).toContain(step.period);
|
||||
}
|
||||
if (step.granularity !== undefined) {
|
||||
expect(["day", "week", "month"]).toContain(step.granularity);
|
||||
}
|
||||
expect(STATUSES.has(step.status)).toBe(true);
|
||||
if (step.count !== undefined) {
|
||||
expect(Number.isSafeInteger(step.count)).toBe(true);
|
||||
expect(step.count).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
if (step.reason !== undefined) expect(REASONS.has(step.reason)).toBe(true);
|
||||
const wire = JSON.stringify(step);
|
||||
for (const planted of PLANTED) expect(wire).not.toContain(planted);
|
||||
expect(wire.toLowerCase()).not.toContain("zqx");
|
||||
}
|
||||
|
||||
describe("toStep — property: only catalog keys, domains, windows and integers", () => {
|
||||
it("holds for every tool over fuzzed arguments and results in every locale", () => {
|
||||
const r = rng(0x5eed);
|
||||
let produced = 0;
|
||||
for (let i = 0; i < 4000; i += 1) {
|
||||
const name = COACH_TOOL_NAMES[i % COACH_TOOL_NAMES.length];
|
||||
const locale = locales[Math.floor(r() * locales.length)];
|
||||
const call = { id: `c${i}`, name, arguments: fuzzArgs(r) };
|
||||
const step = toStep({
|
||||
call,
|
||||
index: Math.floor(r() * 14),
|
||||
parsedArgs: parseCoachToolArgs(call.name, call.arguments),
|
||||
result: fuzzResult(r),
|
||||
locale,
|
||||
...(r() < 0.5
|
||||
? { fallbackWindow: WINDOWS[Math.floor(r() * WINDOWS.length)] }
|
||||
: {}),
|
||||
});
|
||||
if (step === null) continue;
|
||||
produced += 1;
|
||||
assertClean(step, locale);
|
||||
}
|
||||
// The generator must actually exercise the mapper, not null out.
|
||||
expect(produced).toBeGreaterThan(2000);
|
||||
});
|
||||
|
||||
it("covers every tool with a step", () => {
|
||||
for (const name of COACH_TOOL_NAMES) {
|
||||
const args = name === "get_metric_series" ? '{"metric":"bp"}' : "{}";
|
||||
const step = toStep({
|
||||
call: { id: "x", name, arguments: args },
|
||||
index: 0,
|
||||
parsedArgs: parseCoachToolArgs(name, args),
|
||||
locale: "en",
|
||||
});
|
||||
expect(step, name).not.toBeNull();
|
||||
assertClean(step!, "en");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("toStep — mapping", () => {
|
||||
const bp = {
|
||||
id: "a",
|
||||
name: "get_metric_series",
|
||||
arguments: '{"metric":"bp","window":"last90days"}',
|
||||
};
|
||||
const parsed = parseCoachToolArgs(bp.name, bp.arguments);
|
||||
|
||||
it("a started call is running, with the label naming domain and window", () => {
|
||||
expect(
|
||||
toStep({ call: bp, index: 0, parsedArgs: parsed, locale: "en" }),
|
||||
).toEqual({
|
||||
id: "s1",
|
||||
tool: "get_metric_series",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "Checking: Blood pressure, last 90 days",
|
||||
domain: "bp",
|
||||
window: "last90days",
|
||||
status: "running",
|
||||
});
|
||||
});
|
||||
|
||||
it("a found result counts the readings the aggregate covered", () => {
|
||||
const step = toStep({
|
||||
call: bp,
|
||||
index: 2,
|
||||
parsedArgs: parsed,
|
||||
locale: "de",
|
||||
result: {
|
||||
present: true,
|
||||
data: {
|
||||
metric: "bp",
|
||||
section: { aggregate: { coverage: { count: 142 }, mean: 128.4 } },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(step).toMatchObject({ id: "s3", status: "done", count: 142 });
|
||||
expect(step?.label).toBe("Lese Blutdruck, letzte 90 Tage");
|
||||
});
|
||||
|
||||
it("maps each miss to its status and reason", () => {
|
||||
const at = (reason: string, extra: Partial<CoachToolResult> = {}) =>
|
||||
toStep({
|
||||
call: bp,
|
||||
index: 0,
|
||||
parsedArgs: parsed,
|
||||
locale: "en",
|
||||
result: { present: false, reason, ...extra },
|
||||
});
|
||||
expect(at("no_data")).toMatchObject({ status: "empty", reason: "no_data" });
|
||||
expect(
|
||||
at("outside_window", {
|
||||
available: {
|
||||
count: 40,
|
||||
firstDate: "2023-01-01",
|
||||
lastDate: "2023-06-01",
|
||||
reachableWithWindow: null,
|
||||
},
|
||||
}),
|
||||
).toMatchObject({ status: "empty", reason: "outside_window", count: 40 });
|
||||
expect(at("retrieval_failed")).toMatchObject({
|
||||
status: "failed",
|
||||
reason: "retrieval_failed",
|
||||
});
|
||||
expect(at("no_data_unconfirmed")).toMatchObject({
|
||||
status: "failed",
|
||||
reason: "retrieval_failed",
|
||||
});
|
||||
expect(at("invalid_arguments")).toMatchObject({
|
||||
status: "failed",
|
||||
reason: "invalid_arguments",
|
||||
});
|
||||
const inScope = at("unavailable_in_scope");
|
||||
expect(inScope?.status).toBe("empty");
|
||||
expect(inScope?.reason).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses the conversation window when the call names none", () => {
|
||||
const call = { id: "s", name: "get_sleep", arguments: "{}" };
|
||||
expect(
|
||||
toStep({
|
||||
call,
|
||||
index: 0,
|
||||
parsedArgs: {},
|
||||
locale: "en",
|
||||
fallbackWindow: "last7days",
|
||||
}),
|
||||
).toMatchObject({ domain: "sleep", window: "last7days" });
|
||||
});
|
||||
|
||||
it("never names a lab analyte, and says the fixed year labs read", () => {
|
||||
const call = {
|
||||
id: "l",
|
||||
name: "get_labs",
|
||||
arguments: '{"analyte":"Ferritin"}',
|
||||
};
|
||||
const step = toStep({
|
||||
call,
|
||||
index: 0,
|
||||
parsedArgs: parseCoachToolArgs(call.name, call.arguments),
|
||||
locale: "en",
|
||||
fallbackWindow: "last7days",
|
||||
result: {
|
||||
present: true,
|
||||
data: { recent: [{ name: "Ferritin", value: 37.5, unit: "ng/mL" }] },
|
||||
},
|
||||
});
|
||||
expect(step).toMatchObject({
|
||||
domain: "labs",
|
||||
window: "lastYear",
|
||||
status: "done",
|
||||
count: 1,
|
||||
});
|
||||
expect(JSON.stringify(step)).not.toMatch(/ferritin|ng\/mL|37/i);
|
||||
});
|
||||
|
||||
it("gives no step past the cap, for an unknown tool, or an unreadable metric", () => {
|
||||
expect(
|
||||
toStep({
|
||||
call: bp,
|
||||
index: MAX_TURN_STEPS,
|
||||
parsedArgs: parsed,
|
||||
locale: "en",
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
toStep({
|
||||
call: bp,
|
||||
index: MAX_TURN_STEPS - 1,
|
||||
parsedArgs: parsed,
|
||||
locale: "en",
|
||||
})?.id,
|
||||
).toBe("s12");
|
||||
expect(
|
||||
toStep({
|
||||
call: { id: "u", name: "get_everything", arguments: "{}" },
|
||||
index: 0,
|
||||
parsedArgs: undefined,
|
||||
locale: "en",
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
toStep({
|
||||
call: { id: "m", name: "get_metric_series", arguments: "{nope" },
|
||||
index: 0,
|
||||
parsedArgs: undefined,
|
||||
locale: "en",
|
||||
result: { present: false, reason: "invalid_arguments" },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("snapshotStep", () => {
|
||||
it("is one settled step counting the metrics", () => {
|
||||
expect(snapshotStep({ metricCount: 5, locale: "en" })).toEqual({
|
||||
id: "s1",
|
||||
tool: "snapshot",
|
||||
labelKey: "coach.step.snapshot",
|
||||
label: "Reading your record summary",
|
||||
domain: "snapshot",
|
||||
status: "done",
|
||||
count: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it("an empty snapshot says so", () => {
|
||||
expect(snapshotStep({ metricCount: 0, locale: "en" })).toMatchObject({
|
||||
status: "empty",
|
||||
count: 0,
|
||||
reason: "no_data",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A step that started and never settled would spin forever in the UI and be
|
||||
* persisted as running. The loop's behaviour is tested with mocks in
|
||||
* `tools-loop-callbacks.test.ts`; this pins the shape that makes it true for
|
||||
* every call: nothing between `onCallStart` and `onCallSettled` but the
|
||||
* executor, and the executor turns every failure into a result.
|
||||
*/
|
||||
describe("the tool loop settles every call it starts (structural)", () => {
|
||||
const root = path.resolve(__dirname, "../../../../../..");
|
||||
const loop = readFileSync(
|
||||
path.join(root, "src/lib/ai/coach/tools/loop.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const executor = readFileSync(
|
||||
path.join(root, "src/lib/ai/coach/tools/executor.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
it("fires onCallStart and onCallSettled exactly once each per call", () => {
|
||||
expect(loop.match(/onCallStart\?\.\(/g)).toHaveLength(1);
|
||||
expect(loop.match(/onCallSettled\?\.\(/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("runs only the executor between the two, with no exit in between", () => {
|
||||
const start = loop.indexOf("onCallStart?.(");
|
||||
const settle = loop.indexOf("onCallSettled?.(");
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
expect(settle).toBeGreaterThan(start);
|
||||
const between = loop.slice(start, settle);
|
||||
expect(between).toMatch(/await\s+executeCoachTool\(/);
|
||||
expect(between).not.toMatch(/\breturn\b|\bthrow\b|\bcontinue\b|\bbreak\b/);
|
||||
expect(between).not.toMatch(/\bif\s*\(/);
|
||||
});
|
||||
|
||||
it("the executor answers every failure with a result instead of throwing", () => {
|
||||
const body = executor.slice(
|
||||
executor.indexOf("export async function executeCoachTool("),
|
||||
executor.indexOf("async function dispatch("),
|
||||
);
|
||||
expect(body.length).toBeGreaterThan(0);
|
||||
// The one call that reaches data runs inside a try whose catch returns.
|
||||
expect(body).toMatch(
|
||||
/try\s*\{[\s\S]*await dispatch\([\s\S]*\}\s*catch[\s\S]*return \{ present: false, reason: "retrieval_failed" \}/,
|
||||
);
|
||||
expect(body).not.toMatch(/\bthrow\b/);
|
||||
// JSON parsing is guarded too.
|
||||
expect(body).toMatch(
|
||||
/try\s*\{[\s\S]*JSON\.parse\(rawArguments\)[\s\S]*\}\s*catch/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -211,6 +211,7 @@ export async function runTurnModel(args: {
|
||||
index,
|
||||
parsedArgs: parseCoachToolArgs(call.name, call.arguments),
|
||||
locale,
|
||||
fallbackWindow: effectiveScope?.window,
|
||||
}),
|
||||
),
|
||||
onCallSettled: (call, result, index) => {
|
||||
@@ -220,7 +221,16 @@ export async function runTurnModel(args: {
|
||||
...(parsedArgs ? { args: parsedArgs } : {}),
|
||||
result,
|
||||
};
|
||||
steps.record(toStep({ call, index, parsedArgs, result, locale }));
|
||||
steps.record(
|
||||
toStep({
|
||||
call,
|
||||
index,
|
||||
parsedArgs,
|
||||
result,
|
||||
locale,
|
||||
fallbackWindow: effectiveScope?.window,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
// v1.32.1 — the numeric verifier ACTIVATES only when this turn actually
|
||||
|
||||
+260
-10
@@ -4,38 +4,288 @@
|
||||
*
|
||||
* A step carries a catalog label key, the server-rendered label, the domain,
|
||||
* the window and a server-counted number. Never free text, an analyte name
|
||||
* or a health value.
|
||||
*
|
||||
* Not built yet: both functions answer null, so a turn emits no step frame.
|
||||
* or a health value: every field is either a closed enum member checked
|
||||
* here, a catalog string rendered from those enums, or a non-negative
|
||||
* integer the server counted. Nothing the model wrote and nothing the
|
||||
* record holds as text can reach it, because nothing is copied through
|
||||
* without passing one of those checks.
|
||||
*/
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import type { AiToolCall } from "@/lib/ai/types";
|
||||
import type { CoachStep } from "@/lib/ai/coach/types";
|
||||
import {
|
||||
coachScopeSourceSchema,
|
||||
coachScopeWindowSchema,
|
||||
type CoachResultGranularity,
|
||||
type CoachResultPeriod,
|
||||
type CoachScopeWindow,
|
||||
type CoachStep,
|
||||
type CoachStepDomain,
|
||||
type CoachStepReason,
|
||||
type CoachStepStatus,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import {
|
||||
COACH_STEP_LABEL_KEYS,
|
||||
coachDomainLabelKey,
|
||||
coachWindowLabelKey,
|
||||
} from "@/lib/ai/coach/dialog-keys";
|
||||
import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
|
||||
import {
|
||||
isCoachToolName,
|
||||
type CoachToolName,
|
||||
} from "@/lib/ai/coach/tools/definitions";
|
||||
|
||||
/** At most this many steps per turn; later calls run but show no step. */
|
||||
export const MAX_TURN_STEPS = 12;
|
||||
|
||||
const SCOPE_SOURCES: ReadonlySet<string> = new Set(
|
||||
coachScopeSourceSchema.options,
|
||||
);
|
||||
const WINDOWS: ReadonlySet<string> = new Set(coachScopeWindowSchema.options);
|
||||
const PERIODS: ReadonlySet<string> = new Set<CoachResultPeriod>([
|
||||
"current",
|
||||
"previous",
|
||||
"yearAgo",
|
||||
]);
|
||||
const GRANULARITIES: ReadonlySet<string> = new Set<CoachResultGranularity>([
|
||||
"day",
|
||||
"week",
|
||||
"month",
|
||||
]);
|
||||
|
||||
/**
|
||||
* The domain each tool reads. `null` means the domain is the validated
|
||||
* `metric` argument. A record keyed on the tool name, so a new tool does not
|
||||
* compile until it says what it reads.
|
||||
*/
|
||||
const TOOL_DOMAIN: Readonly<Record<CoachToolName, CoachStepDomain | null>> = {
|
||||
get_metric_series: null,
|
||||
get_glucose_panel: "glucose",
|
||||
get_sleep: "sleep",
|
||||
get_medication_compliance: "compliance",
|
||||
get_labs: "labs",
|
||||
get_illness_recovery: "illness",
|
||||
get_workouts: "workouts",
|
||||
get_cycle: "cycle",
|
||||
get_correlations: "correlations",
|
||||
};
|
||||
|
||||
/**
|
||||
* The window a tool reads when the call names none. `"fallback"` is the
|
||||
* conversation's window (the executor's default); `null` is a read with no
|
||||
* window at all. Labs read a fixed trailing year whatever the conversation
|
||||
* says, so their step says that year rather than the conversation's window.
|
||||
*/
|
||||
const TOOL_WINDOW: Readonly<
|
||||
Record<CoachToolName, CoachScopeWindow | "fallback" | null>
|
||||
> = {
|
||||
get_metric_series: "fallback",
|
||||
get_glucose_panel: "fallback",
|
||||
get_sleep: "fallback",
|
||||
get_medication_compliance: "fallback",
|
||||
get_labs: "lastYear",
|
||||
get_illness_recovery: "fallback",
|
||||
get_workouts: "fallback",
|
||||
get_cycle: null,
|
||||
get_correlations: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* A miss reason from the executor, mapped onto the step. Anything not listed
|
||||
* (a correlation reader's own "no pattern survived" code, a rows-exist-but-
|
||||
* no-block verdict) is an empty step with no stated reason: saying "no
|
||||
* readings" there would be false.
|
||||
*/
|
||||
const MISS: Readonly<
|
||||
Record<string, { status: CoachStepStatus; reason?: CoachStepReason }>
|
||||
> = {
|
||||
no_data: { status: "empty", reason: "no_data" },
|
||||
analyte_not_found: { status: "empty", reason: "no_data" },
|
||||
outside_window: { status: "empty", reason: "outside_window" },
|
||||
module_disabled: { status: "empty", reason: "module_disabled" },
|
||||
retrieval_failed: { status: "failed", reason: "retrieval_failed" },
|
||||
no_data_unconfirmed: { status: "failed", reason: "retrieval_failed" },
|
||||
invalid_arguments: { status: "failed", reason: "invalid_arguments" },
|
||||
unknown_tool: { status: "failed", reason: "invalid_arguments" },
|
||||
unsupported_metric: { status: "failed", reason: "invalid_arguments" },
|
||||
use_get_glucose_panel: { status: "failed", reason: "invalid_arguments" },
|
||||
use_get_medication_compliance: {
|
||||
status: "failed",
|
||||
reason: "invalid_arguments",
|
||||
},
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** A non-negative integer, or undefined. Never a float: a count, not a value. */
|
||||
function asCount(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function pick<T extends string>(
|
||||
allowed: ReadonlySet<string>,
|
||||
value: unknown,
|
||||
): T | undefined {
|
||||
return typeof value === "string" && allowed.has(value)
|
||||
? (value as T)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many readings or rows a present result covered, when the result says
|
||||
* so in a field the server counted:
|
||||
* - a metric series: the aggregate's coverage count;
|
||||
* - workouts: the sessions in the window;
|
||||
* - labs: the biomarkers returned (one row each).
|
||||
* Anything else carries no count rather than a guess.
|
||||
*/
|
||||
function presentCount(tool: CoachToolName, data: unknown): number | undefined {
|
||||
if (!isRecord(data)) return undefined;
|
||||
switch (tool) {
|
||||
case "get_metric_series": {
|
||||
const section = data.section;
|
||||
if (!isRecord(section) || !isRecord(section.aggregate)) return undefined;
|
||||
const coverage = section.aggregate.coverage;
|
||||
return isRecord(coverage) ? asCount(coverage.count) : undefined;
|
||||
}
|
||||
case "get_workouts":
|
||||
return asCount(data.totalInWindow);
|
||||
case "get_labs":
|
||||
return Array.isArray(data.recent) ? data.recent.length : undefined;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function render(
|
||||
locale: Locale,
|
||||
domain: CoachStepDomain,
|
||||
window: CoachScopeWindow | undefined,
|
||||
): { labelKey: string; label: string } {
|
||||
const { t } = getServerTranslator(locale);
|
||||
const labelKey = window
|
||||
? COACH_STEP_LABEL_KEYS.readWindow
|
||||
: COACH_STEP_LABEL_KEYS.read;
|
||||
return {
|
||||
labelKey,
|
||||
label: t(labelKey, {
|
||||
domain: t(coachDomainLabelKey(domain)),
|
||||
...(window ? { window: t(coachWindowLabelKey(window)) } : {}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The step for one tool call: `running` when `result` is absent (the call
|
||||
* just started), its final status once the result is in. `index` counts
|
||||
* calls across the turn from 0; `parsedArgs` are the schema-validated
|
||||
* arguments, absent when they did not validate.
|
||||
* arguments, absent when they did not validate. `fallbackWindow` is the
|
||||
* conversation's window, the one a call without a window reads.
|
||||
*
|
||||
* Null for a call past the cap, for a tool name outside the catalogue, and
|
||||
* for a metric call whose metric did not validate: there is nothing true to
|
||||
* label those with.
|
||||
*/
|
||||
export function toStep(_args: {
|
||||
export function toStep(args: {
|
||||
call: AiToolCall;
|
||||
index: number;
|
||||
parsedArgs: Record<string, unknown> | undefined;
|
||||
result?: CoachToolResult;
|
||||
locale: Locale;
|
||||
fallbackWindow?: CoachScopeWindow;
|
||||
}): CoachStep | null {
|
||||
return null;
|
||||
const { call, index, parsedArgs, result, locale } = args;
|
||||
if (!Number.isSafeInteger(index) || index < 0 || index >= MAX_TURN_STEPS) {
|
||||
return null;
|
||||
}
|
||||
if (!isCoachToolName(call.name)) return null;
|
||||
const tool = call.name;
|
||||
|
||||
const domain =
|
||||
TOOL_DOMAIN[tool] ??
|
||||
pick<CoachStepDomain>(SCOPE_SOURCES, parsedArgs?.metric);
|
||||
// A metric series call whose arguments did not validate names no domain
|
||||
// the executor would read; the executor refuses it and the model is told.
|
||||
// There is nothing true to show, so it gets no step.
|
||||
if (!domain) return null;
|
||||
|
||||
const windowRule = TOOL_WINDOW[tool];
|
||||
const window =
|
||||
windowRule === null
|
||||
? undefined
|
||||
: windowRule === "fallback"
|
||||
? (pick<CoachScopeWindow>(WINDOWS, parsedArgs?.window) ??
|
||||
pick<CoachScopeWindow>(WINDOWS, result?.searchedWindow) ??
|
||||
pick<CoachScopeWindow>(WINDOWS, args.fallbackWindow))
|
||||
: windowRule;
|
||||
const period = pick<CoachResultPeriod>(PERIODS, parsedArgs?.period);
|
||||
const granularity = pick<CoachResultGranularity>(
|
||||
GRANULARITIES,
|
||||
parsedArgs?.granularity,
|
||||
);
|
||||
|
||||
const settled = settle(tool, result);
|
||||
return {
|
||||
id: `s${index + 1}`,
|
||||
tool,
|
||||
...render(locale, domain, window),
|
||||
domain,
|
||||
...(window ? { window } : {}),
|
||||
...(period ? { period } : {}),
|
||||
...(granularity ? { granularity } : {}),
|
||||
status: settled.status,
|
||||
...(settled.count !== undefined ? { count: settled.count } : {}),
|
||||
...(settled.reason ? { reason: settled.reason } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function settle(
|
||||
tool: CoachToolName,
|
||||
result: CoachToolResult | undefined,
|
||||
): { status: CoachStepStatus; count?: number; reason?: CoachStepReason } {
|
||||
if (!result) return { status: "running" };
|
||||
if (result.present) {
|
||||
return { status: "done", count: presentCount(tool, result.data) };
|
||||
}
|
||||
const miss = (typeof result.reason === "string" &&
|
||||
Object.hasOwn(MISS, result.reason) &&
|
||||
MISS[result.reason]) || { status: "empty" as const };
|
||||
// An out-of-window miss knows how much the record holds elsewhere; the
|
||||
// count rides the step for the method line, the row shows the reason.
|
||||
const count =
|
||||
miss.status === "empty" && isRecord(result.available)
|
||||
? asCount(result.available.count)
|
||||
: undefined;
|
||||
return {
|
||||
status: miss.status,
|
||||
...(count !== undefined ? { count } : {}),
|
||||
...(miss.reason ? { reason: miss.reason } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The single step a no-tools turn shows: the full snapshot, with the number
|
||||
* of metrics it covered.
|
||||
* of metrics it covered. The snapshot is already built when this runs, so
|
||||
* the step is settled from the start.
|
||||
*/
|
||||
export function snapshotStep(_args: {
|
||||
export function snapshotStep(args: {
|
||||
metricCount: number;
|
||||
locale: Locale;
|
||||
}): CoachStep | null {
|
||||
return null;
|
||||
const { t } = getServerTranslator(args.locale);
|
||||
const count = asCount(args.metricCount) ?? 0;
|
||||
return {
|
||||
id: "s1",
|
||||
tool: "snapshot",
|
||||
labelKey: COACH_STEP_LABEL_KEYS.snapshot,
|
||||
label: t(COACH_STEP_LABEL_KEYS.snapshot),
|
||||
domain: "snapshot",
|
||||
status: count > 0 ? "done" : "empty",
|
||||
count,
|
||||
...(count === 0 ? { reason: "no_data" as const } : {}),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user