feat(l1): l1_session_service resolve / escalate / escalate_without_walk
resolve: sets status=resolved, helpful, resolution_notes, resolved_at; flips FlowProposal.validated_by_outcome on helpful=True proposal walks; closes linked internal ticket. PSA close is a Phase 2 stub. escalate: marks session + internal ticket as escalated. PSA reassign deferred to Phase 2. escalate_without_walk: creates an immediately-escalated adhoc session with no walked_path, used by the BuildAbortedNoKB → Escalate path. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -9,8 +9,10 @@ from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.flow_proposal import FlowProposal
|
||||
from app.models.l1_walk_session import L1WalkSession
|
||||
from app.models.user import User
|
||||
from app.services import internal_ticket_service
|
||||
|
||||
|
||||
def _resolve_acting_as(user: User) -> Optional[str]:
|
||||
@@ -149,3 +151,126 @@ async def update_notes(
|
||||
session.last_step_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
return session
|
||||
|
||||
|
||||
async def resolve(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
session_id: UUID,
|
||||
helpful: bool,
|
||||
resolution_notes: str,
|
||||
) -> L1WalkSession:
|
||||
"""Close a session as resolved.
|
||||
|
||||
- Sets status='resolved', helpful, resolution_notes, resolved_at.
|
||||
- On helpful=True AND session_kind='proposal': flips
|
||||
flow_proposal.validated_by_outcome=True (one-bit aggregate signal).
|
||||
- Closes the linked internal ticket (PSA close stubbed for Phase 2).
|
||||
- Raises ValueError on missing or non-active session.
|
||||
"""
|
||||
session = await db.get(L1WalkSession, session_id)
|
||||
if not session:
|
||||
raise ValueError(f"L1WalkSession {session_id} not found")
|
||||
if session.status != "active":
|
||||
raise ValueError(f"Session not active (status={session.status})")
|
||||
now = datetime.now(timezone.utc)
|
||||
session.status = "resolved"
|
||||
session.helpful = helpful
|
||||
session.resolution_notes = resolution_notes
|
||||
session.resolved_at = now
|
||||
session.last_step_at = now
|
||||
|
||||
if helpful and session.session_kind == "proposal" and session.flow_proposal_id:
|
||||
proposal = await db.get(FlowProposal, session.flow_proposal_id)
|
||||
if proposal:
|
||||
proposal.validated_by_outcome = True
|
||||
|
||||
if session.ticket_kind == "internal":
|
||||
await internal_ticket_service.update_status(
|
||||
db,
|
||||
ticket_id=UUID(session.ticket_id),
|
||||
status="resolved",
|
||||
resolution_notes=resolution_notes,
|
||||
)
|
||||
# PSA close deferred to Phase 2 — no-op for now
|
||||
|
||||
await db.flush()
|
||||
return session
|
||||
|
||||
|
||||
async def escalate(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
session_id: UUID,
|
||||
reason: str,
|
||||
reason_category: str,
|
||||
) -> L1WalkSession:
|
||||
"""Escalate an active session to engineering.
|
||||
|
||||
- Sets status='escalated', escalation_reason, escalation_reason_category, resolved_at.
|
||||
- Marks the linked internal ticket as escalated (PSA reassign deferred to Phase 2).
|
||||
- Raises ValueError on missing or non-active session.
|
||||
"""
|
||||
session = await db.get(L1WalkSession, session_id)
|
||||
if not session:
|
||||
raise ValueError(f"L1WalkSession {session_id} not found")
|
||||
if session.status != "active":
|
||||
raise ValueError(f"Session not active (status={session.status})")
|
||||
now = datetime.now(timezone.utc)
|
||||
session.status = "escalated"
|
||||
session.escalation_reason = reason
|
||||
session.escalation_reason_category = reason_category
|
||||
session.resolved_at = now
|
||||
session.last_step_at = now
|
||||
|
||||
if session.ticket_kind == "internal":
|
||||
await internal_ticket_service.update_status(
|
||||
db,
|
||||
ticket_id=UUID(session.ticket_id),
|
||||
status="escalated",
|
||||
)
|
||||
# PSA reassign deferred to Phase 2
|
||||
|
||||
await db.flush()
|
||||
return session
|
||||
|
||||
|
||||
async def escalate_without_walk(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
account_id: UUID,
|
||||
user: User,
|
||||
ticket_id: str,
|
||||
ticket_kind: str,
|
||||
reason_category: str,
|
||||
reason: Optional[str] = None,
|
||||
) -> L1WalkSession:
|
||||
"""Create an immediately-escalated session with no walked_path.
|
||||
|
||||
Used from the BuildAbortedNoKB screen (no KB content available to walk a
|
||||
tree). Captures the call as an audit record + escalates the ticket without
|
||||
requiring a walker session in between.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
session = L1WalkSession(
|
||||
account_id=account_id,
|
||||
created_by_user_id=user.id,
|
||||
acting_as=_resolve_acting_as(user),
|
||||
ticket_id=ticket_id,
|
||||
ticket_kind=ticket_kind,
|
||||
session_kind="adhoc",
|
||||
status="escalated",
|
||||
escalation_reason=reason,
|
||||
escalation_reason_category=reason_category,
|
||||
resolved_at=now,
|
||||
last_step_at=now,
|
||||
)
|
||||
db.add(session)
|
||||
if ticket_kind == "internal":
|
||||
await internal_ticket_service.update_status(
|
||||
db,
|
||||
ticket_id=UUID(ticket_id),
|
||||
status="escalated",
|
||||
)
|
||||
await db.flush()
|
||||
return session
|
||||
|
||||
Reference in New Issue
Block a user