Skip to content

feat: add parallel chunk processing for large documents in transformations - #529

Open
kevincolten wants to merge 9 commits into
lfnovo:mainfrom
Notebooker-ai:feat/small-context-chunking
Open

feat: add parallel chunk processing for large documents in transformations#529
kevincolten wants to merge 9 commits into
lfnovo:mainfrom
Notebooker-ai:feat/small-context-chunking

Conversation

@kevincolten

@kevincolten kevincolten commented Feb 2, 2026

Copy link
Copy Markdown
Contributor

Description

Enable smaller context models to process large documents by automatically chunking content that exceeds context limits and processing chunks in parallel using LangGraph's Send API.

How it works:

  1. try_full_content - Attempts to process entire document optimistically
  2. On context limit error, parses the error to calculate optimal chunk size
  3. fan_out_chunks - Creates parallel Send() calls for each chunk
  4. process_chunk - Processes chunks concurrently via LangGraph Send API
  5. synthesize_results - Merges chunk results into unified output

Related Issue

Fixes #990

Type of Change

  • New feature (non-breaking change that adds functionality)
  • Performance improvement

How Has This Been Tested?

  • Tested locally with development setup
  • Added new unit tests
  • Existing tests pass (uv run pytest)
  • Updated existing tests for new imports
  • Manual testing performed (describe below)

Test Details:

  • Added 23 new unit tests for chunking functionality (31 total graph tests)
  • All tests pass
  • Real-world test with 145K token document:
    • Successfully split into 2 chunks
    • Parallel processing completed in ~5.5 minutes
    • Synthesis completed in ~26 seconds
    • Total: ~6 minutes vs ~11+ minutes sequential

Design Alignment

Which design principles does this PR support? (See DESIGN_PRINCIPLES.md)

  • Simplicity Over Features
  • Multi-Provider Flexibility
  • Async-First for Performance

Explanation:

  • Simplicity: Automatic fallback with no configuration needed - just works
  • Multi-Provider Flexibility: Enables smaller/cheaper models to handle large documents that previously required expensive large-context models
  • Async-First: Parallel chunk processing via LangGraph Send API maximizes performance

Checklist

Code Quality

  • My code follows PEP 8 style guidelines (Python)
  • I have added type hints to my code (Python)
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • My changes generate no new warnings or errors

Testing

  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I ran linting: make ruff or ruff check . --fix

Documentation

  • I have added/updated docstrings for new/modified functions
  • I have added comments to complex logic

Screenshots (if applicable)

N/A - Backend changes only

Additional Context

Files Modified:

  • open_notebook/graphs/transformation.py - Full restructure to use Send API
  • tests/test_graphs.py - Updated imports for new function names

New Components:

  • ChunkResult / ChunkState - TypedDicts for parallel processing
  • try_full_content() - Optimistic processing with error-based fallback
  • fan_out_chunks() - Conditional edge creating Send objects
  • process_chunk() - Individual chunk processor
  • synthesize_results() - Result aggregator using Annotated[list, operator.add]

Pre-Submission Verification

Before submitting, please verify:

  • I have read CONTRIBUTING.md
  • I have read DESIGN_PRINCIPLES.md
  • I have not included unrelated changes in this PR
  • My PR title follows conventional commits format (e.g., "feat: add user authentication")

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 6 files

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name="open_notebook/utils/token_utils.py">

<violation number="1" location="open_notebook/utils/token_utils.py:153">
P2: `is_context_limit_error` matches generic substrings like "limit" and "exceeded", so common rate-limit errors (e.g., "Rate limit exceeded") will be treated as context-length errors. In transformation, that triggers parallel chunk retries, likely worsening rate limiting and causing retry spikes.</violation>

<violation number="2" location="open_notebook/utils/token_utils.py:268">
P2: Sentence-level splitting can still create chunks that exceed max_tokens when a single sentence is longer than the limit, violating the function’s contract and potentially re-triggering context-limit errors.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread open_notebook/utils/token_utils.py Outdated
Comment thread open_notebook/utils/token_utils.py Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name="open_notebook/utils/token_utils.py">

<violation number="1" location="open_notebook/utils/token_utils.py:310">
P2: `current_chunk` is set to a list of words, but remaining chunks are joined with `"\n\n"`, so an oversized sentence fragment at the end will be emitted with double newlines between every word instead of spaces.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread open_notebook/utils/token_utils.py Outdated
… synthesis

When a transformation's full content exceeds the model's context window, split
it into token-sized chunks, process them in parallel, and synthesize the partial
results back into one output. The synthesis is reduced hierarchically — chunk
results are batched to fit a token budget and combined in rounds — so merging
many (or large) results never overflows the context window either.

- token_utils: add context-limit error parsing (OpenAI/Anthropic/Google),
  is_context_limit_error, token-aware text chunking, and output-buffer helpers.
- transformation: try_full_content -> fan_out_chunks -> process_chunk ->
  synthesize_results (with budgeted reduce). Preserves the single-call fast path
  and upstream's classify_error behavior.
