Files
FabledScribe/docs/plans/2026-03-25-rag-scoping.md
T
bvandeusen ebc79b34f9 feat(rag): RAG scoping and context isolation controls
- 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 <noreply@anthropic.com>
2026-03-25 17:44:39 -04:00

37 KiB
Raw Blame History

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

# 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
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:

rag_project_id: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)

In to_dict(), add after "briefing_date":

"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:

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:

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
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:

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:

@chat_bp.route("/conversations/<int:conv_id>", 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
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:

"""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
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:

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:

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:

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:

if orphan_only:
    query = query.where(Note.project_id.is_(None))
  • Step 5: Run tests — expect pass
docker compose exec app pytest tests/test_rag_scoping.py::test_semantic_search_orphan_only_adds_filter -v

Expected: PASS

  • Step 6: Commit
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 ~539560)

  • 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:

# 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
docker compose exec app pytest tests/test_rag_scoping.py -v

Expected: all PASS

  • Step 3: Commit
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:

@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
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:

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():

    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):

    # 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:

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):

        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
docker compose exec app pytest tests/test_rag_scoping.py -v

Expected: all PASS

  • Step 8: Commit
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:

@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
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:

{
    "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:

async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:

To:

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}"}):

    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
docker compose exec app pytest tests/test_rag_scoping.py -v

Expected: all PASS

  • Step 7: Commit
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:

result = await execute_tool(user_id, tool_name, arguments)

Replace with:

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:

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:

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):

buf.append_event("done", {"done": True, "message_id": msg_id, "timing": timing})

Replace with:

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
docker compose exec app pytest tests/ -v 2>&1 | tail -30

Expected: all PASS

  • Step 4: Commit
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:

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):

const ragProjectId = computed<number | null>(
  () => 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:

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:

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:

<!-- RAG project scope -->
<div class="rag-scope-section">
  <label class="rag-scope-label">Scope notes to project</label>
  <ProjectSelector v-model="ragProjectId" />
  <p v-if="ragProjectId" class="rag-scope-hint">RAG search restricted to this project</p>
</div>

Load projects list in the script:

import { listProjects } from "@/api/client";  // or use apiGet

interface ScopeProject { id: number; title: string; }
const scopeProjects = ref<ScopeProject[]>([]);
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):

<div class="scope-chip-row">
  <div class="scope-chip" :class="{ active: scopeDropdownOpen }" @click="scopeDropdownOpen = !scopeDropdownOpen">
    <span class="scope-dot"></span>
    <span class="scope-label">{{ scopeLabel }}</span>
    <span class="scope-chevron"></span>
  </div>
  <div v-if="scopeDropdownOpen" class="scope-dropdown">
    <button class="scope-option" @click="setScope(null)">Orphan notes only</button>
    <button
      v-for="p in scopeProjects"
      :key="p.id"
      class="scope-option"
      @click="setScope(p.id)"
    >{{ p.title }}</button>
    <button class="scope-option" @click="setScope(-1)">All notes</button>
  </div>
</div>

Add scoped styles:

.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
cd frontend && npx vue-tsc --noEmit 2>&1 | head -30

Expected: no errors

  • Step 6: Commit
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
docker compose exec app pytest tests/ -v 2>&1 | tail -30

Expected: all PASS

  • Step 2: TypeScript check
cd frontend && npx vue-tsc --noEmit

Expected: no errors

  • Step 3: Final commit
git add -A
git commit -m "feat(rag): RAG scoping — orphan-only default, project summaries, search_projects + set_rag_scope tools, scope chip UI"