mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(coach): seams for steps, tables, charts, method, chips and clarification
Each later piece of the dialog gets its own module, answering nothing for now: the step builder, the table projection and chart choice, the result ref marks, the follow-up derivation, sentinel, resolution and continue chip, the reuse turn, the clarification parse and answer, the method line, and the four prompt addenda. The turn pipeline calls them where they belong, so the frames go out in the documented order (step, token, provenance, result, cards, clarification, followUps, done) and a blocked turn drops its tables, chips and clarification. With every piece empty the stream and the prompt stay byte for byte as before.
This commit is contained in:
1 parent
742ef2c9b0
commit
8fcd204172
27 files changed
+863
-16
No files matched your search
@@ -109,6 +109,8 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
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<Response> {
|
||||
scope,
|
||||
guidedQuestion,
|
||||
workoutId,
|
||||
followUp,
|
||||
clarification,
|
||||
recheckCapability: coachCapabilityRefusal,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<string | null> {
|
||||
return null;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -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: [] };
|
||||
}
|
||||
@@ -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<ResolvedFollowUp | null> {
|
||||
return null;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<CoachResultTable, "chart" | "chartKind">,
|
||||
): CoachChartSpec | null {
|
||||
return null;
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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 [];
|
||||
}
|
||||
@@ -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: [] };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ModelOutcome, { ok: true }>;
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 "";
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
@@ -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<string, CoachStep>();
|
||||
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<ModelOutcome> {
|
||||
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
|
||||
|
||||
@@ -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<ModelOutcome, { ok: true }>;
|
||||
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
|
||||
|
||||
@@ -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<Response> {
|
||||
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<Response> {
|
||||
toolMode,
|
||||
reservation,
|
||||
emitter,
|
||||
turnHints,
|
||||
});
|
||||
if (!model.ok) return model;
|
||||
|
||||
@@ -120,12 +151,21 @@ export async function runCoachTurn(input: TurnInput): Promise<Response> {
|
||||
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<Response> {
|
||||
model,
|
||||
ctx,
|
||||
toolMode,
|
||||
results: dialog.results,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -142,6 +183,9 @@ export async function runCoachTurn(input: TurnInput): Promise<Response> {
|
||||
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,
|
||||
|
||||
@@ -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<ModelOutcome, { ok: true }>;
|
||||
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
|
||||
|
||||
@@ -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<typeof parseFollowUpsSentinel>["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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<Response | null> {
|
||||
return null;
|
||||
}
|
||||
@@ -87,9 +87,10 @@ function flushTick(): Promise<void> {
|
||||
|
||||
/**
|
||||
* 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",
|
||||
|
||||
@@ -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<string, unknown> | 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Record<string, unknown>> }>;
|
||||
};
|
||||
const [turn] = coachConversations[0].messages;
|
||||
expect(turn).toMatchObject({ resultsJson: COACH_RESULTS_JSON });
|
||||
expect(turn).not.toHaveProperty("resultsEncrypted");
|
||||
|
||||
|
||||
Reference in new issue
Block a user