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
+92 -1
View File
@@ -838,9 +838,48 @@ _IMAGE_TOOLS = [
]
_RAG_TOOLS = [
{
"type": "function",
"function": {
"name": "search_projects",
"description": "Search for projects by name, description, or theme. Use this to find which project a user is referring to and get its ID for set_rag_scope.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query — project name, topic, or theme",
}
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "set_rag_scope",
"description": "Change the RAG scope for this conversation. Use project_id=<int> to scope to a project, project_id=null to scope to orphan notes only, project_id=-1 for all notes.",
"parameters": {
"type": "object",
"properties": {
"project_id": {
"type": ["integer", "null"],
"description": "Project ID to scope to, null for orphan-only, -1 for all notes",
}
},
"required": ["project_id"],
},
},
},
]
async def get_tools_for_user(user_id: int) -> list[dict]:
"""Build the tool list for a user based on their configured integrations."""
tools = list(_CORE_TOOLS)
tools.extend(_RAG_TOOLS)
if await is_caldav_configured(user_id):
tools.extend(_CALDAV_TOOLS)
if Config.searxng_enabled():
@@ -883,7 +922,13 @@ def _fuzzy_title_match(title: str, candidates, threshold: float = 0.82):
async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
async def execute_tool(
user_id: int,
tool_name: str,
arguments: dict,
conv_id: int | None = None,
workspace_project_id: int | None = None,
) -> dict:
"""Execute a tool call and return the result."""
try:
if tool_name == "create_task":
@@ -1735,6 +1780,52 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
items = await get_recent_items(user_id, limit=limit)
return {"data": {"items": items, "count": len(items)}}
elif tool_name == "search_projects":
from difflib import SequenceMatcher
query = str(arguments.get("query", "")).lower()
from fabledassistant.services.projects import list_projects
projects = await list_projects(user_id)
scored: list[tuple[float, object]] = []
for p in projects:
combined = f"{p.title} {p.description or ''} {p.auto_summary or ''}".lower()
base_score = SequenceMatcher(None, query, combined).ratio()
# Bonus for keyword overlap
query_words = set(query.split())
overlap = sum(1 for w in query_words if w in combined)
score = base_score + overlap * 0.05
scored.append((score, p))
scored.sort(key=lambda x: x[0], reverse=True)
results = []
for score, p in scored[:5]:
results.append({
"id": p.id,
"title": p.title,
"summary_snippet": (p.auto_summary or p.description or "")[:200],
"score": round(score, 3),
})
return {"type": "projects_list", "data": {"projects": results}}
elif 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 conversation context available"}
project_id = arguments.get("project_id") # None, -1, or positive int
# Validate positive project_id belongs to user
if project_id is not None and project_id != -1:
from fabledassistant.services.projects import get_project
proj = await get_project(user_id, int(project_id))
if proj is None:
return {"success": False, "error": "Project not found"}
scope_label = proj.title
elif project_id == -1:
scope_label = "All notes"
else:
scope_label = "Orphan notes only"
from fabledassistant.services.chat import update_conversation
await update_conversation(user_id, conv_id, rag_project_id=project_id)
return {"success": True, "type": "rag_scope_set", "scope_label": scope_label}
else:
return {"success": False, "error": f"Unknown tool: {tool_name}"}