- tests: chunking helpers, fan-out routing, and a regression test asserting
  synthesis batches instead of overflowing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kevincolten
kevincolten force-pushed the feat/small-context-chunking branch from 3c76e16 to 19448a1 Compare June 14, 2026 04:35

@lfnovo lfnovo left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @kevincolten — this is a genuinely nice piece of work: the optimistic-full → chunk-on-context-error → parallel Send → hierarchical synthesis flow is well structured, it keeps Prompter intact, doesn't disturb the token_count fix, and it's well tested (the 145K-token real run is a great touch). Two blocking items before it can merge, plus a few smaller notes.

Blocking

1. Output cap silently drops from 8192 → 4096 on the normal (non-chunked) path.
try_full_content sets output_buffer = DEFAULT_OUTPUT_TOKENS (4096) and calls provision_langchain_model(..., max_tokens=output_buffer). main currently uses max_tokens=8192. Since try_full runs for every transformation (not just large docs), any transformation whose output exceeds 4096 tokens would now be truncated — a silent regression on the common path. Please preserve the current 8192 default for the full-content attempt (or make the output budget configurable and default it to 8192).

2. Module-level asyncio.Semaphore bound at import.

_chunk_semaphore = asyncio.Semaphore(_CHUNK_CONCURRENCY_LIMIT)  # module scope

There are no other module-level asyncio primitives in the codebase, and this one is a footgun: an asyncio.Semaphore binds to the first event loop it's awaited from. This graph is imported by the worker (run_transformation_command) and can also be exercised from the API's loop; awaiting the same module-level semaphore from two loops raises RuntimeError: bound to a different event loop. Please create it inside the function (or lazily per-invocation) rather than at import.

Non-blocking (worth addressing)

  1. Context-limit detection parses provider error strings (is_context_limit_error / get_context_limit_from_error). That's inherently provider-format-dependent and will silently fall back to DEFAULT_CONTEXT_LIMIT (8192) when a wording doesn't match, which can mis-size chunks. A short comment on the supported formats + how the fallback behaves would help future maintenance.

  2. Chunk meta-prefix leaks into content. Each chunk is sent as "[Processing section X of N from a larger document]\n\n{chunk}". For summarization that's fine, but for extraction-style transformations that instruction text can bleed into or skew the output. Consider putting the "section X of N" hint in the system prompt instead of the user content.

  3. _CHUNK_CONCURRENCY_LIMIT = 3 is a second concurrency layer on top of the worker's own limit (OPEN_NOTEBOOK_WORKER_MAX_TASKS, see #893). Worth a comment noting the interaction, or deriving it from the same config.

Process note

There's no linked issue — features go through an approved issue first (CONTRIBUTING). The feature itself is well-aligned, so this is easy to formalize; I'll get an issue opened to track it. Once (1) and (2) are addressed I'm happy to re-review. (Heads up: maintainerCanModify is off on this PR, so these need to come from your side.)

@lfnovo

lfnovo commented Jul 3, 2026

Copy link
Copy Markdown
Owner

Opened #990 to track this feature (with the review notes captured as design/acceptance criteria) — this PR is the implementation for it. Once the two blocking items above are addressed, happy to re-review. 🙏

- Restore the 8192 output cap on the full-content path: bump
  DEFAULT_OUTPUT_TOKENS from 4096 to 8192 so the optimistic attempt
  matches the pre-chunking max_tokens and never truncates outputs on
  the common path.
- Create chunk semaphores lazily per event loop instead of at module
  import: an asyncio.Semaphore binds to the loop it is first awaited
  from, and the graph runs from both the worker's and the API's loops.
- Move the "section X of N" hint from the user content into the system
  prompt so it can't bleed into extraction-style outputs.
- Document the supported provider error formats and the
  DEFAULT_CONTEXT_LIMIT fallback behavior in token_utils.
- Note the interaction between _CHUNK_CONCURRENCY_LIMIT and the
  worker's task limit (OPEN_NOTEBOOK_WORKER_MAX_TASKS).
- Add tests for the 8192 cap, verbatim chunk content, and per-loop
  semaphores.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="open_notebook/graphs/transformation.py">

<violation number="1" location="open_notebook/graphs/transformation.py:248">
P1: Chunk and synthesis execution paths lack the exception classification used in `try_full_content`, which means raw provider exceptions can propagate with unsanitized messages instead of the user-friendly, truncated errors the rest of the codebase guarantees. `process_chunk` and `_synthesize_once` should wrap their LLM calls in a `try/except Exception` block and route errors through `classify_error()`, matching the pattern in `try_full_content` and the other graphs. Without this, failures during parallel chunk or synthesis processing leak internal details and produce inconsistent user-facing behavior.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

SystemMessage(content=chunk_system_prompt),
HumanMessage(content=state["chunk"]),
]
async with _get_chunk_semaphore():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Chunk and synthesis execution paths lack the exception classification used in try_full_content, which means raw provider exceptions can propagate with unsanitized messages instead of the user-friendly, truncated errors the rest of the codebase guarantees. process_chunk and _synthesize_once should wrap their LLM calls in a try/except Exception block and route errors through classify_error(), matching the pattern in try_full_content and the other graphs. Without this, failures during parallel chunk or synthesis processing leak internal details and produce inconsistent user-facing behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At open_notebook/graphs/transformation.py, line 248:

