mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge branch 'coach/wp7-continue' into release/v1.39.4
# Conflicts: # src/lib/ai/coach/turn/pipeline.ts # src/lib/ai/coach/turn/provenance.ts
This commit is contained in:
commit
d63d2e6d83
10 files changed
+796
-6
No files matched your search
@@ -50407,6 +50407,11 @@ components:
|
||||
description: v1.39.4 — the answer was forced at the round cap while the Coach still wanted to read. Absent otherwise.
|
||||
type: boolean
|
||||
const: true
|
||||
continuationOf:
|
||||
description: "v1.39.4 — this reply continues an answer that was forced at the round cap (the `continue` chip): the id of
|
||||
that earlier assistant message. A continuation offers no further `continue` chip. Absent otherwise."
|
||||
type: string
|
||||
maxLength: 64
|
||||
required:
|
||||
- windows
|
||||
- metrics
|
||||
|
||||
@@ -138,6 +138,7 @@ const FULL: CoachProvenance = {
|
||||
freeText: true,
|
||||
},
|
||||
forcedFinal: true,
|
||||
continuationOf: "m0",
|
||||
};
|
||||
|
||||
function stubEchoCreate(overrideJson?: string): void {
|
||||
@@ -161,7 +162,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("appendMessage — the dialog fields", () => {
|
||||
it("restores steps, method, table metadata, chips, clarification and the forced marker", async () => {
|
||||
it("restores steps, method, table metadata, chips, clarification, the forced marker and the continuation", async () => {
|
||||
stubEchoCreate();
|
||||
const out = await appendMessage({
|
||||
conversationId: "c1",
|
||||
@@ -183,6 +184,7 @@ describe("appendMessage — the dialog fields", () => {
|
||||
method: { entries: "nope", text: "x" },
|
||||
clarification: { kind: "metric", choices: [], freeText: "yes" },
|
||||
forcedFinal: "true",
|
||||
continuationOf: 42,
|
||||
}),
|
||||
);
|
||||
const out = await appendMessage({
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* v1.39.4 — "keep looking": a chip only when the loop forced the answer,
|
||||
* the continued turn told what the question was and what was already read
|
||||
* (by the names `show_result` takes), and never a second continuation.
|
||||
*/
|
||||
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),
|
||||
}));
|
||||
// The stored body is plain text here; the codec is exercised elsewhere.
|
||||
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
decryptFromBytes: (bytes: Uint8Array) => new TextDecoder().decode(bytes),
|
||||
}));
|
||||
|
||||
import type { CoachFollowUp, CoachStep } from "@/lib/ai/coach/types";
|
||||
import type { PriorResultTurn } from "@/lib/ai/coach/results/refs";
|
||||
|
||||
import {
|
||||
CONTINUE_QUESTION_MAX_CHARS,
|
||||
buildContinueFollowUp,
|
||||
continuationHint,
|
||||
resolveContinuation,
|
||||
} from "../continue";
|
||||
|
||||
const CONTINUE_CHIP: CoachFollowUp = {
|
||||
id: "f1",
|
||||
kind: "continue",
|
||||
labelKey: "coach.followUp.continue",
|
||||
label: "Keep looking",
|
||||
reuse: false,
|
||||
origin: "server",
|
||||
};
|
||||
|
||||
const STEPS: CoachStep[] = [
|
||||
{
|
||||
id: "s1",
|
||||
tool: "get_metric_table",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "x",
|
||||
domain: "bp",
|
||||
window: "last90days",
|
||||
granularity: "week",
|
||||
period: "current",
|
||||
status: "done",
|
||||
count: 142,
|
||||
resultRef: "r1",
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
tool: "get_sleep",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "x",
|
||||
domain: "sleep",
|
||||
window: "last30days",
|
||||
status: "empty",
|
||||
reason: "no_data",
|
||||
},
|
||||
{
|
||||
id: "s3",
|
||||
tool: "get_labs",
|
||||
labelKey: "coach.step.read",
|
||||
label: "x",
|
||||
domain: "labs",
|
||||
status: "failed",
|
||||
reason: "retrieval_failed",
|
||||
},
|
||||
{
|
||||
id: "s4",
|
||||
tool: "get_metric_series",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "x",
|
||||
domain: "weight",
|
||||
window: "last30days",
|
||||
status: "running",
|
||||
},
|
||||
];
|
||||
|
||||
const PRIOR: PriorResultTurn[] = [
|
||||
{
|
||||
messageId: "m-forced",
|
||||
turnIndex: 3,
|
||||
results: [
|
||||
{
|
||||
ref: "r1",
|
||||
source: {
|
||||
tool: "get_metric_table",
|
||||
domain: "bp",
|
||||
window: "last90days",
|
||||
period: "current",
|
||||
granularity: "week",
|
||||
},
|
||||
shape: "timeSeries",
|
||||
titleKey: "k",
|
||||
title: "t",
|
||||
rowCount: 13,
|
||||
chartKind: null,
|
||||
displayed: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const bytes = (text: string) => new TextEncoder().encode(text);
|
||||
|
||||
function forcedReply(extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "m-forced",
|
||||
role: "assistant",
|
||||
providerType: "openai",
|
||||
encryptedContent: bytes("Partial answer."),
|
||||
metricSourceJson: JSON.stringify({
|
||||
windows: [],
|
||||
metrics: [],
|
||||
steps: STEPS,
|
||||
followUps: [CONTINUE_CHIP],
|
||||
forcedFinal: true,
|
||||
...extra,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function question(
|
||||
text = "How did my blood pressure and sleep go this quarter?",
|
||||
) {
|
||||
return {
|
||||
id: "m-question",
|
||||
role: "user",
|
||||
providerType: null,
|
||||
encryptedContent: bytes(text),
|
||||
metricSourceJson: null,
|
||||
};
|
||||
}
|
||||
|
||||
const TAP = { messageId: "m-forced", id: "f1" };
|
||||
|
||||
beforeEach(() => {
|
||||
findMany.mockReset();
|
||||
annotate.mockReset();
|
||||
});
|
||||
|
||||
describe("buildContinueFollowUp", () => {
|
||||
it("offers the chip only when the answer was forced", () => {
|
||||
expect(
|
||||
buildContinueFollowUp({ forcedFinal: false, locale: "en" }),
|
||||
).toBeNull();
|
||||
expect(buildContinueFollowUp({ forcedFinal: true, locale: "en" })).toEqual(
|
||||
CONTINUE_CHIP,
|
||||
);
|
||||
expect(
|
||||
buildContinueFollowUp({ forcedFinal: true, locale: "de" })?.label,
|
||||
).not.toBe("Keep looking");
|
||||
});
|
||||
|
||||
it("offers no second continuation", () => {
|
||||
expect(
|
||||
buildContinueFollowUp({
|
||||
forcedFinal: true,
|
||||
continuationOf: "m-forced",
|
||||
locale: "en",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveContinuation", () => {
|
||||
it("names the question and what was already read, tables by their show_result name", async () => {
|
||||
findMany.mockResolvedValue([forcedReply(), question()]);
|
||||
const out = await resolveContinuation({
|
||||
userId: "u1",
|
||||
conversationId: "c1",
|
||||
followUp: TAP,
|
||||
priorResults: PRIOR,
|
||||
});
|
||||
expect(findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { conversationId: "c1", conversation: { userId: "u1" } },
|
||||
}),
|
||||
);
|
||||
expect(out).toEqual({
|
||||
sourceMessageId: "m-forced",
|
||||
contextHint:
|
||||
'CONTINUE: the person asked you to keep looking. Unfinished answer for: "How did my blood pressure and sleep go this quarter?". Already fetched: get_metric_table(bp, last90days, week) → m3.r1; get_sleep(sleep, last30days) → no readings. Use show_result for these tables, do not fetch them again. Fetch only what is still missing, then answer the question in full.',
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a second continuation", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
forcedReply({ continuationOf: "m-earlier" }),
|
||||
question(),
|
||||
]);
|
||||
expect(
|
||||
await resolveContinuation({
|
||||
userId: "u1",
|
||||
conversationId: "c1",
|
||||
followUp: TAP,
|
||||
priorResults: PRIOR,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(annotate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: { name: "coach.followUp.continue_refused" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("is null for a chip that is not current or not a continue chip", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{ ...forcedReply(), id: "m-newer" },
|
||||
question(),
|
||||
]);
|
||||
expect(
|
||||
await resolveContinuation({
|
||||
userId: "u1",
|
||||
conversationId: "c1",
|
||||
followUp: TAP,
|
||||
priorResults: PRIOR,
|
||||
}),
|
||||
).toBeNull();
|
||||
|
||||
findMany.mockResolvedValue([
|
||||
forcedReply({
|
||||
followUps: [{ ...CONTINUE_CHIP, kind: "previous_period" }],
|
||||
}),
|
||||
question(),
|
||||
]);
|
||||
expect(
|
||||
await resolveContinuation({
|
||||
userId: "u1",
|
||||
conversationId: "c1",
|
||||
followUp: TAP,
|
||||
priorResults: PRIOR,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("reads nothing without a chip", async () => {
|
||||
expect(
|
||||
await resolveContinuation({
|
||||
userId: "u1",
|
||||
conversationId: "c1",
|
||||
followUp: undefined,
|
||||
priorResults: [],
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("looks past an interrupted turn's marker", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{
|
||||
id: "m-cancel",
|
||||
role: "assistant",
|
||||
providerType: "cancelled",
|
||||
encryptedContent: bytes(""),
|
||||
metricSourceJson: null,
|
||||
},
|
||||
forcedReply(),
|
||||
question(),
|
||||
]);
|
||||
const out = await resolveContinuation({
|
||||
userId: "u1",
|
||||
conversationId: "c1",
|
||||
followUp: TAP,
|
||||
priorResults: PRIOR,
|
||||
});
|
||||
expect(out?.sourceMessageId).toBe("m-forced");
|
||||
});
|
||||
});
|
||||
|
||||
describe("continuationHint", () => {
|
||||
it("caps the question and keeps it on one line", () => {
|
||||
const long = `line one\nline two ${"x".repeat(2000)}`;
|
||||
const hint = continuationHint({ question: long, fetched: [] });
|
||||
const quoted = hint.slice(hint.indexOf('"') + 1, hint.lastIndexOf('"'));
|
||||
expect([...quoted]).toHaveLength(CONTINUE_QUESTION_MAX_CHARS);
|
||||
expect(quoted).not.toContain("\n");
|
||||
expect(hint).not.toContain("Already fetched");
|
||||
});
|
||||
});
|
||||
@@ -2,14 +2,228 @@
|
||||
* "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.
|
||||
* The chip leads the reply's chips. Tapping it runs a normal model turn
|
||||
* (budget, capability and rate limit as ever) with two server-written
|
||||
* context lines: the question that was left unfinished, and what the forced
|
||||
* turn already read, each table by the name `show_result` takes, so the
|
||||
* continued turn shows those again instead of fetching them a second time.
|
||||
*
|
||||
* An answer is continued at most once. The continued reply records the
|
||||
* message it continues (`metricSource.continuationOf`) and offers no further
|
||||
* "keep looking" chip, even if it is forced again; a tapped chip on a reply
|
||||
* that is itself a continuation is refused.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import type { CoachFollowUp } from "@/lib/ai/coach/types";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import type { CoachFollowUp, CoachStep } from "@/lib/ai/coach/types";
|
||||
import {
|
||||
coachFollowUpSchema,
|
||||
coachStepSchema,
|
||||
} from "@/lib/ai/coach/stream-events";
|
||||
import { COACH_FOLLOW_UP_KEYS } from "@/lib/ai/coach/dialog-keys";
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
formatPriorResultRef,
|
||||
type PriorResultTurn,
|
||||
} from "@/lib/ai/coach/results/refs";
|
||||
|
||||
export function buildContinueFollowUp(_args: {
|
||||
/** The unfinished question rides the context capped at this many characters. */
|
||||
export const CONTINUE_QUESTION_MAX_CHARS = 1000;
|
||||
|
||||
/**
|
||||
* The "keep looking" chip for a reply, or null: only when the loop forced
|
||||
* the answer, and never on a reply that already continues another.
|
||||
*/
|
||||
export function buildContinueFollowUp(args: {
|
||||
forcedFinal: boolean;
|
||||
/** Set when this reply continues an earlier forced answer. */
|
||||
continuationOf?: string;
|
||||
locale: Locale;
|
||||
}): CoachFollowUp | null {
|
||||
return null;
|
||||
if (!args.forcedFinal || args.continuationOf) return null;
|
||||
const { t } = getServerTranslator(args.locale);
|
||||
const labelKey = COACH_FOLLOW_UP_KEYS.continue;
|
||||
return {
|
||||
id: "f1",
|
||||
kind: "continue",
|
||||
labelKey,
|
||||
label: t(labelKey),
|
||||
reuse: false,
|
||||
origin: "server",
|
||||
};
|
||||
}
|
||||
|
||||
/** A tapped "keep looking" chip that resolved. */
|
||||
export interface ResolvedContinuation {
|
||||
/** The forced reply being continued; persisted as `continuationOf`. */
|
||||
sourceMessageId: string;
|
||||
/** The context lines for the continued turn. */
|
||||
contextHint: string;
|
||||
}
|
||||
|
||||
/** 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,
|
||||
encryptedContent: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
interface StoredForced {
|
||||
followUps: CoachFollowUp[];
|
||||
steps: CoachStep[];
|
||||
continuationOf: string | null;
|
||||
}
|
||||
|
||||
function storedDialog(metricSourceJson: string | null): StoredForced | null {
|
||||
if (!metricSourceJson) return null;
|
||||
try {
|
||||
const raw = JSON.parse(metricSourceJson) as Record<string, unknown>;
|
||||
const followUps = Array.isArray(raw.followUps)
|
||||
? raw.followUps.flatMap((item) => {
|
||||
const parsed = coachFollowUpSchema.safeParse(item);
|
||||
return parsed.success ? [parsed.data] : [];
|
||||
})
|
||||
: [];
|
||||
const steps = Array.isArray(raw.steps)
|
||||
? raw.steps.flatMap((item) => {
|
||||
const parsed = coachStepSchema.safeParse(item);
|
||||
return parsed.success ? [parsed.data] : [];
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
followUps,
|
||||
steps,
|
||||
continuationOf:
|
||||
typeof raw.continuationOf === "string" ? raw.continuationOf : null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** One line of the question, cut at the cap on a code point. */
|
||||
function questionLine(text: string): string {
|
||||
const collapsed = text.replace(/\s+/g, " ").trim();
|
||||
const points = [...collapsed];
|
||||
return points.length <= CONTINUE_QUESTION_MAX_CHARS
|
||||
? collapsed
|
||||
: `${points.slice(0, CONTINUE_QUESTION_MAX_CHARS - 1).join("")}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a step read, in the tool's own terms: `get_metric_table(bp,
|
||||
* last90days, week) → m3.r1`. Only enum fields and server names, so no
|
||||
* record text reaches the line.
|
||||
*/
|
||||
function fetchedLine(
|
||||
step: CoachStep,
|
||||
sourceMessageId: string,
|
||||
priorResults: readonly PriorResultTurn[],
|
||||
): string | null {
|
||||
if (step.tool === "snapshot" || !step.domain) return null;
|
||||
if (step.status !== "done" && step.status !== "empty") return null;
|
||||
const args = [
|
||||
step.domain,
|
||||
...(step.window ? [step.window] : []),
|
||||
...(step.granularity ? [step.granularity] : []),
|
||||
...(step.period && step.period !== "current" ? [step.period] : []),
|
||||
].join(", ");
|
||||
const call = `${step.tool}(${args})`;
|
||||
if (step.status === "empty") return `${call} → no readings`;
|
||||
const turn = priorResults.find((p) => p.messageId === sourceMessageId);
|
||||
const name =
|
||||
step.resultRef && turn?.results.some((r) => r.ref === step.resultRef)
|
||||
? formatPriorResultRef(turn.turnIndex, step.resultRef)
|
||||
: null;
|
||||
return name ? `${call} → ${name}` : call;
|
||||
}
|
||||
|
||||
/** The context lines of a continued turn. Exported for the tests. */
|
||||
export function continuationHint(args: {
|
||||
question: string;
|
||||
fetched: string[];
|
||||
}): string {
|
||||
const fetched =
|
||||
args.fetched.length > 0
|
||||
? ` Already fetched: ${args.fetched.join("; ")}. Use show_result for these tables, do not fetch them again.`
|
||||
: "";
|
||||
return `CONTINUE: the person asked you to keep looking. Unfinished answer for: "${questionLine(args.question)}".${fetched} Fetch only what is still missing, then answer the question in full.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a tapped "keep looking" chip, or null when the request carries
|
||||
* none. The chip must sit on the conversation's latest reply, stored by the
|
||||
* server; a reply that is itself a continuation is refused.
|
||||
*/
|
||||
export async function resolveContinuation(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<ResolvedContinuation | null> {
|
||||
const { userId, conversationId, followUp } = args;
|
||||
if (!followUp || !conversationId) return null;
|
||||
let rows: Awaited<ReturnType<typeof latestMessages>>;
|
||||
try {
|
||||
rows = await latestMessages(userId, conversationId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const live = rows.filter((m) => m.providerType !== "cancelled");
|
||||
const latest = live[0];
|
||||
if (
|
||||
!latest ||
|
||||
latest.role !== "assistant" ||
|
||||
latest.id !== followUp.messageId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const stored = storedDialog(latest.metricSourceJson);
|
||||
const chip = stored?.followUps.find(
|
||||
(candidate) => candidate.id === followUp.id,
|
||||
);
|
||||
if (!stored || chip?.kind !== "continue") return null;
|
||||
if (stored.continuationOf) {
|
||||
annotate({
|
||||
action: { name: "coach.followUp.continue_refused" },
|
||||
meta: { reason: "already_continued" },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// The question is the person's message the forced reply answered.
|
||||
const asked = live.slice(1).find((m) => m.role === "user");
|
||||
let question = "";
|
||||
if (asked) {
|
||||
try {
|
||||
question = decryptFromBytes(asked.encryptedContent);
|
||||
} catch {
|
||||
question = "";
|
||||
}
|
||||
}
|
||||
if (!question.trim()) return null;
|
||||
|
||||
const fetched = stored.steps
|
||||
.map((step) => fetchedLine(step, latest.id, args.priorResults))
|
||||
.filter((line): line is string => line !== null);
|
||||
annotate({
|
||||
action: { name: "coach.followUp.continued" },
|
||||
meta: { fetched: fetched.length },
|
||||
});
|
||||
return {
|
||||
sourceMessageId: latest.id,
|
||||
contextHint: continuationHint({ question, fetched }),
|
||||
};
|
||||
}
|
||||
@@ -325,6 +325,12 @@ function provenanceFromJson(raw: string | null): CoachProvenance | null {
|
||||
const followUps = restoreFollowUps(parsed.followUps);
|
||||
const clarification = restoreClarification(parsed.clarification);
|
||||
const forcedFinal = parsed.forcedFinal === true;
|
||||
const continuationOf =
|
||||
typeof parsed.continuationOf === "string" &&
|
||||
parsed.continuationOf.length > 0 &&
|
||||
parsed.continuationOf.length <= 64
|
||||
? parsed.continuationOf
|
||||
: undefined;
|
||||
return {
|
||||
windows,
|
||||
metrics,
|
||||
@@ -341,6 +347,7 @@ function provenanceFromJson(raw: string | null): CoachProvenance | null {
|
||||
...(followUps ? { followUps } : {}),
|
||||
...(clarification ? { clarification } : {}),
|
||||
...(forcedFinal ? { forcedFinal: true as const } : {}),
|
||||
...(continuationOf ? { continuationOf } : {}),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -429,6 +429,13 @@ export const coachProvenanceSchema = z
|
||||
.describe(
|
||||
"v1.39.4 — the answer was forced at the round cap while the Coach still wanted to read. Absent otherwise.",
|
||||
),
|
||||
continuationOf: z
|
||||
.string()
|
||||
.max(64)
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.39.4 — this reply continues an answer that was forced at the round cap (the `continue` chip): the id of that earlier assistant message. A continuation offers no further `continue` chip. Absent otherwise.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "CoachProvenance",
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* v1.39.4 — "keep looking" through the turn pipeline: a forced answer offers
|
||||
* the chip first; tapping it runs a normal model turn (the budget gate
|
||||
* applies) with the continuation lines, records what it continues, and
|
||||
* offers no second "keep looking" even when it is forced again.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const m = vi.hoisted(() => ({
|
||||
findMany: vi.fn(),
|
||||
annotate: 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(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { coachMessage: { findMany: m.findMany } },
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: m.annotate }));
|
||||
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
decryptFromBytes: (bytes: Uint8Array) => new TextDecoder().decode(bytes),
|
||||
encryptToBytes: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/persistence", () => ({
|
||||
appendMessage: vi.fn(),
|
||||
createConversation: vi.fn(),
|
||||
}));
|
||||
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 { CoachProvenance, 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 bytes = (text: string) => new TextEncoder().encode(text);
|
||||
|
||||
function input(over: Partial<TurnInput> = {}): TurnInput {
|
||||
return {
|
||||
userId: "u1",
|
||||
locale: "en",
|
||||
signal: new AbortController().signal,
|
||||
conversationId: "c1",
|
||||
message: "Keep looking",
|
||||
scope: undefined,
|
||||
guidedQuestion: undefined,
|
||||
workoutId: undefined,
|
||||
followUp: undefined,
|
||||
clarification: undefined,
|
||||
recheckCapability: async () => null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
async function frames(res: Response): Promise<CoachStreamEvent[]> {
|
||||
const text = await res.text();
|
||||
return text
|
||||
.split("\n\n")
|
||||
.filter((chunk) => chunk.startsWith("data: "))
|
||||
.map((chunk) => JSON.parse(chunk.slice(6)) as CoachStreamEvent);
|
||||
}
|
||||
|
||||
function modelPath(forcedFinal: boolean) {
|
||||
m.assembleTurnContext.mockResolvedValue({
|
||||
coachPrefs: DEFAULT_COACH_PREFS,
|
||||
snapshot: { provenance: { windows: [], metrics: [] } },
|
||||
});
|
||||
m.resolveTurnChain.mockResolvedValue({ ok: true, chain: [], toolMode: true });
|
||||
m.reserveTurnBudget.mockResolvedValue({ ok: true, reservation: {} });
|
||||
m.runTurnModel.mockResolvedValue({
|
||||
ok: true,
|
||||
result: { content: "x", model: "gpt" },
|
||||
workingProviderType: "openai",
|
||||
toolTrace: [],
|
||||
totalTokens: 12,
|
||||
cachedTokens: 0,
|
||||
steps: [],
|
||||
results: [],
|
||||
forcedFinal,
|
||||
inventory: [],
|
||||
});
|
||||
m.guardReply.mockResolvedValue({
|
||||
ok: true,
|
||||
reply: {
|
||||
replyText: "Partial.",
|
||||
outboundBlocked: false,
|
||||
referencedResults: [],
|
||||
followUpProposals: [],
|
||||
clarification: null,
|
||||
keyValuesSentinel: {
|
||||
keyValues: [],
|
||||
malformed: false,
|
||||
malformedEntries: [],
|
||||
},
|
||||
groundedFigures: [],
|
||||
unverifiedStripped: 0,
|
||||
},
|
||||
});
|
||||
m.surfaceCards.mockResolvedValue({ suggestion: null, action: null });
|
||||
m.persistAssistantReply.mockResolvedValue({ messageId: "m-new" });
|
||||
}
|
||||
|
||||
function persistedProvenance(): CoachProvenance {
|
||||
return m.persistAssistantReply.mock.calls[0][0].provenance;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(m)) fn.mockReset();
|
||||
m.resolveTurnConversation.mockResolvedValue({
|
||||
conversation: {
|
||||
conversationId: "c1",
|
||||
priorTurns: [],
|
||||
priorUserMessages: [],
|
||||
priorToolFigures: [],
|
||||
priorSummary: null,
|
||||
priorResults: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("a forced answer", () => {
|
||||
it("offers 'keep looking' first", async () => {
|
||||
modelPath(true);
|
||||
const out = await frames(await runCoachTurn(input()));
|
||||
const chips = out.find((f) => f.type === "followUps");
|
||||
expect(chips?.type === "followUps" && chips.followUps[0]).toMatchObject({
|
||||
id: "f1",
|
||||
kind: "continue",
|
||||
label: "Keep looking",
|
||||
});
|
||||
expect(persistedProvenance().forcedFinal).toBe(true);
|
||||
});
|
||||
|
||||
it("offers nothing to continue when the answer was not forced", async () => {
|
||||
modelPath(false);
|
||||
const out = await frames(await runCoachTurn(input()));
|
||||
expect(out.some((f) => f.type === "followUps")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the continued turn", () => {
|
||||
beforeEach(() => {
|
||||
m.findMany.mockResolvedValue([
|
||||
{
|
||||
id: "m-forced",
|
||||
role: "assistant",
|
||||
providerType: "openai",
|
||||
encryptedContent: bytes("Partial."),
|
||||
metricSourceJson: JSON.stringify({
|
||||
windows: [],
|
||||
metrics: [],
|
||||
forcedFinal: true,
|
||||
followUps: [
|
||||
{
|
||||
id: "f1",
|
||||
kind: "continue",
|
||||
labelKey: "coach.followUp.continue",
|
||||
label: "Keep looking",
|
||||
reuse: false,
|
||||
origin: "server",
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "m-q",
|
||||
role: "user",
|
||||
providerType: null,
|
||||
encryptedContent: bytes("Why is my pulse up?"),
|
||||
metricSourceJson: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("runs the model under the budget gate with the continuation lines", async () => {
|
||||
modelPath(false);
|
||||
await frames(
|
||||
await runCoachTurn(
|
||||
input({ followUp: { messageId: "m-forced", id: "f1" } }),
|
||||
),
|
||||
);
|
||||
expect(m.reserveTurnBudget).toHaveBeenCalledTimes(1);
|
||||
const { turnHints } = m.runTurnModel.mock.calls[0][0];
|
||||
expect(turnHints).toEqual([
|
||||
expect.stringContaining('Unfinished answer for: "Why is my pulse up?"'),
|
||||
]);
|
||||
expect(persistedProvenance().continuationOf).toBe("m-forced");
|
||||
});
|
||||
|
||||
it("offers no second 'keep looking' when forced again", async () => {
|
||||
modelPath(true);
|
||||
const out = await frames(
|
||||
await runCoachTurn(
|
||||
input({ followUp: { messageId: "m-forced", id: "f1" } }),
|
||||
),
|
||||
);
|
||||
const chips = out.find((f) => f.type === "followUps");
|
||||
expect(
|
||||
chips?.type === "followUps" &&
|
||||
chips.followUps.some((c) => c.kind === "continue"),
|
||||
).toBeFalsy();
|
||||
expect(persistedProvenance()).toMatchObject({
|
||||
forcedFinal: true,
|
||||
continuationOf: "m-forced",
|
||||
});
|
||||
});
|
||||
|
||||
it("stops at the budget gate like any turn", async () => {
|
||||
modelPath(false);
|
||||
m.reserveTurnBudget.mockResolvedValue({
|
||||
ok: false,
|
||||
response: new Response("budget", { status: 200 }),
|
||||
});
|
||||
const res = await runCoachTurn(
|
||||
input({ followUp: { messageId: "m-forced", id: "f1" } }),
|
||||
);
|
||||
expect(await res.text()).toBe("budget");
|
||||
expect(m.runTurnModel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ 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 { resolveContinuation } from "@/lib/ai/coach/follow-ups/continue";
|
||||
|
||||
import { runTurnModel } from "./model";
|
||||
import { persistAssistantReply } from "./persist";
|
||||
@@ -72,8 +73,17 @@ export async function runCoachTurn(input: TurnInput): Promise<Response> {
|
||||
conversationId: input.conversationId,
|
||||
clarification: input.clarification,
|
||||
});
|
||||
// v1.39.4 — "keep looking": the unfinished question and what the forced
|
||||
// turn already read, so the continued turn does not fetch it again.
|
||||
const continuation = await resolveContinuation({
|
||||
userId,
|
||||
conversationId: input.conversationId,
|
||||
followUp: input.followUp,
|
||||
priorResults: conversation.priorResults ?? [],
|
||||
});
|
||||
const turnHints = [
|
||||
...(resolvedFollowUp?.contextHint ? [resolvedFollowUp.contextHint] : []),
|
||||
...(continuation ? [continuation.contextHint] : []),
|
||||
...(clarifiedLine ? [clarifiedLine] : []),
|
||||
];
|
||||
|
||||
@@ -170,6 +180,7 @@ export async function runCoachTurn(input: TurnInput): Promise<Response> {
|
||||
prefs: ctx.coachPrefs,
|
||||
locale,
|
||||
history,
|
||||
continuationOf: continuation?.sourceMessageId,
|
||||
});
|
||||
const provenance = buildTurnProvenance({
|
||||
snapshotProvenance: ctx.snapshot.provenance,
|
||||
@@ -180,6 +191,7 @@ export async function runCoachTurn(input: TurnInput): Promise<Response> {
|
||||
steps: model.steps,
|
||||
dialog,
|
||||
forcedFinal: model.forcedFinal,
|
||||
continuationOf: continuation?.sourceMessageId,
|
||||
});
|
||||
const { messageId } = await persistAssistantReply({
|
||||
conversationId: workingConversationId,
|
||||
|
||||
@@ -70,6 +70,8 @@ export function assembleTurnDialog(args: {
|
||||
locale: Locale;
|
||||
/** The record's history for the tables' metrics, for the history chips. */
|
||||
history?: FollowUpHistory;
|
||||
/** The forced reply this turn continues, if it does. */
|
||||
continuationOf?: string;
|
||||
}): TurnDialog {
|
||||
const { model, reply, prefs, locale } = args;
|
||||
const blocked = reply.outboundBlocked;
|
||||
@@ -87,7 +89,11 @@ export function assembleTurnDialog(args: {
|
||||
const offerChips =
|
||||
!blocked && followUpChipsEnabled(prefs) && reply.clarification === null;
|
||||
const continueChip = offerChips
|
||||
? buildContinueFollowUp({ forcedFinal: model.forcedFinal, locale })
|
||||
? buildContinueFollowUp({
|
||||
forcedFinal: model.forcedFinal,
|
||||
continuationOf: args.continuationOf,
|
||||
locale,
|
||||
})
|
||||
: null;
|
||||
const followUps = offerChips
|
||||
? numberFollowUps([
|
||||
@@ -123,6 +129,8 @@ export function buildTurnProvenance(args: {
|
||||
steps: CoachStep[];
|
||||
dialog: TurnDialog;
|
||||
forcedFinal: boolean;
|
||||
/** v1.39.4 — the forced reply this turn continues, if it does. */
|
||||
continuationOf?: string;
|
||||
}): CoachProvenance {
|
||||
const { snapshotProvenance, reply, toolTrace, steps, dialog } = args;
|
||||
const surfacedSuggestion = args.suggestion;
|
||||
@@ -174,6 +182,7 @@ export function buildTurnProvenance(args: {
|
||||
...(dialog.followUps.length > 0 ? { followUps: dialog.followUps } : {}),
|
||||
...(dialog.clarification ? { clarification: dialog.clarification } : {}),
|
||||
...(args.forcedFinal ? { forcedFinal: true as const } : {}),
|
||||
...(args.continuationOf ? { continuationOf: args.continuationOf } : {}),
|
||||
};
|
||||
if (sentinel.malformed) {
|
||||
// Graceful degrade: log so ops can spot a provider whose
|
||||
|
||||
@@ -660,6 +660,12 @@ export interface CoachProvenance {
|
||||
* to read, so the answer was forced. Absent otherwise.
|
||||
*/
|
||||
forcedFinal?: true;
|
||||
/**
|
||||
* v1.39.4 — this reply continues an answer that was forced at the round
|
||||
* cap: the id of that earlier assistant message. A continuation offers no
|
||||
* further "keep looking" chip, so an answer is continued at most once.
|
||||
*/
|
||||
continuationOf?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user