Server-assigns a uuid4 id to every AI-generated node (Finding 1 showstopper:
nodes had no id but the advance protocol keys on node_id, so ai_build walks
never advanced past question 1). Replaces the hidden {"node_type":"meta"}
walked_path convention with real category/problem_text/pending_node columns on
l1_walk_sessions (migration 61dda4f615c6) — fixes junk proposals + off-by-one
depth cap (Findings 8,9), and pending_node replays the served node on re-mount
(no duplicate paid LLM call). Intake honors explicit flow_id and adhoc=True
(Findings 4,5); flow_proposals.l1_session_id FK -> CASCADE (Finding 6 time
bomb); L1 category GET is owner+admin like PATCH and require_account_owner_or_admin
delegates to User.can_manage_account (Finding 7); escalate falls back to default
recipients + filters deleted_at + warns when empty (Finding 10). Cleanups: dead
ticket_ref removed, IntakeResponse per-outcome validator, unused acknowledged
dropped, escalations partial index, restored a deleted audit assertion.
Full Phase 2A backend set: 110 passed / 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
167 lines
6.9 KiB
Python
167 lines
6.9 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
|
|
|
|
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]"
|
|
)
|