feat: add AI assistant with in-session copilot and standalone chat with RAG

Implements three-phase AI assistant feature:
- Phase 0: RAG infrastructure with pgvector embeddings, Voyage AI integration,
  tree chunking service, and semantic search over team's flow library
- Phase 1: In-session copilot panel during flow navigation with contextual
  AI help, current step awareness, and suggested related flows
- Phase 2: Standalone AI chat page with persistent conversation history,
  pin/delete, and configurable retention policies (account-level)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-03-04 01:36:36 -05:00
parent 41cb7956cb
commit 1aa60dada2
44 changed files with 3080 additions and 14 deletions

View File

@@ -29,6 +29,9 @@ from .feedback import Feedback
from .ai_conversation import AIConversation
from .ai_usage import AIUsage
from .ai_chat_session import AIChatSession
from .tree_embedding import TreeEmbedding
from .copilot_conversation import CopilotConversation
from .assistant_chat import AssistantChat
__all__ = [
"User",
@@ -69,4 +72,7 @@ __all__ = [
"AIConversation",
"AIUsage",
"AIChatSession",
"TreeEmbedding",
"CopilotConversation",
"AssistantChat",
]

View File

@@ -1,7 +1,7 @@
import uuid
from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, DateTime, ForeignKey, Boolean
from sqlalchemy import String, DateTime, ForeignKey, Boolean, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
@@ -35,6 +35,14 @@ class Account(Base):
comment="Policy: engineers can create public shares. Only affects NEW shares (grandfathered)."
)
# Chat retention settings
chat_retention_days: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True, default=90, server_default="90"
)
chat_retention_max_count: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True, default=100, server_default="100"
)
# Relationships
owner: Mapped["User"] = relationship("User", foreign_keys=[owner_id], back_populates="owned_account")
users: Mapped[list["User"]] = relationship("User", foreign_keys="[User.account_id]", back_populates="account")

View File

@@ -0,0 +1,59 @@
"""Standalone AI assistant chat model.
Persistent conversation history for general IT questions with RAG context.
"""
import uuid
from datetime import datetime, timezone
from typing import Optional, Any
from sqlalchemy import String, DateTime, ForeignKey, Integer, Boolean
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
class AssistantChat(Base):
__tablename__ = "assistant_chats"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
title: Mapped[str] = mapped_column(
String(255), nullable=False, default="New Chat"
)
messages: Mapped[list[dict[str, Any]]] = mapped_column(
JSONB, nullable=False, default=list
)
message_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0
)
total_input_tokens: Mapped[int] = mapped_column(
Integer, nullable=False, default=0
)
total_output_tokens: Mapped[int] = mapped_column(
Integer, nullable=False, default=0
)
pinned: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)

View File

@@ -0,0 +1,69 @@
"""Copilot conversation model for in-session AI assistant.
Tracks conversation state during flow navigation with contextual AI help.
"""
import uuid
from datetime import datetime, timezone
from typing import Optional, Any
from sqlalchemy import String, DateTime, ForeignKey, Integer
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
class CopilotConversation(Base):
__tablename__ = "copilot_conversations"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("sessions.id", ondelete="SET NULL"),
nullable=True,
)
tree_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("trees.id", ondelete="CASCADE"),
nullable=False,
)
messages: Mapped[list[dict[str, Any]]] = mapped_column(
JSONB, nullable=False, default=list
)
current_node_id: Mapped[Optional[str]] = mapped_column(
String(100), nullable=True
)
message_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0
)
total_input_tokens: Mapped[int] = mapped_column(
Integer, nullable=False, default=0
)
total_output_tokens: Mapped[int] = mapped_column(
Integer, nullable=False, default=0
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)

View File

@@ -0,0 +1,72 @@
"""Tree embedding storage for RAG-powered AI assistant.
Stores vector embeddings of tree content chunks for semantic search.
Each tree is split into multiple chunks (node, solution, tree_summary)
and embedded via Voyage AI for cosine similarity retrieval.
"""
import uuid
from datetime import datetime, timezone
from typing import Optional, Any
from sqlalchemy import String, Text, DateTime, ForeignKey, Index
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
# pgvector column type — imported at runtime to avoid import errors
# when pgvector is not installed locally
try:
from pgvector.sqlalchemy import Vector
except ImportError:
Vector = None
class TreeEmbedding(Base):
__tablename__ = "tree_embeddings"
__table_args__ = (
Index("ix_tree_embeddings_account_id", "account_id"),
Index("ix_tree_embeddings_tree_id", "tree_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tree_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("trees.id", ondelete="CASCADE"),
nullable=False,
)
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=True,
)
chunk_type: Mapped[str] = mapped_column(
String(30),
nullable=False,
comment="node | solution | tree_summary",
)
node_type: Mapped[Optional[str]] = mapped_column(
String(30), nullable=True
)
node_id: Mapped[Optional[str]] = mapped_column(
String(100), nullable=True
)
chunk_text: Mapped[str] = mapped_column(Text, nullable=False)
embedding_model: Mapped[str] = mapped_column(
String(50), nullable=False, default="voyage-3.5"
)
# The embedding column is created via migration with vector(1024) type
# We store it as a generic column here and handle it in queries
meta: Mapped[dict[str, Any]] = mapped_column(
JSONB, nullable=False, default=dict
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)