Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions graphify/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions tests/test_image_vision.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading