Skip to content

#144: tolerate LLM prose preamble before JSON in draft/grade parsers - #147

Merged
wjduenow merged 2 commits into
devfrom
bugfix/144-json-prefill
May 27, 2026
Merged

#144: tolerate LLM prose preamble before JSON in draft/grade parsers#147
wjduenow merged 2 commits into
devfrom
bugfix/144-json-prefill

Conversation

@wjduenow

@wjduenow wjduenow commented May 27, 2026

Copy link
Copy Markdown
Owner

Fixes #144.

Problem

tests/cli/test_e2e_business_rules.py::test_e2e_business_rules_drafts_prunes_custom_sql failed reproducibly: claude-sonnet-4-6 emits a reasoning preamble ("I need to analyze the business rules carefully...") before the JSON object on the business-rules drafting path, so parse_draft_response's strict model_validate_json raised LLMOutputJSONError at line 1 (generate → exit 2).

Why not the issue's option 1 (assistant prefill)

The issue ranked assistant-turn prefill as most robust. It is not available on this model — the API rejects it with HTTP 400:

"This model does not support assistant message prefill. The conversation must end with a user message."

(Confirmed live: wiring assistant_prefill="{" traded the parse error for a hard 400 / exit 3.) So the parser is the only place a JSON-only guarantee can live; prompts are advisory. This PR implements option 2 (tolerant parser).

Fix

signalforge._common.json_payload.extract_json_payload strips a leading prose preamble (and trailing content) around a cleanly-decodable JSON value, applied in both draft/parser.py and grade/parser.py before strict parsing.

