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>
This commit is contained in:
2026-03-25 17:44:39 -04:00
parent 1e0d11c907
commit ebc79b34f9
16 changed files with 1578 additions and 38 deletions
+35 -2
View File
@@ -22,6 +22,29 @@ def _normalize_tags(tags: list[str]) -> list[str]:
return out
async def _maybe_trigger_project_summary(user_id: int, project_id: int) -> None:
"""Fire generate_project_summary() if the project summary is missing or >1h old."""
import asyncio
from datetime import timedelta
from fabledassistant.models.project import Project
from fabledassistant.services.projects import generate_project_summary
try:
async with async_session() as session:
project = (await session.execute(
select(Project).where(Project.id == project_id)
)).scalars().first()
if project is None:
return
stale = (
project.summary_updated_at is None
or (datetime.now(timezone.utc) - project.summary_updated_at) > timedelta(hours=1)
)
if stale:
asyncio.create_task(generate_project_summary(user_id, project_id))
except Exception:
logger.debug("_maybe_trigger_project_summary failed for project %d", project_id, exc_info=True)
async def create_note(
user_id: int,
title: str = "",
@@ -61,7 +84,11 @@ async def create_note(
session.add(note)
await session.commit()
await session.refresh(note)
return note
if project_id is not None:
await _maybe_trigger_project_summary(user_id, project_id)
return note
async def get_note(user_id: int, note_id: int) -> Note | None:
@@ -222,6 +249,9 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
from fabledassistant.services.note_versions import create_version
await create_version(user_id, note_id, old_body, old_title, old_tags)
if note.project_id is not None:
await _maybe_trigger_project_summary(user_id, note.project_id)
return note
@@ -303,6 +333,7 @@ async def search_notes_for_context(
exclude_ids: set[int] | None = None,
limit: int = 3,
project_id: int | None = None,
orphan_only: bool = False,
) -> list[Note]:
"""Search notes by keywords with OR logic. Optimized for context building — no count query."""
async with async_session() as session:
@@ -313,7 +344,9 @@ async def search_notes_for_context(
keyword_filters.append(or_(Note.title.ilike(pattern), Note.body.ilike(pattern)))
query = select(Note).where(Note.user_id == user_id, or_(*keyword_filters))
if project_id is not None:
if orphan_only:
query = query.where(Note.project_id.is_(None))
elif project_id is not None:
query = query.where(Note.project_id == project_id)
if exclude_ids:
query = query.where(Note.id.notin_(exclude_ids))