Skip to content

Commit 11ca9c1

Browse files
authored
refactor(api): single context-building implementation (#1079)
Consolidate the three copies of context assembly into open_notebook/utils/context_builder.py: - POST /api/chat/context now delegates to build_notebook_context() (same request/response shapes, same string-matching config semantics) - The source-chat graph now calls build_source_context() instead of the 495-line generalized ContextBuilder class, which had exactly one caller and whose notebook/notes/priority-config flexibility was dead - POST /api/notebooks/{notebook_id}/context removed: it duplicated /api/chat/context with a slightly different envelope and had zero callers (frontend, docs, tests) Behavior is pinned by new characterization tests written before the refactor (tests/test_context_endpoint_characterization.py) plus unit tests for build_source_context.
1 parent c4749ad commit 11ca9c1

13 files changed

Lines changed: 508 additions & 758 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Changed
11+
- Context building consolidated into a single implementation (`open_notebook/utils/context_builder.py`): the copy-pasted source/note assembly loops behind `POST /api/chat/context` and the removed notebook-context endpoint, plus the 495-line generalized `ContextBuilder` class (whose only caller was the source-chat graph), are now two focused functions — `build_notebook_context()` (backs `POST /api/chat/context`, unchanged request/response shapes and config semantics) and `build_source_context()` (backs the source-chat graph, same context shape and 50k-token budget). Pinned by new characterization tests — no behavior change for the surviving paths
12+
- **Removed** `POST /api/notebooks/{notebook_id}/context`: it duplicated `POST /api/chat/context` (same assembly logic, slightly different response envelope) and had zero callers — frontend, docs and tests only use `/api/chat/context`. If you called it programmatically, switch to `POST /api/chat/context` (body: `{notebook_id, context_config}`; response fields: `context.sources`/`context.notes`, `token_count`, `char_count`)
1113
- Backend provider metadata now lives in a single registry (`open_notebook/ai/provider_registry.py`): env var config, modalities, connection-test models, OpenAI-compatible discovery URLs and docs links are defined once per provider, and `PROVIDER_ENV_CONFIG`, `PROVIDER_MODALITIES`, `TEST_MODELS` and `OPENAI_COMPAT_PROVIDERS` are derived from it. Adding a provider drops from ~6 hand-synced dicts to the registry plus two manual copies (the `SupportedProvider` Literal and the frontend provider table), both enforced by tests
1214
- Frontend convention cleanup (no user-facing change): hook files unified to kebab-case (`useNotebookChat.ts`/`useSourceChat.ts``use-notebook-chat.ts`/`use-source-chat.ts`), `src/components/source/` merged into `src/components/sources/`, the localStorage auth-token parsing ritual extracted into a single `getAuthToken()` helper (`src/lib/auth-token.ts`), and non-streaming raw `fetch` calls routed through `apiClient` (podcast audio download, auth-status check). SSE/streaming paths and the login/checkAuth credential probes deliberately keep raw `fetch`
1315
- Pruned unused langchain packages: removed `langchain-community` and `langchain-deepseek` from the dependencies (nothing imports them — DeepSeek and xAI route through esperanto's OpenAI-compatible path, which uses `langchain-openai`). The remaining `langchain-*` provider packages are documented as runtime requirements of esperanto's dynamic `to_langchain()` and the whole langchain/langgraph family now carries explicit upper bounds; `langchain-core` and `langchain-text-splitters` (both directly imported but previously only transitive) are now declared explicitly

api/main.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
auth,
2020
chat,
2121
config,
22-
context,
2322
credentials,
2423
embedding,
2524
embedding_rebuild,
@@ -382,7 +381,6 @@ async def open_notebook_error_handler(request: Request, exc: OpenNotebookError):
382381
embedding_rebuild.router, prefix="/api/embeddings", tags=["embeddings"]
383382
)
384383
app.include_router(settings.router, prefix="/api", tags=["settings"])
385-
app.include_router(context.router, prefix="/api", tags=["context"])
386384
app.include_router(sources.router, prefix="/api", tags=["sources"])
387385
app.include_router(insights.router, prefix="/api", tags=["insights"])
388386
app.include_router(commands_router.router, prefix="/api", tags=["commands"])

api/models.py

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -386,30 +386,6 @@ class SourceListResponse(BaseModel):
386386
processing_info: Optional[Dict[str, Any]] = None
387387

388388

389-
# Context API models
390-
class ContextConfig(BaseModel):
391-
sources: Dict[str, str] = Field(
392-
default_factory=dict, description="Source inclusion config {source_id: level}"
393-
)
394-
notes: Dict[str, str] = Field(
395-
default_factory=dict, description="Note inclusion config {note_id: level}"
396-
)
397-
398-
399-
class ContextRequest(BaseModel):
400-
notebook_id: str = Field(..., description="Notebook ID to get context for")
401-
context_config: Optional[ContextConfig] = Field(
402-
None, description="Context configuration"
403-
)
404-
405-
406-
class ContextResponse(BaseModel):
407-
notebook_id: str
408-
sources: List[Dict[str, Any]] = Field(..., description="Source context data")
409-
notes: List[Dict[str, Any]] = Field(..., description="Note context data")
410-
total_tokens: Optional[int] = Field(None, description="Estimated token count")
411-
412-
413389
# Insights API models
414390
class SourceInsightResponse(BaseModel):
415391
id: str

api/routers/chat.py

Lines changed: 8 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,16 @@
1212
SuccessResponse,
1313
extract_chat_messages,
1414
get_session_or_404,
15-
normalize_record_id,
1615
)
1716
from open_notebook.database.repository import ensure_record_id, repo_query
18-
from open_notebook.domain.notebook import (
19-
ChatSession,
20-
Note,
21-
Notebook,
22-
Source,
23-
SourceInsight,
24-
)
17+
from open_notebook.domain.notebook import ChatSession, Notebook
2518
from open_notebook.exceptions import (
2619
NotFoundError,
2720
OpenNotebookError,
2821
)
2922
from open_notebook.graphs.chat import graph as chat_graph
23+
from open_notebook.utils import token_count
24+
from open_notebook.utils.context_builder import build_notebook_context
3025
from open_notebook.utils.graph_utils import get_session_message_count
3126

