From a76c6af8d9c2c66f84eec53ec8eb8eee8a9bb1fd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 14:47:27 +0000 Subject: [PATCH] fix(llm): make JSON extraction string-aware so braces in text cannot drop findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parse_llm_json counted braces without tracking string context, so any '}' inside a JSON string value truncated the object mid-string; the resulting decode error was swallowed upstream and the whole structured result (all findings) was silently discarded, leaving only raw text. Use json.JSONDecoder().raw_decode, trying candidate '{' positions in order — string/escape aware, and also recovers when a stray '{' precedes the real object. Fixes #136 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01W4svt5QTs4WUMSy4HwiVt9 --- src/medcheck/llm/base.py | 26 ++++++++++++++++---------- tests/unit/test_llm/test_base.py | 23 +++++++++++++++++++++++ 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/src/medcheck/llm/base.py b/src/medcheck/llm/base.py index ee1252e..587c613 100644 --- a/src/medcheck/llm/base.py +++ b/src/medcheck/llm/base.py @@ -109,17 +109,23 @@ class AnalysisResult: def parse_llm_json(raw: str) -> dict[str, Any]: - """Extract and parse the first valid JSON object from *raw* using brace-depth tracking.""" - start = raw.index("{") - depth = 0 - for i, ch in enumerate(raw[start:], start): - if ch == "{": - depth += 1 - elif ch == "}": - depth -= 1 - if depth == 0: - result: dict[str, Any] = json.loads(raw[start : i + 1]) + """Extract and parse the first JSON object embedded in *raw*. + + Uses json's incremental decoder — which is string/escape aware — rather + than manual brace counting, so a ``}`` inside a string value (plausible in + LLM free-text fields) cannot truncate the object. Candidate ``{`` positions + are tried in order until one decodes to an object. + """ + decoder = json.JSONDecoder() + pos = raw.find("{") + while pos != -1: + try: + result, _ = decoder.raw_decode(raw, pos) + except json.JSONDecodeError: + result = None + if isinstance(result, dict): return result + pos = raw.find("{", pos + 1) raise ValueError("No valid JSON found") diff --git a/tests/unit/test_llm/test_base.py b/tests/unit/test_llm/test_base.py index a756ce1..afef265 100644 --- a/tests/unit/test_llm/test_base.py +++ b/tests/unit/test_llm/test_base.py @@ -121,3 +121,26 @@ def fn(): call_with_retries(fn, provider="x", attempts=3, base_delay=0) # Non-transient error: no retries. assert calls["n"] == 1 + + +def test_parse_llm_json_braces_inside_string_values(): + raw = ( + '{"overall_impression": "Normal study (note: {contrast} was not administered)",' + ' "structures": [{"name": "ACL", "status": "intact", "confidence": 0.9}]}' + ) + result = parse_llm_response(raw) + assert "not administered" in result.overall_impression + assert len(result.structures) == 1 + assert result.structures[0].name == "ACL" + + +def test_parse_llm_json_object_after_stray_brace(): + raw = 'Header {not json. {"overall_impression": "ok", "structures": []}' + result = parse_llm_response(raw) + assert result.overall_impression == "ok" + + +def test_parse_llm_json_markdown_fenced(): + raw = 'Here is the analysis:\n```json\n{"overall_impression": "unremarkable", "structures": []}\n```\nDone.' + result = parse_llm_response(raw) + assert result.overall_impression == "unremarkable"