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:
@@ -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}"}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user