3227
router = APIRouter()
@@ -402,100 +397,12 @@ async def build_context(request: BuildContextRequest):
402397
if not notebook:
403398
raise HTTPException(status_code=404, detail="Notebook not found")
404399

405-
context_data: dict[str, list[dict[str, str]]] = {"sources": [], "notes": []}
406-
total_content = ""
407-
408-
# Process context configuration if provided
409-
if request.context_config:
410-
# Process sources
411-
for source_id, status in request.context_config.get("sources", {}).items():
412-
if "not in" in status:
413-
continue
414-
415-
try:
416-
# Add table prefix if not present
417-
full_source_id = normalize_record_id("source", source_id)
418-
419-
try:
420-
source = await Source.get(full_source_id)
421-
except Exception:
422-
continue
423-
424-
if "insights" in status:
425-
source_context = await source.get_context(context_size="short")
426-
context_data["sources"].append(source_context)
427-
total_content += str(source_context)
428-
elif "full content" in status:
429-
source_context = await source.get_context(context_size="long")
430-
context_data["sources"].append(source_context)
431-
total_content += str(source_context)
432-
except Exception as e:
433-
logger.warning(f"Error processing source {source_id}: {str(e)}")
434-
continue
435-
436-
# Process notes
437-
for note_id, status in request.context_config.get("notes", {}).items():
438-
if "not in" in status:
439-
continue
440-
441-
try:
442-
# Add table prefix if not present
443-
full_note_id = normalize_record_id("note", note_id)
444-
note = await Note.get(full_note_id)
445-
if not note:
446-
continue
447-
448-
if "full content" in status:
449-
note_context = note.get_context(context_size="long")
450-
context_data["notes"].append(note_context)
451-
total_content += str(note_context)
452-
except Exception as e:
453-
logger.warning(f"Error processing note {note_id}: {str(e)}")
454-
continue
455-
else:
456-
# Default behavior - include all sources and notes with short context
457-
sources = await notebook.get_sources()
458-
try:
459-
insights_by_source = await SourceInsight.get_for_sources(
460-
[source.id for source in sources if source.id]
461-
)
462-
except Exception as e:
463-
# Match the per-source fallback below: a hiccup fetching
464-
# insights shouldn't fail the whole context request.
465-
logger.warning(f"Error batch-fetching source insights: {str(e)}")
466-
insights_by_source = {}
467-
for source in sources:
468-
try:
469-
source_context = await source.get_context(
470-
context_size="short",
471-
insights=insights_by_source.get(source.id or "", []),
472-
)
473-
context_data["sources"].append(source_context)
474-
total_content += str(source_context)
475-
except Exception as e:
476-
logger.warning(f"Error processing source {source.id}: {str(e)}")
477-
continue
478-
479-
notes = await notebook.get_notes()
480-
for note in notes:
481-
try:
482-
note_context = note.get_context(context_size="short")
483-
context_data["notes"].append(note_context)
484-
total_content += str(note_context)
485-
except Exception as e:
486-
logger.warning(f"Error processing note {note.id}: {str(e)}")
487-
continue
488-
489-
# Calculate character and token counts
400+
context_data, total_content = await build_notebook_context(
401+
notebook, request.context_config
402+
)
403+
490404
char_count = len(total_content)
491-
# Use token count utility if available
492-
try:
493-
from open_notebook.utils import token_count
494-
495-
estimated_tokens = token_count(total_content) if total_content else 0
496-
except ImportError:
497-
# Fallback to simple estimation
498-
estimated_tokens = char_count // 4
405+
estimated_tokens = token_count(total_content) if total_content else 0
499406

