How the ContextBuilder Pattern Injects Source Insights into Chat Conversations
Open Notebook uses an asynchronous ContextBuilder utility to fetch SourceInsight records, package them into structured ContextItem objects, and inject them into LLM system prompts via Jinja2 templates, enabling context-aware conversations that reference analytical annotations.
The ContextBuilder pattern in the lfnovo/open-notebook repository serves as the critical bridge between raw source material and AI-driven chat interactions. This utility orchestrates the retrieval of both source content and associated analytical insights, ensuring language models receive enriched context that includes original text alongside higher-level annotations from the SourceInsight model.
How ContextBuilder Assembles Source Insights
In open_notebook/utils/context_builder.py, the ContextBuilder class constructs a comprehensive context dictionary that powers source-aware conversations. When instantiated with a source_id and include_insights=True, the builder executes a multi-step pipeline to gather and structure relevant data.
Fetching Source Records and Insights
The _add_source_context coroutine (lines 61-70 and 82-86) initiates the data gathering process by first retrieving the source record via Source.get. When insights are requested, it subsequently calls source.get_insights() to fetch all analytical annotations associated with that source. This occurs asynchronously to ensure efficient I/O operations when building the context payload.
Packaging Insights into ContextItem Objects
Between lines 86-100, the builder creates a ContextItem with the type explicitly set to "insight". Each item encapsulates the insight's ID, type classification, and content string. These items are appended to the builder's internal collection, maintaining a structured representation of the analytical data separate from raw source text and notes.
Deduplication and Prioritization
Before returning the final payload, the builder processes the collected items to remove duplicates and apply prioritization logic (lines 67-71 and 91-95). It optionally truncates the content to respect a specified token budget, ensuring the context fits within LLM constraints. The build() method ultimately returns a structured dictionary containing three top-level arrays: "sources", "insights", and "notes", providing a clean interface for downstream consumers.
How ContextBuilder Injects Source Insights into Chat Conversations
The source_chat graph in open_notebook/graphs/source_chat.py consumes the context built by ContextBuilder and transforms it into LLM-ready prompts. This integration ensures that analytical insights are not merely stored but actively participate in model reasoning.
The source_chat Graph Node
The call_model_with_source_context function (lines 61-73) creates a dedicated event loop to execute the asynchronous ContextBuilder.build() method. This synchronous-wrapper pattern allows the LangGraph-based workflow to bridge async context building with synchronous graph execution. The node passes the same source_id and include_insights=True parameters to ensure consistent data retrieval.
Extracting and Validating Insights
Once the context dictionary returns, the node extracts the insights array (lines 92-115) and iterates over each entry, converting the raw dictionaries back into SourceInsight Pydantic models. This validation step ensures type safety and provides access to the model's methods and properties. The node similarly extracts the first source entry for inclusion in the prompt data.
Rendering Insights via Jinja2 Templates
The extracted insights are packed into a prompt_data dictionary alongside the source and formatted context. Between lines 119-125, this data structure is rendered through the source_chat/system Jinja2 template, which formats the insight IDs, types, and content into a readable system prompt. The final payload sent to the language model consists of the rendered system prompt followed by the chat history, enabling the model to reference specific insights by ID and type in its responses.
Practical Implementation Examples
To build a source-only context with insights enabled, instantiate the builder with the appropriate flags:
from open_notebook.utils.context_builder import ContextBuilder
async def get_source_context(source_id: str):
builder = ContextBuilder(
source_id=source_id,
include_insights=True, # ensure insights are fetched
include_notes=False,
max_tokens=50_000,
)
return await builder.build()
Within the chat graph, the context is consumed and injected into the prompt as follows:
# Inside open_notebook/graphs/source_chat.py
def _call_model_with_source_context_inner(state, config):
source_id = state["source_id"]
# 1️⃣ Build the context (runs in a fresh event loop)
context_data = ContextBuilder(
source_id=source_id,
include_insights=True,
include_notes=False,
max_tokens=50_000,
).build_sync() # pseudo-sync wrapper shown for brevity
# 2️⃣ Pull out insights
insights = [
SourceInsight(**i) for i in context_data.get("insights", [])
]
# 3️⃣ Assemble prompt data
prompt_data = {
"source": Source(**context_data["sources"][0]),
"insights": [ins.model_dump() for ins in insights],
"context": _format_source_context(context_data),
"context_indicators": {"insights": [i.id for i in insights]},
}
# 4️⃣ Render system prompt and send to LLM
system_prompt = Prompter(prompt_template="source_chat/system").render(
data=prompt_data
)
payload = [SystemMessage(content=system_prompt)] + state.get("messages", [])
...
The resulting system prompt includes structured insight blocks similar to this simplified representation:
You are chatting about source ID: source:12345
## SOURCE INSIGHTS
**Insight ID:** insight:abc
**Type:** key_point
**Content:** The main finding is that ...
...
Summary
- ContextBuilder in
open_notebook/utils/context_builder.pyasynchronously gathers source content, insights, and notes, deduplicates them, and returns a structured dictionary with"sources","insights", and"notes"arrays. - SourceInsight retrieval occurs via
source.get_insights()wheninclude_insights=True, with each insight packaged as aContextItemof type"insight". - Chat integration happens in
open_notebook/graphs/source_chat.py, where thecall_model_with_source_contextnode extracts insights, validates them as Pydantic models, and injects them into Jinja2-rendered system prompts. - Template rendering uses the
source_chat/systemtemplate to format insight metadata (ID, type, content) for direct LLM consumption, enabling context-aware responses that reference specific analytical annotations.
Frequently Asked Questions
How does ContextBuilder handle token limits when including insights?
The ContextBuilder accepts a max_tokens parameter during initialization. When building the context, it optionally truncates the collected items to respect this budget, ensuring the total context size—including source content, insights, and notes—remains within the specified limit. This prevents token overflow when sending requests to the language model.
What is the difference between ContextItem and SourceInsight models?
ContextItem is an internal builder representation used within context_builder.py to standardize different content types (sources, insights, notes) during the assembly phase. SourceInsight is the domain model defined in the notebook domain layer (likely in open_notebook/domain/notebook.py) that represents the actual database entity. The chat graph converts ContextItem dictionaries back into SourceInsight objects to leverage Pydantic validation and domain methods.
Can ContextBuilder include notes alongside insights in the same context?
Yes. The ContextBuilder accepts both include_insights and include_notes boolean parameters. When both are set to True, the builder fetches associated notes via the source's relationship methods and packages them as ContextItem objects with type "note". The final dictionary contains both arrays, allowing prompts to reference annotations and user-generated notes simultaneously.
Why does the source_chat graph create a new event loop for ContextBuilder?
The call_model_with_source_context node operates within LangGraph's synchronous execution environment, while ContextBuilder.build() is an asynchronous coroutine that performs database I/O. The node creates a fresh event loop—using asyncio.run() or similar mechanisms—to bridge this gap, allowing the async context building to execute without blocking the graph's thread pool, then returns control to the synchronous workflow.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →