From ebc79b34f99c602027137dc8e353b02ee5aaf82f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 25 Mar 2026 17:44:39 -0400 Subject: [PATCH] feat(rag): RAG scoping and context isolation controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration 0030: add conversations.rag_project_id (NULL=orphan-only, -1=all notes, positive=project), projects.auto_summary and projects.summary_updated_at - Three-value scope semantics thread from build_context() → semantic search and keyword fallback via orphan_only + effective_project_id - Project summarization background job (generate_project_summary, backfill_project_summaries) called via Ollama; triggered on project update and note saves (debounced 1h); runs at startup - New LLM tools: search_projects (SequenceMatcher scoring on title+description+auto_summary) and set_rag_scope (persists to DB, workspace-guarded, emits new_rag_scope in SSE done event) - execute_tool() accepts conv_id + workspace_project_id; generation_task passes both and captures scope changes for SSE done enrichment - Frontend: Conversation type gets rag_project_id; chat store adds ragProjectId computed + updateRagScope(); SSE done handler syncs scope - ChatView: replace sidebar ProjectSelector with a scope chip pill above the input bar, animated dropdown, pulse on model-driven scope change Co-Authored-By: Claude Sonnet 4.6 --- alembic/versions/0030_rag_scoping.py | 23 + docs/plans/2026-03-25-rag-scoping.md | 1156 +++++++++++++++++ frontend/src/stores/chat.ts | 17 + frontend/src/types/chat.ts | 1 + frontend/src/views/ChatView.vue | 157 ++- src/fabledassistant/app.py | 5 + src/fabledassistant/models/conversation.py | 3 + src/fabledassistant/models/project.py | 7 +- src/fabledassistant/routes/chat.py | 8 +- src/fabledassistant/services/chat.py | 6 + src/fabledassistant/services/embeddings.py | 5 +- .../services/generation_task.py | 19 +- src/fabledassistant/services/llm.py | 11 +- src/fabledassistant/services/notes.py | 37 +- src/fabledassistant/services/projects.py | 68 +- src/fabledassistant/services/tools.py | 93 +- 16 files changed, 1578 insertions(+), 38 deletions(-) create mode 100644 alembic/versions/0030_rag_scoping.py create mode 100644 docs/plans/2026-03-25-rag-scoping.md diff --git a/alembic/versions/0030_rag_scoping.py b/alembic/versions/0030_rag_scoping.py new file mode 100644 index 0000000..38858a3 --- /dev/null +++ b/alembic/versions/0030_rag_scoping.py @@ -0,0 +1,23 @@ +"""Add rag_project_id to conversations; auto_summary columns to projects.""" + +from alembic import op + +revision = "0030" +down_revision = "0029" + + +def upgrade() -> None: + op.execute(""" + ALTER TABLE conversations + ADD COLUMN IF NOT EXISTS rag_project_id INTEGER DEFAULT NULL + """) + op.execute(""" + ALTER TABLE projects + ADD COLUMN IF NOT EXISTS auto_summary TEXT DEFAULT NULL, + ADD COLUMN IF NOT EXISTS summary_updated_at TIMESTAMPTZ DEFAULT NULL + """) + + +def downgrade() -> None: + op.execute("ALTER TABLE conversations DROP COLUMN IF EXISTS rag_project_id") + op.execute("ALTER TABLE projects DROP COLUMN IF EXISTS auto_summary, DROP COLUMN IF EXISTS summary_updated_at") diff --git a/docs/plans/2026-03-25-rag-scoping.md b/docs/plans/2026-03-25-rag-scoping.md new file mode 100644 index 0000000..66dc696 --- /dev/null +++ b/docs/plans/2026-03-25-rag-scoping.md @@ -0,0 +1,1156 @@ +# RAG Scoping and Context Isolation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Silo project notes from global chat RAG by default, persist scope per conversation, and give the LLM `search_projects` and `set_rag_scope` tools. + +**Architecture:** Add `rag_project_id` to conversations (NULL=orphan-only, -1=all, positive=project). Add `auto_summary` to projects, generated by a background job. New `orphan_only` param threads from `build_context()` down to the search functions. Two new LLM tools let the model discover and switch scope. + +**Tech Stack:** Python/Quart/SQLAlchemy (backend), Vue 3/TypeScript/Pinia (frontend), Ollama (summary generation), pytest + AsyncMock (tests) + +**Spec:** `docs/specs/2026-03-25-rag-scoping-design.md` + +--- + +### Task 1: Migration 0030 — rag_project_id + project summary columns + +**Files:** +- Create: `alembic/versions/0030_rag_scoping.py` + +- [ ] **Step 1: Create the migration file** + +```python +# alembic/versions/0030_rag_scoping.py +"""Add rag_project_id to conversations; auto_summary columns to projects.""" + +from alembic import op + +revision = "0030" +down_revision = "0029" + + +def upgrade() -> None: + op.execute(""" + ALTER TABLE conversations + ADD COLUMN IF NOT EXISTS rag_project_id INTEGER DEFAULT NULL + """) + op.execute(""" + ALTER TABLE projects + ADD COLUMN IF NOT EXISTS auto_summary TEXT DEFAULT NULL, + ADD COLUMN IF NOT EXISTS summary_updated_at TIMESTAMPTZ DEFAULT NULL + """) + + +def downgrade() -> None: + op.execute("ALTER TABLE conversations DROP COLUMN IF EXISTS rag_project_id") + op.execute("ALTER TABLE projects DROP COLUMN IF EXISTS auto_summary, DROP COLUMN IF EXISTS summary_updated_at") +``` + +- [ ] **Step 2: Commit** + +```bash +git add alembic/versions/0030_rag_scoping.py +git commit -m "feat(rag): migration 0030 — add rag_project_id and project summary columns" +``` + +--- + +### Task 2: Update models — Conversation and Project + +**Files:** +- Modify: `src/fabledassistant/models/conversation.py` +- Modify: `src/fabledassistant/models/project.py` + +- [ ] **Step 1: Add `rag_project_id` to Conversation model** + +In `models/conversation.py`, after the `briefing_date` field (line 25), add: + +```python +rag_project_id: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None) +``` + +In `to_dict()`, add after `"briefing_date"`: + +```python +"rag_project_id": self.rag_project_id, +``` + +- [ ] **Step 2: Add summary fields to Project model** + +In `models/project.py`, add imports `DateTime` from sqlalchemy and `datetime` from datetime. After the `color` field add: + +```python +auto_summary: Mapped[str | None] = mapped_column(Text, nullable=True, default=None) +summary_updated_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, default=None +) +``` + +Full updated imports needed at top of file: +```python +import enum +from datetime import datetime +from sqlalchemy import DateTime, ForeignKey, Integer, Text +from sqlalchemy.orm import Mapped, mapped_column +from fabledassistant.models import Base +from fabledassistant.models.base import TimestampMixin +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/fabledassistant/models/conversation.py src/fabledassistant/models/project.py +git commit -m "feat(rag): add rag_project_id to Conversation; auto_summary to Project" +``` + +--- + +### Task 3: Update chat service and route for rag_project_id + +**Files:** +- Modify: `src/fabledassistant/services/chat.py:142-165` +- Modify: `src/fabledassistant/routes/chat.py:115-127` + +- [ ] **Step 1: Add `rag_project_id` param to `update_conversation()`** + +In `services/chat.py`, update the `update_conversation()` signature and body: + +```python +async def update_conversation( + user_id: int, + conversation_id: int, + title: str | None = None, + model: str | None = None, + rag_project_id: int | None | bool = False, # False = "not provided" +) -> Conversation | None: + async with async_session() as session: + result = await session.execute( + select(Conversation).where( + Conversation.id == conversation_id, + Conversation.user_id == user_id, + ) + ) + conv = result.scalars().first() + if conv is None: + return None + if title is not None: + conv.title = title + if model is not None: + conv.model = model + if rag_project_id is not False: + conv.rag_project_id = rag_project_id # type: ignore[assignment] + conv.updated_at = datetime.now(timezone.utc) + await session.commit() + await session.refresh(conv) + return conv +``` + +Note: using `False` as sentinel (not `None`) because `None` is a valid value to set (orphan-only). + +- [ ] **Step 2: Update PATCH route to accept `rag_project_id`** + +In `routes/chat.py`, replace the `update_conversation_route` function: + +```python +@chat_bp.route("/conversations/", methods=["PATCH"]) +@login_required +async def update_conversation_route(conv_id: int): + uid = get_current_user_id() + data = await request.get_json() + title = data.get("title") + model = data.get("model") + # rag_project_id: use sentinel _MISSING to distinguish null from not-provided + _MISSING = object() + rag_project_id = data.get("rag_project_id", _MISSING) + if title is None and model is None and rag_project_id is _MISSING: + return jsonify({"error": "title, model, or rag_project_id is required"}), 400 + kwargs: dict = {} + if title is not None: + kwargs["title"] = title + if model is not None: + kwargs["model"] = model + if rag_project_id is not _MISSING: + kwargs["rag_project_id"] = rag_project_id + conv = await update_conversation(uid, conv_id, **kwargs) + if conv is None: + return not_found("Conversation") + return jsonify(conv.to_dict()) +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/fabledassistant/services/chat.py src/fabledassistant/routes/chat.py +git commit -m "feat(rag): expose rag_project_id on conversation PATCH + service" +``` + +--- + +### Task 4: Add `orphan_only` param to search functions + +**Files:** +- Modify: `src/fabledassistant/services/embeddings.py:76-130` +- Modify: `src/fabledassistant/services/notes.py:300-323` +- Modify: `tests/test_rag_scoping.py` (create) + +- [ ] **Step 1: Write tests** + +Create `tests/test_rag_scoping.py`: + +```python +"""Tests for RAG scoping: orphan_only filtering and build_context scope logic.""" +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + + +def _make_note(id, project_id=None): + n = MagicMock() + n.id = id + n.project_id = project_id + n.title = f"Note {id}" + n.body = "body" + n.status = None + return n + + +@pytest.mark.asyncio +async def test_semantic_search_orphan_only_adds_filter(): + """semantic_search_notes(orphan_only=True) must add project_id IS NULL filter.""" + from fabledassistant.services.embeddings import semantic_search_notes + + with patch("fabledassistant.services.embeddings.get_embedding", new_callable=AsyncMock) as mock_embed, \ + patch("fabledassistant.services.embeddings.async_session") as mock_session_cls: + + mock_embed.return_value = [0.1] * 768 + + mock_session = AsyncMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + mock_session.execute = AsyncMock(return_value=MagicMock(all=MagicMock(return_value=[]))) + mock_session_cls.return_value = mock_session + + result = await semantic_search_notes(1, "query", orphan_only=True) + assert result == [] + # The key assertion is that it ran without error — the filter is applied internally. + # Full DB-level testing is done via integration tests. + + +@pytest.mark.asyncio +async def test_build_context_scope_logic(): + """build_context() derives correct orphan_only + effective_project_id.""" + from fabledassistant.services import llm as llm_mod + + calls = [] + + async def fake_search(user_id, query, exclude_ids=None, limit=8, + project_id=None, orphan_only=False): + calls.append({"project_id": project_id, "orphan_only": orphan_only}) + return [] + + with patch.object(llm_mod, "semantic_search_notes", fake_search), \ + patch("fabledassistant.services.llm.get_setting", new_callable=AsyncMock, return_value="Fable"), \ + patch("fabledassistant.services.llm.is_caldav_configured", new_callable=AsyncMock, return_value=False), \ + patch("fabledassistant.services.llm.get_note", new_callable=AsyncMock, return_value=None): + + # None → orphan only + await llm_mod.build_context(1, [], None, "hello", rag_project_id=None) + assert calls[-1] == {"project_id": None, "orphan_only": True} + + # -1 → all notes + await llm_mod.build_context(1, [], None, "hello", rag_project_id=-1) + assert calls[-1] == {"project_id": None, "orphan_only": False} + + # 5 → project 5 + await llm_mod.build_context(1, [], None, "hello", rag_project_id=5) + assert calls[-1] == {"project_id": 5, "orphan_only": False} +``` + +- [ ] **Step 2: Run tests — expect failures** + +```bash +docker compose exec app pytest tests/test_rag_scoping.py -v 2>&1 | tail -20 +``` + +Expected: FAIL (orphan_only param not yet on functions) + +- [ ] **Step 3: Add `orphan_only` to `semantic_search_notes()`** + +In `services/embeddings.py`, update the signature and query: + +```python +async def semantic_search_notes( + user_id: int, + query: str, + exclude_ids: set[int] | None = None, + limit: int = 8, + threshold: float = _SIMILARITY_THRESHOLD, + project_id: int | None = None, + is_task: bool | None = None, + orphan_only: bool = False, +) -> list[tuple[float, Note]]: +``` + +After the `is_task` filter block and before `if exclude_ids:`, add: + +```python +if orphan_only: + stmt = stmt.where(Note.project_id.is_(None)) +``` + +- [ ] **Step 4: Add `orphan_only` to `search_notes_for_context()`** + +In `services/notes.py`, update the signature: + +```python +async def search_notes_for_context( + user_id: int, + keywords: list[str], + exclude_ids: set[int] | None = None, + limit: int = 3, + project_id: int | None = None, + orphan_only: bool = False, +) -> list[Note]: +``` + +After the `project_id` filter block and before `if exclude_ids:`, add: + +```python +if orphan_only: + query = query.where(Note.project_id.is_(None)) +``` + +- [ ] **Step 5: Run tests — expect pass** + +```bash +docker compose exec app pytest tests/test_rag_scoping.py::test_semantic_search_orphan_only_adds_filter -v +``` + +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/fabledassistant/services/embeddings.py src/fabledassistant/services/notes.py tests/test_rag_scoping.py +git commit -m "feat(rag): add orphan_only param to semantic_search_notes and search_notes_for_context" +``` + +--- + +### Task 5: Update `build_context()` for three-value scope semantics + +**Files:** +- Modify: `src/fabledassistant/services/llm.py` (lines ~539–560) + +- [ ] **Step 1: Update the two search call sites in `build_context()`** + +Locate the semantic search block (around line 539) and the keyword fallback (around line 554). Replace both calls to use the derived scope: + +```python +# Compute scope flags from rag_project_id three-value system +orphan_only = rag_project_id is None +effective_project_id = rag_project_id if (rag_project_id is not None and rag_project_id != -1) else None + +# Try semantic search first; fall back to keyword search on failure / no results. +try: + from fabledassistant.services.embeddings import semantic_search_notes + for score, note in await semantic_search_notes( + user_id, user_message, exclude_ids=search_exclude or None, limit=8, + project_id=effective_project_id, + orphan_only=orphan_only, + ): + found_scored.append((score, note)) +except Exception: + logger.warning("Semantic note search failed, falling back to keyword search", exc_info=True) + +if not found_scored: + keywords = _extract_keywords(user_message) + if keywords: + try: + for note in await search_notes_for_context( + user_id, keywords, exclude_ids=search_exclude or None, limit=8, + project_id=effective_project_id, + orphan_only=orphan_only, + ): + found_scored.append((None, note)) + except Exception: + logger.warning("Failed to search notes for context", exc_info=True) +``` + +- [ ] **Step 2: Run scope logic tests** + +```bash +docker compose exec app pytest tests/test_rag_scoping.py -v +``` + +Expected: all PASS + +- [ ] **Step 3: Commit** + +```bash +git add src/fabledassistant/services/llm.py +git commit -m "feat(rag): build_context derives orphan_only from rag_project_id three-value semantics" +``` + +--- + +### Task 6: Project summarization background job + +**Files:** +- Modify: `src/fabledassistant/services/projects.py` +- Modify: `src/fabledassistant/services/notes.py` (create_note / update_note triggers) +- Modify: `src/fabledassistant/app.py` +- Modify: `tests/test_rag_scoping.py` + +- [ ] **Step 1: Write tests for generate_project_summary** + +Append to `tests/test_rag_scoping.py`: + +```python +@pytest.mark.asyncio +async def test_generate_project_summary_stores_result(): + """generate_project_summary() writes auto_summary to project.""" + from fabledassistant.services.projects import generate_project_summary + + mock_project = MagicMock() + mock_project.id = 1 + mock_project.title = "SciFi World" + mock_project.description = "A space opera" + mock_project.goal = "Finish the novel" + mock_project.auto_summary = None + + mock_note = MagicMock() + mock_note.title = "Chapter 1" + mock_note.body = "It was a dark night..." + + mock_session = AsyncMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + mock_session.commit = AsyncMock() + mock_session.refresh = AsyncMock() + execute_result = MagicMock() + execute_result.scalars.return_value.first.return_value = mock_project + execute_result.scalars.return_value.all.return_value = [mock_note] + mock_session.execute = AsyncMock(return_value=execute_result) + + with patch("fabledassistant.services.projects.async_session", return_value=mock_session), \ + patch("fabledassistant.services.projects.generate_completion", + new_callable=AsyncMock, return_value="A space opera about finishing a novel."): + await generate_project_summary(1, 1) + assert mock_project.auto_summary == "A space opera about finishing a novel." + + +@pytest.mark.asyncio +async def test_generate_project_summary_handles_ollama_failure(): + """generate_project_summary() swallows Ollama errors gracefully.""" + from fabledassistant.services.projects import generate_project_summary + + mock_project = MagicMock() + mock_project.title = "Test" + mock_project.description = "" + mock_project.goal = "" + mock_project.auto_summary = None + + mock_session = AsyncMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + execute_result = MagicMock() + execute_result.scalars.return_value.first.return_value = mock_project + execute_result.scalars.return_value.all.return_value = [] + mock_session.execute = AsyncMock(return_value=execute_result) + + with patch("fabledassistant.services.projects.async_session", return_value=mock_session), \ + patch("fabledassistant.services.projects.generate_completion", + side_effect=Exception("Ollama down")): + # Must not raise + await generate_project_summary(1, 1) + assert mock_project.auto_summary is None +``` + +- [ ] **Step 2: Run tests — expect failures** + +```bash +docker compose exec app pytest tests/test_rag_scoping.py::test_generate_project_summary_stores_result -v +``` + +Expected: FAIL (`generate_project_summary` not defined) + +- [ ] **Step 3: Add `generate_project_summary` and `backfill_project_summaries` to `services/projects.py`** + +Append to the end of `services/projects.py`: + +```python +async def generate_project_summary(user_id: int, project_id: int) -> None: + """Generate and store a concise project summary via Ollama. Fire-and-forget safe.""" + try: + async with async_session() as session: + project_result = await session.execute( + select(Project).where(Project.id == project_id, Project.user_id == user_id) + ) + project = project_result.scalars().first() + if project is None: + return + + notes_result = await session.execute( + select(Note.title, Note.body) + .where(Note.project_id == project_id, Note.user_id == user_id) + .order_by(Note.updated_at.desc()) + .limit(10) + ) + note_rows = notes_result.all() + + note_lines = "\n".join( + f"- {row.title}: {(row.body or '')[:200]}" for row in note_rows + ) + prompt = ( + f"Summarize this project in 3-4 sentences covering its purpose, themes, and content.\n" + f"Title: {project.title}\n" + f"Description: {project.description or '(none)'}\n" + f"Goal: {project.goal or '(none)'}\n" + f"Recent notes:\n{note_lines or '(none)'}" + ) + + from fabledassistant.services.llm import generate_completion + summary = await generate_completion(prompt) + if not summary: + return + + async with async_session() as session: + project_result = await session.execute( + select(Project).where(Project.id == project_id, Project.user_id == user_id) + ) + project = project_result.scalars().first() + if project is None: + return + project.auto_summary = summary.strip() + project.summary_updated_at = datetime.now(timezone.utc) + await session.commit() + + logger.info("Generated summary for project %d", project_id) + except Exception: + logger.debug("Failed to generate summary for project %d", project_id, exc_info=True) + + +async def backfill_project_summaries() -> None: + """Generate summaries for all projects that don't have one yet. Called at startup.""" + try: + async with async_session() as session: + result = await session.execute( + select(Project.id, Project.user_id).where(Project.auto_summary.is_(None)) + ) + rows = result.all() + except Exception: + logger.warning("Project summary backfill: failed to query projects", exc_info=True) + return + + if not rows: + logger.info("Project summary backfill: all projects already have summaries") + return + + logger.info("Project summary backfill: generating for %d projects", len(rows)) + for project_id, user_id in rows: + await generate_project_summary(user_id, project_id) + + logger.info("Project summary backfill complete") +``` + +Also add `from fabledassistant.models.note import Note` at the top of `services/projects.py` (it already imports `Note`? — check; if not, add it). + +- [ ] **Step 4: Trigger summary regen from `update_project()`** + +In `services/projects.py`, after the final `return project` in `update_project()`: + +```python + import asyncio as _asyncio + _asyncio.create_task(generate_project_summary(user_id, project_id)) + return project +``` + +- [ ] **Step 5: Trigger from note saves (debounced)** + +In `services/notes.py`, in both `create_note()` and `update_note()`, after `await session.commit()` / `await session.refresh(note)`, add (only when `project_id` is set): + +```python + # Trigger project summary refresh if note belongs to a project (debounced: 1h) + if note.project_id is not None: + import asyncio as _asyncio + from fabledassistant.services.projects import _should_refresh_summary, generate_project_summary + if await _should_refresh_summary(note.user_id, note.project_id): + _asyncio.create_task(generate_project_summary(note.user_id, note.project_id)) +``` + +Add helper `_should_refresh_summary` to `services/projects.py`: + +```python +async def _should_refresh_summary(user_id: int, project_id: int) -> bool: + """Return True if summary is absent or more than 1 hour old.""" + from datetime import timedelta + try: + async with async_session() as session: + result = await session.execute( + select(Project.summary_updated_at).where( + Project.id == project_id, Project.user_id == user_id + ) + ) + row = result.scalars().first() + if row is None: + return True + return datetime.now(timezone.utc) - row > timedelta(hours=1) + except Exception: + return False +``` + +- [ ] **Step 6: Wire backfill into `app.py` startup** + +In `app.py`, inside the `_delayed_backfill()` async function (after the `backfill_note_embeddings()` call): + +```python + try: + from fabledassistant.services.projects import backfill_project_summaries + await backfill_project_summaries() + except Exception: + logger.warning("Project summary backfill failed", exc_info=True) +``` + +- [ ] **Step 7: Run tests** + +```bash +docker compose exec app pytest tests/test_rag_scoping.py -v +``` + +Expected: all PASS + +- [ ] **Step 8: Commit** + +```bash +git add src/fabledassistant/services/projects.py src/fabledassistant/services/notes.py src/fabledassistant/app.py tests/test_rag_scoping.py +git commit -m "feat(rag): project summarization background job with startup backfill" +``` + +--- + +### Task 7: New LLM tools — search_projects and set_rag_scope + +**Files:** +- Modify: `src/fabledassistant/services/tools.py` +- Modify: `tests/test_rag_scoping.py` + +- [ ] **Step 1: Write tests for the new tools** + +Append to `tests/test_rag_scoping.py`: + +```python +@pytest.mark.asyncio +async def test_search_projects_returns_ranked_results(): + """search_projects() ranks projects by text similarity to query.""" + from fabledassistant.services.tools import execute_tool + + mock_projects = [ + MagicMock(id=1, title="SciFi World", description="space opera", goal="", auto_summary="A story about aliens and humans"), + MagicMock(id=2, title="Work Tasks", description="productivity", goal="", auto_summary="Task management for work"), + ] + + with patch("fabledassistant.services.tools.list_projects", new_callable=AsyncMock, return_value=mock_projects): + result = await execute_tool(1, "search_projects", {"query": "scifi aliens"}) + assert result["success"] is True + assert result["type"] == "projects_list" + # SciFi project should score higher + projects = result["data"]["projects"] + assert len(projects) > 0 + assert projects[0]["id"] == 1 + + +@pytest.mark.asyncio +async def test_set_rag_scope_persists_to_db(): + """set_rag_scope() writes rag_project_id to conversation.""" + from fabledassistant.services.tools import execute_tool + + mock_project = MagicMock() + mock_project.title = "SciFi World" + + with patch("fabledassistant.services.tools.get_project", new_callable=AsyncMock, return_value=mock_project), \ + patch("fabledassistant.services.tools.update_conversation", new_callable=AsyncMock, return_value=MagicMock()): + result = await execute_tool(1, "set_rag_scope", {"project_id": 3}, conv_id=42) + assert result["success"] is True + assert result["type"] == "rag_scope_set" + assert "SciFi World" in result["data"]["scope_label"] + + +@pytest.mark.asyncio +async def test_set_rag_scope_blocked_in_workspace(): + """set_rag_scope() returns error when workspace_project_id is set.""" + from fabledassistant.services.tools import execute_tool + + result = await execute_tool(1, "set_rag_scope", {"project_id": 3}, + conv_id=42, workspace_project_id=5) + assert result["success"] is False + assert "workspace" in result["error"].lower() +``` + +- [ ] **Step 2: Run tests — expect failures** + +```bash +docker compose exec app pytest tests/test_rag_scoping.py::test_search_projects_returns_ranked_results -v +``` + +Expected: FAIL (`search_projects` not in `_CORE_TOOLS`) + +- [ ] **Step 3: Add tool definitions to `_CORE_TOOLS` in `tools.py`** + +In `services/tools.py`, add to the `_CORE_TOOLS` list alongside existing tool definitions: + +```python +{ + "type": "function", + "function": { + "name": "search_projects", + "description": "Search your projects by name or description to find which project to scope the conversation to. Returns ranked matches.", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query — project name, theme, or topic"}, + }, + "required": ["query"], + }, + }, +}, +{ + "type": "function", + "function": { + "name": "set_rag_scope", + "description": "Change the note search scope for this conversation. project_id=null means orphan notes only (default). project_id=-1 means all notes. A positive integer scopes to that project.", + "parameters": { + "type": "object", + "properties": { + "project_id": { + "type": ["integer", "null"], + "description": "Project ID to scope to, null for orphan-only, or -1 for all notes", + }, + }, + "required": ["project_id"], + }, + }, +}, +``` + +- [ ] **Step 4: Update `execute_tool()` signature** + +Change: +```python +async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict: +``` + +To: +```python +async def execute_tool( + user_id: int, + tool_name: str, + arguments: dict, + conv_id: int | None = None, + workspace_project_id: int | None = None, +) -> dict: +``` + +- [ ] **Step 5: Add `search_projects` handler in `execute_tool()`** + +Add the handler after the existing tool handlers (before the final `return {"success": False, "error": f"Unknown tool: {tool_name}"}`): + +```python + if tool_name == "search_projects": + from difflib import SequenceMatcher + from fabledassistant.services.projects import list_projects + query = str(arguments.get("query", "")).lower().strip() + if not query: + return {"success": False, "error": "query is required"} + projects = await list_projects(user_id) + scored = [] + query_words = set(query.split()) + for p in projects: + combined = f"{p.title} {p.description or ''} {p.auto_summary or ''}".lower() + ratio = SequenceMatcher(None, query, combined).ratio() + keyword_bonus = sum(0.05 for w in query_words if w in combined) + score = min(ratio + keyword_bonus, 1.0) + scored.append((score, p)) + scored.sort(key=lambda x: x[0], reverse=True) + top = scored[:5] + return { + "success": True, + "type": "projects_list", + "data": { + "projects": [ + { + "id": p.id, + "title": p.title, + "summary_snippet": (p.auto_summary or p.description or "")[:200], + "score": round(score, 2), + } + for score, p in top + ] + }, + } + + if tool_name == "set_rag_scope": + if workspace_project_id is not None: + return {"success": False, "error": "Cannot change RAG scope in workspace view"} + if conv_id is None: + return {"success": False, "error": "No active conversation"} + from fabledassistant.services.chat import update_conversation + from fabledassistant.services.projects import get_project + raw_id = arguments.get("project_id") + # project_id can be null (None), -1, or positive int + if raw_id is None: + project_id_val = None + scope_label = "Orphan notes only" + elif int(raw_id) == -1: + project_id_val = -1 + scope_label = "All notes" + else: + pid = int(raw_id) + project = await get_project(user_id, pid) + if project is None: + return {"success": False, "error": f"Project {pid} not found"} + project_id_val = pid + scope_label = project.title + await update_conversation(user_id, conv_id, rag_project_id=project_id_val) + return { + "success": True, + "type": "rag_scope_set", + "data": { + "project_id": project_id_val, + "scope_label": scope_label, + }, + } +``` + +Also add `"rag_scope_set"` and `"projects_list"` to the `label` computed in `ToolCallCard.vue` (frontend Task 9). + +- [ ] **Step 6: Run tests** + +```bash +docker compose exec app pytest tests/test_rag_scoping.py -v +``` + +Expected: all PASS + +- [ ] **Step 7: Commit** + +```bash +git add src/fabledassistant/services/tools.py tests/test_rag_scoping.py +git commit -m "feat(rag): add search_projects and set_rag_scope LLM tools" +``` + +--- + +### Task 8: Thread conv_id through generation_task; emit new_rag_scope in SSE done + +**Files:** +- Modify: `src/fabledassistant/services/generation_task.py` + +- [ ] **Step 1: Thread `conv_id` and `workspace_project_id` into `execute_tool()` call** + +Find the line (around 283) in `generation_task.py`: + +```python +result = await execute_tool(user_id, tool_name, arguments) +``` + +Replace with: + +```python +result = await execute_tool( + user_id, tool_name, arguments, + conv_id=conv_id, + workspace_project_id=workspace_project_id, +) +``` + +`conv_id` and `workspace_project_id` are already in scope as parameters of `run_generation()`. + +- [ ] **Step 2: Capture `new_rag_scope` and emit in done event** + +Before the tool execution loop (around where `round_tool_calls = []` is initialized), add: + +```python +new_rag_scope: int | None | bool = False # False = "not changed" +new_rag_scope_label: str | None = None +``` + +After the `result = await execute_tool(...)` line, add: + +```python +if result.get("type") == "rag_scope_set" and result.get("success"): + new_rag_scope = result["data"]["project_id"] + new_rag_scope_label = result["data"]["scope_label"] +``` + +Find the `done` event emission (line ~355): + +```python +buf.append_event("done", {"done": True, "message_id": msg_id, "timing": timing}) +``` + +Replace with: + +```python +done_payload: dict = {"done": True, "message_id": msg_id, "timing": timing} +if new_rag_scope is not False: + done_payload["new_rag_scope"] = new_rag_scope + done_payload["new_rag_scope_label"] = new_rag_scope_label +buf.append_event("done", done_payload) +``` + +- [ ] **Step 3: Run full test suite** + +```bash +docker compose exec app pytest tests/ -v 2>&1 | tail -30 +``` + +Expected: all PASS + +- [ ] **Step 4: Commit** + +```bash +git add src/fabledassistant/services/generation_task.py +git commit -m "feat(rag): thread conv_id to execute_tool; emit new_rag_scope in SSE done event" +``` + +--- + +### Task 9: Frontend — types, chat store, scope chip in ChatView + +**Files:** +- Modify: `frontend/src/types/chat.ts` +- Modify: `frontend/src/stores/chat.ts` +- Modify: `frontend/src/views/ChatView.vue` + +- [ ] **Step 1: Update `Conversation` type in `types/chat.ts`** + +Add `rag_project_id?: number | null` to the `Conversation` interface: + +```typescript +export interface Conversation { + id: number; + title: string; + model: string; + message_count: number; + rag_project_id?: number | null; + created_at: string; + updated_at: string; +} +``` + +- [ ] **Step 2: Add `ragProjectId` computed and `updateRagScope()` to chat store** + +In `stores/chat.ts`, add after the `lastContextMeta` computed (around line 76): + +```typescript +const ragProjectId = computed( + () => currentConversation.value?.rag_project_id ?? null +); + +async function updateRagScope(convId: number, ragProjectId: number | null) { + await apiPatch(`/api/chat/conversations/${convId}`, { rag_project_id: ragProjectId }); + if (currentConversation.value?.id === convId) { + currentConversation.value.rag_project_id = ragProjectId; + } + const idx = conversations.value.findIndex((c) => c.id === convId); + if (idx !== -1) conversations.value[idx].rag_project_id = ragProjectId; +} +``` + +In the SSE `done` handler (inside the `case "done":` block, after `s.streaming = false`), add: + +```typescript +if (event.data.new_rag_scope !== undefined) { + const newScope = event.data.new_rag_scope as number | null; + if (currentConversation.value?.id === convId) { + currentConversation.value.rag_project_id = newScope; + } + const idx2 = conversations.value.findIndex((c) => c.id === convId); + if (idx2 !== -1) conversations.value[idx2].rag_project_id = newScope; +} +``` + +Add `ragProjectId` and `updateRagScope` to the store's return object. + +- [ ] **Step 3: Add `rag_scope_set` and `projects_list` labels to ToolCallCard** + +In `frontend/src/components/ToolCallCard.vue`, add to the `label` computed: + +```typescript +case "rag_scope_set": return "Scope changed"; +case "projects_list": return "Projects"; +``` + +- [ ] **Step 4: Replace sidebar ProjectSelector with scope chip in ChatView** + +Remove the existing RAG scope section in the sidebar: +```vue + +
+ + +

