From 16315f1956afe7b3e728629bfe287644e55a26ba Mon Sep 17 00:00:00 2001 From: safishamsi Date: Wed, 22 Jul 2026 15:57:54 +0100 Subject: [PATCH] fix(llm): parse claude-cli structured_output, not the prose result field (follow-up to #2095) --- graphify/llm.py | 12 +++++++++++- tests/test_claude_cli_backend.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/graphify/llm.py b/graphify/llm.py index 2c2dbc42..8c34cde6 100644 --- a/graphify/llm.py +++ b/graphify/llm.py @@ -1509,7 +1509,17 @@ def _call_claude_cli(user_message: str, max_tokens: int = 8192, *, deep_mode: bo envelope = _claude_cli_envelope(proc.stdout) - raw_content = envelope.get("result", "") + # When --json-schema is in effect the CLI puts the CONSTRAINED object in the + # `structured_output` envelope field; `result` stays the model's discretionary + # text, which on a "reporting" turn is prose even with the flag set (verified + # live on Claude Code 2.1.185). Prefer the structured channel and route it + # through the same _parse_llm_json normalizer; fall back to parsing `result` + # for older CLIs that don't emit structured_output (#2076 review). + structured = envelope.get("structured_output") + if isinstance(structured, dict): + raw_content = json.dumps(structured) + else: + raw_content = envelope.get("result", "") result = _parse_llm_json(raw_content or "{}") usage = envelope.get("usage") or {} result["input_tokens"] = ( diff --git a/tests/test_claude_cli_backend.py b/tests/test_claude_cli_backend.py index 28c4e191..7699ad82 100644 --- a/tests/test_claude_cli_backend.py +++ b/tests/test_claude_cli_backend.py @@ -349,3 +349,35 @@ def test_simple_completion_resolves_cmd_shim_on_windows(monkeypatch): assert out == "ok" assert captured["argv0"] == r"C:\npm\claude.cmd" + + +def test_prefers_structured_output_over_prose_result(monkeypatch): + """#2076 review: with --json-schema the CLI puts the constrained object in + `structured_output` while `result` may be prose (a 'reporting' turn). The + backend must parse the structured object; parsing the prose would read as an + empty/hollow extraction and bisect forever.""" + envelope = { + "type": "result", "subtype": "success", "is_error": False, + "result": "Knowledge graph extracted successfully: 2 nodes, 1 edge.", # prose only + "structured_output": { + "nodes": [ + {"id": "foo_module", "label": "Foo", "file_type": "document", "source_file": "foo.md"}, + {"id": "foo_greet", "label": "greet", "file_type": "code", "source_file": "foo.md"}, + ], + "edges": [ + {"source": "foo_module", "target": "foo_greet", + "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0}, + ], + "hyperedges": [], + }, + "stop_reason": "end_turn", + "usage": {"input_tokens": 6, "output_tokens": 11}, + "modelUsage": {"claude-opus-4-7[1m]": {}}, + } + completed = MagicMock(returncode=0, stdout=json.dumps(envelope), stderr="") + with patch("shutil.which", return_value="/fake/bin/claude"), \ + patch("subprocess.run", return_value=completed): + result = llm._call_claude_cli("dummy", max_tokens=8192) + assert len(result["nodes"]) == 2, "must parse structured_output, not the prose result" + assert len(result["edges"]) == 1 + assert result["finish_reason"] == "stop"