Teaches l1_walk_sessions a new session_kind='ai_build' for AI-generated decision-tree walks. FK shape matches adhoc: both flow_id and flow_proposal_id must be NULL. Drops and recreates the two affected CHECK constraints (session_kind allowlist + target_consistency). Migration beca7464b6b4 chains from b3358ba0e48c. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
143 lines
5.4 KiB
Python
143 lines
5.4 KiB
Python
"""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
|
|
|
|
import sqlalchemy as sa
|
|
from sqlalchemy import String, Text, DateTime, Boolean, ForeignKey, CheckConstraint
|
|
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",
|
|
),
|
|
)
|
|
|
|
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)
|
|
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)
|
|
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")
|
|
flow_proposal: Mapped[Optional["FlowProposal"]] = relationship("FlowProposal")
|