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>
398 lines
14 KiB
Python
398 lines
14 KiB
Python
"""L1 Workspace endpoints (Phase 1).
|
|
|
|
PSA-merge queue support + AI build path are deferred to Phase 2.
|
|
"""
|
|
from typing import Annotated, Optional
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status as http_status
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.deps import get_db, require_engineer_or_admin, require_l1_or_coverage
|
|
from app.models.l1_walk_session import L1WalkSession
|
|
from app.models.user import User
|
|
from app.schemas.l1 import (
|
|
EscalateRequest,
|
|
EscalateWithoutWalkRequest,
|
|
IntakeRequest,
|
|
IntakeResponse,
|
|
NextNodeRequest,
|
|
NextNodeResponse,
|
|
NotesRequest,
|
|
QueueRow,
|
|
ResolveRequest,
|
|
StepRequest,
|
|
WalkSessionResponse,
|
|
)
|
|
from app.services import internal_ticket_service, l1_session_service, match_or_build
|
|
|
|
|
|
router = APIRouter(prefix="/l1", tags=["l1"])
|
|
|
|
|
|
def _to_response(session: L1WalkSession) -> WalkSessionResponse:
|
|
return WalkSessionResponse(
|
|
id=session.id,
|
|
session_kind=session.session_kind,
|
|
category=session.category,
|
|
problem_text=session.problem_text,
|
|
flow_id=session.flow_id,
|
|
flow_proposal_id=session.flow_proposal_id,
|
|
current_node_id=session.current_node_id,
|
|
walked_path=session.walked_path or [],
|
|
walk_notes=session.walk_notes or [],
|
|
status=session.status,
|
|
started_at=session.started_at,
|
|
last_step_at=session.last_step_at,
|
|
resolved_at=session.resolved_at,
|
|
)
|
|
|
|
|
|
async def _get_session_or_404(
|
|
db: AsyncSession, session_id: UUID, user: User
|
|
) -> L1WalkSession:
|
|
"""Fetch a session by id, scoped to the caller's account.
|
|
|
|
Phase 1 policy (per spec §7.9): sessions are account-scoped, not
|
|
user-scoped. Any L1 or coverage engineer in the same account can
|
|
step/note/resolve/escalate any session — supports team coverage
|
|
(e.g., L1 hands off mid-shift; coverage engineer takes over a call).
|
|
For a stricter "creator-only" policy, add
|
|
``created_by_user_id == user.id`` here.
|
|
"""
|
|
session = await db.get(L1WalkSession, session_id)
|
|
if session is None or session.account_id != user.account_id:
|
|
raise HTTPException(
|
|
status_code=http_status.HTTP_404_NOT_FOUND,
|
|
detail="Session not found",
|
|
)
|
|
return session
|
|
|
|
|
|
async def _create_intake_ticket(db: AsyncSession, payload: IntakeRequest, user: User):
|
|
return await internal_ticket_service.create_ticket(
|
|
db,
|
|
account_id=user.account_id,
|
|
created_by_user_id=user.id,
|
|
problem_statement=payload.problem_statement,
|
|
customer_name=payload.customer_name,
|
|
customer_contact=payload.customer_contact,
|
|
)
|
|
|
|
|
|
@router.post("/intake", response_model=IntakeResponse)
|
|
async def intake(
|
|
payload: IntakeRequest,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
user: Annotated[User, Depends(require_l1_or_coverage)],
|
|
):
|
|
"""L1 intake (Phase 2A): match a published flow, else gate + build.
|
|
|
|
Two explicit shortcuts run before the matcher (the client already knows what
|
|
it wants, so re-running the embedding + pgvector + keyword pipeline would be
|
|
wasteful and — for flow_id — can't reliably re-derive the same flow):
|
|
- flow_id set → start that published flow directly (suggest card's "Use this flow").
|
|
- adhoc=True → start a free-form ad-hoc walk (out_of_scope prompt's fallback).
|
|
|
|
Otherwise match_or_build dispatches:
|
|
- matched → create ticket + flow session, walk the published flow.
|
|
- build → create ticket + ai_build session (category + problem_text stored
|
|
on the session for /next-node), walk an AI-built tree.
|
|
- suggest → near-miss prompt; no session created.
|
|
- out_of_scope → category disabled/unknown; no session created.
|
|
"""
|
|
# Explicit flow_id: bypass the matcher, walk the flow the client already holds.
|
|
if payload.flow_id is not None:
|
|
ticket = await _create_intake_ticket(db, payload, user)
|
|
session = await l1_session_service.start_flow_session(
|
|
db, account_id=user.account_id, user=user, flow_id=payload.flow_id,
|
|
ticket_id=str(ticket.id), ticket_kind="internal",
|
|
)
|
|
await db.commit()
|
|
return IntakeResponse(
|
|
outcome="matched", session_id=session.id, session_kind=session.session_kind,
|
|
ticket_id=str(ticket.id), ticket_kind="internal", flow_id=payload.flow_id,
|
|
)
|
|
|
|
# Explicit ad-hoc walk: the out_of_scope fallback ("Walk it ad-hoc").
|
|
if payload.adhoc:
|
|
ticket = await _create_intake_ticket(db, payload, user)
|
|
session = await l1_session_service.start_adhoc_session(
|
|
db, account_id=user.account_id, user=user,
|
|
ticket_id=str(ticket.id), ticket_kind="internal",
|
|
)
|
|
await db.commit()
|
|
return IntakeResponse(
|
|
outcome="adhoc", session_id=session.id, session_kind=session.session_kind,
|
|
ticket_id=str(ticket.id), ticket_kind="internal",
|
|
)
|
|
|
|
result = await match_or_build.match_or_build(
|
|
user.account_id,
|
|
payload.problem_statement,
|
|
None,
|
|
db=db,
|
|
force_build=payload.force_build,
|
|
)
|
|
outcome = result["outcome"]
|
|
|
|
if outcome in ("suggest", "out_of_scope"):
|
|
await db.commit()
|
|
return IntakeResponse(
|
|
outcome=outcome,
|
|
near_miss=result.get("near_miss"),
|
|
category=result.get("category"),
|
|
)
|
|
|
|
# matched OR build → create a ticket and a session
|
|
ticket = await _create_intake_ticket(db, payload, user)
|
|
if outcome == "matched":
|
|
session = await l1_session_service.start_flow_session(
|
|
db,
|
|
account_id=user.account_id,
|
|
user=user,
|
|
flow_id=UUID(result["flow_id"]),
|
|
ticket_id=str(ticket.id),
|
|
ticket_kind="internal",
|
|
)
|
|
else: # build
|
|
session = await l1_session_service.start_ai_build_session(
|
|
db,
|
|
account_id=user.account_id,
|
|
user=user,
|
|
ticket_id=str(ticket.id),
|
|
ticket_kind="internal",
|
|
category=result.get("category", "unknown"),
|
|
problem_text=payload.problem_statement,
|
|
)
|
|
|
|
await db.commit()
|
|
return IntakeResponse(
|
|
outcome=outcome,
|
|
session_id=session.id,
|
|
session_kind=session.session_kind,
|
|
ticket_id=str(ticket.id),
|
|
ticket_kind="internal",
|
|
flow_id=UUID(result["flow_id"]) if outcome == "matched" else None,
|
|
)
|
|
|
|
|
|
@router.get("/queue", response_model=list[QueueRow])
|
|
async def queue(
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
user: Annotated[User, Depends(require_l1_or_coverage)],
|
|
status_filter: Optional[str] = None,
|
|
limit: int = 50,
|
|
):
|
|
"""Phase 1 queue: internal tickets only. PSA-fed rows in Phase 2."""
|
|
tickets = await internal_ticket_service.list_tickets_for_account(
|
|
db,
|
|
account_id=user.account_id,
|
|
status=status_filter,
|
|
limit=limit,
|
|
)
|
|
return [
|
|
QueueRow(
|
|
ticket_id=str(t.id),
|
|
ticket_kind="internal",
|
|
problem_statement=t.problem_statement,
|
|
customer_name=t.customer_name,
|
|
status=t.status,
|
|
created_at=t.created_at,
|
|
)
|
|
for t in tickets
|
|
]
|
|
|
|
|
|
@router.get("/sessions/active", response_model=list[WalkSessionResponse])
|
|
async def list_active_sessions(
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
user: Annotated[User, Depends(require_l1_or_coverage)],
|
|
):
|
|
"""The caller's currently-active sessions (for the dashboard 'Resume in progress' widget)."""
|
|
stmt = (
|
|
select(L1WalkSession)
|
|
.where(L1WalkSession.created_by_user_id == user.id)
|
|
.where(L1WalkSession.status == "active")
|
|
.order_by(L1WalkSession.last_step_at.desc())
|
|
.limit(20)
|
|
)
|
|
result = await db.execute(stmt)
|
|
return [_to_response(s) for s in result.scalars()]
|
|
|
|
|
|
@router.get("/sessions/{session_id}", response_model=WalkSessionResponse)
|
|
async def get_session(
|
|
session_id: UUID,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
user: Annotated[User, Depends(require_l1_or_coverage)],
|
|
):
|
|
session = await _get_session_or_404(db, session_id, user)
|
|
return _to_response(session)
|
|
|
|
|
|
@router.post("/sessions/{session_id}/step", response_model=WalkSessionResponse)
|
|
async def post_step(
|
|
session_id: UUID,
|
|
payload: StepRequest,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
user: Annotated[User, Depends(require_l1_or_coverage)],
|
|
):
|
|
await _get_session_or_404(db, session_id, user)
|
|
try:
|
|
updated = await l1_session_service.record_step(
|
|
db,
|
|
session_id=session_id,
|
|
node_id=payload.node_id,
|
|
question=payload.question,
|
|
answer=payload.answer,
|
|
note=payload.note,
|
|
)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=http_status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
|
await db.commit()
|
|
return _to_response(updated)
|
|
|
|
|
|
@router.post("/sessions/{session_id}/notes", response_model=WalkSessionResponse)
|
|
async def post_notes(
|
|
session_id: UUID,
|
|
payload: NotesRequest,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
user: Annotated[User, Depends(require_l1_or_coverage)],
|
|
):
|
|
await _get_session_or_404(db, session_id, user)
|
|
try:
|
|
updated = await l1_session_service.update_notes(
|
|
db,
|
|
session_id=session_id,
|
|
notes=payload.notes,
|
|
)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=http_status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
|
await db.commit()
|
|
return _to_response(updated)
|
|
|
|
|
|
@router.post("/sessions/{session_id}/resolve", response_model=WalkSessionResponse)
|
|
async def post_resolve(
|
|
session_id: UUID,
|
|
payload: ResolveRequest,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
user: Annotated[User, Depends(require_l1_or_coverage)],
|
|
):
|
|
await _get_session_or_404(db, session_id, user)
|
|
try:
|
|
updated = await l1_session_service.resolve(
|
|
db,
|
|
session_id=session_id,
|
|
helpful=payload.helpful,
|
|
resolution_notes=payload.resolution_notes,
|
|
)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=http_status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
|
await db.commit()
|
|
return _to_response(updated)
|
|
|
|
|
|
@router.post("/sessions/{session_id}/escalate", response_model=WalkSessionResponse)
|
|
async def post_escalate(
|
|
session_id: UUID,
|
|
payload: EscalateRequest,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
user: Annotated[User, Depends(require_l1_or_coverage)],
|
|
):
|
|
await _get_session_or_404(db, session_id, user)
|
|
try:
|
|
updated = await l1_session_service.escalate(
|
|
db,
|
|
session_id=session_id,
|
|
reason=payload.reason or "",
|
|
reason_category=payload.reason_category,
|
|
)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=http_status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
|
await db.commit()
|
|
return _to_response(updated)
|
|
|
|
|
|
@router.post("/sessions/{session_id}/next-node", response_model=NextNodeResponse)
|
|
async def next_node(
|
|
session_id: UUID,
|
|
payload: NextNodeRequest,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
user: Annotated[User, Depends(require_l1_or_coverage)],
|
|
):
|
|
"""Record the answer/ack on the current node, then generate the next node.
|
|
|
|
problem_text + category are read straight off the session (stored at intake) —
|
|
no ticket re-fetch, no walked_path scan. node_text is the rendered text of the
|
|
node being answered (the client holds it) so the walked path and the captured
|
|
tree stay legible.
|
|
"""
|
|
session = await _get_session_or_404(db, session_id, user)
|
|
try:
|
|
node = await l1_session_service.advance_ai_build(
|
|
db,
|
|
session_id=session_id,
|
|
problem_text=session.problem_text or "",
|
|
category=session.category or "unknown",
|
|
node_id=payload.node_id,
|
|
node_text=payload.node_text,
|
|
answer=payload.answer,
|
|
note=payload.note,
|
|
)
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=http_status.HTTP_409_CONFLICT, detail=str(exc)
|
|
)
|
|
await db.commit()
|
|
return NextNodeResponse(node=node, session_status=session.status)
|
|
|
|
|
|
@router.get("/escalations", response_model=list[WalkSessionResponse])
|
|
async def l1_escalations(
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
user: Annotated[User, Depends(require_engineer_or_admin)],
|
|
limit: int = 50,
|
|
):
|
|
"""Engineer-visible list of escalated L1 sessions (the handoff queue)."""
|
|
rows = await db.execute(
|
|
select(L1WalkSession)
|
|
.where(
|
|
L1WalkSession.account_id == user.account_id,
|
|
L1WalkSession.status == "escalated",
|
|
)
|
|
.order_by(L1WalkSession.last_step_at.desc())
|
|
.limit(limit)
|
|
)
|
|
return [_to_response(s) for s in rows.scalars()]
|
|
|
|
|
|
@router.post("/escalate-without-walk", response_model=WalkSessionResponse)
|
|
async def post_escalate_without_walk(
|
|
payload: EscalateWithoutWalkRequest,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
user: Annotated[User, Depends(require_l1_or_coverage)],
|
|
):
|
|
ticket = await internal_ticket_service.create_ticket(
|
|
db,
|
|
account_id=user.account_id,
|
|
created_by_user_id=user.id,
|
|
problem_statement=payload.problem_statement,
|
|
customer_name=payload.customer_name,
|
|
customer_contact=payload.customer_contact,
|
|
)
|
|
session = await l1_session_service.escalate_without_walk(
|
|
db,
|
|
account_id=user.account_id,
|
|
user=user,
|
|
ticket_id=str(ticket.id),
|
|
ticket_kind="internal",
|
|
reason_category=payload.reason_category,
|
|
reason=payload.reason,
|
|
)
|
|
await db.commit()
|
|
return _to_response(session)
|