<comment>Chunk and synthesis execution paths lack the exception classification used in `try_full_content`, which means raw provider exceptions can propagate with unsanitized messages instead of the user-friendly, truncated errors the rest of the codebase guarantees. `process_chunk` and `_synthesize_once` should wrap their LLM calls in a `try/except Exception` block and route errors through `classify_error()`, matching the pattern in `try_full_content` and the other graphs. Without this, failures during parallel chunk or synthesis processing leak internal details and produce inconsistent user-facing behavior.</comment>

<file context>
@@ -214,16 +234,18 @@ async def process_chunk(state: ChunkState, config: RunnableConfig) -> dict:
+        HumanMessage(content=state["chunk"]),
     ]
-    async with _chunk_semaphore:
+    async with _get_chunk_semaphore():
         chain = await provision_langchain_model(
             str(payload),
</file context>

@kevincolten

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review @lfnovo! All items addressed in 69e5156:

Blocking:

  1. 8192 output cap restoredDEFAULT_OUTPUT_TOKENS bumped to 8192, so the full-content attempt provisions with the same max_tokens=8192 as before this PR. The chunked path is unaffected (it computes its own budget via calculate_output_buffer). Added a test asserting the 8192 cap on the full-content path.
  2. Module-level semaphore removed — chunk semaphores are now created lazily per event loop (WeakKeyDictionary keyed by the running loop), so the worker's and API's loops each get their own, while the parallel Send nodes of a single invocation still share one bound. Added a test asserting distinct semaphores across loops.

Non-blocking:

  1. Documented the supported provider error formats (OpenAI/Anthropic/Google + generic patterns) and the conservative DEFAULT_CONTEXT_LIMIT fallback behavior in token_utils.py.
  2. Moved the "section X of N" hint from the user content into the system prompt — chunks are now sent verbatim, with a test pinning that.
  3. Added a comment on _CHUNK_CONCURRENCY_LIMIT noting it multiplies with the worker's task limit (OPEN_NOTEBOOK_WORKER_MAX_TASKS, Fix: Sequential processing mode for single-GPU setups (prevents LLM rate limits) #893).

Also linked the PR to #990. All 204 tests pass, ruff clean. Ready for re-review 🙏

@kevincolten
kevincolten requested a review from lfnovo July 3, 2026 15:15
…chunking

# Conflicts:
#	open_notebook/graphs/transformation.py
The chunking work split run_transformation into try_full_content ->
process_chunk -> synthesize_results. try_full_content owns the
non-chunking path and its add_insight() call, so it is where upstream's
propagation contract now lives.
- parse_context_limit_error: widen to Optional[Tuple[Optional[int], int]].
  The docstring already documented that tokens_sent may be None when only
  the limit is parseable, and its sole caller (get_context_limit_from_error)
  already returns that wider type.
- test_graphs: narrow await_args before attribute access, and type the
  process_chunk state as ChunkState.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 1 file (changes from recent commits).

Confidence score: 5/5

  • In tests/test_add_insight_failure_propagation.py, the test name/scope currently suggests compiled-graph propagation coverage while it directly calls try_full_content(), which could create a false sense of end-to-end failure-path protection and let graph-wiring regressions slip through unnoticed—either rename it to reflect the node-level contract or invoke the compiled graph path explicitly.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="tests/test_add_insight_failure_propagation.py">

<violation number="1" location="tests/test_add_insight_failure_propagation.py:114">
P3: The test now exercises `try_full_content()` directly, but its `run_transformation` name and graph-level class imply that compiled-graph propagation is covered. Renaming it to the node-level contract or invoking the compiled graph would keep the test's stated coverage accurate.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic


with pytest.raises(DatabaseOperationError):
await run_transformation(state, config={"configurable": {}})
await try_full_content(state, config={"configurable": {}})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The test now exercises try_full_content() directly, but its run_transformation name and graph-level class imply that compiled-graph propagation is covered. Renaming it to the node-level contract or invoking the compiled graph would keep the test's stated coverage accurate.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/test_add_insight_failure_propagation.py, line 114:

<comment>The test now exercises `try_full_content()` directly, but its `run_transformation` name and graph-level class imply that compiled-graph propagation is covered. Renaming it to the node-level contract or invoking the compiled graph would keep the test's stated coverage accurate.</comment>

<file context>
@@ -103,13 +111,13 @@ async def test_add_insight_failure_propagates_out_of_run_transformation(self):
 
             with pytest.raises(DatabaseOperationError):
-                await run_transformation(state, config={"configurable": {}})
+                await try_full_content(state, config={"configurable": {}})
 
         mock_add_insight.assert_awaited_once()
</file context>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: parallel chunk processing for large-document transformations

2 participants