diff --git a/backend/alembic/versions/e2d81e82ea5e_add_ai_chat_sessions_table.py b/backend/alembic/versions/e2d81e82ea5e_add_ai_chat_sessions_table.py new file mode 100644 index 00000000..42d2264c --- /dev/null +++ b/backend/alembic/versions/e2d81e82ea5e_add_ai_chat_sessions_table.py @@ -0,0 +1,46 @@ +"""add ai_chat_sessions table + +Revision ID: e2d81e82ea5e +Revises: 1490781700bc +Create Date: 2026-02-27 03:41:33.832260 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID, JSONB + + +# revision identifiers, used by Alembic. +revision: str = 'e2d81e82ea5e' +down_revision: Union[str, None] = '1490781700bc' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "ai_chat_sessions", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("account_id", UUID(as_uuid=True), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("status", sa.String(20), nullable=False, server_default="active"), + sa.Column("current_phase", sa.String(20), nullable=False, server_default="scoping"), + sa.Column("flow_type", sa.String(20), nullable=False), + sa.Column("conversation_history", JSONB, nullable=False, server_default="[]"), + sa.Column("working_tree", JSONB, nullable=True), + sa.Column("tree_metadata", JSONB, nullable=False, server_default="{}"), + sa.Column("provider_used", sa.String(20), nullable=True), + sa.Column("message_count", sa.Integer, nullable=False, server_default="0"), + sa.Column("total_input_tokens", sa.Integer, nullable=False, server_default="0"), + sa.Column("total_output_tokens", sa.Integer, nullable=False, server_default="0"), + sa.Column("generated_tree_id", UUID(as_uuid=True), sa.ForeignKey("trees.id", ondelete="SET NULL"), nullable=True), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + +def downgrade() -> None: + op.drop_table("ai_chat_sessions") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 3731740b..3748d9c8 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -28,6 +28,7 @@ from .maintenance_schedule import MaintenanceSchedule from .feedback import Feedback from .ai_conversation import AIConversation from .ai_usage import AIUsage +from .ai_chat_session import AIChatSession __all__ = [ "User", @@ -67,4 +68,5 @@ __all__ = [ "Feedback", "AIConversation", "AIUsage", + "AIChatSession", ] diff --git a/backend/app/models/ai_chat_session.py b/backend/app/models/ai_chat_session.py new file mode 100644 index 00000000..8fbde1c6 --- /dev/null +++ b/backend/app/models/ai_chat_session.py @@ -0,0 +1,88 @@ +"""AI Chat Builder session tracking. + +Stores conversational flow builder state across the multi-phase interview. +Sessions expire after 24 hours. +""" +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 AIChatSession(Base): + __tablename__ = "ai_chat_sessions" + + 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, + ) + status: Mapped[str] = mapped_column( + String(20), + nullable=False, + default="active", + comment="active | completed | abandoned", + ) + current_phase: Mapped[str] = mapped_column( + String(20), + nullable=False, + default="scoping", + comment="scoping | discovery | enrichment | review | generation", + ) + flow_type: Mapped[str] = mapped_column( + String(20), + nullable=False, + comment="troubleshooting | procedural", + ) + conversation_history: Mapped[list[dict[str, Any]]] = mapped_column( + JSONB, nullable=False, default=list + ) + working_tree: Mapped[Optional[dict[str, Any]]] = mapped_column( + JSONB, nullable=True + ) + tree_metadata: Mapped[dict[str, Any]] = mapped_column( + JSONB, nullable=False, default=dict + ) + provider_used: Mapped[Optional[str]] = mapped_column( + String(20), 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 + ) + generated_tree_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("trees.id", ondelete="SET NULL"), + nullable=True, + ) + expires_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=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), + )