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) ──────────────────────────────────────────