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
28 changes: 26 additions & 2 deletions graphify/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -1044,6 +1044,30 @@ def _parse_llm_json(raw: str) -> dict:
return {"nodes": [], "edges": [], "hyperedges": []}


def _bedrock_response_text(resp: dict, default: str = "") -> str:
"""Return the first Converse content block that carries text.

Converse returns ``output.message.content`` as a list of blocks, and the
API does not promise a text block is first: reasoning-capable models emit a
``reasoningContent`` block ahead of the answer, and ``toolUse`` or future
block types can precede it too. Indexing position 0 therefore yields no text
at all for those models, which reads downstream as a hollow response, gets
reclassified as truncation, and sends the chunk into bisection that cannot
converge. Select on the block's shape instead of its position so this holds
for any model; a response whose first block is already text is unaffected.
"""
content = resp.get("output", {}).get("message", {}).get("content", [])
if not isinstance(content, list):
return default
for block in content:
if not isinstance(block, dict):
continue
text = block.get("text")
if isinstance(text, str) and text.strip():
return text
return default


def _response_is_hollow(raw_content: str | None, parsed: dict) -> bool:
"""Detect a successful HTTP response that yielded no usable extraction.

Expand Down Expand Up @@ -1632,7 +1656,7 @@ def _call_bedrock(model: str, user_message: str, max_tokens: int = 8192, *, deep
msg = exc.response["Error"]["Message"]
raise RuntimeError(f"Bedrock API error ({code}): {msg}") from exc

text = resp.get("output", {}).get("message", {}).get("content", [{}])[0].get("text", "{}")
text = _bedrock_response_text(resp, default="{}")
result = _parse_llm_json(text)
usage = resp.get("usage", {})
result["input_tokens"] = usage.get("inputTokens", 0)
Expand Down Expand Up @@ -2579,7 +2603,7 @@ def _rec(inp, out) -> None:
bu = resp.get("usage") or {}
if bu:
_rec(bu.get("inputTokens", 0), bu.get("outputTokens", 0))
return resp.get("output", {}).get("message", {}).get("content", [{}])[0].get("text", "")
return _bedrock_response_text(resp, default="")

if backend == "azure":
endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT", "").strip()
Expand Down
79 changes: 79 additions & 0 deletions tests/test_image_vision.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,85 @@ def test_call_bedrock_sends_raw_image_bytes(tmp_path, monkeypatch):
assert img_block["image"]["source"]["bytes"] == _PNG_BYTES


# ── Converse content-block selection ─────────────────────────────────────────
# Converse returns a LIST of blocks and does not promise text is first: a
# reasoning-capable model emits reasoningContent ahead of the answer. Selection
# must key on block shape, not position, or those models yield no text at all.

def _bedrock_resp(blocks: list) -> dict:
return {"output": {"message": {"content": blocks}}}


def test_bedrock_response_text_single_text_block_unchanged():
resp = _bedrock_resp([{"text": _NODE_JSON}])
assert llm._bedrock_response_text(resp) == _NODE_JSON


def test_bedrock_response_text_skips_leading_reasoning_block():
resp = _bedrock_resp([
{"reasoningContent": {"reasoningText": {"text": "deliberating"}}},
{"text": _NODE_JSON},
])
assert llm._bedrock_response_text(resp, default="{}") == _NODE_JSON


@pytest.mark.parametrize("leading", [
{"reasoningContent": {}},
{"toolUse": {"name": "x", "input": {}}},
{"someFutureBlockType": {"a": 1}},
{"text": " "},
])
def test_bedrock_response_text_skips_non_text_leading_blocks(leading):
resp = _bedrock_resp([leading, {"text": _NODE_JSON}])
assert llm._bedrock_response_text(resp, default="{}") == _NODE_JSON


@pytest.mark.parametrize("resp", [
{"output": {"message": {"content": []}}},
{"output": {"message": {"content": [{"reasoningContent": {}}]}}},
{"output": {"message": {"content": "not-a-list"}}},
{"output": {}},
{},
])
def test_bedrock_response_text_falls_back_without_text(resp):
assert llm._bedrock_response_text(resp, default="SENTINEL") == "SENTINEL"


def test_bedrock_response_text_tolerates_malformed_blocks():
resp = _bedrock_resp(["not-a-dict", {"text": 123}, {"text": _NODE_JSON}])
assert llm._bedrock_response_text(resp, default="{}") == _NODE_JSON


def test_call_bedrock_parses_reasoning_model_response(monkeypatch):
"""End-to-end: a reasoning-model response must not look hollow."""
def _fake(monkeypatch):
class _Client:
def converse(self, **kw):
return {
"output": {"message": {"content": [
{"reasoningContent": {"reasoningText": {"text": "think"}}},
{"text": _NODE_JSON},
]}},
"usage": {"inputTokens": 1, "outputTokens": 2},
"stopReason": "end_turn",
}
boto3 = types.ModuleType("boto3")
boto3.Session = lambda **kw: SimpleNamespace(client=lambda svc, **kwargs: _Client())
monkeypatch.setitem(sys.modules, "boto3", boto3)
botocore = types.ModuleType("botocore")
exc = types.ModuleType("botocore.exceptions")
exc.ClientError = type("ClientError", (Exception,), {})
botocore.exceptions = exc
monkeypatch.setitem(sys.modules, "botocore", botocore)
monkeypatch.setitem(sys.modules, "botocore.exceptions", exc)

_fake(monkeypatch)
result = llm._call_bedrock("model", "CORPUS")
assert len(result["nodes"]) == 1
# Hard-indexing block 0 yielded "{}" -> zero nodes -> relabelled "length".
assert result["finish_reason"] == "stop"


# ── CLI backends (mocked subprocess) ──────────────────────────────────────────


Expand Down