"""L1 walk session model. Per-session state for an L1 technician walking a ticket through a flow, flow proposal, or ad-hoc investigation. Tracks the walked path, notes captured at each step, and terminal resolution / escalation metadata. """ import uuid from datetime import datetime, timezone from typing import Any, Optional, TYPE_CHECKING from sqlalchemy import String, Text, DateTime, Boolean, ForeignKey, CheckConstraint, Index from sqlalchemy import text as sa_text 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.account import Account from app.models.user import User from app.models.tree import Tree from app.models.flow_proposal import FlowProposal class L1WalkSession(Base): """A single L1 technician session walking a ticket. session_kind values: - flow: Walking a published flow (flow_id required, flow_proposal_id null). - proposal: Walking a draft flow proposal (flow_proposal_id required, flow_id null). - adhoc: Free-form investigation (both flow_id and flow_proposal_id null). - ai_build: AI-generated decision-tree walk (both flow_id and flow_proposal_id null). status lifecycle: - active: Session is in progress. - resolved: Issue resolved; resolution_notes captured. - escalated: Could not resolve; escalation_reason captured. - abandoned: Session exited without resolution or explicit escalation. """ __tablename__ = "l1_walk_sessions" __table_args__ = ( CheckConstraint( "ticket_kind IN ('psa', 'internal')", name="ck_l1_walk_sessions_ticket_kind", ), CheckConstraint( "session_kind IN ('flow', 'proposal', 'adhoc', 'ai_build')", name="ck_l1_walk_sessions_session_kind", ), CheckConstraint( "status IN ('active', 'resolved', 'escalated', 'abandoned')", name="ck_l1_walk_sessions_status", ), CheckConstraint( "(session_kind = 'flow' AND flow_id IS NOT NULL AND flow_proposal_id IS NULL) " "OR (session_kind = 'proposal' AND flow_proposal_id IS NOT NULL AND flow_id IS NULL) " "OR (session_kind IN ('adhoc', 'ai_build') AND flow_id IS NULL AND flow_proposal_id IS NULL)", name="ck_l1_walk_sessions_target_consistency", ), # Partial index backing GET /l1/escalations (the engineer handoff queue). Index( "ix_l1_walk_sessions_escalated", "account_id", sa_text("last_step_at DESC"), postgresql_where=sa_text("status = 'escalated'"), ), ) id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) account_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True, ) created_by_user_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True, ) # ── Actor context ── acting_as: Mapped[Optional[str]] = mapped_column(String(30), nullable=True) # ── Ticket reference ── ticket_id: Mapped[str] = mapped_column(String(64), nullable=False) ticket_kind: Mapped[str] = mapped_column(String(10), nullable=False) # ── Session kind + target ── session_kind: Mapped[str] = mapped_column(String(20), nullable=False) # AI-build context (ai_build sessions only). Persisted at intake so /next-node # never has to re-fetch the ticket or scan walked_path to recover them — they # are immutable for the life of the session. Replaces the former hidden # ``{"node_type":"meta"}`` walked_path entry (deleted: it leaked into every # consumer that forgot to skip it — junk proposals, off-by-one depth cap, # blank escalation rows). category: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) problem_text: Mapped[Optional[str]] = mapped_column(Text(), nullable=True) flow_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("trees.id", ondelete="SET NULL"), nullable=True, ) flow_proposal_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("flow_proposals.id", ondelete="SET NULL"), nullable=True, ) # ── Navigation state ── current_node_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) # The node served to the tech but not yet answered (ai_build only). Replayed on # the next /next-node call with node_id=None so a refresh / StrictMode double-mount # doesn't fire a fresh paid LLM call (and possibly swap the question mid-answer). pending_node: Mapped[Optional[dict[str, Any]]] = mapped_column( JSONB(), nullable=True, ) walked_path: Mapped[list[dict[str, Any]]] = mapped_column( JSONB(), nullable=False, server_default=sa_text("'[]'::jsonb"), ) walk_notes: Mapped[list[dict[str, Any]]] = mapped_column( JSONB(), nullable=False, server_default=sa_text("'[]'::jsonb"), ) # ── Lifecycle ── status: Mapped[str] = mapped_column( String(20), nullable=False, server_default=sa_text("'active'"), index=True, ) # ── Resolution ── resolution_notes: Mapped[Optional[str]] = mapped_column(Text(), nullable=True) helpful: Mapped[Optional[bool]] = mapped_column(Boolean(), nullable=True) # ── Escalation ── escalation_reason: Mapped[Optional[str]] = mapped_column(Text(), nullable=True) escalation_reason_category: Mapped[Optional[str]] = mapped_column( String(30), nullable=True, ) # ── Timestamps ── started_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) ) last_step_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), index=True, ) resolved_at: Mapped[Optional[datetime]] = mapped_column( DateTime(timezone=True), nullable=True, ) # ── Relationships ── account: Mapped["Account"] = relationship("Account") created_by: Mapped["User"] = relationship("User", foreign_keys=[created_by_user_id]) flow: Mapped[Optional["Tree"]] = relationship("Tree") # Two FK paths exist between L1WalkSession and FlowProposal # (L1WalkSession.flow_proposal_id here, FlowProposal.l1_session_id there), # so each relationship must name its foreign_keys explicitly. flow_proposal: Mapped[Optional["FlowProposal"]] = relationship( "FlowProposal", foreign_keys="[L1WalkSession.flow_proposal_id]" )