Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
7.7 KiB
RAG Scoping and Context Isolation Design
For agentic workers: Use superpowers:executing-plans or superpowers:subagent-driven-development to implement this plan task-by-task.
Goal: Prevent project-associated notes from polluting global chat RAG, and give the LLM tools to discover and switch project scope during a conversation.
Problem being solved: Users with multiple projects (e.g. scifi worldbuilding, personal productivity) find that semantic search pulls notes from whichever project has the most content, contaminating general-purpose chat context. Project notes should be siloed by default.
Architecture
Default RAG behavior change
Currently rag_project_id=None in build_context() searches all notes. After this change:
rag_project_id = None→ search only orphan notes (project_id IS NULL)rag_project_id = <positive int>→ search only that project's notes (existing behavior)rag_project_id = -1→ search all notes (explicit opt-in to current global behavior)
This change is isolated to semantic_search_notes() and search_notes_for_context() in services/embeddings.py and services/notes.py. A new orphan_only: bool parameter threads through from build_context().
Conversation scope persistence
conversations.rag_project_id INTEGER (nullable) stores the active scope per conversation:
NULL→ orphan-only (default for new conversations)- positive int → scoped to a project
-1→ all notes
Loaded when a conversation is opened; written when the user changes the scope via the UI or when the model calls set_rag_scope.
Project summarization background job
Each project gets an auto_summary TEXT and summary_updated_at TIMESTAMP column. A new generate_project_summary(user_id, project_id) function calls Ollama with:
- Project title, description, goal
- Up to 10 note titles + first 200 chars of their body
Triggers:
- Startup backfill — any project with
auto_summary IS NULLgets generated (fire-and-forget, alongsidebackfill_note_embeddings) - On project update —
update_project()fires a background task unconditionally - On note save to a project —
create_note/update_notewithproject_idchecks ifsummary_updated_atis more than 1 hour old before triggering (debounce)
Graceful degradation: if Ollama is unavailable the function exits silently; the old summary (or None) is retained. The project search tool falls back to title + description matching only.
New LLM tools
Both tools are added to _CORE_TOOLS (always available, no integration dependency).
search_projects(query: str)
- Loads all active projects for the user from the DB
- Scores each against the query: SequenceMatcher ratio on title + description + auto_summary combined, plus keyword overlap
- Returns top 5 as
[{id, title, summary_snippet, score}] - Used by the model to identify which project the user is referring to
set_rag_scope(project_id: int | None)
project_id = <positive int>→ scope to that projectproject_id = null→ orphan-onlyproject_id = -1→ all notes- Side effects: (1) writes
conversations.rag_project_idimmediately, (2) returns{success, scope_label}for model to confirm to user, (3) emitsnew_rag_scopein the SSEdoneevent metadata so the frontend scope chip updates reactively
set_rag_scope needs conv_id available in the tool executor — it is already threaded through generation_task.py → execute_tool().
Typical model flow:
User: "let's talk about my scifi project" Model calls
search_projects("scifi")→ finds project id 3 → callsset_rag_scope(3)→ responds "I've scoped our conversation to your SciFi Project."
Frontend scope indicator
A scope chip sits just above the message input bar (replaces the sidebar ProjectSelector). It shows the active scope: ⊙ Orphan notes / ⊙ SciFi Project / ⊙ All notes. Clicking opens a compact dropdown listing:
- "Orphan notes only" (default)
- All active projects by title
- "All notes"
Selecting an option calls PATCH /api/chat/conversations/:id with { rag_project_id } and updates local state immediately.
When the model calls set_rag_scope, the SSE done event's new_rag_scope field causes the chat store to update ragProjectId for the active conversation, re-rendering the chip with a brief pulse animation.
Files Changed
Backend
| File | Change |
|---|---|
alembic/versions/0030_rag_scoping.py |
Add conversations.rag_project_id, projects.auto_summary, projects.summary_updated_at |
models/conversation.py |
Add rag_project_id: Mapped[int | None] |
models/project.py |
Add auto_summary: Mapped[str | None], summary_updated_at: Mapped[datetime | None] |
services/projects.py |
Add generate_project_summary(), backfill_project_summaries(); trigger from update_project() |
services/notes.py |
Trigger summary regeneration (debounced) from create_note() / update_note() when project_id is set |
services/embeddings.py |
Add orphan_only: bool = False param to semantic_search_notes() |
services/notes.py |
Add orphan_only param to search_notes_for_context() |
services/llm.py |
Pass orphan_only=True when rag_project_id is None; pass orphan_only=False when -1; add new_rag_scope to SSE done metadata |
services/tools.py |
Add search_projects and set_rag_scope tool definitions and handlers |
services/generation_task.py |
Thread conv_id into execute_tool(); apply new_rag_scope from tool result to context meta |
routes/chat.py |
Include rag_project_id in conversation responses; handle PATCH body field |
app.py |
Call backfill_project_summaries() at startup |
Frontend
| File | Change |
|---|---|
api/client.ts |
Add updateConversationScope(convId, ragProjectId) helper |
stores/chat.ts |
Load ragProjectId per conversation; update on new_rag_scope SSE metadata; expose setter |
views/ChatView.vue |
Replace sidebar ProjectSelector with scope chip above input bar; load projects list for dropdown |
Data Flow
User changes scope via chip
→ PATCH /api/chat/conversations/:id { rag_project_id }
→ conversations.rag_project_id updated
→ chip re-renders immediately
User sends message
→ chat store sends rag_project_id with message
→ build_context() applies orphan/project/all filter
→ RAG results scoped accordingly
Model calls set_rag_scope(3)
→ execute_tool() writes conversations.rag_project_id = 3
→ returns { success, scope_label: "SciFi Project" }
→ generation_task adds new_rag_scope to done metadata
→ chat store receives new_rag_scope → updates ragProjectId
→ chip pulses and shows "⊙ SciFi Project"
Error Handling
- Ollama unavailable during summary gen: silent skip, retain existing summary
- set_rag_scope with unknown project_id: return
{success: false, error: "Project not found"}; scope unchanged - search_projects with no summaries yet: fall back to title + description matching only; still returns results
- Conversation has no rag_project_id column yet (pre-migration): default to
None(orphan-only); non-breaking
Testing
- Unit:
semantic_search_notes()withorphan_only=Trueexcludes project notes - Unit:
search_projects()scoring ranks correct project first for representative queries - Unit:
generate_project_summary()builds correct prompt and stores result - Integration:
set_rag_scope()tool handler writes to DB and returns correct scope_label - Integration: SSE
doneevent includesnew_rag_scopewhen scope changes mid-conversation - Frontend: scope chip reflects loaded conversation scope; updates on model tool call