mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(coach): partial-malformed observability on sentinel parser
The W3-of-v1.4.22 sentinel parser silently dropped malformed rows and fired `coach.keyvalues.parse_failed` only when the entire block was unusable. Mixed-format drift (one row breaks, the other six parse) landed without any signal. Surface it. - `SentinelParseResult.malformedEntries[]` records each skipped row with a typed reason (`missing_colon` / `value_overflow` / `label_overflow` / `no_END_marker` / `byte_overflow` / `schema_invalid`). Block-level failures (truncation, missing close marker) land here as synthetic entries so the wide-event always carries a typed cause. - Per-row label and value caps moved out of the Zod schema into the parser so the row-level reason can be `label_overflow` / `value_overflow` rather than the catch-all `schema_invalid`. - Chat route splits the annotation: `coach.keyvalues.parse_partial` when at least one row parsed AND at least one row failed, the existing `coach.keyvalues.parse_failed` stays for full-block failures. Both annotations carry the per-line reason array. - Backwards-compatible `parseKeyValueLine` wrapper preserved. Tests +5: label_overflow, value_overflow, byte_overflow, mixed-malformed entry assertions, and a tryParseKeyValueLine direct-API surface check. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
921cb7d42f
commit
58ae9bca42
3 files changed
+205
-24
No files matched your search
@@ -300,11 +300,24 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
|
||||
if (sentinel.malformed) {
|
||||
// Graceful degrade: log so ops can spot a provider whose
|
||||
// sentinel format has drifted, but pass the prose through
|
||||
// unchanged.
|
||||
// unchanged. v1.4.23 H1 — split the annotation:
|
||||
// - parse_partial: at least one row parsed AND at least one
|
||||
// row failed (mixed-format drift on a single reply)
|
||||
// - parse_failed: the whole block was unusable
|
||||
// Both annotations carry the per-line `reasons` array so an ops
|
||||
// dashboard can attribute the failure cause without re-running
|
||||
// the parser.
|
||||
const reasons = sentinel.malformedEntries.map((entry) => entry.reason);
|
||||
const annotationName =
|
||||
sentinel.keyValues.length > 0 && sentinel.malformedEntries.length > 0
|
||||
? "coach.keyvalues.parse_partial"
|
||||
: "coach.keyvalues.parse_failed";
|
||||
annotate({
|
||||
action: { name: "coach.keyvalues.parse_failed" },
|
||||
action: { name: annotationName },
|
||||
meta: {
|
||||
kept: sentinel.keyValues.length,
|
||||
malformedCount: sentinel.malformedEntries.length,
|
||||
reasons,
|
||||
promptVersion: PROMPT_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseKeyValueLine, parseKeyValuesSentinel } from "../keyvalues";
|
||||
import {
|
||||
parseKeyValueLine,
|
||||
parseKeyValuesSentinel,
|
||||
tryParseKeyValueLine,
|
||||
} from "../keyvalues";
|
||||
|
||||
/**
|
||||
* v1.4.22 Wave 3 B1+B2 — Coach evidence-block sentinel parser.
|
||||
@@ -91,6 +95,7 @@ describe("parseKeyValuesSentinel", () => {
|
||||
},
|
||||
]);
|
||||
expect(out.malformed).toBe(false);
|
||||
expect(out.malformedEntries).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns prose untouched when no sentinel is present", () => {
|
||||
@@ -100,6 +105,7 @@ describe("parseKeyValuesSentinel", () => {
|
||||
expect(out.prose).toBe("Just a qualitative reply with no numbers cited.");
|
||||
expect(out.keyValues).toEqual([]);
|
||||
expect(out.malformed).toBe(false);
|
||||
expect(out.malformedEntries).toEqual([]);
|
||||
});
|
||||
|
||||
it("graceful-degrades when the closing ---END--- is missing", () => {
|
||||
@@ -115,6 +121,9 @@ describe("parseKeyValuesSentinel", () => {
|
||||
expect(out.prose).toBe("Prose still streams cleanly.");
|
||||
expect(out.keyValues.length).toBe(1);
|
||||
expect(out.malformed).toBe(true);
|
||||
expect(out.malformedEntries).toEqual([
|
||||
{ rawLine: "---END---", reason: "no_END_marker" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("clips the block to 8 entries (prompt-contract cap)", () => {
|
||||
@@ -143,7 +152,7 @@ describe("parseKeyValuesSentinel", () => {
|
||||
expect(out.malformed).toBe(true);
|
||||
});
|
||||
|
||||
it("silently drops malformed lines mixed in with valid ones", () => {
|
||||
it("records malformed lines mixed in with valid ones (v1.4.23 H1)", () => {
|
||||
const raw = [
|
||||
"Prose.",
|
||||
"",
|
||||
@@ -160,6 +169,65 @@ describe("parseKeyValuesSentinel", () => {
|
||||
"avg30 systolic",
|
||||
"avg30 mood",
|
||||
]);
|
||||
// Partial malformed surfaces — the good rows survive while the
|
||||
// bad ones land in the entry array with typed reasons.
|
||||
expect(out.malformed).toBe(true);
|
||||
expect(out.malformedEntries).toEqual([
|
||||
{ rawLine: "not a valid line", reason: "missing_colon" },
|
||||
{ rawLine: ":no-label-here", reason: "missing_colon" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("flags label_overflow when a row's label exceeds the cap", () => {
|
||||
const longLabel = "x".repeat(120);
|
||||
const raw = [
|
||||
"---KEYVALUES---",
|
||||
"ok label: 138 [mmHg] (last30days)",
|
||||
`${longLabel}: 99 [mmHg]`,
|
||||
"---END---",
|
||||
].join("\n");
|
||||
const out = parseKeyValuesSentinel(raw);
|
||||
expect(out.keyValues.length).toBe(1);
|
||||
expect(out.keyValues[0].label).toBe("ok label");
|
||||
expect(out.malformed).toBe(true);
|
||||
expect(out.malformedEntries).toEqual([
|
||||
{ rawLine: `${longLabel}: 99 [mmHg]`, reason: "label_overflow" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("flags value_overflow when a row's value exceeds the cap", () => {
|
||||
const longValue = "9".repeat(220);
|
||||
const raw = [
|
||||
"---KEYVALUES---",
|
||||
`avg30 systolic: ${longValue} [mmHg]`,
|
||||
"ok label: 138 [mmHg] (last30days)",
|
||||
"---END---",
|
||||
].join("\n");
|
||||
const out = parseKeyValuesSentinel(raw);
|
||||
expect(out.keyValues.length).toBe(1);
|
||||
expect(out.keyValues[0].label).toBe("ok label");
|
||||
expect(out.malformed).toBe(true);
|
||||
expect(out.malformedEntries[0]?.reason).toBe("value_overflow");
|
||||
});
|
||||
|
||||
it("flags byte_overflow when the block exceeds the 1 KB cap", () => {
|
||||
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.malformed).toBe(true);
|
||||
expect(
|
||||
out.malformedEntries.some((entry) => entry.reason === "byte_overflow"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("tryParseKeyValueLine surfaces missing_colon for separator-less rows", () => {
|
||||
expect(tryParseKeyValueLine("no separator here")).toEqual({
|
||||
ok: false,
|
||||
reason: "missing_colon",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns malformed when the sentinel block is empty", () => {
|
||||
@@ -168,6 +236,8 @@ describe("parseKeyValuesSentinel", () => {
|
||||
expect(out.keyValues).toEqual([]);
|
||||
expect(out.malformed).toBe(true);
|
||||
expect(out.prose).toBe("Prose.");
|
||||
// No body lines to record — only the block-level state flips.
|
||||
expect(out.malformedEntries).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns prose and an empty array for empty input", () => {
|
||||
@@ -175,5 +245,6 @@ describe("parseKeyValuesSentinel", () => {
|
||||
expect(out.prose).toBe("");
|
||||
expect(out.keyValues).toEqual([]);
|
||||
expect(out.malformed).toBe(false);
|
||||
expect(out.malformedEntries).toEqual([]);
|
||||
});
|
||||
});
|
||||
+117
-20
@@ -19,11 +19,14 @@
|
||||
* - 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.
|
||||
* lines surface in `parsed.malformed[]` with a typed reason
|
||||
* (v1.4.23 H1 — replaced the silent-drop path).
|
||||
* - 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.
|
||||
* unbalanced sentinels), the parser still returns whatever rows it
|
||||
* could parse plus a malformed flag; the caller logs
|
||||
* `coach.keyvalues.parse_failed` (full block invalid) or
|
||||
* `coach.keyvalues.parse_partial` (mixed valid + invalid) for ops
|
||||
* visibility.
|
||||
*/
|
||||
import { coachKeyValueSchema, type CoachKeyValue } from "./types";
|
||||
|
||||
@@ -31,31 +34,76 @@ import { coachKeyValueSchema, type CoachKeyValue } from "./types";
|
||||
const SENTINEL_BYTE_CAP = 1024;
|
||||
/** Max key/value rows kept inside the block (prompt-contract value). */
|
||||
const SENTINEL_LINE_CAP = 8;
|
||||
/** Per-line value cap before we reject the row (defence-in-depth). */
|
||||
const VALUE_BYTE_CAP = 200;
|
||||
/** Per-line label cap mirroring the prompt-contract value of 40 chars. */
|
||||
const LABEL_BYTE_CAP = 80;
|
||||
|
||||
const OPEN_SENTINEL = "---KEYVALUES---";
|
||||
const CLOSE_SENTINEL = "---END---";
|
||||
|
||||
/**
|
||||
* Why a candidate row failed to parse. Surfaces to the caller via
|
||||
* `SentinelParseResult.malformed[]` so ops dashboards can attribute
|
||||
* partial failures (mixed valid + invalid rows) without the silent
|
||||
* drops the v1.4.22 W3 implementation paid.
|
||||
*
|
||||
* - `missing_colon` — line had no `:` separator.
|
||||
* - `value_overflow` — value exceeded `VALUE_BYTE_CAP`.
|
||||
* - `label_overflow` — label exceeded `LABEL_BYTE_CAP`.
|
||||
* - `no_END_marker` — the closing `---END---` was missing.
|
||||
* - `byte_overflow` — block exceeded `SENTINEL_BYTE_CAP`.
|
||||
* - `schema_invalid` — row passed shape but Zod rejected it
|
||||
* (e.g. empty value after stripping).
|
||||
*/
|
||||
export type SentinelMalformedReason =
|
||||
| "missing_colon"
|
||||
| "value_overflow"
|
||||
| "label_overflow"
|
||||
| "no_END_marker"
|
||||
| "byte_overflow"
|
||||
| "schema_invalid";
|
||||
|
||||
export interface SentinelMalformedEntry {
|
||||
rawLine: string;
|
||||
reason: SentinelMalformedReason;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of parsing one body line. Either returns the `CoachKeyValue`
|
||||
* shape or a typed reason the line failed (so the caller can surface
|
||||
* partial-failure observability in the wide-event annotation).
|
||||
*/
|
||||
export type LineParseOutcome =
|
||||
| { ok: true; value: CoachKeyValue }
|
||||
| { ok: false; reason: SentinelMalformedReason };
|
||||
|
||||
/**
|
||||
* Parse one body line of the form
|
||||
* `<label>: <value> [<unit>] (<window>)`
|
||||
* into a `CoachKeyValue`. The unit + window are both optional; an
|
||||
* input missing either or both still parses cleanly. Returns `null`
|
||||
* when the line does not match the expected shape — the caller drops
|
||||
* the line rather than rejecting the whole block.
|
||||
* input missing either or both still parses cleanly. Returns
|
||||
* `{ ok: false, reason }` when the line does not match the expected
|
||||
* shape — the caller now records the malformed row rather than
|
||||
* silently dropping it (v1.4.23 H1).
|
||||
*/
|
||||
export function parseKeyValueLine(line: string): CoachKeyValue | null {
|
||||
export function tryParseKeyValueLine(line: string): LineParseOutcome {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return null;
|
||||
if (!trimmed) return { ok: false, reason: "missing_colon" };
|
||||
|
||||
// Find the first `:` — the label cannot itself contain `:`. The
|
||||
// remainder may carry the value, an optional `[unit]`, and an
|
||||
// optional `(window)`.
|
||||
const colon = trimmed.indexOf(":");
|
||||
if (colon < 1) return null;
|
||||
if (colon < 1) return { ok: false, reason: "missing_colon" };
|
||||
|
||||
const label = trimmed.slice(0, colon).trim();
|
||||
let rest = trimmed.slice(colon + 1).trim();
|
||||
if (!label || !rest) return null;
|
||||
if (!label) return { ok: false, reason: "missing_colon" };
|
||||
if (!rest) return { ok: false, reason: "schema_invalid" };
|
||||
if (label.length > LABEL_BYTE_CAP) {
|
||||
return { ok: false, reason: "label_overflow" };
|
||||
}
|
||||
|
||||
let unit: string | undefined;
|
||||
let window: string | undefined;
|
||||
@@ -78,7 +126,10 @@ export function parseKeyValueLine(line: string): CoachKeyValue | null {
|
||||
}
|
||||
|
||||
const value = rest.trim();
|
||||
if (!value) return null;
|
||||
if (!value) return { ok: false, reason: "schema_invalid" };
|
||||
if (value.length > VALUE_BYTE_CAP) {
|
||||
return { ok: false, reason: "value_overflow" };
|
||||
}
|
||||
|
||||
const parsed = coachKeyValueSchema.safeParse({
|
||||
label,
|
||||
@@ -86,8 +137,18 @@ export function parseKeyValueLine(line: string): CoachKeyValue | null {
|
||||
...(unit ? { unit } : {}),
|
||||
...(window ? { window } : {}),
|
||||
});
|
||||
if (!parsed.success) return null;
|
||||
return parsed.data;
|
||||
if (!parsed.success) return { ok: false, reason: "schema_invalid" };
|
||||
return { ok: true, value: parsed.data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Backwards-compatible wrapper around `tryParseKeyValueLine`. Returns
|
||||
* the parsed value on success, `null` on any failure — kept so existing
|
||||
* callers and the public test surface keep working unchanged.
|
||||
*/
|
||||
export function parseKeyValueLine(line: string): CoachKeyValue | null {
|
||||
const outcome = tryParseKeyValueLine(line);
|
||||
return outcome.ok ? outcome.value : null;
|
||||
}
|
||||
|
||||
export interface SentinelParseResult {
|
||||
@@ -101,6 +162,17 @@ export interface SentinelParseResult {
|
||||
* Callers log this for ops visibility (graceful degrade).
|
||||
*/
|
||||
malformed: boolean;
|
||||
/**
|
||||
* Per-line diagnostics for rows the parser skipped — populated even
|
||||
* when `keyValues.length > 0` so ops can spot mixed-format drift
|
||||
* (v1.4.23 H1). Empty when every body line parsed cleanly.
|
||||
*
|
||||
* Block-level failures (missing `---END---`, payload truncated to
|
||||
* fit the byte cap) land here as a synthetic entry with `rawLine`
|
||||
* set to the marker / cap descriptor and the corresponding reason
|
||||
* code so the wide-event annotation always carries a typed cause.
|
||||
*/
|
||||
malformedEntries: SentinelMalformedEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,12 +184,17 @@ export interface SentinelParseResult {
|
||||
*/
|
||||
export function parseKeyValuesSentinel(raw: string): SentinelParseResult {
|
||||
if (!raw) {
|
||||
return { prose: "", keyValues: [], malformed: false };
|
||||
return { prose: "", keyValues: [], malformed: false, malformedEntries: [] };
|
||||
}
|
||||
|
||||
const openIdx = raw.indexOf(OPEN_SENTINEL);
|
||||
if (openIdx === -1) {
|
||||
return { prose: raw, keyValues: [], malformed: false };
|
||||
return {
|
||||
prose: raw,
|
||||
keyValues: [],
|
||||
malformed: false,
|
||||
malformedEntries: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Everything before the opening marker is prose; trim trailing
|
||||
@@ -125,6 +202,8 @@ export function parseKeyValuesSentinel(raw: string): SentinelParseResult {
|
||||
// bubble doesn't render an extra blank paragraph.
|
||||
const prose = raw.slice(0, openIdx).replace(/\s+$/u, "");
|
||||
|
||||
const malformedEntries: SentinelMalformedEntry[] = [];
|
||||
|
||||
const afterOpen = raw.slice(openIdx + OPEN_SENTINEL.length);
|
||||
// Look for the closing sentinel; if missing, the block is
|
||||
// malformed — fall back to the original prose and emit nothing.
|
||||
@@ -137,6 +216,10 @@ export function parseKeyValuesSentinel(raw: string): SentinelParseResult {
|
||||
// the contract wasn't fully met.
|
||||
bodyRaw = afterOpen;
|
||||
malformedClose = true;
|
||||
malformedEntries.push({
|
||||
rawLine: CLOSE_SENTINEL,
|
||||
reason: "no_END_marker",
|
||||
});
|
||||
} else {
|
||||
bodyRaw = afterOpen.slice(0, closeRel);
|
||||
}
|
||||
@@ -149,6 +232,10 @@ export function parseKeyValuesSentinel(raw: string): SentinelParseResult {
|
||||
if (body.length > SENTINEL_BYTE_CAP) {
|
||||
body = body.slice(0, SENTINEL_BYTE_CAP);
|
||||
truncated = true;
|
||||
malformedEntries.push({
|
||||
rawLine: `<payload truncated at ${SENTINEL_BYTE_CAP} bytes>`,
|
||||
reason: "byte_overflow",
|
||||
});
|
||||
}
|
||||
|
||||
const lines = body
|
||||
@@ -162,19 +249,29 @@ export function parseKeyValuesSentinel(raw: string): SentinelParseResult {
|
||||
const kept: CoachKeyValue[] = [];
|
||||
for (const line of lines) {
|
||||
if (kept.length >= SENTINEL_LINE_CAP) break;
|
||||
const entry = parseKeyValueLine(line);
|
||||
if (entry) kept.push(entry);
|
||||
const outcome = tryParseKeyValueLine(line);
|
||||
if (outcome.ok) {
|
||||
kept.push(outcome.value);
|
||||
} else {
|
||||
malformedEntries.push({ rawLine: line, reason: outcome.reason });
|
||||
}
|
||||
}
|
||||
|
||||
// Malformed when:
|
||||
// - closing sentinel missing, OR
|
||||
// - block payload was truncated to fit the 1 KB cap, OR
|
||||
// - the block was present but yielded zero valid rows.
|
||||
const malformed = malformedClose || truncated || kept.length === 0;
|
||||
// - the block was present but yielded zero valid rows, OR
|
||||
// - any individual row failed to parse (mixed-format drift).
|
||||
const malformed =
|
||||
malformedClose ||
|
||||
truncated ||
|
||||
kept.length === 0 ||
|
||||
malformedEntries.length > 0;
|
||||
|
||||
return {
|
||||
prose,
|
||||
keyValues: kept,
|
||||
malformed,
|
||||
malformedEntries,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user