-
Notifications
You must be signed in to change notification settings - Fork 0
#144: tolerate LLM prose preamble before JSON in draft/grade parsers #147
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| """Tolerant JSON-payload extraction shared by the LLM-response parsers. | ||
|
|
||
| Issue #144: ``claude-sonnet-4-6`` reproducibly emitted a reasoning preamble | ||
| ("I need to analyze the business rules carefully...") *before* the JSON | ||
| object on the business-rules drafting path, so the strict | ||
| ``json.loads`` / ``model_validate_json`` parse failed at line 1. The | ||
| obvious guardrail — an assistant-turn prefill forcing JSON-only output — | ||
| is **not available**: the API rejects it with | ||
| ``"This model does not support assistant message prefill. The | ||
| conversation must end with a user message."`` (HTTP 400). The parser is | ||
| therefore the only place a JSON-only guarantee can live, and prompts are | ||
| advisory at best. | ||
|
|
||
| :func:`extract_json_payload` locates the first complete JSON value | ||
| embedded in a response and returns just that substring, so the caller's | ||
| existing strict parser runs on clean JSON. Both the drafter | ||
| (:func:`signalforge.draft.parser.parse_draft_response`) and the grader | ||
| (:func:`signalforge.grade.parser.parse_grade_response`) route through it. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
|
|
||
| __all__ = ("extract_json_payload",) | ||
|
|
||
|
|
||
| def extract_json_payload(text: str) -> str: | ||
| """Return the first complete JSON value embedded in ``text``. | ||
|
|
||
| Tolerates a leading prose preamble (and any trailing content) around a | ||
| single JSON object/array — the issue #144 failure mode, where the model | ||
| narrates plain sentences before the ``{``. The text is | ||
| whitespace-stripped, then decoding is attempted **at the first** ``{`` | ||
| or ``[`` via :meth:`json.JSONDecoder.raw_decode`; on success the | ||
| substring spanning that value is returned (trailing content discarded). | ||
|
|
||
| Decoding is attempted at the first structural character ONLY — never at | ||
| later braces. Scanning deeper would let a *truncated* outer object | ||
| (whose first ``{`` fails to decode) match a complete **inner** fragment, | ||
| silently turning a "not valid JSON" failure into a wrong-shape parse. | ||
| So if the first candidate fails to decode, the whitespace-stripped input | ||
| is returned unchanged and the caller's strict parser raises its normal | ||
| JSON error with the correct excerpt/position. This helper never raises | ||
| and never mutates the JSON bytes — it only trims a leading preamble and | ||
| trailing content around a cleanly-decodable value. | ||
| """ | ||
| stripped = text.strip() | ||
| first = next((i for i, ch in enumerate(stripped) if ch in "{["), -1) | ||
| if first == -1: | ||
| return stripped | ||
| try: | ||
| _value, end = json.JSONDecoder().raw_decode(stripped, first) | ||
| except json.JSONDecodeError: | ||
| return stripped | ||
| return stripped[first:end] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| """Tests for :func:`signalforge._common.json_payload.extract_json_payload`. | ||
|
|
||
| The helper is the load-bearing JSON-only guardrail for the LLM-response | ||
| parsers (issue #144): claude-sonnet-4-6 can narrate a prose preamble | ||
| before the JSON object and does NOT support an assistant-turn prefill to | ||
| force JSON-only output, so the parser must tolerate the preamble. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
|
|
||
| import pytest | ||
|
|
||
| from signalforge._common.json_payload import extract_json_payload | ||
|
|
||
| pytestmark = pytest.mark.unit | ||
|
|
||
|
|
||
| def test_plain_json_object_passthrough() -> None: | ||
| text = '{"a": 1, "b": [2, 3]}' | ||
| assert extract_json_payload(text) == text | ||
|
|
||
|
|
||
| def test_strips_leading_prose_preamble() -> None: | ||
| """The issue #144 failure mode: prose before the object.""" | ||
| preamble = "I need to analyze the business rules carefully. The first rule is a tautology. " | ||
| payload = '{"columns": [], "tests": []}' | ||
| extracted = extract_json_payload(preamble + payload) | ||
| assert extracted == payload | ||
| assert json.loads(extracted) == {"columns": [], "tests": []} | ||
|
|
||
|
|
||
| def test_strips_trailing_content_after_object() -> None: | ||
| payload = '{"score": 0.8, "passed": true}' | ||
| assert extract_json_payload(payload + "\n\nHope that helps!") == payload | ||
|
|
||
|
|
||
| def test_strips_markdown_code_fence_via_first_brace() -> None: | ||
| text = '```json\n{"ok": true}\n```' | ||
| assert json.loads(extract_json_payload(text)) == {"ok": True} | ||
|
|
||
|
|
||
| def test_decodes_at_first_brace_when_it_is_valid_json() -> None: | ||
| """When the first structural char begins a valid object, it is returned | ||
| even if more prose/JSON follows.""" | ||
| text = 'Result: {"verdict": "yes"} and some trailing note {"ignored": 1}' | ||
| assert json.loads(extract_json_payload(text)) == {"verdict": "yes"} | ||
|
|
||
|
|
||
| def test_truncated_outer_object_not_rescued_by_inner_fragment() -> None: | ||
| """A truncated outer object must NOT be silently replaced by a complete | ||
| inner fragment — decoding is attempted at the first brace only, so this | ||
| returns unchanged and the caller's strict parser fails loud.""" | ||
| truncated = '{"columns": [{"name": "order_id"}], "tests": [' # no closing braces | ||
| assert extract_json_payload(truncated) == truncated | ||
| with pytest.raises(json.JSONDecodeError): | ||
| json.loads(extract_json_payload(truncated)) | ||
|
|
||
|
|
||
| def test_extracts_leading_array() -> None: | ||
| text = "Sure! [1, 2, 3]" | ||
| assert extract_json_payload(text) == "[1, 2, 3]" | ||
|
|
||
|
|
||
| def test_no_json_returns_stripped_input_unchanged() -> None: | ||
| """No decodable JSON → return the stripped text so the caller's strict | ||
| parser raises its normal error with the right excerpt.""" | ||
| text = " this is not json at all " | ||
| assert extract_json_payload(text) == "this is not json at all" | ||
|
|
||
|
|
||
| def test_empty_input_returns_empty() -> None: | ||
| assert extract_json_payload(" ") == "" | ||
|
|
||
|
|
||
| def test_does_not_mutate_json_bytes() -> None: | ||
| """Whitespace inside the object is preserved verbatim (no re-encoding).""" | ||
| payload = '{"a": 1,\n "b": 2}' | ||
| assert extract_json_payload("preamble " + payload) == payload |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.