"""AI-powered troubleshooting session model. Represents a complete FlowPilot interaction from intake to resolution/escalation. This is the central entity of the FlowPilot-First pivot. """ import uuid from datetime import datetime, timezone from typing import Optional, Any, TYPE_CHECKING from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, Integer, Float, CheckConstraint import sqlalchemy as sa from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.dialects.postgresql import UUID, JSONB from app.core.database import Base if TYPE_CHECKING: from app.models.user import User from app.models.team import Team from app.models.account import Account from app.models.tree import Tree from app.models.psa_connection import PsaConnection from app.models.session_branch import SessionBranch from app.models.fork_point import ForkPoint from app.models.session_handoff import SessionHandoff from app.models.session_resolution_output import SessionResolutionOutput class AISession(Base): """A FlowPilot-guided troubleshooting session. Lifecycle: active → resolved | escalated | abandoned Sessions may be paused and resumed (e.g., escalation handoff). """ __tablename__ = "ai_sessions" __table_args__ = ( CheckConstraint( "intake_type IN ('free_text', 'psa_ticket', 'screenshot', 'log_paste', 'combined')", name="ck_ai_sessions_intake_type", ), CheckConstraint( "status IN ('active', 'paused', 'resolved', 'escalated', 'requesting_escalation', 'abandoned')", name="ck_ai_sessions_status", ), CheckConstraint( "confidence_tier IN ('guided', 'exploring', 'discovery')", name="ck_ai_sessions_confidence_tier", ), ) 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, ) team_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("teams.id", ondelete="SET NULL"), nullable=True, index=True, ) # ── Session type ── session_type: Mapped[str] = mapped_column( String(10), nullable=False, default="guided", index=True, comment="Session type: guided (FlowPilot) or chat (assistant)", ) title: Mapped[Optional[str]] = mapped_column( String(255), nullable=True, comment="Display title for chat sessions; guided sessions use problem_summary", ) # ── Intake ── intake_type: Mapped[str] = mapped_column( String(20), nullable=False, default="free_text" ) intake_content: Mapped[dict[str, Any]] = mapped_column( JSONB, nullable=False, default=dict, comment="Original intake data: {text, image_urls, log_content, ticket_data}", ) problem_summary: Mapped[Optional[str]] = mapped_column( Text, nullable=True, comment="AI-generated one-line problem summary from intake", ) problem_domain: Mapped[Optional[str]] = mapped_column( String(100), nullable=True, comment="Classified domain: active_directory, networking, m365, hardware, etc.", ) # ── Session state ── status: Mapped[str] = mapped_column( String(30), nullable=False, default="active", index=True, ) confidence_tier: Mapped[str] = mapped_column( String(20), nullable=False, default="discovery", comment="Current AI confidence: guided (>80%), exploring (40-80%), discovery (<40%)", ) confidence_score: Mapped[float] = mapped_column( Float, nullable=False, default=0.0, comment="Numeric confidence 0.0-1.0 for internal tracking", ) # ── Flow matching ── matched_flow_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("trees.id", ondelete="SET NULL"), nullable=True, comment="If following an existing flow, which one", ) match_score: Mapped[Optional[float]] = mapped_column( Float, nullable=True, comment="Similarity score of the matched flow (0.0-1.0)", ) # ── PSA link ── psa_ticket_id: Mapped[Optional[str]] = mapped_column( String(100), nullable=True, comment="External PSA ticket ID if session was started from a ticket", ) psa_connection_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("psa_connections.id", ondelete="SET NULL"), nullable=True, ) ticket_data: Mapped[Optional[dict[str, Any]]] = mapped_column( JSONB, nullable=True, comment="Snapshot of PSA ticket data at session start", ) # ── Resolution / Escalation ── resolution_summary: Mapped[Optional[str]] = mapped_column( Text, nullable=True, comment="What fixed the issue (set on resolution)", ) resolution_action: Mapped[Optional[str]] = mapped_column( Text, nullable=True, comment="The specific action/step that resolved the issue", ) escalation_reason: Mapped[Optional[str]] = mapped_column( Text, nullable=True, comment="Why escalated (set on escalation)", ) escalation_package: Mapped[Optional[dict[str, Any]]] = mapped_column( JSONB, nullable=True, comment="Context package for receiving engineer: steps_tried, hypotheses, suggestions", ) escalated_to_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, ) # ── Feedback ── session_rating: Mapped[Optional[int]] = mapped_column( Integer, nullable=True, comment="1-5 engineer feedback rating", ) session_feedback: Mapped[Optional[str]] = mapped_column( Text, nullable=True, comment="Optional feedback text from engineer", ) # ── Knowledge Flywheel ── analysis_status: Mapped[Optional[str]] = mapped_column( String(20), nullable=True, comment="Knowledge Flywheel status: null (N/A), pending, completed, failed", ) # ── AI tracking ── total_input_tokens: Mapped[int] = mapped_column( Integer, nullable=False, default=0, ) total_output_tokens: Mapped[int] = mapped_column( Integer, nullable=False, default=0, ) step_count: Mapped[int] = mapped_column( Integer, nullable=False, default=0, ) # ── Timestamps ── 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), ) resolved_at: Mapped[Optional[datetime]] = mapped_column( DateTime(timezone=True), nullable=True, ) # ── LLM conversation context ── system_prompt_snapshot: Mapped[Optional[str]] = mapped_column( Text, nullable=True, comment="Snapshot of the system prompt used (for debugging/training)", ) conversation_messages: Mapped[list[dict[str, Any]]] = mapped_column( JSONB, nullable=False, default=list, comment="Full LLM message history for context continuity", ) pending_task_lane: Mapped[Optional[dict[str, Any]]] = mapped_column( JSONB, nullable=True, comment="Current task lane state: {questions: [...], actions: [...]}", ) # ── Resolution / Escalation artifacts (Phase 1 — FlowPilot migration) ── # Markdown of the posted note + PSA external ID for round-trip traceability. resolution_note_markdown: Mapped[Optional[str]] = mapped_column( Text, nullable=True, comment="Final Resolve note markdown, as posted to the PSA", ) resolution_note_posted_at: Mapped[Optional[datetime]] = mapped_column( DateTime(timezone=True), nullable=True, ) resolution_note_external_id: Mapped[Optional[str]] = mapped_column( String(128), nullable=True, comment="PSA (e.g. CW) ticket-note ID returned at post time", ) escalation_package_markdown: Mapped[Optional[str]] = mapped_column( Text, nullable=True, comment="Final Escalate handoff package markdown, as posted to the PSA", ) escalation_package_posted_at: Mapped[Optional[datetime]] = mapped_column( DateTime(timezone=True), nullable=True, ) escalation_package_external_id: Mapped[Optional[str]] = mapped_column( String(128), nullable=True, comment="PSA ticket-note ID for the escalation package", ) # Incremented atomically by any write that invalidates the resolution # note preview cache (facts, suggested fixes, script generations). # See FLOWPILOT-MIGRATION.md Section 5.5. state_version: Mapped[int] = mapped_column( Integer, nullable=False, default=0, server_default=sa.text("0"), comment="Monotonic preview-cache version; bumped on state-changing writes", ) # ── Branching ── is_branching: Mapped[bool] = mapped_column( default=False, comment="Whether conversational branching is active for this session", ) active_branch_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), nullable=True, comment="Currently viewed branch. No FK — soft pointer to avoid circular FK with session_branches", ) handoff_count: Mapped[int] = mapped_column( Integer, nullable=False, default=0, comment="Number of times this session has been handed off", ) total_active_seconds: Mapped[int] = mapped_column( Integer, nullable=False, default=0, comment="Cumulative active time in seconds", ) total_parked_seconds: Mapped[int] = mapped_column( Integer, nullable=False, default=0, comment="Cumulative parked time in seconds", ) # ── Relationships ── user: Mapped["User"] = relationship("User", foreign_keys=[user_id]) account: Mapped["Account"] = relationship("Account") team: Mapped[Optional["Team"]] = relationship("Team") matched_flow: Mapped[Optional["Tree"]] = relationship("Tree", foreign_keys=[matched_flow_id]) escalated_to: Mapped[Optional["User"]] = relationship("User", foreign_keys=[escalated_to_id]) psa_connection: Mapped[Optional["PsaConnection"]] = relationship("PsaConnection") steps: Mapped[list["AISessionStep"]] = relationship( "AISessionStep", back_populates="session", cascade="all, delete-orphan", order_by="AISessionStep.step_order", ) branches: Mapped[list["SessionBranch"]] = relationship( "SessionBranch", foreign_keys="SessionBranch.session_id", back_populates="session", cascade="all, delete-orphan", order_by="SessionBranch.branch_order", ) handoffs: Mapped[list["SessionHandoff"]] = relationship( "SessionHandoff", back_populates="session", cascade="all, delete-orphan", order_by="SessionHandoff.created_at", ) resolution_outputs: Mapped[list["SessionResolutionOutput"]] = relationship( "SessionResolutionOutput", back_populates="session", cascade="all, delete-orphan", )