From 7b8efaae14f911ba9bbed2a513cecd2420a8676e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 21:07:32 +0200 Subject: [PATCH] feat(coach): parse evidence-block sentinel out of streamed prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a text-marker sentinel parser (`---KEYVALUES---` … `---END---`) that strips the Coach's evidence block out of the prose before it streams to the client, validates each line against a Zod schema, and attaches the parsed entries to `provenance.keyValues`. The persisted envelope round-trips so the disclosure renders on conversation reload. Hard-capped at 8 lines and 1 KB to guard against prompt-injection padding; malformed blocks log a wide-event and pass the prose through unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/insights/chat/route.ts | 33 +++- .../coach/__tests__/keyvalues-parse.test.ts | 180 ++++++++++++++++++ src/lib/ai/coach/keyvalues.ts | 180 ++++++++++++++++++ src/lib/ai/coach/persistence.ts | 41 +++- src/lib/ai/coach/types.ts | 37 +++- tests/integration/coach-chat.test.ts | 116 +++++++++++ 6 files changed, 579 insertions(+), 8 deletions(-) create mode 100644 src/lib/ai/coach/__tests__/keyvalues-parse.test.ts create mode 100644 src/lib/ai/coach/keyvalues.ts diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 6aaa7a2f0..db50b5b5a 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -60,6 +60,7 @@ import { import { detectRefusal } from "@/lib/ai/coach/refusal"; import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt"; import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot"; +import { parseKeyValuesSentinel } from "@/lib/ai/coach/keyvalues"; /** * Hard cap on total turns kept inside the per-call prompt window. @@ -260,18 +261,42 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`; throw err; } - const replyText = (result.content ?? "").trim(); - if (!replyText) { + const rawReply = (result.content ?? "").trim(); + if (!rawReply) { return streamProviderError({ code: "coach.provider.empty" }); } + // v1.4.22 — strip the optional `---KEYVALUES---` … `---END---` + // sentinel out of the prose. The stripped prose is what we stream + // to the client and persist; the parsed entries enrich the + // provenance envelope so the UI can render the collapsible + // "Worauf bezieht sich das?" disclosure. + const sentinel = parseKeyValuesSentinel(rawReply); + const replyText = sentinel.prose.trim() || rawReply; + const enrichedProvenance = + sentinel.keyValues.length > 0 + ? { ...snapshot.provenance, keyValues: sentinel.keyValues } + : snapshot.provenance; + if (sentinel.malformed) { + // Graceful degrade: log so ops can spot a provider whose + // sentinel format has drifted, but pass the prose through + // unchanged. + annotate({ + action: { name: "coach.keyvalues.parse_failed" }, + meta: { + kept: sentinel.keyValues.length, + promptVersion: PROMPT_VERSION, + }, + }); + } + // Persist the assistant message BEFORE we begin streaming; if the // client disconnects we still have the canonical row. const assistantMessage = await appendMessage({ conversationId: workingConversationId, role: "assistant", content: replyText, - metricSource: snapshot.provenance, + metricSource: enrichedProvenance, providerType: workingProviderType, promptVersion: PROMPT_VERSION, }); @@ -305,7 +330,7 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`; controller.enqueue( encodeFrame({ type: "provenance", - metricSource: snapshot.provenance, + metricSource: enrichedProvenance, }), ); controller.enqueue( diff --git a/src/lib/ai/coach/__tests__/keyvalues-parse.test.ts b/src/lib/ai/coach/__tests__/keyvalues-parse.test.ts new file mode 100644 index 000000000..977094827 --- /dev/null +++ b/src/lib/ai/coach/__tests__/keyvalues-parse.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; + +import { + parseKeyValueLine, + parseKeyValuesSentinel, +} from "../keyvalues"; + +/** + * v1.4.22 Wave 3 B1+B2 — Coach evidence-block sentinel parser. + */ +describe("parseKeyValueLine", () => { + it("parses a full line with unit + window", () => { + expect(parseKeyValueLine("avg30 systolic: 138 [mmHg] (last30days)")).toEqual({ + label: "avg30 systolic", + value: "138", + unit: "mmHg", + window: "last30days", + }); + }); + + it("parses a line with unit but no window (day-level pin)", () => { + expect(parseKeyValueLine("Tue 6 May: 142/88 [mmHg]")).toEqual({ + label: "Tue 6 May", + value: "142/88", + unit: "mmHg", + }); + }); + + it("parses a line with neither unit nor window", () => { + expect(parseKeyValueLine("30-day adherence: 96")).toEqual({ + label: "30-day adherence", + value: "96", + }); + }); + + it("tolerates extra whitespace around tokens", () => { + expect( + parseKeyValueLine(" avg30 systolic : 138 [mmHg] (last30days) "), + ).toEqual({ + label: "avg30 systolic", + value: "138", + unit: "mmHg", + window: "last30days", + }); + }); + + it("returns null for an empty line", () => { + expect(parseKeyValueLine("")).toBeNull(); + expect(parseKeyValueLine(" ")).toBeNull(); + }); + + it("returns null for a line with no colon", () => { + expect(parseKeyValueLine("no separator here")).toBeNull(); + }); + + it("returns null when the label is empty", () => { + expect(parseKeyValueLine(": 138")).toBeNull(); + }); + + it("returns null when the value would be empty", () => { + expect(parseKeyValueLine("label only:")).toBeNull(); + }); +}); + +describe("parseKeyValuesSentinel", () => { + it("strips the sentinel block out of the prose", () => { + const raw = [ + "Your last week sits a touch higher than your usual run.", + "", + "---KEYVALUES---", + "avg7 systolic: 138 [mmHg] (last7days)", + "avg30 systolic: 134 [mmHg] (last30days)", + "---END---", + ].join("\n"); + + const out = parseKeyValuesSentinel(raw); + expect(out.prose).toBe( + "Your last week sits a touch higher than your usual run.", + ); + expect(out.keyValues).toEqual([ + { + label: "avg7 systolic", + value: "138", + unit: "mmHg", + window: "last7days", + }, + { + label: "avg30 systolic", + value: "134", + unit: "mmHg", + window: "last30days", + }, + ]); + expect(out.malformed).toBe(false); + }); + + it("returns prose untouched when no sentinel is present", () => { + const out = parseKeyValuesSentinel( + "Just a qualitative reply with no numbers cited.", + ); + expect(out.prose).toBe("Just a qualitative reply with no numbers cited."); + expect(out.keyValues).toEqual([]); + expect(out.malformed).toBe(false); + }); + + it("graceful-degrades when the closing ---END--- is missing", () => { + const raw = [ + "Prose still streams cleanly.", + "", + "---KEYVALUES---", + "avg7 systolic: 138 [mmHg] (last7days)", + ].join("\n"); + const out = parseKeyValuesSentinel(raw); + // Prose stripped, body parsed, but flagged malformed so the route + // can log a wide-event for ops visibility. + expect(out.prose).toBe("Prose still streams cleanly."); + expect(out.keyValues.length).toBe(1); + expect(out.malformed).toBe(true); + }); + + it("clips the block to 8 entries (prompt-contract cap)", () => { + const lines = Array.from( + { length: 12 }, + (_, i) => `metric-${i}: ${100 + i} [mmHg] (last30days)`, + ); + const raw = ["---KEYVALUES---", ...lines, "---END---"].join("\n"); + const out = parseKeyValuesSentinel(raw); + expect(out.keyValues.length).toBe(8); + expect(out.keyValues[0].label).toBe("metric-0"); + expect(out.keyValues[7].label).toBe("metric-7"); + }); + + it("clips an oversized block at the 1 KB cap", () => { + // 1 200 bytes of label/value pairs — every line passes the regex, + // but the parser trims to 1 024 bytes before splitting so we end + // up with whichever rows fit. + const padded = Array.from( + { length: 60 }, + (_, i) => `label-${i}: ${i.toString().padEnd(15, "x")}`, + ); + const raw = ["---KEYVALUES---", ...padded, "---END---"].join("\n"); + const out = parseKeyValuesSentinel(raw); + expect(out.keyValues.length).toBeLessThanOrEqual(8); + expect(out.malformed).toBe(true); + }); + + it("silently drops malformed lines mixed in with valid ones", () => { + const raw = [ + "Prose.", + "", + "---KEYVALUES---", + "avg30 systolic: 138 [mmHg] (last30days)", + "not a valid line", + ":no-label-here", + "avg30 mood: 4.1 [/5] (last30days)", + "---END---", + ].join("\n"); + const out = parseKeyValuesSentinel(raw); + expect(out.keyValues.length).toBe(2); + expect(out.keyValues.map((k) => k.label)).toEqual([ + "avg30 systolic", + "avg30 mood", + ]); + }); + + it("returns malformed when the sentinel block is empty", () => { + const raw = ["Prose.", "---KEYVALUES---", "---END---"].join("\n"); + const out = parseKeyValuesSentinel(raw); + expect(out.keyValues).toEqual([]); + expect(out.malformed).toBe(true); + expect(out.prose).toBe("Prose."); + }); + + it("returns prose and an empty array for empty input", () => { + const out = parseKeyValuesSentinel(""); + expect(out.prose).toBe(""); + expect(out.keyValues).toEqual([]); + expect(out.malformed).toBe(false); + }); +}); diff --git a/src/lib/ai/coach/keyvalues.ts b/src/lib/ai/coach/keyvalues.ts new file mode 100644 index 000000000..99cdcf45f --- /dev/null +++ b/src/lib/ai/coach/keyvalues.ts @@ -0,0 +1,180 @@ +/** + * v1.4.22 — Coach evidence-block (`---KEYVALUES---` … `---END---`) + * parser. + * + * The Coach prompt instructs the model to append, after its prose + * reply, a sentinel block carrying the load-bearing numbers it cited: + * + * ---KEYVALUES--- + * avg30 systolic: 138 [mmHg] (last30days) + * Tue 6 May: 142/88 [mmHg] + * ---END--- + * + * This module strips that block out of the streamed prose and parses + * each line into a `CoachKeyValue`. The result feeds the + * `provenance.keyValues` field that the UI renders as a collapsible + * disclosure. + * + * Defence-in-depth: + * - Hard cap 1 KB on the sentinel block (prompt-injection guard). + * - Hard cap 8 lines kept (per the prompt contract). + * - Each line is validated against `coachKeyValueSchema`; malformed + * lines are dropped silently. + * - When the sentinel pair is malformed (e.g. no closing `---END---`, + * unbalanced sentinels), the parser returns the original prose + * untouched and emits no keyValues — the caller logs a wide-event + * `coach.keyvalues.parse_failed` for ops visibility. + */ +import { coachKeyValueSchema, type CoachKeyValue } from "./types"; + +/** Sentinel block payload hard cap — 1 KB after the opening marker. */ +const SENTINEL_BYTE_CAP = 1024; +/** Max key/value rows kept inside the block (prompt-contract value). */ +const SENTINEL_LINE_CAP = 8; + +const OPEN_SENTINEL = "---KEYVALUES---"; +const CLOSE_SENTINEL = "---END---"; + +/** + * Parse one body line of the form + * `