diff --git a/graphify/llm.py b/graphify/llm.py index 46a4785d32..ff959e1918 100644 --- a/graphify/llm.py +++ b/graphify/llm.py @@ -1065,6 +1065,26 @@ def _parse_llm_json(raw: str) -> dict: return {"nodes": [], "edges": [], "hyperedges": []} +def _anthropic_response_text(content, default: str | None = None) -> str | None: + """Return the first Anthropic content block that carries text. + + Current Claude models emit a ``ThinkingBlock`` ahead of the ``TextBlock`` + when extended thinking is enabled (including the default-on path where the + thinking text is omitted). Indexing ``content[0]`` therefore raises or + yields no text (#2697). Select on the block's type instead of its position. + """ + if not content: + return default + for block in content: + block_type = getattr(block, "type", None) + if block_type is not None and block_type != "text": + continue + text = getattr(block, "text", None) + if isinstance(text, str) and text.strip(): + return text + return default + + def _bedrock_response_text(resp: dict, default: str = "") -> str: """Return the first Converse content block that carries text. @@ -1331,7 +1351,7 @@ def _call_claude(api_key: str, model: str, user_message: str, max_tokens: int = system=_extraction_system(deep=deep_mode), messages=[{"role": "user", "content": _anthropic_content(user_message, images or [])}], ) - raw_content = resp.content[0].text if resp.content else None + raw_content = _anthropic_response_text(resp.content) result = _parse_llm_json(raw_content or "{}") result["input_tokens"] = resp.usage.input_tokens if resp.usage else 0 result["output_tokens"] = resp.usage.output_tokens if resp.usage else 0 @@ -2602,7 +2622,7 @@ def _rec(inp, out) -> None: u = getattr(resp, "usage", None) if u is not None: _rec(getattr(u, "input_tokens", 0), getattr(u, "output_tokens", 0)) - return resp.content[0].text if resp.content else "" + return _anthropic_response_text(resp.content, default="") if backend == "claude-cli": import platform, shutil, subprocess diff --git a/tests/test_image_vision.py b/tests/test_image_vision.py index 1071450273..fa9b6398fd 100644 --- a/tests/test_image_vision.py +++ b/tests/test_image_vision.py @@ -401,6 +401,53 @@ def test_bedrock_response_text_tolerates_malformed_blocks(): assert llm._bedrock_response_text(resp, default="{}") == _NODE_JSON +def test_anthropic_response_text_single_text_block_unchanged(): + content = [SimpleNamespace(type="text", text=_NODE_JSON)] + assert llm._anthropic_response_text(content) == _NODE_JSON + + +def test_anthropic_response_text_skips_leading_thinking_block(): + content = [ + SimpleNamespace(type="thinking", thinking="planning"), + SimpleNamespace(type="text", text=_NODE_JSON), + ] + assert llm._anthropic_response_text(content, default="{}") == _NODE_JSON + + +def test_anthropic_response_text_legacy_block_without_type(): + content = [SimpleNamespace(text=_NODE_JSON)] + assert llm._anthropic_response_text(content) == _NODE_JSON + + +def test_anthropic_response_text_falls_back_without_text(): + content = [SimpleNamespace(type="thinking", thinking="")] + assert llm._anthropic_response_text(content, default="SENTINEL") == "SENTINEL" + + +def test_call_claude_parses_thinking_model_response(tmp_path, monkeypatch): + """Extended-thinking models must not crash on content[0] being ThinkingBlock.""" + img, _, _ = _make_corpus(tmp_path) + refs = llm._build_image_refs([img], tmp_path) + + class _Messages: + def create(self, **_kw): + return SimpleNamespace( + content=[ + SimpleNamespace(type="thinking", thinking=""), + SimpleNamespace(type="text", text=_NODE_JSON), + ], + usage=SimpleNamespace(input_tokens=5, output_tokens=7), + stop_reason="end_turn", + ) + + mod = types.ModuleType("anthropic") + mod.Anthropic = lambda **_kw: SimpleNamespace(messages=_Messages()) + monkeypatch.setitem(sys.modules, "anthropic", mod) + + result = llm._call_claude("k", "claude-opus-4-6", "CORPUS", images=refs) + assert result["nodes"] + + def test_call_bedrock_parses_reasoning_model_response(monkeypatch): """End-to-end: a reasoning-model response must not look hollow.""" def _fake(monkeypatch):