Load-bearing rules:

  • Decode at the FIRST {/[ only. Scanning deeper would let a truncated outer object (first brace fails to decode) match a complete inner fragment, silently turning "not valid JSON" into a wrong-shape parse. On first-candidate failure the input is returned unchanged so the strict parser raises the normal LLMOutputJSONError/GradeOutputError with the correct excerpt — truncated/garbage paths are byte-unchanged.
  • Error envelopes keep the original raw_text (preamble included); only model_validate_json sees the extracted payload, so audits/incident reports stay honest. response_text_hash is unchanged.

Option 3 (prompt-level "JSON only") was deliberately skipped — it would rotate the cached system-prompt golden for no load-bearing gain once the parser is the guarantee.

Tests / validation

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Enhanced parsing to gracefully handle natural language preambles preceding JSON payloads in LLM-generated responses, improving overall parsing robustness
  • Tests

    • Added comprehensive test coverage for JSON extraction behavior across multiple LLM output patterns and edge cases
  • Documentation

    • Added specifications for tolerant JSON extraction in both grade and draft response parsing

Review Change Stack

claude-sonnet-4-6 reproducibly narrates a reasoning preamble before the
JSON object on the business-rules drafting path, so the strict parse failed
at line 1. The obvious guardrail — an assistant-turn JSON prefill — is
unavailable: the API rejects it with HTTP 400 "This model does not support
assistant message prefill." The parser is therefore the only place a
JSON-only guarantee can live (prompts are advisory).

Add signalforge._common.json_payload.extract_json_payload: decode at the
FIRST { or [ via json.raw_decode, returning that span and discarding a
leading preamble + trailing content. Decode the first candidate ONLY — a
truncated outer object must not be silently rescued by a complete inner
fragment, so on first-candidate failure the text is returned unchanged and
the strict parser raises the normal JSON error with the right excerpt.

- draft/parser.py: extract before model_validate_json; error envelopes keep
  the original raw_text (preamble included) so incident reports are honest.
- grade/parser.py: extract after _strip_code_fence (shares the helper).
- tests: helper unit matrix (preamble, fence, trailing, array, truncated-not-
  rescued, no-json passthrough) + draft/grade parser preamble regressions +
  a pure-prose-still-raises check.
- llm-drafter.md / grade-layer.md: document the contract and why prefill
  (option 1) and prompt hardening (option 3) were not used.

Full suite: 2416 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 27, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (4)
  • feature/.*
  • bug/.*
  • hotfix/.*
  • feat/.*

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 13cd9be8-6b9d-4baa-bfbe-766d13a2bd90

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds a tolerant JSON-payload extraction step to the draft + grade LLM response parsers so they can handle models that emit a prose preamble (and/or trailing text) around an otherwise-valid JSON payload, addressing issue #144.

Changes:

  • Introduces signalforge._common.json_payload.extract_json_payload to extract the first decodable JSON object/array starting at the first {/[ in the response.
  • Applies the helper in draft/parser.py (before model_validate_json) and in grade/parser.py (after _strip_code_fence, before json.loads).
  • Adds targeted unit tests for the helper and new regression tests for both parsers.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/grade/test_parser.py Adds regression coverage for grade-parser prose preambles.
tests/draft/test_parser.py Adds regression coverage for draft-parser prose preambles and ensures pure-prose still fails loud.
tests/_common/test_json_payload.py Adds unit tests for the new JSON extraction helper.
src/signalforge/grade/parser.py Routes judge responses through the shared tolerant JSON extractor.
src/signalforge/draft/parser.py Routes drafter responses through the shared tolerant JSON extractor before strict Pydantic parsing.
src/signalforge/_common/json_payload.py New shared helper implementing first-brace-only JSON extraction.
.claude/rules/llm-drafter.md Documents the new guardrail and its invariants for the drafter.
.claude/rules/grade-layer.md Documents the new guardrail and its invariants for the grader.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/signalforge/grade/parser.py
Comment thread tests/_common/test_json_payload.py Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/signalforge/grade/parser.py (1)

103-108: ⚡ Quick win

Update the module-level parser contract text to match the new prose-tolerant behavior.

The implementation now extracts embedded JSON from prose-wrapped responses, but the top docstring still states no prose extraction. Please align that docstring to avoid contract drift.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/signalforge/grade/parser.py` around lines 103 - 108, Update the
module-level docstring to reflect that the parser now tolerates prose-wrapped
responses and will extract an embedded JSON payload (via extract_json_payload
after _strip_code_fence) rather than requiring strictly JSON-only responses;
mention that extract_json_payload will return the original text when no JSON is
found and that callers should still handle the error path, and update any
wording that currently claims "no prose extraction" to state the new
prose-tolerant extraction behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.claude/rules/grade-layer.md:
- Line 57: Update the wording that currently says “first-brace-only” to
explicitly state it accepts the first `{` or `[` so it matches the helper
contract used by parse_grade_response (which routes through
signalforge._common.json_payload.extract_json_payload after _strip_code_fence)
and mirrors the llm-drafter.md tolerant JSON extraction rule; ensure the doc
mentions that a response with no JSON still produces
GradeOutputError(violation_type="json_parse") and follows the conservative
degrade path.

---

Nitpick comments:
In `@src/signalforge/grade/parser.py`:
- Around line 103-108: Update the module-level docstring to reflect that the
parser now tolerates prose-wrapped responses and will extract an embedded JSON
payload (via extract_json_payload after _strip_code_fence) rather than requiring
strictly JSON-only responses; mention that extract_json_payload will return the
original text when no JSON is found and that callers should still handle the
error path, and update any wording that currently claims "no prose extraction"
to state the new prose-tolerant extraction behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3e75b8d2-b1a7-4439-a794-28a16e8c9033

📥 Commits

Reviewing files that changed from the base of the PR and between 9d028b8 and 84689ce.

📒 Files selected for processing (8)
  • .claude/rules/grade-layer.md
  • .claude/rules/llm-drafter.md
  • src/signalforge/_common/json_payload.py
  • src/signalforge/draft/parser.py
  • src/signalforge/grade/parser.py
  • tests/_common/test_json_payload.py
  • tests/draft/test_parser.py
  • tests/grade/test_parser.py

Comment thread .claude/rules/grade-layer.md Outdated
- grade/parser.py: fix stale module docstring that still claimed the parser
  does not extract JSON from prose (it now does, via extract_json_payload).
- tests/_common/test_json_payload.py: mark `unit` (not `draft`) to match
  CONTRIBUTING.md's unit/integration/error convention + sibling _common tests.
- grade-layer.md: clarify the decode rule is "first structural char ({ or [)",
  not object-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@wjduenow

Copy link
Copy Markdown
Owner Author

PR Review Summary

All three inline comments were real and are fixed in 6fb05b9.

Fixed (3 items)

File Line Issue Fix
src/signalforge/grade/parser.py 14-17 Module docstring still claimed the parser does not extract JSON from prose — stale after this PR Rewrote step 1 to document the extract_json_payload preamble tolerance + first-{/[-only / fail-loud invariants
tests/_common/test_json_payload.py 17 Marked draft; CONTRIBUTING.md uses unit/integration/error, and sibling _common tests use unit Changed pytestmark to pytest.mark.unit (verified -m unit selects it)
.claude/rules/grade-layer.md 57 "first-brace-only" read as object-only, but the helper handles object or array Reworded to "first structural char ({ or [)"

False Positives

None.

Validation after fixes: ruff ✅ · ruff format ✅ · pyright 0/0/0 ✅ · affected tests pass. (CodeRabbit auto-review is skipped on this repo because PRs target dev, not the default branch.)

@wjduenow
wjduenow merged commit c1192cf into dev May 27, 2026
6 checks passed
@wjduenow
wjduenow deleted the bugfix/144-json-prefill branch May 27, 2026 21:31
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.

3 participants