diff --git a/docs/specs/2026-03-25-rag-scoping-design.md b/docs/specs/2026-03-25-rag-scoping-design.md new file mode 100644 index 0000000..58b7fde --- /dev/null +++ b/docs/specs/2026-03-25-rag-scoping-design.md @@ -0,0 +1,148 @@ +# 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 = ` → 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 update** — `update_project()` fires a background task unconditionally +3. **On note save to a project** — `create_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 = ` → 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.py` → `execute_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