From 66718a1c7dfe4341a063b90d903f7e1f1e513ebf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 27 Sep 2026 12:36:01 +0200 Subject: [PATCH] feat(coach): follow-up chips under the latest answer Up to three chips, derived on the server from what the turn read: the other view of a table with a chart, the period before, a year earlier, a wider window, and a metric a discovered pattern links to. A chip is offered only when its read was done this turn and the record holds the metric; the year-ago and wider-window chips also need the record's first reading to reach back that far. The model may name chips in a FOLLOWUPS block, but only to reorder ones the rules already allow; every label is rendered from the catalog. A tapped chip resolves against the chips stored on the conversation's latest reply. "As a chart" and "as a table" are answered from the stored table without a model call or a budget reservation: the rows are copied unchanged and the message is tagged providerType "reuse". Other chips run a normal turn with a server-written context line. A chip that is no longer current falls back to a plain message. A setting turns the chips off (absent means on). --- docs/api/openapi.yaml | 12 + .../golden-transcripts.test.ts.snap | 54 +-- .../__tests__/follow-up-chips.test.tsx | 80 ++++ .../insights/coach-panel/follow-up-chips.tsx | 47 +- .../settings/coach-prefs-section.tsx | 25 ++ .../coach/follow-ups/__tests__/derive.test.ts | 411 +++++++++++++++++ .../__tests__/parse-sentinel.test.ts | 76 ++++ .../follow-ups/__tests__/resolve.test.ts | 273 ++++++++++++ src/lib/ai/coach/follow-ups/catalog.ts | 133 ++++++ src/lib/ai/coach/follow-ups/derive.ts | 340 +++++++++++++- src/lib/ai/coach/follow-ups/parse-sentinel.ts | 91 +++- src/lib/ai/coach/follow-ups/resolve.ts | 186 +++++++- .../turn/__tests__/follow-up-turn.test.ts | 415 ++++++++++++++++++ src/lib/ai/coach/turn/addenda/followups.ts | 20 +- src/lib/ai/coach/turn/model.ts | 13 + src/lib/ai/coach/turn/pipeline.ts | 13 + src/lib/ai/coach/turn/provenance.ts | 35 +- src/lib/ai/coach/turn/reuse-turn.ts | 161 ++++++- src/lib/validations/coach-prefs.ts | 11 + 19 files changed, 2333 insertions(+), 63 deletions(-) create mode 100644 src/components/insights/coach-panel/__tests__/follow-up-chips.test.tsx create mode 100644 src/lib/ai/coach/follow-ups/__tests__/derive.test.ts create mode 100644 src/lib/ai/coach/follow-ups/__tests__/parse-sentinel.test.ts create mode 100644 src/lib/ai/coach/follow-ups/__tests__/resolve.test.ts create mode 100644 src/lib/ai/coach/follow-ups/catalog.ts create mode 100644 src/lib/ai/coach/turn/__tests__/follow-up-turn.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index fa365179b..d5359d366 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 @@ -75367,6 +75375,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 diff --git a/src/app/api/insights/chat/__tests__/__snapshots__/golden-transcripts.test.ts.snap b/src/app/api/insights/chat/__tests__/__snapshots__/golden-transcripts.test.ts.snap index 702c2de79..629b82207 100644 --- a/src/app/api/insights/chat/__tests__/__snapshots__/golden-transcripts.test.ts.snap +++ b/src/app/api/insights/chat/__tests__/__snapshots__/golden-transcripts.test.ts.snap @@ -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, diff --git a/src/components/insights/coach-panel/__tests__/follow-up-chips.test.tsx b/src/components/insights/coach-panel/__tests__/follow-up-chips.test.tsx new file mode 100644 index 000000000..32a3f30d9 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/follow-up-chips.test.tsx @@ -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>[] { + if (Array.isArray(node)) return node.flatMap(buttons); + if (!isValidElement(node)) return []; + const el = node as ReactElement>; + const own = el.props["data-follow-up-id"] ? [el] : []; + return [...own, ...buttons(el.props.children as ReactNode)]; +} + +describe("", () => { + 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"); + 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(); + }); +}); diff --git a/src/components/insights/coach-panel/follow-up-chips.tsx b/src/components/insights/coach-panel/follow-up-chips.tsx index 8d14cf772..ff40dd7f3 100644 --- a/src/components/insights/coach-panel/follow-up-chips.tsx +++ b/src/components/insights/coach-panel/follow-up-chips.tsx @@ -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 ( +
+ {chips.map((chip) => ( + + ))} +
+ ); } diff --git a/src/components/settings/coach-prefs-section.tsx b/src/components/settings/coach-prefs-section.tsx index fd89b98ad..7e12ed1e5 100644 --- a/src/components/settings/coach-prefs-section.tsx +++ b/src/components/settings/coach-prefs-section.tsx @@ -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) { } /> + + {/* v1.39.4 — follow-up chips under the Coach's latest answer. + Absent in the saved prefs means on. */} +
+
+ +

+ {t(COACH_FOLLOW_UP_UI_KEYS.settingHint)} +

