Files
FabledScribe/docs/specs/2026-03-25-rag-scoping-design.md
T
2026-03-25 16:56:25 -04:00

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:

  1. Startup backfill — any project with auto_summary IS NULL gets generated (fire-and-forget, alongside backfill_note_embeddings)
  2. On project updateupdate_project() fires a background task unconditionally
  3. On note save to a projectcreate_note / update_note with project_id checks if summary_updated_at is 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 project
  • project_id = null → orphan-only
  • project_id = -1 → all notes
  • Side effects: (1) writes conversations.rag_project_id immediately, (2) returns {success, scope_label} for model to confirm to user, (3) emits new_rag_scope in the SSE done event 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.pyexecute_tool().

Typical model flow:

User: "let's talk about my scifi project" Model calls search_projects("scifi") → finds project id 3 → calls set_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() with orphan_only=True excludes 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 done event includes new_rag_scope when scope changes mid-conversation
  • Frontend: scope chip reflects loaded conversation scope; updates on model tool call