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) {
|
if (sentinel.malformed) {
|
||||||
// Graceful degrade: log so ops can spot a provider whose
|
// Graceful degrade: log so ops can spot a provider whose
|
||||||
// sentinel format has drifted, but pass the prose through
|
// 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({
|
annotate({
|
||||||
action: { name: "coach.keyvalues.parse_failed" },
|
action: { name: annotationName },
|
||||||
meta: {
|
meta: {
|
||||||
kept: sentinel.keyValues.length,
|
kept: sentinel.keyValues.length,
|
||||||
|
malformedCount: sentinel.malformedEntries.length,
|
||||||
|
reasons,
|
||||||
promptVersion: PROMPT_VERSION,
|
promptVersion: PROMPT_VERSION,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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.
|
* 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.malformed).toBe(false);
|
||||||
|
expect(out.malformedEntries).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns prose untouched when no sentinel is present", () => {
|
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.prose).toBe("Just a qualitative reply with no numbers cited.");
|
||||||
expect(out.keyValues).toEqual([]);
|
expect(out.keyValues).toEqual([]);
|
||||||
expect(out.malformed).toBe(false);
|
expect(out.malformed).toBe(false);
|
||||||
|
expect(out.malformedEntries).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("graceful-degrades when the closing ---END--- is missing", () => {
|
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.prose).toBe("Prose still streams cleanly.");
|
||||||
expect(out.keyValues.length).toBe(1);
|
expect(out.keyValues.length).toBe(1);
|
||||||
expect(out.malformed).toBe(true);
|
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)", () => {
|
it("clips the block to 8 entries (prompt-contract cap)", () => {
|
||||||
@@ -143,7 +152,7 @@ describe("parseKeyValuesSentinel", () => {
|
|||||||
expect(out.malformed).toBe(true);
|
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 = [
|
const raw = [
|
||||||
"Prose.",
|
"Prose.",
|
||||||
"",
|
"",
|
||||||
@@ -160,6 +169,65 @@ describe("parseKeyValuesSentinel", () => {
|
|||||||
"avg30 systolic",
|
"avg30 systolic",
|
||||||
"avg30 mood",
|
"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", () => {
|
it("returns malformed when the sentinel block is empty", () => {
|
||||||
@@ -168,6 +236,8 @@ describe("parseKeyValuesSentinel", () => {
|
|||||||
expect(out.keyValues).toEqual([]);
|
expect(out.keyValues).toEqual([]);
|
||||||
expect(out.malformed).toBe(true);
|
expect(out.malformed).toBe(true);
|
||||||
expect(out.prose).toBe("Prose.");
|
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", () => {
|
it("returns prose and an empty array for empty input", () => {
|
||||||
@@ -175,5 +245,6 @@ describe("parseKeyValuesSentinel", () => {
|
|||||||
expect(out.prose).toBe("");
|
expect(out.prose).toBe("");
|
||||||
expect(out.keyValues).toEqual([]);
|
expect(out.keyValues).toEqual([]);
|
||||||
expect(out.malformed).toBe(false);
|
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 1 KB on the sentinel block (prompt-injection guard).
|
||||||
* - Hard cap 8 lines kept (per the prompt contract).
|
* - Hard cap 8 lines kept (per the prompt contract).
|
||||||
* - Each line is validated against `coachKeyValueSchema`; malformed
|
* - 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---`,
|
* - When the sentinel pair is malformed (e.g. no closing `---END---`,
|
||||||
* unbalanced sentinels), the parser returns the original prose
|
* unbalanced sentinels), the parser still returns whatever rows it
|
||||||
* untouched and emits no keyValues — the caller logs a wide-event
|
* could parse plus a malformed flag; the caller logs
|
||||||
* `coach.keyvalues.parse_failed` for ops visibility.
|
* `coach.keyvalues.parse_failed` (full block invalid) or
|
||||||
|
* `coach.keyvalues.parse_partial` (mixed valid + invalid) for ops
|
||||||
|
* visibility.
|
||||||
*/
|
*/
|
||||||
import { coachKeyValueSchema, type CoachKeyValue } from "./types";
|
import { coachKeyValueSchema, type CoachKeyValue } from "./types";
|
||||||
|
|
||||||
@@ -31,31 +34,76 @@ import { coachKeyValueSchema, type CoachKeyValue } from "./types";
|
|||||||
const SENTINEL_BYTE_CAP = 1024;
|
const SENTINEL_BYTE_CAP = 1024;
|
||||||
/** Max key/value rows kept inside the block (prompt-contract value). */
|
/** Max key/value rows kept inside the block (prompt-contract value). */
|
||||||
const SENTINEL_LINE_CAP = 8;
|
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 OPEN_SENTINEL = "---KEYVALUES---";
|
||||||
const CLOSE_SENTINEL = "---END---";
|
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
|
* Parse one body line of the form
|
||||||
* `<label>: <value> [<unit>] (<window>)`
|
* `<label>: <value> [<unit>] (<window>)`
|
||||||
* into a `CoachKeyValue`. The unit + window are both optional; an
|
* into a `CoachKeyValue`. The unit + window are both optional; an
|
||||||
* input missing either or both still parses cleanly. Returns `null`
|
* input missing either or both still parses cleanly. Returns
|
||||||
* when the line does not match the expected shape — the caller drops
|
* `{ ok: false, reason }` when the line does not match the expected
|
||||||
* the line rather than rejecting the whole block.
|
* 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();
|
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
|
// Find the first `:` — the label cannot itself contain `:`. The
|
||||||
// remainder may carry the value, an optional `[unit]`, and an
|
// remainder may carry the value, an optional `[unit]`, and an
|
||||||
// optional `(window)`.
|
// optional `(window)`.
|
||||||
const colon = trimmed.indexOf(":");
|
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();
|
const label = trimmed.slice(0, colon).trim();
|
||||||
let rest = trimmed.slice(colon + 1).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 unit: string | undefined;
|
||||||
let window: string | undefined;
|
let window: string | undefined;
|
||||||
@@ -78,7 +126,10 @@ export function parseKeyValueLine(line: string): CoachKeyValue | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const value = rest.trim();
|
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({
|
const parsed = coachKeyValueSchema.safeParse({
|
||||||
label,
|
label,
|
||||||
@@ -86,8 +137,18 @@ export function parseKeyValueLine(line: string): CoachKeyValue | null {
|
|||||||
...(unit ? { unit } : {}),
|
...(unit ? { unit } : {}),
|
||||||
...(window ? { window } : {}),
|
...(window ? { window } : {}),
|
||||||
});
|
});
|
||||||
if (!parsed.success) return null;
|
if (!parsed.success) return { ok: false, reason: "schema_invalid" };
|
||||||
return parsed.data;
|
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 {
|
export interface SentinelParseResult {
|
||||||
@@ -101,6 +162,17 @@ export interface SentinelParseResult {
|
|||||||
* Callers log this for ops visibility (graceful degrade).
|
* Callers log this for ops visibility (graceful degrade).
|
||||||
*/
|
*/
|
||||||
malformed: boolean;
|
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 {
|
export function parseKeyValuesSentinel(raw: string): SentinelParseResult {
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return { prose: "", keyValues: [], malformed: false };
|
return { prose: "", keyValues: [], malformed: false, malformedEntries: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const openIdx = raw.indexOf(OPEN_SENTINEL);
|
const openIdx = raw.indexOf(OPEN_SENTINEL);
|
||||||
if (openIdx === -1) {
|
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
|
// 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.
|
// bubble doesn't render an extra blank paragraph.
|
||||||
const prose = raw.slice(0, openIdx).replace(/\s+$/u, "");
|
const prose = raw.slice(0, openIdx).replace(/\s+$/u, "");
|
||||||
|
|
||||||
|
const malformedEntries: SentinelMalformedEntry[] = [];
|
||||||
|
|
||||||
const afterOpen = raw.slice(openIdx + OPEN_SENTINEL.length);
|
const afterOpen = raw.slice(openIdx + OPEN_SENTINEL.length);
|
||||||
// Look for the closing sentinel; if missing, the block is
|
// Look for the closing sentinel; if missing, the block is
|
||||||
// malformed — fall back to the original prose and emit nothing.
|
// 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.
|
// the contract wasn't fully met.
|
||||||
bodyRaw = afterOpen;
|
bodyRaw = afterOpen;
|
||||||
malformedClose = true;
|
malformedClose = true;
|
||||||
|
malformedEntries.push({
|
||||||
|
rawLine: CLOSE_SENTINEL,
|
||||||
|
reason: "no_END_marker",
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
bodyRaw = afterOpen.slice(0, closeRel);
|
bodyRaw = afterOpen.slice(0, closeRel);
|
||||||
}
|
}
|
||||||
@@ -149,6 +232,10 @@ export function parseKeyValuesSentinel(raw: string): SentinelParseResult {
|
|||||||
if (body.length > SENTINEL_BYTE_CAP) {
|
if (body.length > SENTINEL_BYTE_CAP) {
|
||||||
body = body.slice(0, SENTINEL_BYTE_CAP);
|
body = body.slice(0, SENTINEL_BYTE_CAP);
|
||||||
truncated = true;
|
truncated = true;
|
||||||
|
malformedEntries.push({
|
||||||
|
rawLine: `<payload truncated at ${SENTINEL_BYTE_CAP} bytes>`,
|
||||||
|
reason: "byte_overflow",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = body
|
const lines = body
|
||||||
@@ -162,19 +249,29 @@ export function parseKeyValuesSentinel(raw: string): SentinelParseResult {
|
|||||||
const kept: CoachKeyValue[] = [];
|
const kept: CoachKeyValue[] = [];
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (kept.length >= SENTINEL_LINE_CAP) break;
|
if (kept.length >= SENTINEL_LINE_CAP) break;
|
||||||
const entry = parseKeyValueLine(line);
|
const outcome = tryParseKeyValueLine(line);
|
||||||
if (entry) kept.push(entry);
|
if (outcome.ok) {
|
||||||
|
kept.push(outcome.value);
|
||||||
|
} else {
|
||||||
|
malformedEntries.push({ rawLine: line, reason: outcome.reason });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Malformed when:
|
// Malformed when:
|
||||||
// - closing sentinel missing, OR
|
// - closing sentinel missing, OR
|
||||||
// - block payload was truncated to fit the 1 KB cap, OR
|
// - block payload was truncated to fit the 1 KB cap, OR
|
||||||
// - the block was present but yielded zero valid rows.
|
// - the block was present but yielded zero valid rows, OR
|
||||||
const malformed = malformedClose || truncated || kept.length === 0;
|
// - any individual row failed to parse (mixed-format drift).
|
||||||
|
const malformed =
|
||||||
|
malformedClose ||
|
||||||
|
truncated ||
|
||||||
|
kept.length === 0 ||
|
||||||
|
malformedEntries.length > 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
prose,
|
prose,
|
||||||
keyValues: kept,
|
keyValues: kept,
|
||||||
malformed,
|
malformed,
|
||||||
|
malformedEntries,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user