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"