diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 985da05aa..e89e981c4 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -109,6 +109,8 @@ async function handleChatRequest(request: NextRequest): Promise { scope, guidedQuestion, workoutId, + followUp, + clarification, } = parsed.data; // Per-user request-rate ceiling layered in front of the daily budget @@ -171,6 +173,8 @@ async function handleChatRequest(request: NextRequest): Promise { scope, guidedQuestion, workoutId, + followUp, + clarification, recheckCapability: coachCapabilityRefusal, }); } diff --git a/src/lib/ai/coach/clarify.ts b/src/lib/ai/coach/clarify.ts new file mode 100644 index 000000000..2a02ee4a3 --- /dev/null +++ b/src/lib/ai/coach/clarify.ts @@ -0,0 +1,34 @@ +/** + * Clarifying questions. The model may end a reply with a `---CLARIFY---` + * block when the metric or window stays genuinely ambiguous; the server + * validates the choices against what the record holds, and the question text + * is the reply itself. The person answers with + * `clarification: { messageId, choiceId? }` on the next request. + * + * Not built yet: the prose passes through unchanged, no clarification is + * offered, and an answer adds nothing to the turn. + */ +import type { Locale } from "@/lib/i18n/config"; +import type { CoachClarification } from "@/lib/ai/coach/types"; +import type { InventoryEntry } from "@/lib/ai/coach/tools/inventory"; + +export function parseClarifySentinel(args: { + prose: string; + /** What the record holds; null on the no-tools path. */ + inventory: InventoryEntry[] | null; + locale: Locale; +}): { prose: string; clarification: CoachClarification | null } { + return { prose: args.prose, clarification: null }; +} + +/** + * The turn-context line for an answered clarification, or null when the + * request carries none or its question is no longer current. + */ +export async function resolveClarificationAnswer(_args: { + userId: string; + conversationId: string | undefined; + clarification: { messageId: string; choiceId?: string } | undefined; +}): Promise { + return null; +} diff --git a/src/lib/ai/coach/follow-ups/continue.ts b/src/lib/ai/coach/follow-ups/continue.ts new file mode 100644 index 000000000..34d90ebef --- /dev/null +++ b/src/lib/ai/coach/follow-ups/continue.ts @@ -0,0 +1,15 @@ +/** + * "Keep looking": when the loop forced an answer at its round cap, offer a + * chip that continues the question with what was already fetched. + * + * Not built yet: no chip. + */ +import type { Locale } from "@/lib/i18n/config"; +import type { CoachFollowUp } from "@/lib/ai/coach/types"; + +export function buildContinueFollowUp(_args: { + forcedFinal: boolean; + locale: Locale; +}): CoachFollowUp | null { + return null; +} diff --git a/src/lib/ai/coach/follow-ups/derive.ts b/src/lib/ai/coach/follow-ups/derive.ts new file mode 100644 index 000000000..ae54daf34 --- /dev/null +++ b/src/lib/ai/coach/follow-ups/derive.ts @@ -0,0 +1,32 @@ +/** + * The follow-up chips under the latest assistant reply, derived on the + * server from what the turn read: as a chart, as a table, the period before, + * a year ago, a wider window, a related metric. At most three, deduplicated, + * labels from the catalog only. + * + * Pure. Not built yet: no chips. + */ +import type { Locale } from "@/lib/i18n/config"; +import type { + CoachFollowUp, + CoachFollowUpKind, + CoachResultMeta, + CoachStep, + CoachStepDomain, +} from "@/lib/ai/coach/types"; +import type { InventoryEntry } from "@/lib/ai/coach/tools/inventory"; +import type { CoachPrefs } from "@/lib/validations/coach-prefs"; + +export function deriveFollowUps(_args: { + results: CoachResultMeta[]; + steps: CoachStep[]; + /** What the record holds; null on the no-tools path. */ + inventory: InventoryEntry[] | null; + /** Kind and domain pairs the model proposed, already catalog-checked. */ + proposals: Array<{ kind: CoachFollowUpKind; domain: CoachStepDomain }>; + forcedFinal: boolean; + prefs: CoachPrefs; + locale: Locale; +}): CoachFollowUp[] { + return []; +} diff --git a/src/lib/ai/coach/follow-ups/parse-sentinel.ts b/src/lib/ai/coach/follow-ups/parse-sentinel.ts new file mode 100644 index 000000000..5a1dd3df7 --- /dev/null +++ b/src/lib/ai/coach/follow-ups/parse-sentinel.ts @@ -0,0 +1,15 @@ +/** + * 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. + * + * Not built yet: the prose passes through unchanged, with no proposals. + */ +import type { CoachFollowUpKind, CoachStepDomain } from "@/lib/ai/coach/types"; + +export function parseFollowUpsSentinel(prose: string): { + prose: string; + proposals: Array<{ kind: CoachFollowUpKind; domain: CoachStepDomain }>; +} { + return { prose, proposals: [] }; +} diff --git a/src/lib/ai/coach/follow-ups/resolve.ts b/src/lib/ai/coach/follow-ups/resolve.ts new file mode 100644 index 000000000..26787a854 --- /dev/null +++ b/src/lib/ai/coach/follow-ups/resolve.ts @@ -0,0 +1,26 @@ +/** + * Resolve a tapped follow-up chip (`followUp: { messageId, id }` on the + * request) against what the server persisted: the chip must sit on the + * conversation's latest assistant message. A reuse chip is answered from the + * 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. + */ +import type { CoachFollowUp } from "@/lib/ai/coach/types"; + +/** 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. */ + contextHint: string | null; +} + +export async function resolveFollowUp(_args: { + userId: string; + conversationId: string | undefined; + followUp: { messageId: string; id: string } | undefined; +}): Promise { + return null; +} diff --git a/src/lib/ai/coach/method.ts b/src/lib/ai/coach/method.ts new file mode 100644 index 000000000..fed6d0265 --- /dev/null +++ b/src/lib/ai/coach/method.ts @@ -0,0 +1,22 @@ +/** + * The method line under an answer: which sources, windows, counts and + * aggregation it rests on, rendered on the server in the request locale. + * "Blood pressure, last 90 days: 142 readings, weekly means · Sleep: no + * readings in this window". Never a health value. + * + * Not built yet: no method line. + */ +import type { Locale } from "@/lib/i18n/config"; +import type { + CoachMethod, + CoachResultMeta, + CoachStep, +} from "@/lib/ai/coach/types"; + +export function buildMethod(_args: { + steps: CoachStep[]; + results: CoachResultMeta[]; + locale: Locale; +}): CoachMethod | null { + return null; +} diff --git a/src/lib/ai/coach/results/chart-spec.ts b/src/lib/ai/coach/results/chart-spec.ts new file mode 100644 index 000000000..aa3b6e67e --- /dev/null +++ b/src/lib/ai/coach/results/chart-spec.ts @@ -0,0 +1,15 @@ +/** + * The chart a result table gets, chosen on the server from the table's shape + * and the metric: a line for level metrics over time, a bar for totals and + * category counts, a histogram for a distribution, or none (table only) + * when the table is too short, too long or has no numeric column. + * + * Pure. Not built yet: every table stays table-only. + */ +import type { CoachChartSpec, CoachResultTable } from "@/lib/ai/coach/types"; + +export function deriveChartSpec( + _table: Omit, +): CoachChartSpec | null { + return null; +} diff --git a/src/lib/ai/coach/results/project.ts b/src/lib/ai/coach/results/project.ts new file mode 100644 index 000000000..616b36223 --- /dev/null +++ b/src/lib/ai/coach/results/project.ts @@ -0,0 +1,32 @@ +/** + * Project this turn's tool results into result tables: per-sport counts for + * workouts, the latest reading per analyte for labs, per-night sleep, the + * compliance timeline, and the metric table tool's own rows. + * + * Input is only what the loop settled on THIS turn, so a table can never + * carry another turn's, or another account's, values. + * + * Not built yet: answers no tables. + */ +import type { Locale } from "@/lib/i18n/config"; +import type { CoachResultTable } from "@/lib/ai/coach/types"; +import type { CoachToolResult } from "@/lib/ai/coach/tools/executor"; + +/** One tool call of this turn, as the loop settled it. */ +export interface SettledToolCall { + name: string; + /** Schema-validated arguments; absent when they did not validate. */ + args?: Record; + result: CoachToolResult; +} + +/** + * Tables for this turn, refs `r1`.. in call order. `chart` and `chartKind` + * are left for `deriveChartSpec` to fill. + */ +export function projectResults(_args: { + calls: SettledToolCall[]; + locale: Locale; +}): CoachResultTable[] { + return []; +} diff --git a/src/lib/ai/coach/results/refs.ts b/src/lib/ai/coach/results/refs.ts new file mode 100644 index 000000000..096b3767d --- /dev/null +++ b/src/lib/ai/coach/results/refs.ts @@ -0,0 +1,14 @@ +/** + * `result:rN` references in the reply prose. The model marks the tables its + * answer relies on; the marks are stripped before the prose reaches the + * client, and a referenced table is shown expanded under the reply. + * + * Not built yet: the prose passes through unchanged and nothing counts as + * referenced. + */ +export function stripResultRefs(prose: string): { + prose: string; + referenced: string[]; +} { + return { prose, referenced: [] }; +} diff --git a/src/lib/ai/coach/tools/index.ts b/src/lib/ai/coach/tools/index.ts index 6c3972b28..acee46e47 100644 --- a/src/lib/ai/coach/tools/index.ts +++ b/src/lib/ai/coach/tools/index.ts @@ -6,7 +6,7 @@ * figures, and a bounded loop that executes them. The legacy snapshot path * stays alive as the no-tools fallback (per-provider `supportsTools`). */ -export { COACH_TOOL_DEFS } from "./definitions"; +export { COACH_TOOL_DEFS, parseCoachToolArgs } from "./definitions"; export type { CoachToolTrace } from "./executor"; export { buildCoachDataInventory, diff --git a/src/lib/ai/coach/turn/__tests__/dialog-frames.test.ts b/src/lib/ai/coach/turn/__tests__/dialog-frames.test.ts new file mode 100644 index 000000000..bcd5f85ed --- /dev/null +++ b/src/lib/ai/coach/turn/__tests__/dialog-frames.test.ts @@ -0,0 +1,187 @@ +/** + * v1.39.4 — the dialog frames go out in the documented order, after the + * provenance, and a blocked turn carries none of the tables, chips or + * clarification. + */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/ai/coach/persistence", () => ({ + appendMessage: vi.fn(), + createConversation: vi.fn(), +})); + +import type { + CoachClarification, + CoachFollowUp, + CoachResultTable, + CoachStreamEvent, +} from "@/lib/ai/coach/types"; +import { DEFAULT_COACH_PREFS } from "@/lib/validations/coach-prefs"; + +import { emitReply } from "../sse"; +import { assembleTurnDialog, buildTurnProvenance } from "../provenance"; +import type { ModelOutcome } from "../model"; +import type { GuardedReply } from "../reply-guards"; + +const TABLE: CoachResultTable = { + ref: "r1", + source: { + tool: "get_metric_series", + domain: "bp", + window: "last30days", + period: "current", + }, + shape: "timeSeries", + titleKey: "coach.result.title.metricByPeriod", + title: "Blood pressure by day", + rowCount: 1, + chartKind: null, + displayed: false, + columns: [], + rows: [["2026-09-01", 128]], + truncated: false, + chart: null, +}; +const CHIP: CoachFollowUp = { + id: "f1", + kind: "as_table", + labelKey: "coach.followUp.asTable", + label: "Show as a table", + reuse: true, + origin: "server", +}; +const CLARIFY: CoachClarification = { + kind: "window", + choices: [], + freeText: true, +}; + +function collect() { + const frames: CoachStreamEvent[] = []; + return { + frames, + emitter: { + emit: (f: CoachStreamEvent) => frames.push(f), + aborted: () => false, + }, + }; +} + +describe("emitReply — dialog frames", () => { + it("orders token, provenance, result, cards, clarification, followUps, done", async () => { + const { frames, emitter } = collect(); + await emitReply( + emitter, + { + ok: true, + replyText: "Steady.", + provenance: { windows: [], metrics: [] }, + suggestion: { cadenceId: "c", measurementType: "BP", label: "l" }, + action: null, + results: [TABLE], + followUps: [CHIP], + clarification: CLARIFY, + messageId: "m1", + totalTokens: 10, + model: "x", + }, + "c1", + ); + expect(frames.map((f) => f.type)).toEqual([ + "token", + "provenance", + "result", + "suggestion", + "clarification", + "followUps", + "done", + ]); + }); + + it("emits no dialog frame when the turn has none", async () => { + const { frames, emitter } = collect(); + await emitReply( + emitter, + { + ok: true, + replyText: "Steady.", + provenance: { windows: [], metrics: [] }, + suggestion: null, + action: null, + results: [], + followUps: [], + clarification: null, + messageId: "m1", + totalTokens: 0, + model: null, + }, + "c1", + ); + expect(frames.map((f) => f.type)).toEqual(["token", "provenance", "done"]); + }); +}); + +describe("assembleTurnDialog", () => { + const model = { + ok: true, + steps: [], + results: [TABLE], + forcedFinal: false, + inventory: null, + } as unknown as Extract; + const reply = (blocked: boolean): GuardedReply => + ({ + outboundBlocked: blocked, + referencedResults: ["r1"], + followUpProposals: [], + clarification: blocked ? null : CLARIFY, + keyValuesSentinel: { + keyValues: [], + malformed: false, + malformedEntries: [], + }, + groundedFigures: [], + unverifiedStripped: 0, + }) as unknown as GuardedReply; + + it("marks a referenced table displayed and keeps its values out of the provenance", () => { + const dialog = assembleTurnDialog({ + model, + reply: reply(false), + prefs: DEFAULT_COACH_PREFS, + locale: "en", + }); + expect(dialog.results).toEqual([{ ...TABLE, displayed: true }]); + const provenance = buildTurnProvenance({ + snapshotProvenance: { windows: [], metrics: [] }, + reply: reply(false), + suggestion: null, + action: null, + toolTrace: [], + steps: [], + dialog, + forcedFinal: true, + }); + expect(provenance.results?.[0]).not.toHaveProperty("rows"); + expect(provenance.results?.[0]?.displayed).toBe(true); + expect(provenance.clarification).toEqual(CLARIFY); + expect(provenance.forcedFinal).toBe(true); + expect(JSON.stringify(provenance)).not.toContain("128"); + }); + + it("drops tables, chips and clarification on a blocked turn", () => { + const dialog = assembleTurnDialog({ + model, + reply: reply(true), + prefs: DEFAULT_COACH_PREFS, + locale: "en", + }); + expect(dialog).toEqual({ + results: [], + method: null, + followUps: [], + clarification: null, + }); + }); +}); diff --git a/src/lib/ai/coach/turn/addenda/clarify.ts b/src/lib/ai/coach/turn/addenda/clarify.ts new file mode 100644 index 000000000..d5d37b574 --- /dev/null +++ b/src/lib/ai/coach/turn/addenda/clarify.ts @@ -0,0 +1,12 @@ +/** + * Prompt rules for clarifying questions: ask only when the metric or window + * stays genuinely ambiguous, at most one question, never about doses or + * diagnoses. + * + * Not written yet: empty. + */ +import type { Locale } from "@/lib/i18n/config"; + +export function clarifyAddendum(_locale: Locale): string { + return ""; +} diff --git a/src/lib/ai/coach/turn/addenda/followups.ts b/src/lib/ai/coach/turn/addenda/followups.ts new file mode 100644 index 000000000..11167ff3b --- /dev/null +++ b/src/lib/ai/coach/turn/addenda/followups.ts @@ -0,0 +1,10 @@ +/** + * Prompt rules for proposing follow-up chips from the catalog. + * + * Not written yet: empty. + */ +import type { Locale } from "@/lib/i18n/config"; + +export function followUpsAddendum(_locale: Locale): string { + return ""; +} diff --git a/src/lib/ai/coach/turn/addenda/index.ts b/src/lib/ai/coach/turn/addenda/index.ts new file mode 100644 index 000000000..bb1983e54 --- /dev/null +++ b/src/lib/ai/coach/turn/addenda/index.ts @@ -0,0 +1,23 @@ +/** + * The dialog addenda appended to the tool-mode system prompt: result + * tables, clarification, the method line, follow-up chips. Each lives in its + * own file; an empty one adds nothing, so a turn with every addendum empty + * sends the prompt byte for byte as before. + */ +import type { Locale } from "@/lib/i18n/config"; + +import { clarifyAddendum } from "./clarify"; +import { followUpsAddendum } from "./followups"; +import { methodAddendum } from "./method"; +import { resultsAddendum } from "./results"; + +export function buildDialogAddenda(locale: Locale): string { + return [ + resultsAddendum(locale), + clarifyAddendum(locale), + methodAddendum(locale), + followUpsAddendum(locale), + ] + .filter((block) => block.length > 0) + .join("\n\n"); +} diff --git a/src/lib/ai/coach/turn/addenda/method.ts b/src/lib/ai/coach/turn/addenda/method.ts new file mode 100644 index 000000000..49970dc9d --- /dev/null +++ b/src/lib/ai/coach/turn/addenda/method.ts @@ -0,0 +1,11 @@ +/** + * Prompt rules for the method line and for rechecking a figure the person + * questions. + * + * Not written yet: empty. + */ +import type { Locale } from "@/lib/i18n/config"; + +export function methodAddendum(_locale: Locale): string { + return ""; +} diff --git a/src/lib/ai/coach/turn/addenda/results.ts b/src/lib/ai/coach/turn/addenda/results.ts new file mode 100644 index 000000000..5a64721af --- /dev/null +++ b/src/lib/ai/coach/turn/addenda/results.ts @@ -0,0 +1,11 @@ +/** + * Prompt rules for result tables: when to fetch a table, how to reference + * one, and when a stored table answers without a new fetch. + * + * Not written yet: empty. + */ +import type { Locale } from "@/lib/i18n/config"; + +export function resultsAddendum(_locale: Locale): string { + return ""; +} diff --git a/src/lib/ai/coach/turn/model.ts b/src/lib/ai/coach/turn/model.ts index a6d6ed35d..4c3296383 100644 --- a/src/lib/ai/coach/turn/model.ts +++ b/src/lib/ai/coach/turn/model.ts @@ -23,14 +23,24 @@ import { renderDataInventory, renderFocusHint, buildToolModeAddendum, + parseCoachToolArgs, runCoachToolLoop, type CoachToolTrace, } from "@/lib/ai/coach/tools"; +import type { InventoryEntry } from "@/lib/ai/coach/tools/inventory"; +import type { CoachResultTable, CoachStep } from "@/lib/ai/coach/types"; +import { + projectResults, + type SettledToolCall, +} from "@/lib/ai/coach/results/project"; +import { deriveChartSpec } from "@/lib/ai/coach/results/chart-spec"; +import { buildDialogAddenda } from "./addenda"; import { refundReservation, type TurnReservation } from "./budget"; import type { TurnChain } from "./chain"; import type { TurnContext } from "./context"; import { classifyBubblingProviderError } from "./errors"; +import { snapshotStep, toStep } from "./steps"; import type { TurnEmitter } from "./types"; export type ModelOutcome = @@ -77,9 +87,56 @@ export type ModelOutcome = * to the daily meter). */ cachedTokens: number; + /** + * v1.39.4 — the steps this turn read, in first-seen order: the tool + * calls on the tool path, one snapshot step on the no-tools path. + * Already streamed as `step` frames; persisted on the provenance. + */ + steps: CoachStep[]; + /** + * v1.39.4 — the tables this turn's tool results project to, with their + * charts. Not streamed here: they go out after the reply guards, and + * not at all on a blocked turn. + */ + results: CoachResultTable[]; + /** v1.39.4 — the loop forced the answer at its round cap. */ + forcedFinal: boolean; + /** v1.39.4 — the DATA INVENTORY entries; null on the no-tools path. */ + inventory: InventoryEntry[] | null; } | { ok: false; code: string }; +/** + * v1.39.4 — the turn's steps, upserted by id and streamed as they change. + * A frame is never written to a stream the client already closed. + */ +function stepRecorder(emitter: TurnEmitter): { + record(step: CoachStep | null): void; + list(): CoachStep[]; +} { + const byId = new Map(); + return { + record(step) { + if (!step) return; + byId.set(step.id, step); + if (!emitter.aborted()) emitter.emit({ type: "step", step }); + }, + list: () => [...byId.values()], + }; +} + +/** v1.39.4 — a projected table with the chart the server chose for it. */ +function withChart(table: CoachResultTable): CoachResultTable { + const chart = deriveChartSpec(table); + return { ...table, chart, chartKind: chart?.kind ?? null }; +} + +/** v1.39.4 — server-authored lines appended to the system prompt. */ +function appendBlocks(base: string, blocks: string[]): string { + const extra = blocks.filter((block) => block.length > 0); + return extra.length > 0 ? [base, ...extra].join("\n\n") : base; +} + export async function runTurnModel(args: { userId: string; locale: Locale; @@ -89,14 +146,18 @@ export async function runTurnModel(args: { chain: TurnChain; toolMode: boolean; reservation: TurnReservation; - /** - * The turn's frame channel. Not written to yet: the model step is where - * live progress frames will originate. - */ + /** The turn's frame channel; the live `step` frames go out on it. */ emitter: TurnEmitter; + /** + * v1.39.4 — server-authored context lines for this turn (a resolved + * follow-up chip, an answered clarification). Empty on a plain turn, + * which then sends its prompt unchanged. + */ + turnHints: string[]; }): Promise { const { userId, locale, signal, conversationId, ctx, chain, toolMode } = args; const { effectiveScope, workoutEvidence, turnContext, snapshot } = ctx; + const steps = stepRecorder(args.emitter); try { if (toolMode) { // v1.20.0 (F1) — base context: the full system prompt + a tool-mode @@ -108,7 +169,10 @@ export async function runTurnModel(args: { const inventory = await buildCoachDataInventory(userId, effectiveScope); const toolRequest = buildCoachToolRequest({ systemPrompt: ctx.systemPrompt, - toolModeAddendum: buildToolModeAddendum(locale), + toolModeAddendum: appendBlocks(buildToolModeAddendum(locale), [ + buildDialogAddenda(locale), + ...args.turnHints, + ]), focusHint: renderFocusHint(effectiveScope?.sources), workoutEvidence, dataInventory: renderDataInventory(inventory), @@ -116,6 +180,9 @@ export async function runTurnModel(args: { transcript: turnContext.transcript, languageName: LANGUAGE_NAMES[locale], }); + // v1.39.4 — every call this turn settled, by its turn-wide index, for + // the result tables. Only this turn's own calls ever reach them. + const settled: SettledToolCall[] = []; const loop = await runCoachToolLoop({ userId, providers: chain, @@ -135,6 +202,26 @@ export async function runTurnModel(args: { signal, // v1.22 (#89) — per-user response timeout for each tool-round call. timeoutMs: ctx.aiResponseTimeoutMs, + // v1.39.4 — live steps: a `running` step as each call starts, its + // final status as it settles. + onCallStart: (call, index) => + steps.record( + toStep({ + call, + index, + parsedArgs: parseCoachToolArgs(call.name, call.arguments), + locale, + }), + ), + onCallSettled: (call, result, index) => { + const parsedArgs = parseCoachToolArgs(call.name, call.arguments); + settled[index] = { + name: call.name, + ...(parsedArgs ? { args: parsedArgs } : {}), + result, + }; + steps.record(toStep({ call, index, parsedArgs, result, locale })); + }, }); // v1.32.1 — the numeric verifier ACTIVATES only when this turn actually // delivered figures the model was told to ground against: a pinned @@ -171,6 +258,13 @@ export async function runTurnModel(args: { inventoryPayloads: [inventory.entries], totalTokens: loop.totalTokens, cachedTokens: loop.cachedTokens, + steps: steps.list(), + results: projectResults({ + calls: settled.filter((call) => call !== undefined), + locale, + }).map(withChart), + forcedFinal: loop.forcedFinal === true, + inventory: inventory.entries, }; } // v1.22 (#89) — the no-tools path (local / Ollama / exo, and any chain @@ -180,6 +274,13 @@ export async function runTurnModel(args: { // heartbeat keeps the proxy connection warm and the assembled reply is // returned in full so every guard still runs on the complete text. let streamedDeltas = 0; + // v1.39.4 — the no-tools path reads the whole snapshot: one step. + steps.record( + snapshotStep({ + metricCount: snapshot.provenance.metrics.length, + locale, + }), + ); const fallback = await runStreamingRawCompletionWithFallback({ surface: "coach", userId, @@ -192,7 +293,7 @@ export async function runTurnModel(args: { // on every stateless no-tools request), so it ships as a single user // message. The stable persona rides `system` and is cache-eligible. params: singleUserTurn({ - system: ctx.systemPrompt, + system: appendBlocks(ctx.systemPrompt, args.turnHints), user: ctx.userPrompt, temperature: AI_BUDGETS.coach.temperature, maxTokens: AI_BUDGETS.coach.maxTokens, @@ -228,6 +329,10 @@ export async function runTurnModel(args: { inventoryPayloads: [], totalTokens: result.tokensUsed ?? 0, cachedTokens: result.cachedInputTokens ?? 0, + steps: steps.list(), + results: [], + forcedFinal: false, + inventory: null, }; } catch (err) { // The provider chain failed outright — no tokens were billed, so refund diff --git a/src/lib/ai/coach/turn/persist.ts b/src/lib/ai/coach/turn/persist.ts index 516fcde44..3db6d66d9 100644 --- a/src/lib/ai/coach/turn/persist.ts +++ b/src/lib/ai/coach/turn/persist.ts @@ -4,7 +4,7 @@ import { annotate } from "@/lib/logging/context"; import type { ProviderChainType } from "@/lib/ai/provider-chain"; import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator"; -import type { CoachProvenance } from "@/lib/ai/coach/types"; +import type { CoachProvenance, CoachResultTable } from "@/lib/ai/coach/types"; import { appendMessage } from "@/lib/ai/coach/persistence"; import type { TurnContext } from "./context"; @@ -17,6 +17,8 @@ export async function persistAssistantReply(args: { model: Extract; ctx: TurnContext; toolMode: boolean; + /** v1.39.4 — the tables to seal beside the message. */ + results: CoachResultTable[]; }): Promise<{ messageId: string }> { const { conversationId, replyText, provenance, model, ctx, toolMode } = args; const workingProviderType: ProviderChainType = model.workingProviderType; @@ -38,6 +40,8 @@ export async function persistAssistantReply(args: { // reflects the true turn cost on the tool path too. tokensUsed: totalTokensSpent || null, model: model.result.model ?? null, + // v1.39.4 — the tables, encrypted into their own column. + ...(args.results.length > 0 ? { results: args.results } : {}), }); // v1.18.7 — the day's spend was already reconciled against the diff --git a/src/lib/ai/coach/turn/pipeline.ts b/src/lib/ai/coach/turn/pipeline.ts index 7b109ac53..5a3b82f9c 100644 --- a/src/lib/ai/coach/turn/pipeline.ts +++ b/src/lib/ai/coach/turn/pipeline.ts @@ -19,10 +19,14 @@ import { resolveTurnChain } from "./chain"; import { assembleTurnContext } from "./context"; 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 { runTurnModel } from "./model"; import { persistAssistantReply } from "./persist"; -import { buildTurnProvenance } from "./provenance"; +import { assembleTurnDialog, buildTurnProvenance } from "./provenance"; import { guardReply } from "./reply-guards"; +import { runReuseTurn } from "./reuse-turn"; import { SSE_HEADERS, createTurnEmitter, @@ -45,6 +49,32 @@ export async function runCoachTurn(input: TurnInput): Promise { const conversation = resolved.conversation; const workingConversationId = conversation.conversationId; + // v1.39.4 — a tapped chip or an answered question. Resolved against what + // the server persisted, never against what the client says the chip was. + // A reuse chip is answered from a stored table without a model call. + const resolvedFollowUp = await resolveFollowUp({ + userId, + conversationId: input.conversationId, + followUp: input.followUp, + }); + if (resolvedFollowUp?.followUp.reuse) { + const reused = await runReuseTurn({ + input, + conversation, + resolved: resolvedFollowUp, + }); + if (reused) return reused; + } + const clarifiedLine = await resolveClarificationAnswer({ + userId, + conversationId: input.conversationId, + clarification: input.clarification, + }); + const turnHints = [ + ...(resolvedFollowUp?.contextHint ? [resolvedFollowUp.contextHint] : []), + ...(clarifiedLine ? [clarifiedLine] : []), + ]; + await persistUserTurn(workingConversationId, message); const ctx = await assembleTurnContext({ @@ -95,6 +125,7 @@ export async function runCoachTurn(input: TurnInput): Promise { toolMode, reservation, emitter, + turnHints, }); if (!model.ok) return model; @@ -120,12 +151,21 @@ export async function runCoachTurn(input: TurnInput): Promise { coachPrefs: ctx.coachPrefs, reply, }); + const dialog = assembleTurnDialog({ + model, + reply, + prefs: ctx.coachPrefs, + locale, + }); const provenance = buildTurnProvenance({ snapshotProvenance: ctx.snapshot.provenance, reply, suggestion: cards.suggestion, action: cards.action, toolTrace: model.toolTrace, + steps: model.steps, + dialog, + forcedFinal: model.forcedFinal, }); const { messageId } = await persistAssistantReply({ conversationId: workingConversationId, @@ -134,6 +174,7 @@ export async function runCoachTurn(input: TurnInput): Promise { model, ctx, toolMode, + results: dialog.results, }); return { @@ -142,6 +183,9 @@ export async function runCoachTurn(input: TurnInput): Promise { provenance, suggestion: cards.suggestion, action: cards.action, + results: dialog.results, + followUps: dialog.followUps, + clarification: dialog.clarification, messageId, totalTokens: model.totalTokens, model: model.result.model ?? null, diff --git a/src/lib/ai/coach/turn/provenance.ts b/src/lib/ai/coach/turn/provenance.ts index 6000fb403..437d73982 100644 --- a/src/lib/ai/coach/turn/provenance.ts +++ b/src/lib/ai/coach/turn/provenance.ts @@ -1,24 +1,119 @@ /** * The provenance envelope a Coach reply is persisted and streamed with: * what the snapshot covered, plus what this turn added (key values, cards, - * the tool trace, the grounded figures, the withheld-figure count). + * the tool trace, the grounded figures, the withheld-figure count, and the + * dialog: steps, tables, method, chips, clarification). */ import { annotate } from "@/lib/logging/context"; +import type { Locale } from "@/lib/i18n/config"; import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator"; -import type { CoachProvenance, CoachSuggestion } from "@/lib/ai/coach/types"; +import type { + CoachClarification, + CoachFollowUp, + CoachMethod, + CoachProvenance, + CoachResultMeta, + CoachResultTable, + CoachStep, + CoachSuggestion, +} from "@/lib/ai/coach/types"; 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 { 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. */ + results: CoachResultTable[]; + method: CoachMethod | null; + /** Empty on a blocked turn. */ + followUps: CoachFollowUp[]; + /** Null on a blocked turn. */ + clarification: CoachClarification | null; +} + +/** A table's metadata: everything but the values, for the plaintext blob. */ +function toResultMeta(table: CoachResultTable): CoachResultMeta { + return { + ref: table.ref, + source: table.source, + shape: table.shape, + titleKey: table.titleKey, + title: table.title, + rowCount: table.rowCount, + chartKind: table.chartKind, + displayed: table.displayed, + ...(table.reusedFrom ? { reusedFrom: table.reusedFrom } : {}), + }; +} + +/** + * v1.39.4 — the dialog of a guarded reply. On an outbound block the tables, + * chips and clarification are dropped, the way key values already are: the + * reply they belonged to was replaced. + */ +export function assembleTurnDialog(args: { + model: Extract; + reply: GuardedReply; + prefs: CoachPrefs; + locale: Locale; +}): TurnDialog { + const { model, reply, prefs, locale } = args; + const blocked = reply.outboundBlocked; + const referenced = new Set(reply.referencedResults); + const results = blocked + ? [] + : model.results.map((table) => + referenced.has(table.ref) ? { ...table, displayed: true } : table, + ); + 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 + ? [] + : [ + ...(continueChip ? [continueChip] : []), + ...deriveFollowUps({ + results: metas, + steps: model.steps, + inventory: model.inventory, + proposals: reply.followUpProposals, + forcedFinal: model.forcedFinal, + prefs, + locale, + }), + ].slice(0, MAX_FOLLOW_UPS); + return { + results, + method, + followUps, + clarification: reply.clarification, + }; +} + export function buildTurnProvenance(args: { snapshotProvenance: CoachProvenance; reply: GuardedReply; suggestion: CoachSuggestion | null; action: CoachSuggestedAction | null; toolTrace: CoachToolTrace[]; + /** v1.39.4 — the steps the turn read; kept on a blocked turn too. */ + steps: CoachStep[]; + dialog: TurnDialog; + forcedFinal: boolean; }): CoachProvenance { - const { snapshotProvenance, reply, toolTrace } = args; + const { snapshotProvenance, reply, toolTrace, steps, dialog } = args; const surfacedSuggestion = args.suggestion; const surfacedAction = args.action; const sentinel = reply.keyValuesSentinel; @@ -58,6 +153,16 @@ export function buildTurnProvenance(args: { ...(unverifiedStripped > 0 ? { unverifiedFigures: unverifiedStripped } : {}), + // v1.39.4 — the dialog. Metadata only: a table's values are in the + // encrypted column, never in this plaintext envelope. + ...(steps.length > 0 ? { steps } : {}), + ...(dialog.method ? { method: dialog.method } : {}), + ...(dialog.results.length > 0 + ? { results: dialog.results.map(toResultMeta) } + : {}), + ...(dialog.followUps.length > 0 ? { followUps: dialog.followUps } : {}), + ...(dialog.clarification ? { clarification: dialog.clarification } : {}), + ...(args.forcedFinal ? { forcedFinal: true as const } : {}), }; if (sentinel.malformed) { // Graceful degrade: log so ops can spot a provider whose diff --git a/src/lib/ai/coach/turn/reply-guards.ts b/src/lib/ai/coach/turn/reply-guards.ts index 00be74862..ae7d9d9fd 100644 --- a/src/lib/ai/coach/turn/reply-guards.ts +++ b/src/lib/ai/coach/turn/reply-guards.ts @@ -29,6 +29,10 @@ import { captureReminderFromSentinel, } from "@/lib/ai/coach/reminders"; import { parseSuggestAction } from "@/lib/ai/coach/suggest-action"; +import { parseClarifySentinel } from "@/lib/ai/coach/clarify"; +import { parseFollowUpsSentinel } from "@/lib/ai/coach/follow-ups/parse-sentinel"; +import { stripResultRefs } from "@/lib/ai/coach/results/refs"; +import type { CoachClarification } from "@/lib/ai/coach/types"; import type { TurnContext } from "./context"; import type { ModelOutcome } from "./model"; @@ -43,6 +47,15 @@ export interface GuardedReply { outboundBlocked: boolean; groundedFigures: number[]; unverifiedStripped: number; + /** v1.39.4 — the refs (`r1`..) the prose marked as the tables it uses. */ + referencedResults: string[]; + /** v1.39.4 — the chip kinds and domains the model proposed. */ + followUpProposals: ReturnType["proposals"]; + /** + * v1.39.4 — the validated clarification, when this reply asks one. Null + * when it does not, and on a blocked turn (the question was replaced). + */ + clarification: CoachClarification | null; } export async function guardReply(args: { @@ -131,6 +144,21 @@ export async function guardReply(args: { const actionParse = parseSuggestAction(replyText); replyText = actionParse.prose.trim() || replyText; + // v1.39.4 — the dialog sentinels and marks, stripped before the safety + // screen and the number check read the prose: the optional + // `---FOLLOWUPS---` proposal, the optional `---CLARIFY---` block, and the + // `result:rN` marks (whose digits must never reach the number check). + const followUpsParse = parseFollowUpsSentinel(replyText); + replyText = followUpsParse.prose.trim() || replyText; + const clarifyParse = parseClarifySentinel({ + prose: replyText, + inventory: model.inventory, + locale, + }); + replyText = clarifyParse.prose.trim() || replyText; + const resultRefs = stripResultRefs(replyText); + replyText = resultRefs.prose.trim() || replyText; + // v1.18.10 (HIGH-2) — OUTBOUND safety screen on the assembled assistant // reply, before persistence and streaming. The inbound `detectRefusal` // guards the user's message; this guards the model's reply for a @@ -333,6 +361,11 @@ export async function guardReply(args: { outboundBlocked: outbound.block, groundedFigures, unverifiedStripped, + referencedResults: resultRefs.referenced, + followUpProposals: followUpsParse.proposals, + // A blocked turn carries the fallback prose, so a question it asked is + // gone and its choices must not ride along. + clarification: outbound.block ? null : clarifyParse.clarification, }, }; } diff --git a/src/lib/ai/coach/turn/reuse-turn.ts b/src/lib/ai/coach/turn/reuse-turn.ts new file mode 100644 index 000000000..66c14d23e --- /dev/null +++ b/src/lib/ai/coach/turn/reuse-turn.ts @@ -0,0 +1,20 @@ +/** + * A model-free turn: a reuse chip ("as a chart", "as a table") answered from + * a table already stored on the conversation. No budget reservation and no + * provider call; the capability and the rate limit still apply at the route. + * 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. + */ +import type { ResolvedFollowUp } from "@/lib/ai/coach/follow-ups/resolve"; + +import type { TurnConversation, TurnInput } from "./types"; + +export async function runReuseTurn(_args: { + input: TurnInput; + conversation: TurnConversation; + resolved: ResolvedFollowUp; +}): Promise { + return null; +} diff --git a/src/lib/ai/coach/turn/sse.ts b/src/lib/ai/coach/turn/sse.ts index 9eb296148..d1a70394e 100644 --- a/src/lib/ai/coach/turn/sse.ts +++ b/src/lib/ai/coach/turn/sse.ts @@ -87,9 +87,10 @@ function flushTick(): Promise { /** * Emit a finished turn. A failed outcome is one `error` frame. A guarded - * reply goes out as `token* → provenance → suggestion? → suggestedAction? → - * done`; the token frames carry the FULLY-GUARDED text, and every guard ran - * before the first one leaves. + * reply goes out as `token* → provenance → result* → suggestion? → + * suggestedAction? → clarification? → followUps? → done`; the token frames + * carry the FULLY-GUARDED text, and every guard ran before the first one + * leaves. The live `step` frames went out earlier, while the model ran. */ export async function emitReply( emitter: TurnEmitter, @@ -115,6 +116,11 @@ export async function emitReply( } if (emitter.aborted()) return; emitter.emit({ type: "provenance", metricSource: outcome.provenance }); + // v1.39.4 — additive `result` frames: the tables this turn read, after + // every guard and only on the owner's own stream. + for (const result of outcome.results) { + emitter.emit({ type: "result", result }); + } // v1.18.1 (Workstream C) — additive `suggestion` frame. if (outcome.suggestion) { emitter.emit({ type: "suggestion", suggestion: outcome.suggestion }); @@ -126,6 +132,16 @@ export async function emitReply( suggestedAction: outcome.action, }); } + // v1.39.4 — additive `clarification` and `followUps` frames. + if (outcome.clarification) { + emitter.emit({ + type: "clarification", + clarification: outcome.clarification, + }); + } + if (outcome.followUps.length > 0) { + emitter.emit({ type: "followUps", followUps: outcome.followUps }); + } // v1.18.9 — additive `usage` envelope on the `done` frame. emitter.emit({ type: "done", diff --git a/src/lib/ai/coach/turn/steps.ts b/src/lib/ai/coach/turn/steps.ts new file mode 100644 index 000000000..72cd3d381 --- /dev/null +++ b/src/lib/ai/coach/turn/steps.ts @@ -0,0 +1,41 @@ +/** + * Live steps: one `CoachStep` per tool call, emitted as `step` frames while + * the turn runs and persisted on `metricSource.steps`. + * + * A step carries a catalog label key, the server-rendered label, the domain, + * the window and a server-counted number. Never free text, an analyte name + * or a health value. + * + * Not built yet: both functions answer null, so a turn emits no step frame. + */ +import type { Locale } from "@/lib/i18n/config"; +import type { AiToolCall } from "@/lib/ai/types"; +import type { CoachStep } from "@/lib/ai/coach/types"; +import type { CoachToolResult } from "@/lib/ai/coach/tools/executor"; + +/** + * The step for one tool call: `running` when `result` is absent (the call + * just started), its final status once the result is in. `index` counts + * calls across the turn from 0; `parsedArgs` are the schema-validated + * arguments, absent when they did not validate. + */ +export function toStep(_args: { + call: AiToolCall; + index: number; + parsedArgs: Record | undefined; + result?: CoachToolResult; + locale: Locale; +}): CoachStep | null { + return null; +} + +/** + * The single step a no-tools turn shows: the full snapshot, with the number + * of metrics it covered. + */ +export function snapshotStep(_args: { + metricCount: number; + locale: Locale; +}): CoachStep | null { + return null; +} diff --git a/src/lib/ai/coach/turn/types.ts b/src/lib/ai/coach/turn/types.ts index c8a58b9dd..439c392fd 100644 --- a/src/lib/ai/coach/turn/types.ts +++ b/src/lib/ai/coach/turn/types.ts @@ -15,7 +15,10 @@ import type { Locale } from "@/lib/i18n/config"; import type { CoachTurn } from "@/lib/ai/coach/chat-request-builder"; import type { CoachSuggestedAction } from "@/lib/ai/coach/suggest-action"; import type { + CoachClarification, + CoachFollowUp, CoachProvenance, + CoachResultTable, CoachScope, CoachStreamEvent, CoachSuggestion, @@ -32,6 +35,10 @@ export interface TurnInput { scope: CoachScope | undefined; guidedQuestion: string | undefined; workoutId: string | undefined; + /** v1.39.4 — the follow-up chip this message came from, if any. */ + followUp: { messageId: string; id: string } | undefined; + /** v1.39.4 — the clarifying question this message answers, if any. */ + clarification: { messageId: string; choiceId?: string } | undefined; /** * The `coach` capability gate, re-run at the egress site. Owned by the * route (the capability inventory reads it there); returns the refusal @@ -80,6 +87,12 @@ export type ReplyOutcome = provenance: CoachProvenance; suggestion: CoachSuggestion | null; action: CoachSuggestedAction | null; + /** v1.39.4 — tables streamed after the provenance, owner only. */ + results: CoachResultTable[]; + /** v1.39.4 — the chips under the reply. */ + followUps: CoachFollowUp[]; + /** v1.39.4 — the choices, when the reply is a question. */ + clarification: CoachClarification | null; messageId: string; totalTokens: number; model: string | null; diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 8f50d291b..046743cbb 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -2778,7 +2778,10 @@ describe("every model the plan claims two-ended survives a real restore", () => const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, { purpose: "portable-export", }); - const [turn] = payload.coachConversations[0].messages; + const { coachConversations } = payload as { + coachConversations: Array<{ messages: Array> }>; + }; + const [turn] = coachConversations[0].messages; expect(turn).toMatchObject({ resultsJson: COACH_RESULTS_JSON }); expect(turn).not.toHaveProperty("resultsEncrypted");