Skip to content

review: eval JSON extraction survives prose braces and invalid escapes - #292

Merged
jwbron merged 1 commit into
mainfrom
jwies/eval-json-extraction
Jul 29, 2026
Merged

review: eval JSON extraction survives prose braces and invalid escapes#292
jwbron merged 1 commit into
mainfrom
jwies/eval-json-extraction

Conversation

@jwbron

@jwbron jwbron commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes the two recurring live A/B eval failures that have shown up on the eval comments of PRs 279, 282, 283, 284, and 287 (for example this comment):

  • incident-cache-missing-key: correctness-reviewer / claim-validator: malformed output: Expected property name or '}' in JSON at position 1
  • Judge scoring failed: Bad escaped character in JSON at position ~3xx

Root causes

Every live seam (producer finders/validator, judge scoring, match arbiter) shared the same extraction rule: slice /\{[\s\S]*\}/ (the FIRST { through the LAST }) out of the model's final text and strict-JSON.parse it.

  1. Prose braces poison the slice. The incident-cache-missing-key diff is full of template literals (`user-profile:${tenantId}:${userId}`). When an agent quotes one in prose before its JSON payload, the slice starts at {tenantId... and parsing dies with exactly "Expected property name or '}' at position 1 (line 1 column 2)". The malformed-output retry re-quotes the same snippet, so it never recovers; that is why it is always this case that fails.
  2. One invalid string escape kills the judge. The judge occasionally emits an invalid escape (for example \') in its rationale string; strict parsing then drops that arm's entire quality score.

The match arbiter had the same flaw with a worse failure mode: on any parse failure it silently returns false, so a correct {"match": true} verdict preceded by prose braces was counted as a miss (inflating the "unmatched posted" noise metric).

Fix

New shared workflows/review/eval/extract-json.ts:

  • walks { candidates left to right and takes each candidate's balanced, string-aware extent (braces inside JSON strings do not confuse it);
  • a slice that fails to parse is retried once with invalid string escapes repaired (drop the backslash, the lenient-parser rule; valid escapes are consumed pairwise so \\ is never corrupted);
  • of the top-level slices that parse, the last one wins, since every caller instructs its agent to end with the JSON object.

All three call sites now use it: live-producer.ts (parseJsonObject removed), judge-live-model.ts, and match-arbiter.ts.

Testing

  • 14 new unit tests in extract-json.test.ts, including reproductions of both production failure shapes.
  • Producer-level regression test: a payload preceded by quoted template-literal braces parses with no retry.
  • Arbiter-level regression test: a yes verdict past prose braces is read as a match (previously a silent no).
  • pnpm test (1073 tests), pnpm typecheck, and eslint on the touched files all pass.

…ose braces and invalid escapes

Every live seam (producer finders/validator, judge scoring, match
arbiter) sliced model output with /\{[\s\S]*\}/ (first { through
last }) plus strict JSON.parse. Two recurring live failures follow:
an agent quoting a template literal from the diff before its payload
fails with "Expected property name or '}'" (the standing
incident-cache-missing-key failures; the retry re-quotes the same
snippet, so it never recovers), and one invalid string escape from
the judge kills an arm's scoring ("Bad escaped character in JSON").

The shared extractJsonObject walks balanced brace candidates left to
right, retries a failed slice with invalid string escapes repaired
(drop the backslash, the lenient-parser rule), and returns the last
top-level object that parses. The match arbiter also stops silently
reporting no-match when its yes verdict rides alongside prose braces.
@changeset-bot

changeset-bot Bot commented Jul 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bcd937f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
review Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

Copy link
Copy Markdown
Contributor

Review live A/B

No reviewable delta: review.md is byte-identical in both arms (baseline origin/main, sha af901654d627), so the extracted prompts and the orchestrator body match and no arms were run. Pass --force-arms for a deliberate wobble control.

@khan-actions-bot
khan-actions-bot requested review from a team, jaredly and somewhatabstract and removed request for a team July 22, 2026 23:40
@github-actions

Copy link
Copy Markdown
Contributor

Review Guidance

github-actions (1 file)
File Reason
extract-json.ts New single JSON-extraction seam feeding the producer, judge, and arbiter; a parsing bug here silently skews every eval arm's scores. Implementation and tests hold up under review.

Common patterns

3 files: Replaced the inline /\{[\s\S]*\}/ + JSON.parse extraction with the shared extractJsonObject at every live seam (live-producer.ts, judge-live-model.ts, match-arbiter.ts).

-    const match = text.match(/\{[\s\S]*\}/);
-    if (!match) { throw ... }
-    const parsed = JSON.parse(match[0]) as ...;
+    const parsed = extractJsonObject(text) as ...;

3 files: Added regression tests for the prose-braces and bad-escape scenarios (extract-json.test.ts, live-producer.test.ts, match-arbiter.test.ts).

Excluded from review (6 files)

Not individually reviewed — formatting-only or fully explained by a common pattern above:

  • .changeset/eval-json-extraction-hardening.md — formatting-only
  • workflows/review/eval/live-producer.ts — pattern-only
  • workflows/review/eval/live-producer.test.ts — pattern-only
  • workflows/review/eval/judge-live-model.ts — pattern-only
  • workflows/review/eval/match-arbiter.ts — pattern-only
  • workflows/review/eval/match-arbiter.test.ts — pattern-only

};

/** Extract the JSON object from a model's final text; throws when none. */
export const extractJsonObject = (text: string): Record<string, unknown> => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): extractJsonObject is position-based last-wins, so it only survives prose braces before the payload. A parseable object quoted after the payload displaces the real one — for the arbiter that is a silent wrong verdict, the exact pathology this PR removes (the old first/last-brace slice would have spanned payload+trailing prose and failed to parse instead). Each caller already knows its required top-level key (findings, claims, match, verdict), so preferring the last candidate that carries the expected key would survive prose braces on both sides; the new tests cover only the before-payload case.

Lower-confidence notes (2)
  • extract-json.ts:56 — escape repair runs instead of the producer's malformed-output retry, so already-invalid producer JSON (e.g. an unescaped \d in a suggested_patch) is silently repaired rather than fed back to the model to fix. Valid escapes still parse on the first attempt, so this only bites output that was already malformed; consider strict-first extraction at the seams that have a retry.
  • judge-live-model.ts:100 — both direct Messages-API seams (judge, arbiter) own their API calls end to end and could force a JSON-schema tool choice to eliminate malformed JSON at the source, rather than repairing after the fact.

if (!match) {
let parsed: Omit<JudgeScore, "findingId">;
try {
parsed = extractJsonObject(text) as Omit<JudgeScore, "findingId">;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): This seam casts the extracted object straight to JudgeScore with no shape check — unlike the producer (validates a findings array), the validator (claims), and the arbiter (match === true). Because extractJsonObject is far more willing than the old first/last-brace slice to return some object, a reply whose real verdict payload is missing but whose prose holds a parseable object now yields that prose object: verdict/quality come back undefined, and in judge.ts qualitySum += undefined makes the arm's meanQuality silently NaN (previously this shape threw a loud judgeError). A one-line guard that verdict is one of good/borderline/bad and quality is a number would restore the loud failure.

@jwbron
jwbron merged commit cc23d7c into main Jul 29, 2026
10 checks passed
@jwbron
jwbron deleted the jwies/eval-json-extraction branch July 29, 2026 23:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants