"""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)