mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge branch 'coach/wp4-followups' into release/v1.39.4
This commit is contained in:
commit
968b8b1efd
19 files changed
+2333
-63
No files matched your search
@@ -24988,6 +24988,10 @@ components:
|
||||
- type: string
|
||||
maxLength: 40
|
||||
- type: "null"
|
||||
followUpChips:
|
||||
description: v1.39.4 — offer up to three follow-up chips under the Coach's latest answer. Absent means on; only `false`
|
||||
switches them off.
|
||||
type: boolean
|
||||
baseUpdatedAt:
|
||||
description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the
|
||||
legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored
|
||||
@@ -46837,6 +46841,10 @@ components:
|
||||
- dismissedCadences
|
||||
- lastSuggestedAt
|
||||
additionalProperties: false
|
||||
followUpChips:
|
||||
description: v1.39.4 — offer up to three follow-up chips under the Coach's latest answer. Absent means on; only `false`
|
||||
switches them off.
|
||||
type: boolean
|
||||
updatedAt:
|
||||
description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as
|
||||
`baseUpdatedAt` on the next write. Opaque — only ever echo a server-returned value, never parse or
|
||||
@@ -75378,6 +75386,10 @@ components:
|
||||
- dismissedCadences
|
||||
- lastSuggestedAt
|
||||
additionalProperties: false
|
||||
followUpChips:
|
||||
description: v1.39.4 — offer up to three follow-up chips under the Coach's latest answer. Absent means on; only `false`
|
||||
switches them off.
|
||||
type: boolean
|
||||
required:
|
||||
- tone
|
||||
- verbosity
|
||||
|
||||
@@ -50,7 +50,7 @@ exports[`coach chat golden transcripts > 01a tool mode, two rounds, present data
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "e3b70b46e831:3852",
|
||||
"system": "ca96f6408388:4509",
|
||||
"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": "e3b70b46e831:3852",
|
||||
"system": "ca96f6408388:4509",
|
||||
"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": "e3b70b46e831:3852",
|
||||
"system": "ca96f6408388:4509",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -1162,7 +1162,7 @@ exports[`coach chat golden transcripts > 07a all providers failed (plain) 1`] =
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "e3b70b46e831:3852",
|
||||
"system": "ca96f6408388:4509",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -1251,7 +1251,7 @@ exports[`coach chat golden transcripts > 07b all providers failed (every hop rat
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "e3b70b46e831:3852",
|
||||
"system": "ca96f6408388:4509",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -1340,7 +1340,7 @@ exports[`coach chat golden transcripts > 07c all providers failed (primary crede
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "e3b70b46e831:3852",
|
||||
"system": "ca96f6408388:4509",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -1429,7 +1429,7 @@ exports[`coach chat golden transcripts > 08 an unwrapped tagged provider error 1
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "e3b70b46e831:3852",
|
||||
"system": "ca96f6408388:4509",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -1519,7 +1519,7 @@ exports[`coach chat golden transcripts > 09 an aborted request leaves a cancelle
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "e3b70b46e831:3852",
|
||||
"system": "ca96f6408388:4509",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -1615,7 +1615,7 @@ exports[`coach chat golden transcripts > 10 outbound block replaces the reply an
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "e3b70b46e831:3852",
|
||||
"system": "ca96f6408388:4509",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -1946,7 +1946,7 @@ exports[`coach chat golden transcripts > 11a KEYVALUES valid 1`] = `
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "e3b70b46e831:3852",
|
||||
"system": "ca96f6408388:4509",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -2151,7 +2151,7 @@ exports[`coach chat golden transcripts > 11b KEYVALUES partial 1`] = `
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "e3b70b46e831:3852",
|
||||
"system": "ca96f6408388:4509",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -2354,7 +2354,7 @@ exports[`coach chat golden transcripts > 11c KEYVALUES malformed (no closing mar
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "e3b70b46e831:3852",
|
||||
"system": "ca96f6408388:4509",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -2541,7 +2541,7 @@ exports[`coach chat golden transcripts > 11d sentinel-only reply is an empty rep
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "e3b70b46e831:3852",
|
||||
"system": "ca96f6408388:4509",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -2630,7 +2630,7 @@ exports[`coach chat golden transcripts > 11e empty provider reply 1`] = `
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "e3b70b46e831:3852",
|
||||
"system": "ca96f6408388:4509",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -2710,7 +2710,7 @@ exports[`coach chat golden transcripts > 12a SUGGEST-REMINDER surfaced 1`] = `
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "e3b70b46e831:3852",
|
||||
"system": "ca96f6408388:4509",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -2935,7 +2935,7 @@ exports[`coach chat golden transcripts > 12b SUGGEST-REMINDER suppressed 1`] = `
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "e3b70b46e831:3852",
|
||||
"system": "ca96f6408388:4509",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -3124,7 +3124,7 @@ exports[`coach chat golden transcripts > 13 SUGGEST-ACTION 1`] = `
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "e3b70b46e831:3852",
|
||||
"system": "ca96f6408388:4509",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -3353,7 +3353,7 @@ exports[`coach chat golden transcripts > 14a REMEMBER valid 1`] = `
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "e3b70b46e831:3852",
|
||||
"system": "ca96f6408388:4509",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -3562,7 +3562,7 @@ exports[`coach chat golden transcripts > 14b REMEMBER malformed 1`] = `
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "e3b70b46e831:3852",
|
||||
"system": "ca96f6408388:4509",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -3766,7 +3766,7 @@ exports[`coach chat golden transcripts > 15 an unknown learn link is scrubbed, a
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "e3b70b46e831:3852",
|
||||
"system": "ca96f6408388:4509",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -4298,7 +4298,7 @@ exports[`coach chat golden transcripts > 17a a workout-scoped first turn 1`] = `
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "e3b70b46e831:3852",
|
||||
"system": "ca96f6408388:4509",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -4508,7 +4508,7 @@ exports[`coach chat golden transcripts > 17b a workout-scoped first turn with th
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "e3b70b46e831:3852",
|
||||
"system": "ca96f6408388:4509",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -4697,7 +4697,7 @@ exports[`coach chat golden transcripts > 18 a persistence throw after the provid
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "e3b70b46e831:3852",
|
||||
"system": "ca96f6408388:4509",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -4790,7 +4790,7 @@ exports[`coach chat golden transcripts > 19 an existing conversation with prior
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "e3b70b46e831:3852",
|
||||
"system": "ca96f6408388:4509",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -5003,7 +5003,7 @@ exports[`coach chat golden transcripts > 20 a guided-question turn with a scope
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "e3b70b46e831:3852",
|
||||
"system": "ca96f6408388:4509",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 30000,
|
||||
"tools": 2,
|
||||
@@ -5172,7 +5172,7 @@ exports[`coach chat golden transcripts > 21 the German locale with a stored self
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "8b39aa0cb29c:4071",
|
||||
"system": "e297a082792b:4728",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
@@ -5377,7 +5377,7 @@ exports[`coach chat golden transcripts > 22 tool mode with live steps: found, em
|
||||
"window": "last30days",
|
||||
},
|
||||
"signal": true,
|
||||
"system": "e3b70b46e831:3852",
|
||||
"system": "ca96f6408388:4509",
|
||||
"temperature": 0.4,
|
||||
"timeoutMs": 180000,
|
||||
"tools": 2,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
|
||||
// The chips' only hook is the translator; with the server one the component
|
||||
// can be called as a plain function and its handlers exercised.
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({
|
||||
t: (key: string) => getServerTranslator("en").t(key),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { CoachFollowUpChips } from "../follow-up-chips";
|
||||
import type { CoachFollowUp } from "@/lib/ai/coach/types";
|
||||
|
||||
function chip(id: string, label: string): CoachFollowUp {
|
||||
return {
|
||||
id,
|
||||
kind: "previous_period",
|
||||
labelKey: "coach.followUp.previousPeriod",
|
||||
label,
|
||||
reuse: false,
|
||||
origin: "server",
|
||||
};
|
||||
}
|
||||
|
||||
const CHIPS = [
|
||||
chip("f1", "Compare with the period before"),
|
||||
chip("f2", "Look at Sleep too"),
|
||||
chip("f3", "Show as a chart"),
|
||||
chip("f4", "A fourth one"),
|
||||
];
|
||||
|
||||
function render(followUps: CoachFollowUp[], disabled = false) {
|
||||
const onSelect = vi.fn();
|
||||
const tree = CoachFollowUpChips({
|
||||
followUps,
|
||||
messageId: "m1",
|
||||
disabled,
|
||||
onSelect,
|
||||
});
|
||||
return { tree, onSelect, html: tree ? renderToStaticMarkup(tree) : "" };
|
||||
}
|
||||
|
||||
function buttons(node: ReactNode): ReactElement<Record<string, unknown>>[] {
|
||||
if (Array.isArray(node)) return node.flatMap(buttons);
|
||||
if (!isValidElement(node)) return [];
|
||||
const el = node as ReactElement<Record<string, unknown>>;
|
||||
const own = el.props["data-follow-up-id"] ? [el] : [];
|
||||
return [...own, ...buttons(el.props.children as ReactNode)];
|
||||
}
|
||||
|
||||
describe("<CoachFollowUpChips>", () => {
|
||||
it("renders at most three outline chips in a labelled group, with the server's label", () => {
|
||||
const { html, tree } = render(CHIPS);
|
||||
expect(html).toContain('role="group"');
|
||||
expect(html).toContain('aria-label="Suggested follow-ups"');
|
||||
expect(html).toContain(">Look at Sleep too</button>");
|
||||
expect(html).not.toContain("A fourth one");
|
||||
const rendered = buttons(tree);
|
||||
expect(rendered).toHaveLength(3);
|
||||
for (const button of rendered) {
|
||||
expect(button.props.variant).toBe("outline");
|
||||
expect(button.props.className).toContain("min-h-11");
|
||||
}
|
||||
});
|
||||
|
||||
it("sends the tapped chip with the message that offered it", () => {
|
||||
const { tree, onSelect } = render(CHIPS);
|
||||
(buttons(tree)[1].props.onClick as () => void)();
|
||||
expect(onSelect).toHaveBeenCalledWith(CHIPS[1], "m1");
|
||||
});
|
||||
|
||||
it("is hidden while a turn is in flight, and when there is nothing to offer", () => {
|
||||
expect(render(CHIPS, true).tree).toBeNull();
|
||||
expect(render([]).tree).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -5,10 +5,20 @@
|
||||
* A tap sends the chip's label as the message with
|
||||
* `followUp: { messageId, id }`; the server resolves what the chip asks for.
|
||||
*
|
||||
* Not built yet: renders nothing.
|
||||
* The label is the server's rendering in the request locale. It is not
|
||||
* re-rendered from `labelKey` here: a related-metric chip names its metric
|
||||
* in the label, and the key alone does not carry it. Hidden while a turn is
|
||||
* in flight, so a chip can never be tapped against a reply that is about to
|
||||
* stop being the latest.
|
||||
*/
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { COACH_FOLLOW_UP_UI_KEYS } from "@/lib/ai/coach/dialog-keys";
|
||||
import type { CoachFollowUp } from "@/lib/ai/coach/types";
|
||||
|
||||
/** At most this many chips render, whatever the payload carries. */
|
||||
const MAX_CHIPS = 3;
|
||||
|
||||
export interface CoachFollowUpChipsProps {
|
||||
followUps: CoachFollowUp[];
|
||||
/** The assistant message that offered the chips. */
|
||||
@@ -18,6 +28,37 @@ export interface CoachFollowUpChipsProps {
|
||||
onSelect: (followUp: CoachFollowUp, messageId: string) => void;
|
||||
}
|
||||
|
||||
export function CoachFollowUpChips(_props: CoachFollowUpChipsProps) {
|
||||
return null;
|
||||
export function CoachFollowUpChips({
|
||||
followUps,
|
||||
messageId,
|
||||
disabled,
|
||||
onSelect,
|
||||
}: CoachFollowUpChipsProps) {
|
||||
const { t } = useTranslations();
|
||||
const chips = followUps.slice(0, MAX_CHIPS);
|
||||
if (disabled || chips.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t(COACH_FOLLOW_UP_UI_KEYS.groupLabel)}
|
||||
data-slot="coach-follow-up-chips"
|
||||
data-message-id={messageId}
|
||||
className="flex flex-wrap gap-2"
|
||||
>
|
||||
{chips.map((chip) => (
|
||||
<Button
|
||||
key={chip.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
data-follow-up-id={chip.id}
|
||||
data-follow-up-kind={chip.kind}
|
||||
onClick={() => onSelect(chip, messageId)}
|
||||
>
|
||||
{chip.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import { COACH_FOLLOW_UP_UI_KEYS } from "@/lib/ai/coach/dialog-keys";
|
||||
import {
|
||||
DEFAULT_COACH_PREFS,
|
||||
type CoachExcludeMetric,
|
||||
@@ -351,6 +352,30 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* v1.39.4 — follow-up chips under the Coach's latest answer.
|
||||
Absent in the saved prefs means on. */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label
|
||||
htmlFor="coach-prefs-follow-up-chips"
|
||||
className="cursor-pointer text-xs font-medium"
|
||||
>
|
||||
{t(COACH_FOLLOW_UP_UI_KEYS.settingLabel)}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
{t(COACH_FOLLOW_UP_UI_KEYS.settingHint)}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="coach-prefs-follow-up-chips"
|
||||
data-slot="coach-prefs-follow-up-chips"
|
||||
checked={draft.followUpChips !== false}
|
||||
onCheckedChange={(next) =>
|
||||
setDraft((prev) => ({ ...prev, followUpChips: next }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<SettingsCardActions>
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* v1.39.4 — follow-up chips are derived from what the turn read, never from
|
||||
* what it did not: every chip's domain is a done read or a table the turn
|
||||
* holds, absence never becomes a chip, the model can only reorder, the pref
|
||||
* switches them off, and there are never more than three.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
|
||||
import type {
|
||||
CoachResultMeta,
|
||||
CoachScopeSource,
|
||||
CoachScopeWindow,
|
||||
CoachStep,
|
||||
CoachStepDomain,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import type { InventoryEntry } from "@/lib/ai/coach/tools/inventory";
|
||||
import type { SettledToolCall } from "@/lib/ai/coach/results/project";
|
||||
import { DEFAULT_COACH_PREFS } from "@/lib/validations/coach-prefs";
|
||||
|
||||
import {
|
||||
correlationPartners,
|
||||
deriveFollowUps,
|
||||
type CorrelationPair,
|
||||
type FollowUpHistory,
|
||||
} from "../derive";
|
||||
|
||||
function table(
|
||||
domain: CoachStepDomain,
|
||||
over: Partial<CoachResultMeta> = {},
|
||||
window: CoachScopeWindow = "last30days",
|
||||
): CoachResultMeta {
|
||||
return {
|
||||
ref: "r1",
|
||||
source: {
|
||||
tool: "get_metric_table",
|
||||
domain,
|
||||
window,
|
||||
period: "current",
|
||||
granularity: "day",
|
||||
},
|
||||
shape: "timeSeries",
|
||||
titleKey: "coach.result.title.byDay",
|
||||
title: `${domain} by day`,
|
||||
rowCount: 30,
|
||||
chartKind: null,
|
||||
displayed: true,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function step(
|
||||
domain: CoachStepDomain,
|
||||
over: Partial<CoachStep> = {},
|
||||
): CoachStep {
|
||||
return {
|
||||
id: "s1",
|
||||
tool: "get_metric_table",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "Checking",
|
||||
domain,
|
||||
status: "done",
|
||||
count: 40,
|
||||
resultRef: "r1",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function present(...metrics: string[]): InventoryEntry[] {
|
||||
return metrics.map((metric) => ({
|
||||
tool: "get_metric_series",
|
||||
metric,
|
||||
domain: metric,
|
||||
present: true,
|
||||
}));
|
||||
}
|
||||
|
||||
const LONG_HISTORY: FollowUpHistory = {
|
||||
today: "2026-09-27",
|
||||
firstDate: { bp: "2024-01-01", weight: "2024-01-01", sleep: "2024-01-01" },
|
||||
};
|
||||
|
||||
function derive(over: Partial<Parameters<typeof deriveFollowUps>[0]> = {}) {
|
||||
return deriveFollowUps({
|
||||
results: [table("bp")],
|
||||
steps: [step("bp")],
|
||||
inventory: present("bp", "weight", "sleep"),
|
||||
proposals: [],
|
||||
forcedFinal: false,
|
||||
prefs: DEFAULT_COACH_PREFS,
|
||||
locale: "en",
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
describe("deriveFollowUps — the rules", () => {
|
||||
it("offers the period before for a current time series the record holds", () => {
|
||||
const chips = derive();
|
||||
expect(chips.map((c) => c.kind)).toEqual(["previous_period"]);
|
||||
expect(chips[0]).toMatchObject({
|
||||
id: "f1",
|
||||
reuse: false,
|
||||
origin: "server",
|
||||
label: "Compare with the period before",
|
||||
anchor: { ref: "r1", domain: "bp", window: "last30days" },
|
||||
});
|
||||
});
|
||||
|
||||
it("offers a year ago and a wider window only when the history reaches back", () => {
|
||||
expect(derive({ history: LONG_HISTORY }).map((c) => c.kind)).toEqual([
|
||||
"previous_period",
|
||||
"year_ago",
|
||||
"widen_window",
|
||||
]);
|
||||
const recent: FollowUpHistory = {
|
||||
today: "2026-09-27",
|
||||
firstDate: { bp: "2026-09-10" },
|
||||
};
|
||||
expect(derive({ history: recent }).map((c) => c.kind)).toEqual([
|
||||
"previous_period",
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers no year ago over a year-long window, and no wider window from all time", () => {
|
||||
const year = derive({
|
||||
results: [table("bp", {}, "lastYear")],
|
||||
history: LONG_HISTORY,
|
||||
});
|
||||
expect(year.map((c) => c.kind)).toEqual([
|
||||
"previous_period",
|
||||
"widen_window",
|
||||
]);
|
||||
const all = derive({
|
||||
results: [table("bp", {}, "allTime")],
|
||||
history: LONG_HISTORY,
|
||||
});
|
||||
expect(all.map((c) => c.kind)).toEqual(["previous_period"]);
|
||||
});
|
||||
|
||||
it("offers the other view of a table that has a chart, as a reuse chip", () => {
|
||||
const shown = derive({
|
||||
results: [table("bp", { chartKind: "line", displayed: true })],
|
||||
});
|
||||
expect(shown[0]).toMatchObject({ kind: "as_table", reuse: true });
|
||||
const tucked = derive({
|
||||
results: [table("bp", { chartKind: "line", displayed: false })],
|
||||
});
|
||||
expect(tucked[0]).toMatchObject({ kind: "as_chart", reuse: true });
|
||||
expect(derive().some((c) => c.reuse)).toBe(false);
|
||||
});
|
||||
|
||||
it("offers a related metric only for a pair whose partner the record holds", () => {
|
||||
const pairs: CorrelationPair[] = [{ a: "sleep", b: "bp" }];
|
||||
const steps = [
|
||||
step("bp"),
|
||||
step("correlations", { id: "s2", resultRef: undefined }),
|
||||
];
|
||||
const chips = derive({ steps, correlations: pairs });
|
||||
const related = chips.find((c) => c.kind === "related_metric");
|
||||
expect(related).toMatchObject({
|
||||
label: "Look at Sleep too",
|
||||
anchor: { domain: "sleep" },
|
||||
});
|
||||
const without = derive({
|
||||
steps,
|
||||
correlations: pairs,
|
||||
inventory: present("bp"),
|
||||
});
|
||||
expect(without.some((c) => c.kind === "related_metric")).toBe(false);
|
||||
});
|
||||
|
||||
it("offers nothing for a table the table tool cannot fetch again, or a shape without periods", () => {
|
||||
expect(
|
||||
derive({ results: [table("glucose")], steps: [step("glucose")] }),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
derive({ results: [table("bp", { shape: "categoryCounts" })] }),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
derive({
|
||||
results: [
|
||||
table("bp", {
|
||||
source: { ...table("bp").source, period: "previous" },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveFollowUps — absence never becomes a chip", () => {
|
||||
it("offers nothing when the turn read nothing", () => {
|
||||
expect(derive({ results: [], steps: [] })).toEqual([]);
|
||||
});
|
||||
|
||||
it("offers nothing for a metric the record does not hold", () => {
|
||||
expect(derive({ inventory: present("weight") })).toEqual([]);
|
||||
});
|
||||
|
||||
it("offers nothing on the no-tools path but the other view of a table", () => {
|
||||
expect(derive({ inventory: null })).toEqual([]);
|
||||
});
|
||||
|
||||
it("offers nothing for an empty or failed read", () => {
|
||||
for (const status of ["empty", "failed", "running"] as const) {
|
||||
expect(derive({ results: [], steps: [step("bp", { status })] })).toEqual(
|
||||
[],
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveFollowUps — proposals, cap and pref", () => {
|
||||
it("lets a proposal lead only when the rules allow the same chip", () => {
|
||||
const chips = derive({
|
||||
history: LONG_HISTORY,
|
||||
proposals: [
|
||||
{ kind: "widen_window", domain: "bp" },
|
||||
// Not read this turn: ignored.
|
||||
{ kind: "previous_period", domain: "weight" },
|
||||
// Read, but nothing to chart: ignored.
|
||||
{ kind: "as_chart", domain: "bp" },
|
||||
],
|
||||
});
|
||||
expect(chips.map((c) => [c.kind, c.origin])).toEqual([
|
||||
["widen_window", "model"],
|
||||
["previous_period", "server"],
|
||||
["year_ago", "server"],
|
||||
]);
|
||||
expect(chips.map((c) => c.id)).toEqual(["f1", "f2", "f3"]);
|
||||
});
|
||||
|
||||
it("never offers more than three", () => {
|
||||
const chips = derive({
|
||||
results: [
|
||||
table("bp", { chartKind: "line" }),
|
||||
table("weight", { ref: "r2" }),
|
||||
],
|
||||
steps: [step("bp"), step("weight", { id: "s2", resultRef: "r2" })],
|
||||
history: LONG_HISTORY,
|
||||
});
|
||||
expect(chips).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("offers nothing when the pref is off", () => {
|
||||
expect(
|
||||
derive({
|
||||
prefs: { ...DEFAULT_COACH_PREFS, followUpChips: false },
|
||||
history: LONG_HISTORY,
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Property: every chip is grounded in this turn ─────────────────────────
|
||||
|
||||
const DOMAINS: CoachScopeSource[] = [
|
||||
"bp",
|
||||
"weight",
|
||||
"sleep",
|
||||
"glucose",
|
||||
"steps",
|
||||
"mood",
|
||||
];
|
||||
const WINDOWS: CoachScopeWindow[] = [
|
||||
"last7days",
|
||||
"last30days",
|
||||
"last90days",
|
||||
"lastYear",
|
||||
"allTime",
|
||||
];
|
||||
const STATUSES = ["done", "empty", "failed", "running"] as const;
|
||||
|
||||
/** Deterministic PRNG so a failure reproduces from its seed. */
|
||||
function rng(seed: number) {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s * 1664525 + 1013904223) >>> 0;
|
||||
return s / 2 ** 32;
|
||||
};
|
||||
}
|
||||
|
||||
describe("deriveFollowUps — property: every chip's domain was read this turn", () => {
|
||||
it("holds over generated turns", () => {
|
||||
for (let seed = 1; seed <= 400; seed++) {
|
||||
const r = rng(seed);
|
||||
const pick = <T>(xs: readonly T[]) => xs[Math.floor(r() * xs.length)];
|
||||
const steps: CoachStep[] = [];
|
||||
const results: CoachResultMeta[] = [];
|
||||
const n = Math.floor(r() * 4);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const domain = pick(DOMAINS);
|
||||
const status = pick(STATUSES);
|
||||
const ref = `r${i + 1}`;
|
||||
steps.push(step(domain, { id: `s${i + 1}`, status, resultRef: ref }));
|
||||
if (status === "done" && r() < 0.8) {
|
||||
results.push(
|
||||
table(
|
||||
domain,
|
||||
{
|
||||
ref,
|
||||
chartKind: r() < 0.5 ? "line" : null,
|
||||
displayed: r() < 0.5,
|
||||
shape: r() < 0.8 ? "timeSeries" : "categoryCounts",
|
||||
},
|
||||
pick(WINDOWS),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (r() < 0.4) {
|
||||
steps.push(step("correlations", { id: "s9", resultRef: undefined }));
|
||||
}
|
||||
const correlations: CorrelationPair[] =
|
||||
r() < 0.5 ? [{ a: pick(DOMAINS), b: pick(DOMAINS) }] : [];
|
||||
const inventory = present(...DOMAINS.filter(() => r() < 0.6));
|
||||
const history: FollowUpHistory = {
|
||||
today: "2026-09-27",
|
||||
firstDate: Object.fromEntries(
|
||||
DOMAINS.map((d) => [d, r() < 0.5 ? "2020-01-01" : "2026-09-20"]),
|
||||
),
|
||||
};
|
||||
const proposals = [
|
||||
{ kind: "previous_period" as const, domain: pick(DOMAINS) },
|
||||
{ kind: "related_metric" as const, domain: pick(DOMAINS) },
|
||||
];
|
||||
|
||||
const chips = derive({
|
||||
results,
|
||||
steps,
|
||||
inventory,
|
||||
history,
|
||||
correlations,
|
||||
proposals,
|
||||
});
|
||||
|
||||
const done = new Set(
|
||||
steps.filter((s) => s.status === "done").map((s) => s.domain),
|
||||
);
|
||||
const stored = new Set(results.map((m) => m.source.domain));
|
||||
const held = new Set(inventory.map((e) => e.metric as CoachStepDomain));
|
||||
expect(chips.length, `seed ${seed}`).toBeLessThanOrEqual(3);
|
||||
expect(new Set(chips.map((c) => c.id)).size).toBe(chips.length);
|
||||
for (const chip of chips) {
|
||||
const domain = chip.anchor?.domain;
|
||||
expect(domain, `seed ${seed}`).toBeDefined();
|
||||
if (chip.kind === "related_metric") {
|
||||
// The partner came from a pair this turn's correlations read
|
||||
// returned, linked to a metric the turn read, and the record
|
||||
// holds it.
|
||||
expect(done.has("correlations") || correlations.length > 0).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
correlations.some(
|
||||
(p) =>
|
||||
(p.a === domain && done.has(p.b)) ||
|
||||
(p.b === domain && done.has(p.a)),
|
||||
),
|
||||
`seed ${seed}`,
|
||||
).toBe(true);
|
||||
expect(held.has(domain!), `seed ${seed}`).toBe(true);
|
||||
} else {
|
||||
expect(done.has(domain!) || stored.has(domain!), `seed ${seed}`).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
if (!chip.reuse) {
|
||||
expect(held.has(domain!), `seed ${seed}`).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("correlationPartners", () => {
|
||||
function correlations(drivers: unknown): SettledToolCall {
|
||||
return {
|
||||
name: "get_correlations",
|
||||
result: { present: true, data: { drivers } },
|
||||
};
|
||||
}
|
||||
|
||||
it("maps measurement channel labels back to their metrics", () => {
|
||||
expect(
|
||||
correlationPartners([
|
||||
correlations([
|
||||
{ behaviour: "sleep duration", outcome: "blood pressure sys" },
|
||||
{ behaviour: "activity steps", outcome: "mood" },
|
||||
]),
|
||||
]),
|
||||
).toEqual([
|
||||
{ a: "sleep", b: "bp" },
|
||||
{ a: "steps", b: "mood" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores channels that name no metric, and absent results", () => {
|
||||
expect(
|
||||
correlationPartners([
|
||||
correlations([
|
||||
{ behaviour: "medication adherence", outcome: "weight" },
|
||||
{ behaviour: "my custom thing", outcome: "weight" },
|
||||
]),
|
||||
{ name: "get_correlations", result: { present: false } },
|
||||
{ name: "get_sleep", result: { present: true, data: {} } },
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const annotate = vi.fn();
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: (...a: unknown[]) => annotate(...a),
|
||||
}));
|
||||
|
||||
import { FOLLOW_UPS_BYTE_CAP, parseFollowUpsSentinel } from "../parse-sentinel";
|
||||
|
||||
describe("parseFollowUpsSentinel", () => {
|
||||
it("leaves a reply without a block untouched", () => {
|
||||
expect(parseFollowUpsSentinel("Steady at 128.")).toEqual({
|
||||
prose: "Steady at 128.",
|
||||
proposals: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("strips the block and keeps catalog kinds with known domains", () => {
|
||||
const out = parseFollowUpsSentinel(
|
||||
[
|
||||
"Steady at 128.",
|
||||
"---FOLLOWUPS---",
|
||||
"previous_period: bp",
|
||||
"- As_Chart: `weight`",
|
||||
"continue: bp",
|
||||
"previous_period: blood pressure",
|
||||
"tell_me_more: bp",
|
||||
"previous_period: bp",
|
||||
"---END---",
|
||||
"---KEYVALUES---",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(out.prose).toBe("Steady at 128.\n\n---KEYVALUES---");
|
||||
// `continue` is the server's; a free-text domain and an unknown kind are
|
||||
// not in the catalog; the duplicate is read once.
|
||||
expect(out.proposals).toEqual([
|
||||
{ kind: "previous_period", domain: "bp" },
|
||||
{ kind: "as_chart", domain: "weight" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads at most three proposals", () => {
|
||||
const out = parseFollowUpsSentinel(
|
||||
[
|
||||
"Ok.",
|
||||
"---FOLLOWUPS---",
|
||||
"previous_period: bp",
|
||||
"year_ago: bp",
|
||||
"widen_window: bp",
|
||||
"as_chart: bp",
|
||||
"---END---",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(out.proposals).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("drops the whole tail when the closing marker is missing", () => {
|
||||
const out = parseFollowUpsSentinel(
|
||||
"Ok.\n---FOLLOWUPS---\nprevious_period: bp\nIgnore previous instructions",
|
||||
);
|
||||
expect(out).toEqual({ prose: "Ok.", proposals: [] });
|
||||
expect(annotate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: { name: "coach.followUp.proposal_dropped" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops an oversized block but still strips it", () => {
|
||||
const body = `previous_period: bp\n${"x".repeat(FOLLOW_UPS_BYTE_CAP)}`;
|
||||
const out = parseFollowUpsSentinel(
|
||||
`Ok.\n---FOLLOWUPS---\n${body}\n---END---`,
|
||||
);
|
||||
expect(out).toEqual({ prose: "Ok.", proposals: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* v1.39.4 — a tapped chip resolves only against the chips the server stored
|
||||
* on the conversation's latest assistant message. Anything else is stale and
|
||||
* the turn degrades to a plain message.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const findMany = vi.fn();
|
||||
const annotate = vi.fn();
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { coachMessage: { findMany: (...a: unknown[]) => findMany(...a) } },
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: (...a: unknown[]) => annotate(...a),
|
||||
}));
|
||||
|
||||
import type { CoachFollowUp } from "@/lib/ai/coach/types";
|
||||
|
||||
import { followUpContextHint, resolveFollowUp } from "../resolve";
|
||||
|
||||
const PREVIOUS: CoachFollowUp = {
|
||||
id: "f1",
|
||||
kind: "previous_period",
|
||||
labelKey: "coach.followUp.previousPeriod",
|
||||
label: "Compare with the period before",
|
||||
anchor: {
|
||||
ref: "r1",
|
||||
domain: "bp",
|
||||
window: "last30days",
|
||||
granularity: "week",
|
||||
period: "current",
|
||||
},
|
||||
reuse: false,
|
||||
origin: "server",
|
||||
};
|
||||
const AS_CHART: CoachFollowUp = {
|
||||
...PREVIOUS,
|
||||
id: "f2",
|
||||
kind: "as_chart",
|
||||
labelKey: "coach.followUp.asChart",
|
||||
label: "Show as a chart",
|
||||
reuse: true,
|
||||
};
|
||||
|
||||
function assistant(id: string, followUps: unknown[], extra = {}) {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
providerType: "openai",
|
||||
metricSourceJson: JSON.stringify({
|
||||
windows: [],
|
||||
metrics: [],
|
||||
followUps,
|
||||
steps: [
|
||||
{
|
||||
id: "s1",
|
||||
tool: "get_metric_table",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "x",
|
||||
domain: "bp",
|
||||
status: "done",
|
||||
count: 42,
|
||||
resultRef: "r1",
|
||||
},
|
||||
],
|
||||
...extra,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const PRIOR = [
|
||||
{
|
||||
messageId: "m-last",
|
||||
turnIndex: 3,
|
||||
results: [
|
||||
{
|
||||
ref: "r1",
|
||||
source: {
|
||||
tool: "get_metric_table" as const,
|
||||
domain: "bp" as const,
|
||||
window: "last30days" as const,
|
||||
period: "current" as const,
|
||||
},
|
||||
shape: "timeSeries" as const,
|
||||
titleKey: "k",
|
||||
title: "t",
|
||||
rowCount: 4,
|
||||
chartKind: "line" as const,
|
||||
displayed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
findMany.mockReset();
|
||||
annotate.mockReset();
|
||||
});
|
||||
|
||||
function staleReasons() {
|
||||
return annotate.mock.calls
|
||||
.map(
|
||||
([arg]) => arg as { action: { name: string }; meta: { reason: string } },
|
||||
)
|
||||
.filter((a) => a.action.name === "coach.followUp.stale")
|
||||
.map((a) => a.meta.reason);
|
||||
}
|
||||
|
||||
describe("resolveFollowUp", () => {
|
||||
it("is null without a chip or a conversation, and reads nothing", async () => {
|
||||
expect(
|
||||
await resolveFollowUp({
|
||||
userId: "u1",
|
||||
conversationId: "c1",
|
||||
followUp: undefined,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
await resolveFollowUp({
|
||||
userId: "u1",
|
||||
conversationId: undefined,
|
||||
followUp: { messageId: "m", id: "f1" },
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves a chip on the latest reply from what was stored, with its table's name", async () => {
|
||||
findMany.mockResolvedValue([assistant("m-last", [PREVIOUS, AS_CHART])]);
|
||||
const out = await resolveFollowUp({
|
||||
userId: "u1",
|
||||
conversationId: "c1",
|
||||
followUp: { messageId: "m-last", id: "f1" },
|
||||
priorResults: PRIOR,
|
||||
});
|
||||
expect(findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { conversationId: "c1", conversation: { userId: "u1" } },
|
||||
}),
|
||||
);
|
||||
expect(out?.followUp).toEqual(PREVIOUS);
|
||||
expect(out?.sourceCount).toBe(42);
|
||||
expect(out?.contextHint).toBe(
|
||||
"FOLLOW-UP: the person tapped the previous_period chip under your last answer. Fetch get_metric_table metric=bp window=last30days period=previous granularity=week and compare it with the current period. The table from your last answer is m3.r1: use show_result for it, do not fetch it again.",
|
||||
);
|
||||
});
|
||||
|
||||
it("looks past an interrupted turn's marker", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{
|
||||
id: "m-cancel",
|
||||
role: "assistant",
|
||||
providerType: "cancelled",
|
||||
metricSourceJson: null,
|
||||
},
|
||||
assistant("m-last", [AS_CHART]),
|
||||
]);
|
||||
const out = await resolveFollowUp({
|
||||
userId: "u1",
|
||||
conversationId: "c1",
|
||||
followUp: { messageId: "m-last", id: "f2" },
|
||||
priorResults: PRIOR,
|
||||
});
|
||||
expect(out?.followUp.reuse).toBe(true);
|
||||
expect(out?.contextHint).toContain(
|
||||
"show_result with ref m3.r1 and view chart",
|
||||
);
|
||||
});
|
||||
|
||||
it("is stale when the chip is not on the latest reply", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
assistant("m-newer", []),
|
||||
assistant("m-last", [PREVIOUS]),
|
||||
]);
|
||||
expect(
|
||||
await resolveFollowUp({
|
||||
userId: "u1",
|
||||
conversationId: "c1",
|
||||
followUp: { messageId: "m-last", id: "f1" },
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(staleReasons()).toEqual(["not_latest"]);
|
||||
});
|
||||
|
||||
it("is stale for a chip id the reply never offered", async () => {
|
||||
findMany.mockResolvedValue([assistant("m-last", [PREVIOUS])]);
|
||||
expect(
|
||||
await resolveFollowUp({
|
||||
userId: "u1",
|
||||
conversationId: "c1",
|
||||
followUp: { messageId: "m-last", id: "f3" },
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(staleReasons()).toEqual(["unknown_chip"]);
|
||||
});
|
||||
|
||||
it("is stale when the latest message is the person's own", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{
|
||||
id: "m-user",
|
||||
role: "user",
|
||||
providerType: null,
|
||||
metricSourceJson: null,
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
await resolveFollowUp({
|
||||
userId: "u1",
|
||||
conversationId: "c1",
|
||||
followUp: { messageId: "m-user", id: "f1" },
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(staleReasons()).toEqual(["not_latest"]);
|
||||
});
|
||||
|
||||
it("is stale, not thrown, when the read fails", async () => {
|
||||
findMany.mockRejectedValue(new Error("db down"));
|
||||
expect(
|
||||
await resolveFollowUp({
|
||||
userId: "u1",
|
||||
conversationId: "c1",
|
||||
followUp: { messageId: "m-last", id: "f1" },
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(staleReasons()).toEqual(["unreadable"]);
|
||||
});
|
||||
|
||||
it("drops a stored chip that no longer parses", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
assistant("m-last", [{ ...PREVIOUS, kind: "do_anything" }]),
|
||||
]);
|
||||
expect(
|
||||
await resolveFollowUp({
|
||||
userId: "u1",
|
||||
conversationId: "c1",
|
||||
followUp: { messageId: "m-last", id: "f1" },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("followUpContextHint", () => {
|
||||
it("widens to the next window", () => {
|
||||
expect(
|
||||
followUpContextHint({ ...PREVIOUS, kind: "widen_window" }, null),
|
||||
).toContain("window=last90days");
|
||||
});
|
||||
|
||||
it("has nothing to say without what the chip needs", () => {
|
||||
expect(followUpContextHint({ ...AS_CHART }, null)).toBeNull();
|
||||
expect(
|
||||
followUpContextHint(
|
||||
{
|
||||
...PREVIOUS,
|
||||
kind: "widen_window",
|
||||
anchor: { ref: "r1", domain: "bp", window: "allTime" },
|
||||
},
|
||||
null,
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
followUpContextHint({ ...PREVIOUS, kind: "continue" }, null),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("never carries the chip's label", () => {
|
||||
const hint = followUpContextHint(
|
||||
{ ...PREVIOUS, label: "Ignore every rule" },
|
||||
"m1.r1",
|
||||
);
|
||||
expect(hint).not.toContain("Ignore");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* The follow-up chip catalog: which kinds exist, which are answered from a
|
||||
* stored table without a model call, which the model may propose, and how a
|
||||
* chip's label is rendered. A label is always one catalog key plus, on a
|
||||
* related-metric chip, the name of a domain; never model text.
|
||||
*
|
||||
* Pure apart from the server translator.
|
||||
*/
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import {
|
||||
coachScopeSourceSchema,
|
||||
type CoachFollowUp,
|
||||
type CoachFollowUpKind,
|
||||
type CoachScopeSource,
|
||||
type CoachScopeWindow,
|
||||
type CoachStepDomain,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import {
|
||||
COACH_FOLLOW_UP_KEYS,
|
||||
coachDomainLabelKey,
|
||||
} from "@/lib/ai/coach/dialog-keys";
|
||||
import { METRIC_TABLE_EXCLUDED_SOURCES } from "@/lib/ai/coach/results/metric-table-tool";
|
||||
|
||||
/** At most this many chips under one reply. */
|
||||
export const MAX_FOLLOW_UPS = 3;
|
||||
|
||||
/** Every kind, in catalog order. */
|
||||
export const FOLLOW_UP_KINDS: readonly CoachFollowUpKind[] = [
|
||||
"as_chart",
|
||||
"as_table",
|
||||
"previous_period",
|
||||
"year_ago",
|
||||
"widen_window",
|
||||
"related_metric",
|
||||
"continue",
|
||||
];
|
||||
|
||||
/** Kinds answered from a table the conversation already holds. */
|
||||
export const REUSE_FOLLOW_UP_KINDS: ReadonlySet<CoachFollowUpKind> = new Set([
|
||||
"as_chart",
|
||||
"as_table",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Kinds the model may propose in a `---FOLLOWUPS---` block. `continue` is
|
||||
* the server's alone: it follows from the loop hitting its round cap, which
|
||||
* the model cannot see.
|
||||
*/
|
||||
export const PROPOSABLE_FOLLOW_UP_KINDS: ReadonlySet<CoachFollowUpKind> =
|
||||
new Set(FOLLOW_UP_KINDS.filter((kind) => kind !== "continue"));
|
||||
|
||||
export function isFollowUpKind(value: string): value is CoachFollowUpKind {
|
||||
return (FOLLOW_UP_KINDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
const SCOPE_SOURCES: ReadonlySet<string> = new Set(
|
||||
coachScopeSourceSchema.options,
|
||||
);
|
||||
|
||||
/**
|
||||
* The domains `get_metric_table` reads: every scope source except the ones
|
||||
* it leaves to their own tools. A chip that asks for another period, a
|
||||
* wider window or a related metric is answered with that tool, so it is
|
||||
* only offered for these.
|
||||
*/
|
||||
export function isTableMetricDomain(
|
||||
domain: CoachStepDomain,
|
||||
): domain is CoachScopeSource {
|
||||
return (
|
||||
SCOPE_SOURCES.has(domain) &&
|
||||
!Object.hasOwn(METRIC_TABLE_EXCLUDED_SOURCES, domain)
|
||||
);
|
||||
}
|
||||
|
||||
/** The next wider window preset, or null from `allTime`. */
|
||||
export function widerWindow(window: CoachScopeWindow): CoachScopeWindow | null {
|
||||
switch (window) {
|
||||
case "last7days":
|
||||
return "last30days";
|
||||
case "last30days":
|
||||
return "last90days";
|
||||
case "last90days":
|
||||
return "lastYear";
|
||||
case "lastYear":
|
||||
return "allTime";
|
||||
case "allTime":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The server-rendered label of a chip, in the request locale. */
|
||||
export function renderFollowUpLabel(
|
||||
kind: CoachFollowUpKind,
|
||||
domain: CoachStepDomain | undefined,
|
||||
locale: Locale,
|
||||
): { labelKey: string; label: string } {
|
||||
const { t } = getServerTranslator(locale);
|
||||
const labelKey = COACH_FOLLOW_UP_KEYS[kind];
|
||||
const label =
|
||||
kind === "related_metric" && domain
|
||||
? t(labelKey, { metric: t(coachDomainLabelKey(domain)) })
|
||||
: t(labelKey);
|
||||
return { labelKey, label };
|
||||
}
|
||||
|
||||
/**
|
||||
* One chip, labelled. The id is provisional: the chips of a reply are
|
||||
* numbered `f1`..`f3` once the final list is known.
|
||||
*/
|
||||
export function buildFollowUp(args: {
|
||||
kind: CoachFollowUpKind;
|
||||
anchor?: CoachFollowUp["anchor"];
|
||||
origin: CoachFollowUp["origin"];
|
||||
locale: Locale;
|
||||
}): CoachFollowUp {
|
||||
const { kind, anchor, origin, locale } = args;
|
||||
return {
|
||||
id: "f0",
|
||||
kind,
|
||||
...renderFollowUpLabel(kind, anchor?.domain, locale),
|
||||
...(anchor ? { anchor } : {}),
|
||||
reuse: REUSE_FOLLOW_UP_KINDS.has(kind),
|
||||
origin,
|
||||
};
|
||||
}
|
||||
|
||||
/** Number a reply's chips `f1`..`f3`, in order. */
|
||||
export function numberFollowUps(chips: CoachFollowUp[]): CoachFollowUp[] {
|
||||
return chips
|
||||
.slice(0, MAX_FOLLOW_UPS)
|
||||
.map((chip, index) => ({ ...chip, id: `f${index + 1}` }));
|
||||
}
|
||||
@@ -4,29 +4,357 @@
|
||||
* a year ago, a wider window, a related metric. At most three, deduplicated,
|
||||
* labels from the catalog only.
|
||||
*
|
||||
* Pure. Not built yet: no chips.
|
||||
* A chip is offered only when what it asks for is grounded in this turn:
|
||||
*
|
||||
* - `as_chart` / `as_table`: a table this turn produced (or copied) that
|
||||
* has a chart; the chip names the view it is not shown in yet.
|
||||
* - `previous_period`: a time series table of the current period, for a
|
||||
* metric the table tool reads and the record holds.
|
||||
* - `year_ago`: the same, over a window of at most 90 days, when the
|
||||
* record's history for the metric reaches back a year.
|
||||
* - `widen_window`: the same, when the record holds readings older than
|
||||
* the table's window.
|
||||
* - `related_metric`: `get_correlations` returned a pair this turn that
|
||||
* links a metric the turn read to another metric the record holds.
|
||||
*
|
||||
* Absence never becomes a chip: every rule needs a done read, and the
|
||||
* history rules need the record's own first reading. `continue` is not
|
||||
* derived here (`continue.ts`).
|
||||
*
|
||||
* The model may propose chips in a `---FOLLOWUPS---` block. A proposal only
|
||||
* reorders: it is honoured when its domain was read this turn and the same
|
||||
* chip is among the ones the rules above allow, which it then leads.
|
||||
*
|
||||
* `deriveFollowUps` and `correlationPartners` are pure. `readFollowUpHistory`
|
||||
* is the one read the rules need, done once per turn before deriving.
|
||||
*/
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import type {
|
||||
CoachFollowUp,
|
||||
CoachFollowUpKind,
|
||||
CoachResultMeta,
|
||||
CoachScopeSource,
|
||||
CoachScopeWindow,
|
||||
CoachStep,
|
||||
CoachStepDomain,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import type { InventoryEntry } from "@/lib/ai/coach/tools/inventory";
|
||||
import {
|
||||
probeCoachAvailability,
|
||||
subjectForTool,
|
||||
type CoachAvailabilitySubject,
|
||||
} from "@/lib/ai/coach/tools/availability";
|
||||
import { COACH_SOURCE_MEASUREMENT_TYPES } from "@/lib/ai/coach/source-measurement-types";
|
||||
import type { SettledToolCall } from "@/lib/ai/coach/results/project";
|
||||
import { presentSources } from "@/lib/ai/coach/clarify";
|
||||
import type { CoachPrefs } from "@/lib/validations/coach-prefs";
|
||||
|
||||
export function deriveFollowUps(_args: {
|
||||
import {
|
||||
MAX_FOLLOW_UPS,
|
||||
buildFollowUp,
|
||||
isTableMetricDomain,
|
||||
numberFollowUps,
|
||||
} from "./catalog";
|
||||
import type { FollowUpProposal } from "./parse-sentinel";
|
||||
|
||||
/** A discovered pair `get_correlations` returned this turn, as scope sources. */
|
||||
export interface CorrelationPair {
|
||||
a: CoachScopeSource;
|
||||
b: CoachScopeSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* The record's history for the metrics a turn's tables read: the first
|
||||
* reading per metric and today, both as day keys in the person's timezone.
|
||||
*/
|
||||
export interface FollowUpHistory {
|
||||
today: string;
|
||||
firstDate: Partial<Record<CoachScopeSource, string>>;
|
||||
}
|
||||
|
||||
/** The windows a year-ago comparison is offered for. */
|
||||
const SHORT_WINDOW_DAYS: Partial<Record<CoachScopeWindow, number>> = {
|
||||
last7days: 7,
|
||||
last30days: 30,
|
||||
last90days: 90,
|
||||
};
|
||||
|
||||
const WINDOW_DAYS: Partial<Record<CoachScopeWindow, number>> = {
|
||||
...SHORT_WINDOW_DAYS,
|
||||
lastYear: 365,
|
||||
};
|
||||
|
||||
/** Whether the pref lets chips be offered. Absent means on. */
|
||||
export function followUpChipsEnabled(prefs: CoachPrefs): boolean {
|
||||
return prefs.followUpChips !== false;
|
||||
}
|
||||
|
||||
/** `YYYY-MM-DD` minus `days`, as a day key. */
|
||||
function dayKeyMinus(dayKey: string, days: number): string {
|
||||
const at = new Date(`${dayKey}T00:00:00Z`);
|
||||
at.setUTCDate(at.getUTCDate() - days);
|
||||
return at.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** True when the record's first reading lies before `dayKey`. */
|
||||
function historyBefore(
|
||||
history: FollowUpHistory | undefined,
|
||||
domain: CoachScopeSource,
|
||||
daysBack: number,
|
||||
): boolean {
|
||||
const first = history?.firstDate[domain];
|
||||
if (!history || !first) return false;
|
||||
return first < dayKeyMinus(history.today, daysBack);
|
||||
}
|
||||
|
||||
/** The readings a table counted, from the step that produced it. */
|
||||
function readingsFor(
|
||||
meta: CoachResultMeta,
|
||||
steps: readonly CoachStep[],
|
||||
): number {
|
||||
const step = steps.find((s) => s.resultRef === meta.ref);
|
||||
return step?.count ?? meta.rowCount;
|
||||
}
|
||||
|
||||
type Candidate = CoachFollowUp;
|
||||
|
||||
export function deriveFollowUps(args: {
|
||||
results: CoachResultMeta[];
|
||||
steps: CoachStep[];
|
||||
/** What the record holds; null on the no-tools path. */
|
||||
/** What the record holds; null on the no-tools path and a reuse turn. */
|
||||
inventory: InventoryEntry[] | null;
|
||||
/** Kind and domain pairs the model proposed, already catalog-checked. */
|
||||
proposals: Array<{ kind: CoachFollowUpKind; domain: CoachStepDomain }>;
|
||||
proposals: FollowUpProposal[];
|
||||
forcedFinal: boolean;
|
||||
prefs: CoachPrefs;
|
||||
locale: Locale;
|
||||
/** The record's history for the tables' metrics; absent → no history chips. */
|
||||
history?: FollowUpHistory;
|
||||
/** Pairs `get_correlations` returned this turn. */
|
||||
correlations?: readonly CorrelationPair[];
|
||||
}): CoachFollowUp[] {
|
||||
return [];
|
||||
const { results, steps, inventory, locale } = args;
|
||||
if (!followUpChipsEnabled(args.prefs)) return [];
|
||||
|
||||
const done = new Set<CoachStepDomain>();
|
||||
for (const step of steps) {
|
||||
if (step.status === "done" && step.domain) done.add(step.domain);
|
||||
}
|
||||
// A table this turn holds is a done read of its domain, whether it was
|
||||
// fetched or copied from an earlier answer.
|
||||
const grounded = new Set<CoachStepDomain>(done);
|
||||
for (const meta of results) grounded.add(meta.source.domain);
|
||||
const present = inventory ? presentSources(inventory) : null;
|
||||
|
||||
const candidates: Candidate[] = [];
|
||||
// Tables the answer showed come first: the chips follow what the person
|
||||
// is looking at.
|
||||
const ordered = [
|
||||
...results.filter((meta) => meta.displayed),
|
||||
...results.filter((meta) => !meta.displayed),
|
||||
];
|
||||
|
||||
for (const meta of ordered) {
|
||||
const { source } = meta;
|
||||
if (!grounded.has(source.domain)) continue;
|
||||
const anchor = {
|
||||
ref: meta.ref,
|
||||
domain: source.domain,
|
||||
window: source.window,
|
||||
...(source.granularity ? { granularity: source.granularity } : {}),
|
||||
period: source.period,
|
||||
};
|
||||
if (meta.chartKind !== null) {
|
||||
candidates.push(
|
||||
buildFollowUp({
|
||||
kind: meta.displayed ? "as_table" : "as_chart",
|
||||
anchor,
|
||||
origin: "server",
|
||||
locale,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const meta of ordered) {
|
||||
const { source } = meta;
|
||||
const domain = source.domain;
|
||||
if (meta.shape !== "timeSeries" || source.period !== "current") continue;
|
||||
if (!grounded.has(domain) || !isTableMetricDomain(domain)) continue;
|
||||
if (!present?.has(domain)) continue;
|
||||
const anchor = {
|
||||
ref: meta.ref,
|
||||
domain,
|
||||
window: source.window,
|
||||
...(source.granularity ? { granularity: source.granularity } : {}),
|
||||
period: source.period,
|
||||
};
|
||||
candidates.push(
|
||||
buildFollowUp({
|
||||
kind: "previous_period",
|
||||
anchor,
|
||||
origin: "server",
|
||||
locale,
|
||||
}),
|
||||
);
|
||||
const shortDays = SHORT_WINDOW_DAYS[source.window];
|
||||
if (shortDays !== undefined && historyBefore(args.history, domain, 365)) {
|
||||
candidates.push(
|
||||
buildFollowUp({ kind: "year_ago", anchor, origin: "server", locale }),
|
||||
);
|
||||
}
|
||||
const windowDays = WINDOW_DAYS[source.window];
|
||||
if (
|
||||
windowDays !== undefined &&
|
||||
readingsFor(meta, steps) > 0 &&
|
||||
historyBefore(args.history, domain, windowDays)
|
||||
) {
|
||||
candidates.push(
|
||||
buildFollowUp({
|
||||
kind: "widen_window",
|
||||
anchor,
|
||||
origin: "server",
|
||||
locale,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const pair of args.correlations ?? []) {
|
||||
for (const [read, partner] of [
|
||||
[pair.a, pair.b],
|
||||
[pair.b, pair.a],
|
||||
] as const) {
|
||||
if (!done.has(read) || grounded.has(partner)) continue;
|
||||
if (!isTableMetricDomain(partner) || !present?.has(partner)) continue;
|
||||
const readTable = ordered.find((meta) => meta.source.domain === read);
|
||||
candidates.push(
|
||||
buildFollowUp({
|
||||
kind: "related_metric",
|
||||
anchor: {
|
||||
ref: readTable?.ref ?? "",
|
||||
domain: partner,
|
||||
...(readTable ? { window: readTable.source.window } : {}),
|
||||
},
|
||||
origin: "server",
|
||||
locale,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate by kind and domain, first wins.
|
||||
const key = (chip: Candidate) => `${chip.kind}:${chip.anchor?.domain ?? ""}`;
|
||||
const unique = new Map<string, Candidate>();
|
||||
for (const chip of candidates) {
|
||||
if (!unique.has(key(chip))) unique.set(key(chip), chip);
|
||||
}
|
||||
|
||||
// A proposal leads when its domain was read this turn and the rules allow
|
||||
// the same chip; otherwise it is ignored.
|
||||
const led: Candidate[] = [];
|
||||
for (const proposal of args.proposals) {
|
||||
if (!done.has(proposal.domain)) continue;
|
||||
const match = unique.get(`${proposal.kind}:${proposal.domain}`);
|
||||
if (!match || led.includes(match)) continue;
|
||||
led.push({ ...match, origin: "model" });
|
||||
}
|
||||
const ledKeys = new Set(led.map(key));
|
||||
const rest = [...unique.values()].filter((chip) => !ledKeys.has(key(chip)));
|
||||
return numberFollowUps([...led, ...rest].slice(0, MAX_FOLLOW_UPS));
|
||||
}
|
||||
|
||||
// ── Correlation pairs ─────────────────────────────────────────────────────
|
||||
|
||||
/** Measurement type → the scope source that reads it. */
|
||||
const SOURCE_BY_TYPE: ReadonlyMap<string, CoachScopeSource> = new Map(
|
||||
(
|
||||
Object.entries(COACH_SOURCE_MEASUREMENT_TYPES) as Array<
|
||||
[CoachScopeSource, readonly string[]]
|
||||
>
|
||||
).flatMap(([source, types]) => types.map((type) => [type, source] as const)),
|
||||
);
|
||||
|
||||
/**
|
||||
* A discovery channel label ("sleep duration", "blood pressure sys") back to
|
||||
* the scope source that reads it, or null when it is not a measurement
|
||||
* channel. The labels are the channel keys lower-cased and spaced, so the
|
||||
* reverse is exact; anything else (an environment field, a custom metric,
|
||||
* adherence) names no metric a chip could fetch.
|
||||
*/
|
||||
function channelSource(label: unknown): CoachScopeSource | null {
|
||||
if (typeof label !== "string") return null;
|
||||
const key = label.trim().toUpperCase().replace(/\s+/g, "_");
|
||||
if (key === "MOOD") return "mood";
|
||||
return SOURCE_BY_TYPE.get(key) ?? null;
|
||||
}
|
||||
|
||||
/** The metric pairs a present `get_correlations` result named this turn. */
|
||||
export function correlationPartners(
|
||||
calls: readonly SettledToolCall[],
|
||||
): CorrelationPair[] {
|
||||
const pairs: CorrelationPair[] = [];
|
||||
for (const call of calls) {
|
||||
if (call.name !== "get_correlations" || !call.result.present) continue;
|
||||
const data = call.result.data as { drivers?: unknown } | undefined;
|
||||
if (!Array.isArray(data?.drivers)) continue;
|
||||
for (const driver of data.drivers) {
|
||||
if (!driver || typeof driver !== "object") continue;
|
||||
const d = driver as { behaviour?: unknown; outcome?: unknown };
|
||||
const a = channelSource(d.behaviour);
|
||||
const b = channelSource(d.outcome);
|
||||
if (a && b && a !== b) pairs.push({ a, b });
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
// ── History ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The record's first reading for each metric a time series table of this
|
||||
* turn read, and today, in the person's timezone. One batched probe, only
|
||||
* when a table could carry a history chip; a failed probe offers no history
|
||||
* chip rather than guessing one.
|
||||
*/
|
||||
export async function readFollowUpHistory(args: {
|
||||
userId: string;
|
||||
results: readonly CoachResultMeta[];
|
||||
prefs: CoachPrefs;
|
||||
now?: Date;
|
||||
}): Promise<FollowUpHistory | undefined> {
|
||||
if (!followUpChipsEnabled(args.prefs)) return undefined;
|
||||
const subjects = new Map<string, CoachAvailabilitySubject>();
|
||||
for (const meta of args.results) {
|
||||
const domain = meta.source.domain;
|
||||
if (meta.shape !== "timeSeries" || meta.source.period !== "current") {
|
||||
continue;
|
||||
}
|
||||
if (meta.source.window === "allTime" || !isTableMetricDomain(domain)) {
|
||||
continue;
|
||||
}
|
||||
const subject = subjectForTool("get_metric_table", domain);
|
||||
if (subject) subjects.set(domain, subject);
|
||||
}
|
||||
if (subjects.size === 0) return undefined;
|
||||
try {
|
||||
const now = args.now ?? new Date();
|
||||
const [probed, tz] = await Promise.all([
|
||||
probeCoachAvailability(args.userId, subjects, { now }),
|
||||
resolveUserTimezone(args.userId),
|
||||
]);
|
||||
const firstDate: FollowUpHistory["firstDate"] = {};
|
||||
for (const [domain, available] of probed) {
|
||||
firstDate[domain as CoachScopeSource] = available.firstDate;
|
||||
}
|
||||
return { today: userDayKey(now, tz), firstDate };
|
||||
} catch (err) {
|
||||
annotate({
|
||||
action: { name: "coach.followUp.history_failed" },
|
||||
meta: { reason: err instanceof Error ? err.name : "unknown" },
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,96 @@
|
||||
/**
|
||||
* The optional `---FOLLOWUPS---` block in the reply: the model may propose
|
||||
* a chip's kind and domain from the catalog, nothing else. The block is
|
||||
* stripped from the prose; a malformed block is dropped silently.
|
||||
* a chip's kind and domain from the catalog, nothing else.
|
||||
*
|
||||
* Not built yet: the prose passes through unchanged, with no proposals.
|
||||
* ---FOLLOWUPS---
|
||||
* previous_period: bp
|
||||
* as_chart: weight
|
||||
* ---END---
|
||||
*
|
||||
* The block is always stripped from the prose. What survives is a list of
|
||||
* `{ kind, domain }` pairs, each checked against the closed catalogs; any
|
||||
* line that does not parse is skipped, and a block that is malformed as a
|
||||
* whole (no closing marker, over the byte cap) is dropped silently. Whether
|
||||
* a proposal becomes a chip is decided later, against what the turn read
|
||||
* (`derive.ts`); the model never writes a label.
|
||||
*/
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import type { CoachFollowUpKind, CoachStepDomain } from "@/lib/ai/coach/types";
|
||||
import { coachStepDomainSchema } from "@/lib/ai/coach/stream-events";
|
||||
|
||||
import { PROPOSABLE_FOLLOW_UP_KINDS, isFollowUpKind } from "./catalog";
|
||||
|
||||
const OPEN_SENTINEL = "---FOLLOWUPS---";
|
||||
const CLOSE_SENTINEL = "---END---";
|
||||
/** Block body cap in bytes, after the opening marker. */
|
||||
export const FOLLOW_UPS_BYTE_CAP = 256;
|
||||
/** At most this many proposals are read. */
|
||||
const MAX_PROPOSALS = 3;
|
||||
|
||||
export interface FollowUpProposal {
|
||||
kind: CoachFollowUpKind;
|
||||
domain: CoachStepDomain;
|
||||
}
|
||||
|
||||
function normalise(raw: string): string {
|
||||
return raw
|
||||
.trim()
|
||||
.replace(/^[-*•\s]+/, "")
|
||||
.replace(/^["'`]+|["'`]+$/g, "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function parseLine(line: string): FollowUpProposal | null {
|
||||
const colon = line.indexOf(":");
|
||||
if (colon < 1) return null;
|
||||
const kind = normalise(line.slice(0, colon));
|
||||
const domain = normalise(line.slice(colon + 1));
|
||||
if (!isFollowUpKind(kind) || !PROPOSABLE_FOLLOW_UP_KINDS.has(kind)) {
|
||||
return null;
|
||||
}
|
||||
const parsedDomain = coachStepDomainSchema.safeParse(domain);
|
||||
if (!parsedDomain.success) return null;
|
||||
return { kind, domain: parsedDomain.data };
|
||||
}
|
||||
|
||||
export function parseFollowUpsSentinel(prose: string): {
|
||||
prose: string;
|
||||
proposals: Array<{ kind: CoachFollowUpKind; domain: CoachStepDomain }>;
|
||||
proposals: FollowUpProposal[];
|
||||
} {
|
||||
return { prose, proposals: [] };
|
||||
const open = prose.indexOf(OPEN_SENTINEL);
|
||||
if (open === -1) return { prose, proposals: [] };
|
||||
const before = prose.slice(0, open);
|
||||
const afterOpen = prose.slice(open + OPEN_SENTINEL.length);
|
||||
const close = afterOpen.indexOf(CLOSE_SENTINEL);
|
||||
const dropped = (reason: string, stripped: string) => {
|
||||
annotate({
|
||||
action: { name: "coach.followUp.proposal_dropped" },
|
||||
meta: { reason },
|
||||
});
|
||||
return { prose: stripped, proposals: [] };
|
||||
};
|
||||
if (close === -1) {
|
||||
// No closing marker: the rest of the reply is the block. The raw marker
|
||||
// must never reach the person, so the whole tail goes.
|
||||
return dropped("no_end_marker", before.trimEnd());
|
||||
}
|
||||
const body = afterOpen.slice(0, close);
|
||||
const after = afterOpen.slice(close + CLOSE_SENTINEL.length).trim();
|
||||
const stripped = `${before.trimEnd()}${after ? `\n\n${after}` : ""}`;
|
||||
if (Buffer.byteLength(body, "utf8") > FOLLOW_UPS_BYTE_CAP) {
|
||||
return dropped("byte_overflow", stripped);
|
||||
}
|
||||
const proposals: FollowUpProposal[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const line of body.split(/\r?\n/)) {
|
||||
const proposal = parseLine(line);
|
||||
if (!proposal) continue;
|
||||
const key = `${proposal.kind}:${proposal.domain}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
proposals.push(proposal);
|
||||
if (proposals.length === MAX_PROPOSALS) break;
|
||||
}
|
||||
return { prose: stripped, proposals };
|
||||
}
|
||||
@@ -5,22 +5,198 @@
|
||||
* stored table without a model call; any other becomes a turn-context hint.
|
||||
* A chip that is no longer current degrades to a plain message.
|
||||
*
|
||||
* Not built yet: every request is a plain message.
|
||||
* The request names only which chip was tapped. What the chip asks for (its
|
||||
* kind, domain, window, period, table) is read from the stored message, so
|
||||
* nothing the client sends can widen it.
|
||||
*/
|
||||
import type { CoachFollowUp } from "@/lib/ai/coach/types";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import type { CoachFollowUp, CoachStep } from "@/lib/ai/coach/types";
|
||||
import {
|
||||
coachFollowUpSchema,
|
||||
coachStepSchema,
|
||||
} from "@/lib/ai/coach/stream-events";
|
||||
import {
|
||||
formatPriorResultRef,
|
||||
type PriorResultTurn,
|
||||
} from "@/lib/ai/coach/results/refs";
|
||||
|
||||
import { widerWindow } from "./catalog";
|
||||
|
||||
/** A chip that resolved: what it asks for, and how it is answered. */
|
||||
export interface ResolvedFollowUp {
|
||||
sourceMessageId: string;
|
||||
followUp: CoachFollowUp;
|
||||
/** A line for the turn context, on a chip answered by the model. */
|
||||
/**
|
||||
* A line for the turn context, on a chip answered by the model. A reuse
|
||||
* chip carries one too: it is the fallback when the stored table can no
|
||||
* longer be served and the turn goes to the model after all.
|
||||
*/
|
||||
contextHint: string | null;
|
||||
/**
|
||||
* The readings the source reply counted for the chip's table, from its
|
||||
* stored step; absent when the step carried no count.
|
||||
*/
|
||||
sourceCount?: number;
|
||||
}
|
||||
|
||||
export async function resolveFollowUp(_args: {
|
||||
/** The newest few messages of an owned conversation, newest first. */
|
||||
async function latestMessages(userId: string, conversationId: string) {
|
||||
return prisma.coachMessage.findMany({
|
||||
where: { conversationId, conversation: { userId } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 4,
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
providerType: true,
|
||||
metricSourceJson: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** The chips and steps a stored reply carries, each parsed against its schema. */
|
||||
function storedDialog(metricSourceJson: string | null): {
|
||||
followUps: CoachFollowUp[];
|
||||
steps: CoachStep[];
|
||||
} {
|
||||
const none = { followUps: [], steps: [] };
|
||||
if (!metricSourceJson) return none;
|
||||
try {
|
||||
const raw = JSON.parse(metricSourceJson) as {
|
||||
followUps?: unknown;
|
||||
steps?: unknown;
|
||||
};
|
||||
const each = <T>(value: unknown, schema: z.ZodType<T>): T[] =>
|
||||
Array.isArray(value)
|
||||
? value.flatMap((item) => {
|
||||
const parsed = schema.safeParse(item);
|
||||
return parsed.success ? [parsed.data] : [];
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
followUps: each(raw.followUps, coachFollowUpSchema),
|
||||
steps: each(raw.steps, coachStepSchema),
|
||||
};
|
||||
} catch {
|
||||
return none;
|
||||
}
|
||||
}
|
||||
|
||||
/** The `m<k>.r<n>` name the turn's context gives a stored table, if any. */
|
||||
function priorName(
|
||||
sourceMessageId: string,
|
||||
ref: string | undefined,
|
||||
priorResults: readonly PriorResultTurn[],
|
||||
): string | null {
|
||||
if (!ref) return null;
|
||||
const turn = priorResults.find((p) => p.messageId === sourceMessageId);
|
||||
if (!turn || !turn.results.some((meta) => meta.ref === ref)) return null;
|
||||
return formatPriorResultRef(turn.turnIndex, ref);
|
||||
}
|
||||
|
||||
/**
|
||||
* The turn-context line for a chip answered by the model. Server-written
|
||||
* from the stored chip's enum fields only; null when the chip carries
|
||||
* nothing to act on.
|
||||
*/
|
||||
export function followUpContextHint(
|
||||
followUp: CoachFollowUp,
|
||||
tableName: string | null,
|
||||
): string | null {
|
||||
const anchor = followUp.anchor;
|
||||
const from = tableName
|
||||
? ` The table from your last answer is ${tableName}: use show_result for it, do not fetch it again.`
|
||||
: "";
|
||||
const lead = `FOLLOW-UP: the person tapped the ${followUp.kind} chip under your last answer.`;
|
||||
switch (followUp.kind) {
|
||||
case "as_chart":
|
||||
case "as_table": {
|
||||
if (!tableName) return null;
|
||||
const view = followUp.kind === "as_chart" ? "chart" : "table";
|
||||
return `${lead} Call show_result with ref ${tableName} and view ${view}; do not fetch it again.`;
|
||||
}
|
||||
case "previous_period":
|
||||
case "year_ago": {
|
||||
if (!anchor?.window) return null;
|
||||
const period = followUp.kind === "year_ago" ? "yearAgo" : "previous";
|
||||
const granularity = anchor.granularity
|
||||
? ` granularity=${anchor.granularity}`
|
||||
: "";
|
||||
return `${lead} Fetch get_metric_table metric=${anchor.domain} window=${anchor.window} period=${period}${granularity} and compare it with the current period.${from}`;
|
||||
}
|
||||
case "widen_window": {
|
||||
const wider = anchor?.window ? widerWindow(anchor.window) : null;
|
||||
if (!anchor || !wider) return null;
|
||||
return `${lead} Fetch get_metric_table metric=${anchor.domain} window=${wider} and say what the longer view adds.${from}`;
|
||||
}
|
||||
case "related_metric": {
|
||||
if (!anchor) return null;
|
||||
const window = anchor.window ? ` window=${anchor.window}` : "";
|
||||
return `${lead} Fetch get_metric_table metric=${anchor.domain}${window} and describe it alongside what you already read. A pattern between two metrics is an association, never a cause.${from}`;
|
||||
}
|
||||
case "continue":
|
||||
// Answered with its own context lines (`continue.ts`).
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveFollowUp(args: {
|
||||
userId: string;
|
||||
conversationId: string | undefined;
|
||||
followUp: { messageId: string; id: string } | undefined;
|
||||
/** The tables earlier replies hold, named as the turn's context names them. */
|
||||
priorResults?: readonly PriorResultTurn[];
|
||||
}): Promise<ResolvedFollowUp | null> {
|
||||
return null;
|
||||
const { userId, conversationId, followUp } = args;
|
||||
if (!followUp || !conversationId) return null;
|
||||
const stale = (reason: string) => {
|
||||
annotate({
|
||||
action: { name: "coach.followUp.stale" },
|
||||
meta: { reason },
|
||||
});
|
||||
return null;
|
||||
};
|
||||
let rows: Awaited<ReturnType<typeof latestMessages>>;
|
||||
try {
|
||||
rows = await latestMessages(userId, conversationId);
|
||||
} catch {
|
||||
return stale("unreadable");
|
||||
}
|
||||
// The chip must sit on the latest reply: an interrupted turn's empty
|
||||
// marker does not count as one.
|
||||
const latest = rows.find((m) => m.providerType !== "cancelled");
|
||||
if (
|
||||
!latest ||
|
||||
latest.role !== "assistant" ||
|
||||
latest.id !== followUp.messageId
|
||||
) {
|
||||
return stale("not_latest");
|
||||
}
|
||||
const stored = storedDialog(latest.metricSourceJson);
|
||||
const chip = stored.followUps.find(
|
||||
(candidate) => candidate.id === followUp.id,
|
||||
);
|
||||
if (!chip) return stale("unknown_chip");
|
||||
const sourceCount = chip.anchor?.ref
|
||||
? stored.steps.find((step) => step.resultRef === chip.anchor?.ref)?.count
|
||||
: undefined;
|
||||
|
||||
const tableName = priorName(
|
||||
latest.id,
|
||||
chip.anchor?.ref,
|
||||
args.priorResults ?? [],
|
||||
);
|
||||
annotate({
|
||||
action: { name: "coach.followUp.resolved" },
|
||||
meta: { kind: chip.kind, reuse: chip.reuse, origin: chip.origin },
|
||||
});
|
||||
return {
|
||||
sourceMessageId: latest.id,
|
||||
followUp: chip,
|
||||
contextHint: followUpContextHint(chip, tableName),
|
||||
...(sourceCount !== undefined ? { sourceCount } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* v1.39.4 — a tapped follow-up chip through the turn pipeline.
|
||||
*
|
||||
* - A reuse chip ("as a chart", "as a table") is answered from the stored
|
||||
* table: no budget reservation, no model, one assistant message tagged
|
||||
* `reuse`, and a `result` frame whose rows are the stored rows.
|
||||
* - A chip that is no longer current degrades to a plain message.
|
||||
* - Any other chip runs the model with a server-written context line.
|
||||
* - With the pref off the model turn carries no `followUps` frame.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const m = vi.hoisted(() => ({
|
||||
findMany: vi.fn(),
|
||||
userFindUnique: vi.fn(),
|
||||
annotate: vi.fn(),
|
||||
readMessageResults: vi.fn(),
|
||||
appendMessage: vi.fn(),
|
||||
resolveTurnConversation: vi.fn(),
|
||||
persistUserTurn: vi.fn(),
|
||||
assembleTurnContext: vi.fn(),
|
||||
resolveTurnChain: vi.fn(),
|
||||
reserveTurnBudget: vi.fn(),
|
||||
settleReservation: vi.fn(),
|
||||
runTurnModel: vi.fn(),
|
||||
guardReply: vi.fn(),
|
||||
surfaceCards: vi.fn(),
|
||||
persistAssistantReply: vi.fn(),
|
||||
resolveModuleMap: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
coachMessage: { findMany: m.findMany },
|
||||
user: { findUnique: m.userFindUnique },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: m.annotate }));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
resolveModuleMap: m.resolveModuleMap,
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/persistence", () => ({
|
||||
appendMessage: m.appendMessage,
|
||||
createConversation: vi.fn(),
|
||||
readMessageResults: m.readMessageResults,
|
||||
}));
|
||||
vi.mock("../conversation", () => ({
|
||||
resolveTurnConversation: m.resolveTurnConversation,
|
||||
persistUserTurn: m.persistUserTurn,
|
||||
}));
|
||||
vi.mock("../context", () => ({ assembleTurnContext: m.assembleTurnContext }));
|
||||
vi.mock("../chain", () => ({ resolveTurnChain: m.resolveTurnChain }));
|
||||
vi.mock("../budget", () => ({
|
||||
reserveTurnBudget: m.reserveTurnBudget,
|
||||
settleReservation: m.settleReservation,
|
||||
}));
|
||||
vi.mock("../model", () => ({ runTurnModel: m.runTurnModel }));
|
||||
vi.mock("../reply-guards", () => ({ guardReply: m.guardReply }));
|
||||
vi.mock("../cards", () => ({ surfaceCards: m.surfaceCards }));
|
||||
vi.mock("../persist", () => ({
|
||||
persistAssistantReply: m.persistAssistantReply,
|
||||
}));
|
||||
|
||||
import type {
|
||||
CoachFollowUp,
|
||||
CoachResultTable,
|
||||
CoachStreamEvent,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import { DEFAULT_COACH_PREFS } from "@/lib/validations/coach-prefs";
|
||||
|
||||
import { runCoachTurn } from "../pipeline";
|
||||
import type { TurnInput } from "../types";
|
||||
|
||||
const STORED: CoachResultTable = {
|
||||
ref: "r1",
|
||||
source: {
|
||||
tool: "get_metric_table",
|
||||
domain: "bp",
|
||||
window: "last30days",
|
||||
period: "current",
|
||||
granularity: "week",
|
||||
},
|
||||
shape: "timeSeries",
|
||||
titleKey: "coach.result.title.byWeek",
|
||||
title: "Blood pressure by week",
|
||||
rowCount: 3,
|
||||
chartKind: "line",
|
||||
displayed: false,
|
||||
columns: [
|
||||
{ key: "week", kind: "period", labelKey: "k", label: "Week" },
|
||||
{ key: "sys", kind: "number", labelKey: "k", label: "Systolic" },
|
||||
],
|
||||
rows: [
|
||||
["2026-W36", 128],
|
||||
["2026-W37", null],
|
||||
["2026-W38", 131],
|
||||
],
|
||||
truncated: false,
|
||||
chart: { kind: "line", x: "week", series: ["sys"] },
|
||||
};
|
||||
|
||||
const AS_CHART: CoachFollowUp = {
|
||||
id: "f1",
|
||||
kind: "as_chart",
|
||||
labelKey: "coach.followUp.asChart",
|
||||
label: "Show as a chart",
|
||||
anchor: {
|
||||
ref: "r1",
|
||||
domain: "bp",
|
||||
window: "last30days",
|
||||
granularity: "week",
|
||||
period: "current",
|
||||
},
|
||||
reuse: true,
|
||||
origin: "server",
|
||||
};
|
||||
const PREVIOUS: CoachFollowUp = {
|
||||
...AS_CHART,
|
||||
id: "f2",
|
||||
kind: "previous_period",
|
||||
labelKey: "coach.followUp.previousPeriod",
|
||||
label: "Compare with the period before",
|
||||
reuse: false,
|
||||
};
|
||||
|
||||
function latestReply(followUps: CoachFollowUp[]) {
|
||||
return [
|
||||
{
|
||||
id: "m-last",
|
||||
role: "assistant",
|
||||
providerType: "openai",
|
||||
metricSourceJson: JSON.stringify({
|
||||
windows: [],
|
||||
metrics: [],
|
||||
followUps,
|
||||
results: [{ ...STORED, rows: undefined, columns: undefined }],
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function input(over: Partial<TurnInput> = {}): TurnInput {
|
||||
return {
|
||||
userId: "u1",
|
||||
locale: "en",
|
||||
signal: new AbortController().signal,
|
||||
conversationId: "c1",
|
||||
message: "Show as a chart",
|
||||
scope: undefined,
|
||||
guidedQuestion: undefined,
|
||||
workoutId: undefined,
|
||||
followUp: { messageId: "m-last", id: "f1" },
|
||||
clarification: undefined,
|
||||
recheckCapability: async () => null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
async function frames(res: Response): Promise<CoachStreamEvent[]> {
|
||||
const text = await res.text();
|
||||
return text
|
||||
.split("\n\n")
|
||||
.filter((chunk) => chunk.startsWith("data: "))
|
||||
.map((chunk) => JSON.parse(chunk.slice(6)) as CoachStreamEvent);
|
||||
}
|
||||
|
||||
function annotated(name: string) {
|
||||
return m.annotate.mock.calls.filter(
|
||||
([arg]) => (arg as { action: { name: string } }).action.name === name,
|
||||
);
|
||||
}
|
||||
|
||||
/** The model path, enough of it to reach the frames. */
|
||||
function modelPath(prefs = DEFAULT_COACH_PREFS) {
|
||||
m.assembleTurnContext.mockResolvedValue({
|
||||
coachPrefs: prefs,
|
||||
snapshot: { provenance: { windows: [], metrics: [] } },
|
||||
});
|
||||
m.resolveTurnChain.mockResolvedValue({ ok: true, chain: [], toolMode: true });
|
||||
m.reserveTurnBudget.mockResolvedValue({ ok: true, reservation: {} });
|
||||
const table = { ...STORED, chart: null, chartKind: null, displayed: true };
|
||||
m.runTurnModel.mockResolvedValue({
|
||||
ok: true,
|
||||
result: { content: "x", model: "gpt" },
|
||||
workingProviderType: "openai",
|
||||
toolTrace: [],
|
||||
totalTokens: 12,
|
||||
cachedTokens: 0,
|
||||
steps: [
|
||||
{
|
||||
id: "s1",
|
||||
tool: "get_metric_table",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "x",
|
||||
domain: "bp",
|
||||
window: "last30days",
|
||||
status: "done",
|
||||
count: 20,
|
||||
resultRef: "r1",
|
||||
},
|
||||
],
|
||||
results: [table],
|
||||
forcedFinal: false,
|
||||
inventory: [
|
||||
{ tool: "get_metric_series", metric: "bp", domain: "bp", present: true },
|
||||
],
|
||||
correlations: [],
|
||||
});
|
||||
m.guardReply.mockResolvedValue({
|
||||
ok: true,
|
||||
reply: {
|
||||
replyText: "Steady.",
|
||||
outboundBlocked: false,
|
||||
referencedResults: ["r1"],
|
||||
followUpProposals: [],
|
||||
clarification: null,
|
||||
keyValuesSentinel: {
|
||||
keyValues: [],
|
||||
malformed: false,
|
||||
malformedEntries: [],
|
||||
},
|
||||
groundedFigures: [],
|
||||
unverifiedStripped: 0,
|
||||
},
|
||||
});
|
||||
m.surfaceCards.mockResolvedValue({ suggestion: null, action: null });
|
||||
m.persistAssistantReply.mockResolvedValue({ messageId: "m-new" });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(m)) fn.mockReset();
|
||||
m.resolveTurnConversation.mockResolvedValue({
|
||||
conversation: {
|
||||
conversationId: "c1",
|
||||
priorTurns: [],
|
||||
priorUserMessages: [],
|
||||
priorToolFigures: [],
|
||||
priorSummary: null,
|
||||
priorResults: [{ messageId: "m-last", turnIndex: 2, results: [STORED] }],
|
||||
},
|
||||
});
|
||||
m.resolveModuleMap.mockResolvedValue({});
|
||||
m.userFindUnique.mockResolvedValue({ coachPrefsJson: null });
|
||||
m.appendMessage.mockImplementation(async (params: { role: string }) => ({
|
||||
id: params.role === "assistant" ? "m-reuse" : "m-user",
|
||||
}));
|
||||
});
|
||||
|
||||
describe("a reuse chip", () => {
|
||||
it("answers from the stored table with no budget and no model", async () => {
|
||||
m.findMany.mockResolvedValue(latestReply([AS_CHART]));
|
||||
m.readMessageResults.mockResolvedValue([STORED]);
|
||||
|
||||
const res = await runCoachTurn(input());
|
||||
const out = await frames(res);
|
||||
|
||||
expect(m.reserveTurnBudget).not.toHaveBeenCalled();
|
||||
expect(m.runTurnModel).not.toHaveBeenCalled();
|
||||
expect(m.resolveTurnChain).not.toHaveBeenCalled();
|
||||
expect(m.readMessageResults).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
"c1",
|
||||
"m-last",
|
||||
expect.any(Function),
|
||||
);
|
||||
// The person's message, then exactly one assistant message.
|
||||
expect(m.persistUserTurn).toHaveBeenCalledWith("c1", "Show as a chart");
|
||||
expect(m.appendMessage).toHaveBeenCalledTimes(1);
|
||||
const persisted = m.appendMessage.mock.calls[0][0];
|
||||
expect(persisted).toMatchObject({
|
||||
role: "assistant",
|
||||
providerType: "reuse",
|
||||
content: "Here are the same figures from my last answer, unchanged.",
|
||||
});
|
||||
expect(persisted.results[0].rows).toEqual(STORED.rows);
|
||||
expect(persisted.tokensUsed).toBeUndefined();
|
||||
|
||||
// Consecutive token frames collapsed: the caption streams word by word.
|
||||
const order = out
|
||||
.map((f) => f.type)
|
||||
.filter((type, i, all) => type !== "token" || all[i - 1] !== "token");
|
||||
expect(order).toEqual([
|
||||
"step",
|
||||
"token",
|
||||
"provenance",
|
||||
"result",
|
||||
"followUps",
|
||||
"done",
|
||||
]);
|
||||
expect(
|
||||
out.flatMap((f) => (f.type === "token" ? [f.token] : [])).join(""),
|
||||
).toBe("Here are the same figures from my last answer, unchanged.");
|
||||
const result = out.find((f) => f.type === "result");
|
||||
expect(result?.type === "result" && result.result).toMatchObject({
|
||||
ref: "r1",
|
||||
rows: STORED.rows,
|
||||
columns: STORED.columns,
|
||||
displayed: true,
|
||||
chart: STORED.chart,
|
||||
reusedFrom: { messageId: "m-last", ref: "r1" },
|
||||
});
|
||||
const followUps = out.find((f) => f.type === "followUps");
|
||||
// Shown as a chart now, so the other view is what is offered.
|
||||
expect(
|
||||
followUps?.type === "followUps" && followUps.followUps.map((c) => c.kind),
|
||||
).toEqual(["as_table"]);
|
||||
const done = out.at(-1);
|
||||
expect(done).toMatchObject({
|
||||
type: "done",
|
||||
conversationId: "c1",
|
||||
messageId: "m-reuse",
|
||||
});
|
||||
// The provenance carries the table's metadata, never its values.
|
||||
const provenance = out.find((f) => f.type === "provenance");
|
||||
expect(JSON.stringify(provenance)).not.toContain("131");
|
||||
expect(annotated("coach.followUp.reused")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("drops the chart on 'as a table'", async () => {
|
||||
const asTable = { ...AS_CHART, kind: "as_table" as const };
|
||||
m.findMany.mockResolvedValue(latestReply([asTable]));
|
||||
m.readMessageResults.mockResolvedValue([STORED]);
|
||||
const out = await frames(await runCoachTurn(input()));
|
||||
const result = out.find((f) => f.type === "result");
|
||||
expect(result?.type === "result" && result.result.chart).toBeNull();
|
||||
expect(result?.type === "result" && result.result.rows).toEqual(
|
||||
STORED.rows,
|
||||
);
|
||||
});
|
||||
|
||||
it("goes to the model with the chip's hint when the table is withheld", async () => {
|
||||
m.findMany.mockResolvedValue(latestReply([AS_CHART]));
|
||||
m.readMessageResults.mockResolvedValue([
|
||||
{ ref: "r1", withheld: "module_disabled" },
|
||||
]);
|
||||
modelPath();
|
||||
await frames(await runCoachTurn(input()));
|
||||
expect(annotated("coach.followUp.reuse_unavailable")).toHaveLength(1);
|
||||
expect(m.appendMessage).not.toHaveBeenCalled();
|
||||
expect(m.reserveTurnBudget).toHaveBeenCalledTimes(1);
|
||||
const { turnHints } = m.runTurnModel.mock.calls[0][0];
|
||||
expect(turnHints).toEqual([
|
||||
expect.stringContaining("show_result with ref m2.r1 and view chart"),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a stale chip", () => {
|
||||
it("degrades to a plain message", async () => {
|
||||
m.findMany.mockResolvedValue([
|
||||
{
|
||||
id: "m-newer",
|
||||
role: "assistant",
|
||||
providerType: "openai",
|
||||
metricSourceJson: null,
|
||||
},
|
||||
...latestReply([AS_CHART]),
|
||||
]);
|
||||
modelPath();
|
||||
await frames(await runCoachTurn(input()));
|
||||
expect(annotated("coach.followUp.stale")).toHaveLength(1);
|
||||
expect(m.readMessageResults).not.toHaveBeenCalled();
|
||||
expect(m.persistUserTurn).toHaveBeenCalledWith("c1", "Show as a chart");
|
||||
expect(m.runTurnModel.mock.calls[0][0].turnHints).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a chip answered by the model", () => {
|
||||
it("carries a server-written context line", async () => {
|
||||
m.findMany.mockResolvedValue(latestReply([PREVIOUS]));
|
||||
modelPath();
|
||||
await frames(
|
||||
await runCoachTurn(
|
||||
input({
|
||||
message: "Compare with the period before",
|
||||
followUp: { messageId: "m-last", id: "f2" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(m.readMessageResults).not.toHaveBeenCalled();
|
||||
expect(m.runTurnModel.mock.calls[0][0].turnHints).toEqual([
|
||||
"FOLLOW-UP: the person tapped the previous_period chip under your last answer. Fetch get_metric_table metric=bp window=last30days period=previous granularity=week and compare it with the current period. The table from your last answer is m2.r1: use show_result for it, do not fetch it again.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the follow-up pref", () => {
|
||||
it("offers chips on a model turn by default", async () => {
|
||||
modelPath();
|
||||
const out = await frames(
|
||||
await runCoachTurn(input({ followUp: undefined })),
|
||||
);
|
||||
expect(out.some((f) => f.type === "followUps")).toBe(true);
|
||||
});
|
||||
|
||||
it("sends no followUps frame when switched off", async () => {
|
||||
modelPath({ ...DEFAULT_COACH_PREFS, followUpChips: false });
|
||||
const out = await frames(
|
||||
await runCoachTurn(input({ followUp: undefined })),
|
||||
);
|
||||
expect(out.map((f) => f.type)).not.toContain("followUps");
|
||||
expect(out.at(-1)?.type).toBe("done");
|
||||
});
|
||||
|
||||
it("sends no followUps frame on a reuse turn when switched off", async () => {
|
||||
m.userFindUnique.mockResolvedValue({
|
||||
coachPrefsJson: { followUpChips: false },
|
||||
});
|
||||
m.findMany.mockResolvedValue(latestReply([AS_CHART]));
|
||||
m.readMessageResults.mockResolvedValue([STORED]);
|
||||
const out = await frames(await runCoachTurn(input()));
|
||||
expect(out.map((f) => f.type)).not.toContain("followUps");
|
||||
expect(out.some((f) => f.type === "result")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,26 @@
|
||||
/**
|
||||
* Prompt rules for proposing follow-up chips from the catalog.
|
||||
*
|
||||
* Not written yet: empty.
|
||||
* The server derives the chips from what the turn read; a proposal only
|
||||
* says which of them the model thinks matter most. The model names a kind
|
||||
* and a domain, never a label: the server renders every word the person
|
||||
* sees, and drops a proposal for a domain the turn did not read.
|
||||
*
|
||||
* English whatever the reply language, like the rest of the tool-mode
|
||||
* prompt: these are instructions to the model, never shown to the person.
|
||||
*/
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
const FOLLOW_UPS_ADDENDUM = `FOLLOW-UP SUGGESTIONS
|
||||
The person may see up to three suggested follow-ups under your answer; the server picks them from what you read. You may name the ones that fit your answer best, in a block after your prose and before any ---KEYVALUES--- block:
|
||||
---FOLLOWUPS---
|
||||
previous_period: bp
|
||||
as_chart: bp
|
||||
---END---
|
||||
- One line each, at most three: a kind, a colon, a metric or domain you read with a tool on THIS turn and that returned data.
|
||||
- Kinds: as_chart, as_table, previous_period, year_ago, widen_window, related_metric.
|
||||
- Never write a label or a question: the server words them. Leave the block out when nothing fits, and never mention it in your prose.`;
|
||||
|
||||
export function followUpsAddendum(_locale: Locale): string {
|
||||
return "";
|
||||
return FOLLOW_UPS_ADDENDUM;
|
||||
}
|
||||
@@ -37,6 +37,10 @@ import {
|
||||
type SettledToolCall,
|
||||
} from "@/lib/ai/coach/results/project";
|
||||
import { deriveChartSpec } from "@/lib/ai/coach/results/chart-spec";
|
||||
import {
|
||||
correlationPartners,
|
||||
type CorrelationPair,
|
||||
} from "@/lib/ai/coach/follow-ups/derive";
|
||||
import {
|
||||
createResultRefAllocator,
|
||||
type PriorResultTurn,
|
||||
@@ -110,6 +114,11 @@ export type ModelOutcome =
|
||||
forcedFinal: boolean;
|
||||
/** v1.39.4 — the DATA INVENTORY entries; null on the no-tools path. */
|
||||
inventory: InventoryEntry[] | null;
|
||||
/**
|
||||
* v1.39.4 — the metric pairs a `get_correlations` call returned this
|
||||
* turn, for the related-metric chip. Empty on the no-tools path.
|
||||
*/
|
||||
correlations: CorrelationPair[];
|
||||
}
|
||||
| { ok: false; code: string };
|
||||
|
||||
@@ -297,6 +306,9 @@ export async function runTurnModel(args: {
|
||||
}).map(withChart),
|
||||
forcedFinal: loop.forcedFinal === true,
|
||||
inventory: inventory.entries,
|
||||
correlations: correlationPartners(
|
||||
settled.filter((call) => call !== undefined),
|
||||
),
|
||||
};
|
||||
}
|
||||
// v1.22 (#89) — the no-tools path (local / Ollama / exo, and any chain
|
||||
@@ -365,6 +377,7 @@ export async function runTurnModel(args: {
|
||||
results: [],
|
||||
forcedFinal: false,
|
||||
inventory: null,
|
||||
correlations: [],
|
||||
};
|
||||
} catch (err) {
|
||||
// The provider chain failed outright — no tokens were billed, so refund
|
||||
|
||||
@@ -21,6 +21,7 @@ import { persistUserTurn, resolveTurnConversation } from "./conversation";
|
||||
import { handleProducerFailure } from "./errors";
|
||||
import { resolveClarificationAnswer } from "@/lib/ai/coach/clarify";
|
||||
import { resolveFollowUp } from "@/lib/ai/coach/follow-ups/resolve";
|
||||
import { readFollowUpHistory } from "@/lib/ai/coach/follow-ups/derive";
|
||||
|
||||
import { runTurnModel } from "./model";
|
||||
import { persistAssistantReply } from "./persist";
|
||||
@@ -56,6 +57,7 @@ export async function runCoachTurn(input: TurnInput): Promise<Response> {
|
||||
userId,
|
||||
conversationId: input.conversationId,
|
||||
followUp: input.followUp,
|
||||
priorResults: conversation.priorResults,
|
||||
});
|
||||
if (resolvedFollowUp?.followUp.reuse) {
|
||||
const reused = await runReuseTurn({
|
||||
@@ -152,11 +154,22 @@ export async function runCoachTurn(input: TurnInput): Promise<Response> {
|
||||
coachPrefs: ctx.coachPrefs,
|
||||
reply,
|
||||
});
|
||||
// v1.39.4 — the record's history for the tables' metrics, which the
|
||||
// year-ago and wider-window chips need. Read only when a table could
|
||||
// carry one.
|
||||
const history = reply.outboundBlocked
|
||||
? undefined
|
||||
: await readFollowUpHistory({
|
||||
userId,
|
||||
results: model.results,
|
||||
prefs: ctx.coachPrefs,
|
||||
});
|
||||
const dialog = assembleTurnDialog({
|
||||
model,
|
||||
reply,
|
||||
prefs: ctx.coachPrefs,
|
||||
locale,
|
||||
history,
|
||||
});
|
||||
const provenance = buildTurnProvenance({
|
||||
snapshotProvenance: ctx.snapshot.provenance,
|
||||
|
||||
@@ -20,16 +20,18 @@ import type {
|
||||
import type { CoachSuggestedAction } from "@/lib/ai/coach/suggest-action";
|
||||
import type { CoachToolTrace } from "@/lib/ai/coach/tools";
|
||||
import { buildMethod } from "@/lib/ai/coach/method";
|
||||
import { deriveFollowUps } from "@/lib/ai/coach/follow-ups/derive";
|
||||
import {
|
||||
deriveFollowUps,
|
||||
followUpChipsEnabled,
|
||||
type FollowUpHistory,
|
||||
} from "@/lib/ai/coach/follow-ups/derive";
|
||||
import { numberFollowUps } from "@/lib/ai/coach/follow-ups/catalog";
|
||||
import { buildContinueFollowUp } from "@/lib/ai/coach/follow-ups/continue";
|
||||
import type { CoachPrefs } from "@/lib/validations/coach-prefs";
|
||||
|
||||
import type { ModelOutcome } from "./model";
|
||||
import type { GuardedReply } from "./reply-guards";
|
||||
|
||||
/** v1.39.4 — at most this many chips under a reply. */
|
||||
const MAX_FOLLOW_UPS = 3;
|
||||
|
||||
/** What the dialog adds to a guarded reply. */
|
||||
export interface TurnDialog {
|
||||
/** The tables to stream and persist; empty on a blocked turn. */
|
||||
@@ -42,7 +44,7 @@ export interface TurnDialog {
|
||||
}
|
||||
|
||||
/** A table's metadata: everything but the values, for the plaintext blob. */
|
||||
function toResultMeta(table: CoachResultTable): CoachResultMeta {
|
||||
export function toResultMeta(table: CoachResultTable): CoachResultMeta {
|
||||
return {
|
||||
ref: table.ref,
|
||||
source: table.source,
|
||||
@@ -66,6 +68,8 @@ export function assembleTurnDialog(args: {
|
||||
reply: GuardedReply;
|
||||
prefs: CoachPrefs;
|
||||
locale: Locale;
|
||||
/** The record's history for the tables' metrics, for the history chips. */
|
||||
history?: FollowUpHistory;
|
||||
}): TurnDialog {
|
||||
const { model, reply, prefs, locale } = args;
|
||||
const blocked = reply.outboundBlocked;
|
||||
@@ -77,12 +81,16 @@ export function assembleTurnDialog(args: {
|
||||
);
|
||||
const metas = results.map(toResultMeta);
|
||||
const method = buildMethod({ steps: model.steps, results: metas, locale });
|
||||
const continueChip = blocked
|
||||
? null
|
||||
: buildContinueFollowUp({ forcedFinal: model.forcedFinal, locale });
|
||||
const followUps = blocked
|
||||
? []
|
||||
: [
|
||||
// No chips on a blocked turn, when the pref is off, or under a clarifying
|
||||
// question: its choices are the next step, and chips beside them would
|
||||
// compete with the answer the question waits for.
|
||||
const offerChips =
|
||||
!blocked && followUpChipsEnabled(prefs) && reply.clarification === null;
|
||||
const continueChip = offerChips
|
||||
? buildContinueFollowUp({ forcedFinal: model.forcedFinal, locale })
|
||||
: null;
|
||||
const followUps = offerChips
|
||||
? numberFollowUps([
|
||||
...(continueChip ? [continueChip] : []),
|
||||
...deriveFollowUps({
|
||||
results: metas,
|
||||
@@ -92,8 +100,11 @@ export function assembleTurnDialog(args: {
|
||||
forcedFinal: model.forcedFinal,
|
||||
prefs,
|
||||
locale,
|
||||
history: args.history,
|
||||
correlations: model.correlations,
|
||||
}),
|
||||
].slice(0, MAX_FOLLOW_UPS);
|
||||
])
|
||||
: [];
|
||||
return {
|
||||
results,
|
||||
method,
|
||||
|
||||
@@ -5,16 +5,171 @@
|
||||
* Streams `step → token (caption) → provenance → result → followUps → done`
|
||||
* and persists an assistant message with `providerType: "reuse"`.
|
||||
*
|
||||
* Not built yet: answers null, and the turn runs as a plain model turn.
|
||||
* The table is copied as it was stored: the same rows, the same columns,
|
||||
* marked with where it came from. Only the view changes: "as a chart" keeps
|
||||
* the stored chart, "as a table" drops it so the table is what shows.
|
||||
*
|
||||
* Answers null, before anything is written, when the stored table cannot be
|
||||
* served (withheld, unreadable, or no chart to show). The pipeline then runs
|
||||
* the turn through the model with the chip's context hint instead.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator";
|
||||
import type {
|
||||
CoachProvenance,
|
||||
CoachResultTable,
|
||||
CoachStep,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import { appendMessage, readMessageResults } from "@/lib/ai/coach/persistence";
|
||||
import { isCoachDomainWithheld } from "@/lib/ai/coach/results/domain-module";
|
||||
import {
|
||||
COACH_REUSE_CAPTION_KEY,
|
||||
COACH_STEP_LABEL_KEYS,
|
||||
} from "@/lib/ai/coach/dialog-keys";
|
||||
import { buildMethod } from "@/lib/ai/coach/method";
|
||||
import { deriveFollowUps } from "@/lib/ai/coach/follow-ups/derive";
|
||||
import type { ResolvedFollowUp } from "@/lib/ai/coach/follow-ups/resolve";
|
||||
import { parseCoachPrefs } from "@/lib/validations/coach-prefs";
|
||||
import { createSseStream } from "@/lib/sse/create-stream";
|
||||
|
||||
import { persistUserTurn } from "./conversation";
|
||||
import { toResultMeta } from "./provenance";
|
||||
import { SSE_HEADERS, createTurnEmitter, emitReply } from "./sse";
|
||||
import type { TurnConversation, TurnInput } from "./types";
|
||||
|
||||
export async function runReuseTurn(_args: {
|
||||
/** The copy of a stored table this turn shows, or null when it cannot. */
|
||||
async function copyStoredTable(args: {
|
||||
userId: string;
|
||||
conversationId: string;
|
||||
resolved: ResolvedFollowUp;
|
||||
}): Promise<CoachResultTable | null> {
|
||||
const { userId, conversationId, resolved } = args;
|
||||
const { followUp, sourceMessageId } = resolved;
|
||||
const ref = followUp.anchor?.ref;
|
||||
if (!ref) return null;
|
||||
const modules = await resolveModuleMap(userId);
|
||||
const entries = await readMessageResults(
|
||||
userId,
|
||||
conversationId,
|
||||
sourceMessageId,
|
||||
(domain) => isCoachDomainWithheld(domain, modules),
|
||||
);
|
||||
const entry = entries?.find((candidate) => candidate.ref === ref);
|
||||
if (!entry || "withheld" in entry) return null;
|
||||
const chart = followUp.kind === "as_chart" ? entry.chart : null;
|
||||
if (followUp.kind === "as_chart" && chart === null) return null;
|
||||
return {
|
||||
...entry,
|
||||
ref: "r1",
|
||||
displayed: true,
|
||||
chart,
|
||||
chartKind: chart?.kind ?? null,
|
||||
reusedFrom: { messageId: sourceMessageId, ref },
|
||||
};
|
||||
}
|
||||
|
||||
export async function runReuseTurn(args: {
|
||||
input: TurnInput;
|
||||
conversation: TurnConversation;
|
||||
resolved: ResolvedFollowUp;
|
||||
}): Promise<Response | null> {
|
||||
return null;
|
||||
const { input, conversation, resolved } = args;
|
||||
const { userId, locale } = input;
|
||||
const conversationId = conversation.conversationId;
|
||||
if (!resolved.followUp.reuse) return null;
|
||||
|
||||
const table = await copyStoredTable({ userId, conversationId, resolved });
|
||||
if (!table) {
|
||||
annotate({
|
||||
action: { name: "coach.followUp.reuse_unavailable" },
|
||||
meta: { kind: resolved.followUp.kind },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
await persistUserTurn(conversationId, input.message);
|
||||
|
||||
const { t } = getServerTranslator(locale);
|
||||
const caption = t(COACH_REUSE_CAPTION_KEY);
|
||||
const { source } = table;
|
||||
const step: CoachStep = {
|
||||
id: "s1",
|
||||
tool: "show_result",
|
||||
labelKey: COACH_STEP_LABEL_KEYS.reuse,
|
||||
label: t(COACH_STEP_LABEL_KEYS.reuse),
|
||||
domain: source.domain,
|
||||
window: source.window,
|
||||
period: source.period,
|
||||
...(source.granularity ? { granularity: source.granularity } : {}),
|
||||
status: "done",
|
||||
...(resolved.sourceCount !== undefined
|
||||
? { count: resolved.sourceCount }
|
||||
: {}),
|
||||
resultRef: table.ref,
|
||||
};
|
||||
const meta = toResultMeta(table);
|
||||
const prefsRow = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { coachPrefsJson: true },
|
||||
});
|
||||
const prefs = parseCoachPrefs(prefsRow?.coachPrefsJson);
|
||||
// The other view of the same table, if it has one. Nothing that would need
|
||||
// a read: a reuse turn reads nothing.
|
||||
const followUps = deriveFollowUps({
|
||||
results: [meta],
|
||||
steps: [step],
|
||||
inventory: null,
|
||||
proposals: [],
|
||||
forcedFinal: false,
|
||||
prefs,
|
||||
locale,
|
||||
});
|
||||
const method = buildMethod({ steps: [step], results: [meta], locale });
|
||||
const provenance: CoachProvenance = {
|
||||
windows: [],
|
||||
metrics: [],
|
||||
steps: [step],
|
||||
...(method ? { method } : {}),
|
||||
results: [meta],
|
||||
...(followUps.length > 0 ? { followUps } : {}),
|
||||
};
|
||||
const message = await appendMessage({
|
||||
conversationId,
|
||||
role: "assistant",
|
||||
content: caption,
|
||||
metricSource: provenance,
|
||||
providerType: "reuse",
|
||||
promptVersion: PROMPT_VERSION,
|
||||
results: [table],
|
||||
});
|
||||
annotate({
|
||||
action: { name: "coach.followUp.reused" },
|
||||
meta: { kind: resolved.followUp.kind, rows: table.rows.length },
|
||||
});
|
||||
|
||||
const stream = createSseStream(async (controller) => {
|
||||
const emitter = createTurnEmitter(controller);
|
||||
if (!emitter.aborted()) emitter.emit({ type: "step", step });
|
||||
await emitReply(
|
||||
emitter,
|
||||
{
|
||||
ok: true,
|
||||
replyText: caption,
|
||||
provenance,
|
||||
suggestion: null,
|
||||
action: null,
|
||||
results: [table],
|
||||
followUps,
|
||||
clarification: null,
|
||||
messageId: message.id,
|
||||
totalTokens: 0,
|
||||
model: null,
|
||||
},
|
||||
conversationId,
|
||||
);
|
||||
});
|
||||
return new Response(stream, { status: 200, headers: SSE_HEADERS });
|
||||
}
|
||||
@@ -187,6 +187,17 @@ export const coachPrefsSchema = z.object({
|
||||
// back to `DEFAULT_REMINDER_SUGGESTION_PREFS`. When the key IS present,
|
||||
// its inner fields default (so `{}` fills to the all-on shape).
|
||||
reminderSuggestions: coachReminderSuggestionPrefsSchema.optional(),
|
||||
// v1.39.4 — up to three follow-up chips under the Coach's latest answer.
|
||||
// On by default: an absent key reads as on (`followUpChipsEnabled`), and
|
||||
// like `reminderSuggestions` it carries no schema default, so a legacy
|
||||
// blob and `parse({})` stay byte-identical. Only an explicit `false`
|
||||
// switches the chips off.
|
||||
followUpChips: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.39.4 — offer up to three follow-up chips under the Coach's latest answer. Absent means on; only `false` switches them off.",
|
||||
),
|
||||
});
|
||||
|
||||
export type CoachPrefs = z.infer<typeof coachPrefsSchema>;
|
||||
|
||||
Reference in new issue
Block a user