RAG search restricted to this project

+
+``` + +Load projects list in the script: +```typescript +import { listProjects } from "@/api/client"; // or use apiGet + +interface ScopeProject { id: number; title: string; } +const scopeProjects = ref([]); +const scopeDropdownOpen = ref(false); + +onMounted(async () => { + try { + const res = await apiGet<{ projects: ScopeProject[] }>("/api/projects?status=active"); + scopeProjects.value = res.projects; + } catch { /* non-fatal */ } +}); + +const scopeLabel = computed(() => { + const id = chatStore.ragProjectId; + if (id === null || id === undefined) return "Orphan notes"; + if (id === -1) return "All notes"; + return scopeProjects.value.find((p) => p.id === id)?.title ?? `Project ${id}`; +}); + +async function setScope(value: number | null) { + scopeDropdownOpen.value = false; + if (!convId.value) return; + await chatStore.updateRagScope(convId.value, value); +} +``` + +Remove the old `ragProjectId` ref (it's now a computed from the store). Update `sendMessage()` to use `chatStore.ragProjectId` instead of the local ref. + +Add the scope chip above the message input in the template (inside `.chat-main`): + +```vue +
+
+ + {{ scopeLabel }} + +
+
+ + + +
+
+``` + +Add scoped styles: + +```css +.scope-chip-row { + position: relative; + padding: 0 1rem 0.35rem; + display: flex; + align-items: center; +} +.scope-chip { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.2rem 0.6rem; + border-radius: 999px; + border: 1px solid var(--color-border); + background: var(--color-bg-secondary); + font-size: 0.75rem; + color: var(--color-text-muted); + cursor: pointer; + user-select: none; + transition: border-color 0.15s; +} +.scope-chip:hover, .scope-chip.active { + border-color: var(--color-primary); + color: var(--color-primary); +} +.scope-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-primary); + flex-shrink: 0; +} +.scope-dropdown { + position: absolute; + top: calc(100% - 0.1rem); + left: 1rem; + z-index: 100; + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + min-width: 180px; + box-shadow: 0 4px 16px var(--color-shadow, rgba(0,0,0,0.2)); + overflow: hidden; +} +.scope-option { + display: block; + width: 100%; + text-align: left; + padding: 0.45rem 0.75rem; + background: none; + border: none; + font-size: 0.82rem; + color: var(--color-text); + cursor: pointer; + font-family: inherit; + transition: background 0.1s; +} +.scope-option:hover { + background: color-mix(in srgb, var(--color-primary) 8%, transparent); + color: var(--color-primary); +} +``` + +- [ ] **Step 5: TypeScript check** + +```bash +cd frontend && npx vue-tsc --noEmit 2>&1 | head -30 +``` + +Expected: no errors + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/types/chat.ts frontend/src/stores/chat.ts frontend/src/views/ChatView.vue frontend/src/components/ToolCallCard.vue +git commit -m "feat(rag): scope chip in chat, ragProjectId in store, SSE done handler" +``` + +--- + +### Task 10: Full test run and final commit + +**Files:** none new + +- [ ] **Step 1: Run full test suite** + +```bash +docker compose exec app pytest tests/ -v 2>&1 | tail -30 +``` + +Expected: all PASS + +- [ ] **Step 2: TypeScript check** + +```bash +cd frontend && npx vue-tsc --noEmit +``` + +Expected: no errors + +- [ ] **Step 3: Final commit** + +```bash +git add -A +git commit -m "feat(rag): RAG scoping — orphan-only default, project summaries, search_projects + set_rag_scope tools, scope chip UI" +``` diff --git a/frontend/src/stores/chat.ts b/frontend/src/stores/chat.ts index 76c1f23..af19e2c 100644 --- a/frontend/src/stores/chat.ts +++ b/frontend/src/stores/chat.ts @@ -75,6 +75,17 @@ export const useChatStore = defineStore("chat", () => { const streamingPendingTool = computed(() => convStreams.value[currentConversation.value?.id ?? 0]?.pendingTool ?? null); const lastContextMeta = computed(() => convStreams.value[currentConversation.value?.id ?? 0]?.contextMeta ?? null); + const ragProjectId = computed( + () => currentConversation.value?.rag_project_id ?? null + ); + + async function updateRagScope(convId: number, ragProjectId: number | null): Promise { + await apiPatch(`/api/chat/conversations/${convId}`, { rag_project_id: ragProjectId }); + if (currentConversation.value?.id === convId) { + currentConversation.value.rag_project_id = ragProjectId; + } + } + function isStreamingConv(id: number): boolean { return convStreams.value[id]?.streaming ?? false; } @@ -387,6 +398,10 @@ export const useChatStore = defineStore("chat", () => { }; if (currentConversation.value?.id === convId) { currentConversation.value.messages.push(assistantMsg); + // Update RAG scope if the model changed it mid-conversation + if (event.data.new_rag_scope !== undefined) { + currentConversation.value.rag_project_id = event.data.new_rag_scope as number | null; + } } // Update updated_at only — message_count was already incremented at send time const idx = conversations.value.findIndex((c) => c.id === convId); @@ -594,6 +609,8 @@ export const useChatStore = defineStore("chat", () => { streamingStatus, streamingPendingTool, lastContextMeta, + ragProjectId, + updateRagScope, ollamaStatus, modelStatus, defaultModel, diff --git a/frontend/src/types/chat.ts b/frontend/src/types/chat.ts index ad430ea..9d97812 100644 --- a/frontend/src/types/chat.ts +++ b/frontend/src/types/chat.ts @@ -43,6 +43,7 @@ export interface Conversation { title: string; model: string; message_count: number; + rag_project_id: number | null; created_at: string; updated_at: string; } diff --git a/frontend/src/views/ChatView.vue b/frontend/src/views/ChatView.vue index 8318baf..6361237 100644 --- a/frontend/src/views/ChatView.vue +++ b/frontend/src/views/ChatView.vue @@ -9,7 +9,6 @@ import ChatMessage from "@/components/ChatMessage.vue"; import ToolCallCard from "@/components/ToolCallCard.vue"; import ToolConfirmCard from "@/components/ToolConfirmCard.vue"; import type { Note } from "@/types/note"; -import ProjectSelector from "@/components/ProjectSelector.vue"; const route = useRoute(); const router = useRouter(); @@ -47,8 +46,34 @@ const autoInjectedNotes = ref<{ id: number; title: string; score?: number | null // Note IDs excluded from auto-injection on next message const excludedNoteIds = ref([]); -// Project scope for RAG — when set, semantic & keyword search is restricted to this project -const ragProjectId = ref(null); +// Scope chip state +const scopeDropdownOpen = ref(false); +const projects = ref<{ id: number; title: string }[]>([]); +const scopePulse = ref(false); + +const scopeLabel = computed(() => { + const id = store.ragProjectId; + if (id === -1) return "All notes"; + if (id === null) return "Orphan notes"; + return projects.value.find((p) => p.id === id)?.title ?? `Project ${id}`; +}); + +async function loadProjects() { + try { + const data = await apiGet<{ projects: { id: number; title: string }[] }>("/api/projects?status=active"); + projects.value = data.projects ?? []; + } catch { + projects.value = []; + } +} + +async function onScopeSelect(value: number | null) { + scopeDropdownOpen.value = false; + if (!convId.value) return; + await store.updateRagScope(convId.value, value); + scopePulse.value = true; + setTimeout(() => { scopePulse.value = false; }, 600); +} let prevConvId: number | null = null; @@ -120,7 +145,7 @@ const inputPlaceholder = computed(() => { onMounted(async () => { document.addEventListener("keydown", onGlobalKeydown); - await store.fetchConversations(); + await Promise.all([store.fetchConversations(), loadProjects()]); if (convId.value) { if (store.currentConversation?.id !== convId.value) { await store.fetchConversation(convId.value); @@ -305,7 +330,7 @@ async function sendMessage() { true, // enable thinking in the full chat view undefined, excludedNoteIds.value.length ? excludedNoteIds.value : undefined, - ragProjectId.value, + store.ragProjectId, ); sending.value = false; @@ -520,13 +545,6 @@ onUnmounted(() => { - -
- - -

RAG search restricted to this project

-
-