feat(l1): intake dispatch via match_or_build + next-node + escalations endpoints
- /intake now runs match_or_build (matched/suggest/out_of_scope/build); build
seeds the classified category as a hidden meta walked_path entry, matched starts
a flow session, suggest/out_of_scope return prompt data with no session.
- New POST /sessions/{id}/next-node (threads node_text to advance_ai_build) and
GET /escalations (engineer-or-above) for the handoff queue.
- New IntakeResponse(outcome=...)/NextNodeRequest/NextNodeResponse schemas and
require_account_owner_or_admin dep.
- Reconcile Phase-1 intake tests to the new contract (mock match_or_build); add
test_l1_api_ai_build.py covering build/out_of_scope/suggest/next-node/escalations.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@ 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_l1_or_coverage
|
||||
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 (
|
||||
@@ -17,13 +17,15 @@ from app.schemas.l1 import (
|
||||
EscalateWithoutWalkRequest,
|
||||
IntakeRequest,
|
||||
IntakeResponse,
|
||||
NextNodeRequest,
|
||||
NextNodeResponse,
|
||||
NotesRequest,
|
||||
QueueRow,
|
||||
ResolveRequest,
|
||||
StepRequest,
|
||||
WalkSessionResponse,
|
||||
)
|
||||
from app.services import internal_ticket_service, l1_session_service
|
||||
from app.services import internal_ticket_service, l1_session_service, match_or_build
|
||||
|
||||
|
||||
router = APIRouter(prefix="/l1", tags=["l1"])
|
||||
@@ -72,11 +74,34 @@ async def intake(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
user: Annotated[User, Depends(require_l1_or_coverage)],
|
||||
):
|
||||
"""L1 intake: creates an internal ticket and starts a walk session.
|
||||
"""L1 intake (Phase 2A): match a published flow, else gate + build.
|
||||
|
||||
Phase 1: internal-ticket only (PSA support follows in Phase 2 escalation polish).
|
||||
If `flow_id` is provided, starts a flow session; otherwise an adhoc session.
|
||||
Runs the match_or_build orchestrator. Outcomes:
|
||||
- matched → create ticket + flow session, walk the published flow.
|
||||
- build → create ticket + ai_build session (category persisted as a hidden
|
||||
meta entry on walked_path for /next-node), walk an AI-built tree.
|
||||
- suggest → near-miss prompt; no session created.
|
||||
- out_of_scope → category disabled/unknown; no session created.
|
||||
"""
|
||||
result = await match_or_build.match_or_build(
|
||||
user.account_id,
|
||||
payload.problem_statement,
|
||||
None,
|
||||
ticket_ref="",
|
||||
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 internal_ticket_service.create_ticket(
|
||||
db,
|
||||
account_id=user.account_id,
|
||||
@@ -85,29 +110,38 @@ async def intake(
|
||||
customer_name=payload.customer_name,
|
||||
customer_contact=payload.customer_contact,
|
||||
)
|
||||
if payload.flow_id is not None:
|
||||
if outcome == "matched":
|
||||
session = await l1_session_service.start_flow_session(
|
||||
db,
|
||||
account_id=user.account_id,
|
||||
user=user,
|
||||
flow_id=payload.flow_id,
|
||||
flow_id=UUID(result["flow_id"]),
|
||||
ticket_id=str(ticket.id),
|
||||
ticket_kind="internal",
|
||||
)
|
||||
else:
|
||||
session = await l1_session_service.start_adhoc_session(
|
||||
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",
|
||||
)
|
||||
# Persist the classified category as a hidden meta entry so /next-node
|
||||
# can recover it (no dedicated column; ai_tree_builder skips meta entries).
|
||||
session.walked_path = [
|
||||
{"node_type": "meta", "category": result.get("category", "unknown")}
|
||||
]
|
||||
await db.flush()
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user