Summary
The bedrock backend reads only the first content block of a Converse response. Reasoning-capable models place a reasoningContent block before the text block, so graphify receives no text at all — every call parses to zero nodes, gets classified as a hollow/truncated response, and the chunk is bisected until the recursion cap. The run reports partial results or produced no nodes while the model was in fact answering correctly and being billed for it.
Root cause
Converse returns output.message.content as a list of content blocks, and the specification does not guarantee that a text block is first — reasoningContent, toolUse, and other block types may precede it. Both bedrock call sites index position 0:
text = resp.get("output", {}).get("message", {}).get("content", [{}])[0].get("text", "{}")
_call_bedrock — primary semantic extraction.
- the
backend == "bedrock" branch of _call_llm — secondary dispatch.
When block 0 is a reasoning block the .get("text", …) default is returned, so _parse_llm_json sees {} → zero nodes → _response_is_hollow is true → finish_reason is rewritten to length → the adaptive retry bisects. Splitting cannot help, because the position assumption fails identically at every chunk size.
Reproduction
Probing Converse directly with global.anthropic.claude-opus-5:
stopReason: end_turn
num content blocks: 2
block[0] keys=['reasoningContent']
block[1] keys=['text']
text[:80]='{"nodes":[{"id":"a","label":"A"}],"edges":[],"hyperedges":[]}'
what content[0].get("text","{}") yields:
'{}'
stopReason is end_turn — the model completed normally. The truncation diagnosis is an artifact of reading the wrong block.
Extraction log for the same corpus (48 documents), before:
[graphify] bedrock returned a hollow response; treating as truncation so adaptive retry can bisect the chunk.
[graphify] chunk of 29 truncated at depth 0, splitting into halves of 14 and 15
[graphify] chunk of 4 still truncated at recursion depth 3 (max 3) — partial result kept (not cached as complete)
[graphify] WARNING: 17/48 dispatched file(s) produced no nodes and are absent from the graph
A distinguishing detail: raising GRAPHIFY_MAX_OUTPUT_TOKENS changes nothing, because output length was never the constraint.
Suggested fix
Select the first content block that actually carries non-empty text rather than assuming its position, at both call sites. This keys on the block's shape, not on a model name, so it holds for any model and for block types added to Converse later. A response whose first block is already text — every non-reasoning model today — is unaffected.
Result after the change
Same corpus and command, only the block selection changed:
|
before |
after |
hollow response lines |
on essentially every call |
none |
bisection to recursion depth 3 (max 3) |
repeatedly |
none |
| files reported as producing no nodes |
17 of 48 |
none |
| output tokens |
217,538 |
53,274 |
| estimated cost |
$4.09 |
$1.44 |
| graph |
2,080 nodes / 4,516 edges |
2,126 nodes / 4,580 edges |
The cost and output-token drop is the wasted bisection disappearing. Document-type nodes rose from 43 to 66 and concept nodes from 98 to 130, so the additional coverage is real rather than a re-labelling.
Not a duplicate
Summary
The bedrock backend reads only the first content block of a Converse response. Reasoning-capable models place a
reasoningContentblock before the text block, so graphify receives no text at all — every call parses to zero nodes, gets classified as a hollow/truncated response, and the chunk is bisected until the recursion cap. The run reports partial results orproduced no nodeswhile the model was in fact answering correctly and being billed for it.Root cause
Converse returns
output.message.contentas a list of content blocks, and the specification does not guarantee that a text block is first —reasoningContent,toolUse, and other block types may precede it. Both bedrock call sites index position 0:_call_bedrock— primary semantic extraction.backend == "bedrock"branch of_call_llm— secondary dispatch.When block 0 is a reasoning block the
.get("text", …)default is returned, so_parse_llm_jsonsees{}→ zero nodes →_response_is_hollowis true →finish_reasonis rewritten tolength→ the adaptive retry bisects. Splitting cannot help, because the position assumption fails identically at every chunk size.Reproduction
Probing Converse directly with
global.anthropic.claude-opus-5:stopReasonisend_turn— the model completed normally. The truncation diagnosis is an artifact of reading the wrong block.Extraction log for the same corpus (48 documents), before:
A distinguishing detail: raising
GRAPHIFY_MAX_OUTPUT_TOKENSchanges nothing, because output length was never the constraint.Suggested fix
Select the first content block that actually carries non-empty text rather than assuming its position, at both call sites. This keys on the block's shape, not on a model name, so it holds for any model and for block types added to Converse later. A response whose first block is already text — every non-reasoning model today — is unaffected.
Result after the change
Same corpus and command, only the block selection changed:
hollow responselinesrecursion depth 3 (max 3)The cost and output-token drop is the wasted bisection disappearing. Document-type nodes rose from 43 to 66 and concept nodes from 98 to 130, so the additional coverage is real rather than a re-labelling.
Not a duplicate
deepseek-v4-flashruns with thinking enabled by default (noextra_bodyoverride) — likely explains an observed JSON-parse failure on a semantic chunk #1621 is DeepSeek running with thinking enabled and leaking chain-of-thought prose into the JSON text on the OpenAI-compatible path — a content-contamination problem where the text is read but polluted. Here the text block is never read at all, and the payload itself is valid.reasoning_effort/ thinking request parameters conflicting at the API level, not response parsing.stopReasonreflects the limit and raising the cap helps, neither of which holds here.