mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
feat(coach): verify Coach prose numbers against fetched figures
The Daily Briefing strips any number absent from its server-computed figures; the Coach's tool path had no equivalent, so a transcription or paraphrase drift (a tool returns systolic 128, the reply says ~138) could ship. Collect the numeric leaves from every present tool result this turn, extract the numbers the reply asserts, and soft-correct any that match no fetched figure to a neutral placeholder — annotated, non-blocking, and a no-op on a qualitative turn or the no-tools path where there is nothing to grade against. The bounded retrieval loop now returns the present results' payloads so the check has an authoritative figure set.
This commit is contained in:
1 parent
6de1f34efb
commit
58376a0705
6 files changed
+358
-2
No files matched your search
@@ -154,6 +154,7 @@ const { buildCoachDataInventory, renderDataInventory, runCoachToolLoop } =
|
||||
totalTokens: 80,
|
||||
rounds: 2,
|
||||
toolTrace: [{ name: "get_metric_series", present: true }],
|
||||
toolResults: [],
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/tools", () => ({
|
||||
@@ -183,6 +184,9 @@ vi.mock("@/lib/validations/coach-prefs", () => ({
|
||||
}));
|
||||
|
||||
const { appendMessage } = await import("@/lib/ai/coach/persistence");
|
||||
const { parseKeyValuesSentinel } = await import("@/lib/ai/coach/keyvalues");
|
||||
const { parseSuggestReminder } =
|
||||
await import("@/lib/ai/coach/suggest-reminder");
|
||||
|
||||
vi.mock("@/lib/sse/create-stream", () => ({
|
||||
createSseStream: (
|
||||
@@ -260,6 +264,50 @@ describe("coach chat — tool-mode routing (F1)", () => {
|
||||
).toEqual([{ name: "get_metric_series", present: true }]);
|
||||
});
|
||||
|
||||
it("soft-strips a prose number the tools never returned (P6)", async () => {
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "anthropic", instance: {} },
|
||||
]);
|
||||
// Echo the real prose through the sentinel + suggest parsers so the
|
||||
// verifier sees the model's actual numbers (the default mocks return a
|
||||
// fixed string).
|
||||
const drift = "Your systolic averaged about 138 lately.";
|
||||
(parseKeyValuesSentinel as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
prose: drift,
|
||||
keyValues: [],
|
||||
malformed: false,
|
||||
malformedEntries: [],
|
||||
});
|
||||
(parseSuggestReminder as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
prose: drift,
|
||||
});
|
||||
// The tool returned systolic 128; the prose drifts to 138.
|
||||
(runCoachToolLoop as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
async () => ({
|
||||
result: {
|
||||
content: "Your systolic averaged about 138 lately.",
|
||||
tokensUsed: 80,
|
||||
model: "m",
|
||||
},
|
||||
workingProviderType: "anthropic",
|
||||
totalTokens: 80,
|
||||
rounds: 2,
|
||||
toolTrace: [{ name: "get_metric_series", present: true }],
|
||||
toolResults: [
|
||||
{ present: true, data: { aggregate: { avgSys30: 128 } } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
await post(chatReq({ message: "How is my BP?" }));
|
||||
const calls = (appendMessage as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const assistantCall = calls.find(
|
||||
(c) => (c[0] as { role: string }).role === "assistant",
|
||||
);
|
||||
const content = (assistantCall?.[0] as { content: string }).content;
|
||||
expect(content).toContain("[unverified]");
|
||||
expect(content).not.toContain("138");
|
||||
});
|
||||
|
||||
it("falls back to the snapshot-stuffing path when a provider lacks tools", async () => {
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "anthropic", instance: {} },
|
||||
|
||||
@@ -84,6 +84,10 @@ import {
|
||||
} from "@/lib/ai/coach/tools";
|
||||
import type { AiMessage } from "@/lib/ai/types";
|
||||
import { parseKeyValuesSentinel } from "@/lib/ai/coach/keyvalues";
|
||||
import {
|
||||
findUnverifiedCoachNumbers,
|
||||
stripUnverifiedNumbers,
|
||||
} from "@/lib/ai/coach/coach-prose-grounding";
|
||||
import { parseSuggestReminder } from "@/lib/ai/coach/suggest-reminder";
|
||||
import { gateSuggestion } from "@/lib/ai/coach/suggest-gate";
|
||||
import {
|
||||
@@ -518,6 +522,9 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
|
||||
let result: CompletionResult;
|
||||
let workingProviderType: string;
|
||||
let toolTrace: CoachToolTrace[] = [];
|
||||
// v1.21.0 (P6) — the present tool-result payloads this turn, for the post-hoc
|
||||
// prose number-verifier. Empty on the no-tools path.
|
||||
let toolResultPayloads: unknown[] = [];
|
||||
let totalTokensSpent: number;
|
||||
try {
|
||||
if (toolMode) {
|
||||
@@ -556,6 +563,7 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch
|
||||
result = loop.result;
|
||||
workingProviderType = loop.workingProviderType;
|
||||
toolTrace = loop.toolTrace;
|
||||
toolResultPayloads = (loop.toolResults ?? []).map((r) => r.data);
|
||||
totalTokensSpent = loop.totalTokens;
|
||||
} else {
|
||||
const fallback = await runRawCompletionWithFallback({
|
||||
@@ -701,6 +709,38 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch
|
||||
});
|
||||
}
|
||||
|
||||
// v1.21.0 (P6 / C2-5) — post-hoc numeric verifier on the Coach prose. On the
|
||||
// tool path, cross-check every number the model cited against the figures the
|
||||
// tools actually returned this turn; an unmatched number (transcription /
|
||||
// paraphrase drift) is soft-stripped to "[unverified]" and annotated. Cheap,
|
||||
// non-blocking, and a no-op when no tool returned figures (a qualitative turn
|
||||
// or the no-tools path) — the prompt-level grounding rule remains the
|
||||
// backstop there, exactly like the briefing's "no signals → skip". A blocked
|
||||
// turn already carries canned fallback prose, so skip it.
|
||||
if (!outbound.block && toolResultPayloads.length > 0) {
|
||||
const unverified = findUnverifiedCoachNumbers(
|
||||
replyText,
|
||||
toolResultPayloads,
|
||||
);
|
||||
if (unverified.length > 0) {
|
||||
const { prose: corrected, stripped } = stripUnverifiedNumbers(
|
||||
replyText,
|
||||
unverified,
|
||||
);
|
||||
replyText = corrected;
|
||||
annotate({
|
||||
action: { name: "coach.prose.number_unverified" },
|
||||
meta: {
|
||||
flagged: unverified.length,
|
||||
stripped,
|
||||
// No raw values — just the count + truncated tokens for ops triage.
|
||||
tokens: unverified.slice(0, 6).map((u) => u.source),
|
||||
promptVersion: PROMPT_VERSION,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let surfacedSuggestion: CoachSuggestion | null = null;
|
||||
if (!outbound.block && suggestParse.cadence) {
|
||||
const decision = await gateSuggestion({
|
||||
|
||||
Reference in new issue
Block a user