500407
return BuildContextResponse(
501408
context=context_data, token_count=estimated_tokens, char_count=char_count

api/routers/context.py

Lines changed: 0 additions & 132 deletions
This file was deleted.

docs/7-DEVELOPMENT/api-reference.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ Instead of memorizing endpoints, use the interactive API docs:
6060
**Chat** - Conversational AI interface
6161
- `GET/POST /chat/sessions` - Manage chat sessions
6262
- `POST /chat/execute` - Send message and get response
63-
- `POST /chat/context/build` - Prepare context for chat
63+
- `POST /chat/context` - Prepare context for chat
6464

6565
**Search** - Find content by text or semantic similarity
6666
- `POST /search` - Full-text or vector search

docs/7-DEVELOPMENT/content-processing.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,11 @@ All embedding is fire-and-forget through the surreal-commands worker — nothing
3535

3636
## Context building (`utils/context_builder.py`)
3737

38-
`ContextBuilder` assembles LLM context from selected sources/notes/insights under a token budget:
38+
The single implementation behind both context consumers:
3939

40-
- Each `ContextItem` counts its own tokens on construction; `build()` adds items in priority order (default weights: source 100 > insight 75 > note 50, see `ContextConfig`) and stops once `max_tokens` is exceeded (no prorating).
41-
- Fetching is lazy (nothing is loaded until `build()`), and every call re-fetches — there is no cache layer.
40+
- `build_notebook_context()` backs `POST /api/chat/context` (chat panel + podcast generation): it assembles source/note contexts from the inclusion config, whose status strings are matched textually ("not in" skips, "insights" → short context, "full content" → long context). Without a config, every source and note is included with its short context. Per-item failures are logged and skipped.
41+
- `build_source_context()` backs the source-chat graph: one source's short context plus its insights, truncated to a token budget by dropping insights (last-fetched first).
42+
- Every call re-fetches — there is no cache layer.
4243
- Token counting uses `o200k_base` via tiktoken and is an estimate (±5-10% vs. the actual model); `token_count()` falls back to a coarse estimate if tiktoken is unavailable.
4344

4445
## Encryption (`utils/encryption.py`) {#encryption}

open_notebook/graphs/source_chat.py

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from open_notebook.domain.notebook import Source, SourceInsight
1616
from open_notebook.exceptions import OpenNotebookError
1717
from open_notebook.utils import clean_thinking_content
18-
from open_notebook.utils.context_builder import ContextBuilder
18+
from open_notebook.utils.context_builder import build_source_context
1919
from open_notebook.utils.error_classifier import classify_error
2020
from open_notebook.utils.text_utils import extract_text_content
2121

@@ -37,7 +37,7 @@ def call_model_with_source_context(
3737
Main function that builds source context and calls the model.
3838
3939
This function:
40-
1. Uses ContextBuilder to build source-specific context
40+
1. Uses build_source_context to build source-specific context
4141
2. Applies the source_chat Jinja2 prompt template
4242
3. Handles model provisioning with override support
4343
4. Tracks context indicators for referenced insights/content
@@ -58,19 +58,18 @@ def _call_model_with_source_context_inner(
5858
if not source_id:
5959
raise ValueError("source_id is required in state")
6060

61-
# Build source context using ContextBuilder (run async code in new loop)
61+
# Build source context using build_source_context (run async code in new loop)
6262
def build_context():
6363
"""Build context in a new event loop"""
6464
new_loop = asyncio.new_event_loop()
6565
try:
6666
asyncio.set_event_loop(new_loop)
67-
context_builder = ContextBuilder(
68-
source_id=source_id,
69-
include_insights=True,
70-
include_notes=False, # Focus on source-specific content
71-
max_tokens=50000, # Reasonable limit for source context
67+
return new_loop.run_until_complete(
68+
build_source_context(
69+
source_id=source_id,
70+
max_tokens=50000, # Reasonable limit for source context
71+
)
7272
)
73-
return new_loop.run_until_complete(context_builder.build())
7473
finally:
7574
new_loop.close()
7675
asyncio.set_event_loop(None)
@@ -192,7 +191,7 @@ def _format_source_context(context_data: Dict) -> str:
192191
Format the context data into a readable string for the prompt.
193192
194193
Args:
195-
context_data: Context data from ContextBuilder
194+
context_data: Context data from build_source_context
196195
197196
Returns:
198197
Formatted context string

0 commit comments

Comments
 (0)