+
+ + setDraft((prev) => ({ ...prev, followUpChips: next })) + } + /> +
)} diff --git a/src/lib/ai/coach/follow-ups/__tests__/derive.test.ts b/src/lib/ai/coach/follow-ups/__tests__/derive.test.ts new file mode 100644 index 000000000..570cd942f --- /dev/null +++ b/src/lib/ai/coach/follow-ups/__tests__/derive.test.ts @@ -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 = {}, + 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 { + 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[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 = (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([]); + }); +}); diff --git a/src/lib/ai/coach/follow-ups/__tests__/parse-sentinel.test.ts b/src/lib/ai/coach/follow-ups/__tests__/parse-sentinel.test.ts new file mode 100644 index 000000000..c49d945ba --- /dev/null +++ b/src/lib/ai/coach/follow-ups/__tests__/parse-sentinel.test.ts @@ -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: [] }); + }); +}); diff --git a/src/lib/ai/coach/follow-ups/__tests__/resolve.test.ts b/src/lib/ai/coach/follow-ups/__tests__/resolve.test.ts new file mode 100644 index 000000000..5ca0ec66e --- /dev/null +++ b/src/lib/ai/coach/follow-ups/__tests__/resolve.test.ts @@ -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"); + }); +}); diff --git a/src/lib/ai/coach/follow-ups/catalog.ts b/src/lib/ai/coach/follow-ups/catalog.ts new file mode 100644 index 000000000..d9e774757 --- /dev/null +++ b/src/lib/ai/coach/follow-ups/catalog.ts @@ -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 = 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 = + 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 = 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}` })); +} diff --git a/src/lib/ai/coach/follow-ups/derive.ts b/src/lib/ai/coach/follow-ups/derive.ts index ae54daf34..292eb869e 100644 --- a/src/lib/ai/coach/follow-ups/derive.ts +++ b/src/lib/ai/coach/follow-ups/derive.ts @@ -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>; +} + +/** The windows a year-ago comparison is offered for. */ +const SHORT_WINDOW_DAYS: Partial> = { + last7days: 7, + last30days: 30, + last90days: 90, +}; + +const WINDOW_DAYS: Partial> = { + ...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(); + 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(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(); + 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 = 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 { + if (!followUpChipsEnabled(args.prefs)) return undefined; + const subjects = new Map(); + 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; + } } diff --git a/src/lib/ai/coach/follow-ups/parse-sentinel.ts b/src/lib/ai/coach/follow-ups/parse-sentinel.ts index 5a1dd3df7..4e277fe07 100644 --- a/src/lib/ai/coach/follow-ups/parse-sentinel.ts +++ b/src/lib/ai/coach/follow-ups/parse-sentinel.ts @@ -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(); + 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 }; } diff --git a/src/lib/ai/coach/follow-ups/resolve.ts b/src/lib/ai/coach/follow-ups/resolve.ts index 26787a854..dc5b5448a 100644 --- a/src/lib/ai/coach/follow-ups/resolve.ts +++ b/src/lib/ai/coach/follow-ups/resolve.ts @@ -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 = (value: unknown, schema: z.ZodType): 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.r` 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 { - 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>; + 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 } : {}), + }; } diff --git a/src/lib/ai/coach/turn/__tests__/follow-up-turn.test.ts b/src/lib/ai/coach/turn/__tests__/follow-up-turn.test.ts new file mode 100644 index 000000000..909e6ddfc --- /dev/null +++ b/src/lib/ai/coach/turn/__tests__/follow-up-turn.test.ts @@ -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 { + 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 { + 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); + }); +}); diff --git a/src/lib/ai/coach/turn/addenda/followups.ts b/src/lib/ai/coach/turn/addenda/followups.ts index 11167ff3b..fd21c85e0 100644 --- a/src/lib/ai/coach/turn/addenda/followups.ts +++ b/src/lib/ai/coach/turn/addenda/followups.ts @@ -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; } diff --git a/src/lib/ai/coach/turn/model.ts b/src/lib/ai/coach/turn/model.ts index 347d75af8..03b9b4ab3 100644 --- a/src/lib/ai/coach/turn/model.ts +++ b/src/lib/ai/coach/turn/model.ts @@ -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 diff --git a/src/lib/ai/coach/turn/pipeline.ts b/src/lib/ai/coach/turn/pipeline.ts index 6720b871f..0f440537b 100644 --- a/src/lib/ai/coach/turn/pipeline.ts +++ b/src/lib/ai/coach/turn/pipeline.ts @@ -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 { 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 { 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, diff --git a/src/lib/ai/coach/turn/provenance.ts b/src/lib/ai/coach/turn/provenance.ts index 437d73982..c649cc7d8 100644 --- a/src/lib/ai/coach/turn/provenance.ts +++ b/src/lib/ai/coach/turn/provenance.ts @@ -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, diff --git a/src/lib/ai/coach/turn/reuse-turn.ts b/src/lib/ai/coach/turn/reuse-turn.ts index 66c14d23e..1543d2699 100644 --- a/src/lib/ai/coach/turn/reuse-turn.ts +++ b/src/lib/ai/coach/turn/reuse-turn.ts @@ -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 { + 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 { - 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 }); } diff --git a/src/lib/validations/coach-prefs.ts b/src/lib/validations/coach-prefs.ts index 958512d48..9c09828b4 100644 --- a/src/lib/validations/coach-prefs.ts +++ b/src/lib/validations/coach-prefs.ts @@ -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;