From 0ed06bb453de307ce6c234760b26b567ec8de663 Mon Sep 17 00:00:00 2001 From: Zhi Yan Liu Date: Wed, 29 Jul 2026 11:48:19 +0800 Subject: [PATCH] fix(llm): read the first text block of a bedrock Converse response Converse returns output.message.content as a list of blocks and 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, but both bedrock call sites indexed position 0: content", [{}])[0].get("text", "{}") For those models the default was returned on every call, so _parse_llm_json saw an empty object, _response_is_hollow reported a hollow result, finish_reason was rewritten to "length", and the adaptive retry bisected the chunk. Splitting could not converge because the position assumption fails identically at every chunk size, and raising GRAPHIFY_MAX_OUTPUT_TOKENS did nothing because output length was never the constraint. stopReason on those responses was end_turn, i.e. the model had answered correctly. Selection now keys on the block's shape rather than its position, at both _call_bedrock and the bedrock branch of _call_llm. A response whose first block is already text -- every non-reasoning model today -- is unaffected. On a 48-document corpus the hollow warnings and the bisection to the recursion cap disappear, the 17 files previously reported as producing no nodes are extracted, and output tokens drop from 217,538 to 53,274 as the wasted retries stop. Fixes #2287 --- graphify/llm.py | 28 +++++++++++++- tests/test_image_vision.py | 79 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/graphify/llm.py b/graphify/llm.py index 8c34cde6b..383e73621 100644 --- a/graphify/llm.py +++ b/graphify/llm.py @@ -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. @@ -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) @@ -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() diff --git a/tests/test_image_vision.py b/tests/test_image_vision.py index c73b1141e..bd58e6e41 100644 --- a/tests/test_image_vision.py +++ b/tests/test_image_vision.py @@ -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) ──────────────────────────────────────────