Files
FabledGrimoire/src/fabledassistant/models/conversation.py
T
bvandeusenandClaude Sonnet 4.6 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

95 lines
3.7 KiB
Python

import datetime
from sqlalchemy import Date, ForeignKey, Index, Integer, Text
from sqlalchemy import inspect
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from fabledassistant.models import Base
from fabledassistant.models.base import CreatedAtMixin, TimestampMixin
class Conversation(Base, TimestampMixin):
__tablename__ = "conversations"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True
)
title: Mapped[str] = mapped_column(Text, default="")
model: Mapped[str] = mapped_column(Text, default="")
# 'chat' (default) or 'briefing'. Briefing conversations are hidden from
# the main chat view and managed by the briefing pipeline.
conversation_type: Mapped[str] = mapped_column(Text, default="chat", server_default="chat")
# For briefing conversations only: the calendar date this briefing covers.
briefing_date: Mapped[datetime.date | None] = mapped_column(Date, nullable=True)
# NULL = orphan notes only; -1 = all notes; positive int = specific project
rag_project_id: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
messages: Mapped[list["Message"]] = relationship(
back_populates="conversation",
cascade="all, delete-orphan",
order_by="Message.created_at",
)
__table_args__ = (
Index("ix_conversations_updated_at", "updated_at"),
Index("ix_conversations_user_id", "user_id"),
)
def to_dict(self) -> dict:
# Use loaded messages if available, otherwise 0
if "messages" in inspect(self).dict:
msg_count = len(self.messages) if self.messages else 0
else:
msg_count = 0
return {
"id": self.id,
"title": self.title,
"model": self.model,
"conversation_type": self.conversation_type,
"briefing_date": self.briefing_date.isoformat() if self.briefing_date else None,
"rag_project_id": self.rag_project_id,
"message_count": msg_count,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
class Message(Base, CreatedAtMixin):
__tablename__ = "messages"
id: Mapped[int] = mapped_column(primary_key=True)
conversation_id: Mapped[int] = mapped_column(
Integer, ForeignKey("conversations.id", ondelete="CASCADE")
)
role: Mapped[str] = mapped_column(Text)
content: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(Text, default="complete")
context_note_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
)
tool_calls: Mapped[list | None] = mapped_column(JSONB, nullable=True)
# 'metadata' is reserved by SQLAlchemy Declarative — use msg_metadata as the
# Python attribute name, mapped to the 'metadata' DB column.
msg_metadata: Mapped[dict | None] = mapped_column("metadata", JSONB, nullable=True)
conversation: Mapped["Conversation"] = relationship(back_populates="messages")
__table_args__ = (
Index("ix_messages_conversation_id", "conversation_id"),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"conversation_id": self.conversation_id,
"role": self.role,
"content": self.content,
"status": self.status,
"context_note_id": self.context_note_id,
"tool_calls": self.tool_calls,
"metadata": self.msg_metadata,
"created_at": self.created_at.isoformat(),
}