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:
@@ -70,6 +70,7 @@ async def list_projects(user_id: int, status: str | None = None) -> list[Project
|
||||
|
||||
|
||||
async def update_project(user_id: int, project_id: int, **fields: object) -> Project | None:
|
||||
import asyncio
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == user_id)
|
||||
@@ -83,7 +84,72 @@ async def update_project(user_id: int, project_id: int, **fields: object) -> Pro
|
||||
project.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(project)
|
||||
return project
|
||||
asyncio.create_task(generate_project_summary(user_id, project_id))
|
||||
return project
|
||||
|
||||
|
||||
async def generate_project_summary(user_id: int, project_id: int) -> None:
|
||||
"""Generate an LLM summary for a project and persist it. Fire-and-forget safe."""
|
||||
try:
|
||||
async with async_session() as session:
|
||||
project = (await session.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == user_id)
|
||||
)).scalars().first()
|
||||
if project is None:
|
||||
return
|
||||
|
||||
note_rows = (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)
|
||||
)).all()
|
||||
|
||||
title = project.title or ""
|
||||
description = project.description or ""
|
||||
goal = project.goal or ""
|
||||
note_snippets = "\n".join(
|
||||
f"- {r.title}: {(r.body or '')[:200]}" for r in note_rows
|
||||
)
|
||||
|
||||
prompt = (
|
||||
f"Summarize this project in 3-4 sentences covering its purpose, themes, and content.\n"
|
||||
f"Title: {title}\nDescription: {description}\nGoal: {goal}\n"
|
||||
f"Recent notes:\n{note_snippets}"
|
||||
)
|
||||
|
||||
from fabledassistant.services.llm import generate_completion
|
||||
from fabledassistant.config import Config
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
summary = await generate_completion(messages, model=Config.OLLAMA_MODEL, max_tokens=400)
|
||||
if not summary:
|
||||
return
|
||||
|
||||
async with async_session() as session:
|
||||
project = (await session.execute(
|
||||
select(Project).where(Project.id == project_id)
|
||||
)).scalars().first()
|
||||
if project:
|
||||
project.auto_summary = summary.strip()
|
||||
project.summary_updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
logger.debug("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 missing auto_summary. Fire-and-forget."""
|
||||
import asyncio
|
||||
try:
|
||||
async with async_session() as session:
|
||||
rows = (await session.execute(
|
||||
select(Project.id, Project.user_id).where(Project.auto_summary.is_(None))
|
||||
)).all()
|
||||
for row in rows:
|
||||
asyncio.create_task(generate_project_summary(row.user_id, row.id))
|
||||
except Exception:
|
||||
logger.debug("backfill_project_summaries failed", exc_info=True)
|
||||
|
||||
|
||||
async def delete_project(user_id: int, project_id: int) -> bool:
|
||||
|
||||
Reference in New Issue
Block a user