# How the ContextBuilder Pattern Injects Source Insights into Chat Conversations

> Discover how Open Notebook's ContextBuilder pattern injects source insights into chat. Learn about structured ContextItem objects and LLM system prompts for aware conversations.

- Repository: [Luis Novo/open-notebook](https://github.com/lfnovo/open-notebook)
- Tags: how-to-guide
- Published: 2026-06-21

---

**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`](https://github.com/lfnovo/open-notebook/blob/main/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`](https://github.com/lfnovo/open-notebook/blob/main/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:

```python
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:

```python

# 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:

```text
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.py`](https://github.com/lfnovo/open-notebook/blob/main/open_notebook/utils/context_builder.py) asynchronously 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()` when `include_insights=True`, with each insight packaged as a `ContextItem` of type `"insight"`.
- **Chat integration** happens in [`open_notebook/graphs/source_chat.py`](https://github.com/lfnovo/open-notebook/blob/main/open_notebook/graphs/source_chat.py), where the `call_model_with_source_context` node extracts insights, validates them as Pydantic models, and injects them into Jinja2-rendered system prompts.
- **Template rendering** uses the `source_chat/system` template 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`](https://github.com/lfnovo/open-notebook/blob/main/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`](https://github.com/lfnovo/open-notebook/blob/main/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.