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
This commit is contained in:
Zhi Yan Liu
2026-07-29 17:00:15 +01:00
committed by safishamsi
parent c5d432710f
commit d47f3ea152
2 changed files with 105 additions and 2 deletions
+26 -2
View File
@@ -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 _call_llm(
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()
+79
View File
@@ -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) ──────────────────────────────────────────