feat(l1): AI decision-tree builder — Phase 2A #193
@@ -284,6 +284,68 @@ async def post_escalate(
|
||||
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 comes from the linked internal ticket; category from the hidden
|
||||
meta entry seeded at intake (ai_tree_builder skips meta entries). 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)
|
||||
ticket = await internal_ticket_service.get_ticket(
|
||||
db, ticket_id=UUID(session.ticket_id)
|
||||
)
|
||||
problem_text = ticket.problem_statement if ticket else ""
|
||||
category = next(
|
||||
(s.get("category") for s in (session.walked_path or [])
|
||||
if s.get("node_type") == "meta"),
|
||||
"unknown",
|
||||
)
|
||||
try:
|
||||
node = await l1_session_service.advance_ai_build(
|
||||
db,
|
||||
session_id=session_id,
|
||||
problem_text=problem_text,
|
||||
category=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,
|
||||
|
||||
@@ -82,27 +82,72 @@ async def _make_l1_user(
|
||||
return {"user_data": {"id": str(user.id), "account_id": str(account_id)}, "headers": None}
|
||||
|
||||
|
||||
async def _create_adhoc_session(db: AsyncSession, info: dict, *, problem: str = "setup") -> str:
|
||||
"""Create an adhoc walk session (backed by a real internal ticket) via the service.
|
||||
|
||||
Phase 2A: POST /l1/intake dispatches through match_or_build and no longer
|
||||
yields an adhoc session directly, so step/notes/resolve/escalate/cross-account
|
||||
tests build their setup session here instead of through intake. The test
|
||||
client shares this same DB session (conftest override_get_db), so the
|
||||
committed session is visible to the API immediately.
|
||||
"""
|
||||
from sqlalchemy import select as sa_select
|
||||
from app.services import internal_ticket_service, l1_session_service
|
||||
|
||||
account_id = info["account_id"]
|
||||
user_id = uuid.UUID(info["user_data"]["id"])
|
||||
user = (await db.execute(sa_select(User).where(User.id == user_id))).scalar_one()
|
||||
ticket = await internal_ticket_service.create_ticket(
|
||||
db,
|
||||
account_id=account_id,
|
||||
created_by_user_id=user_id,
|
||||
problem_statement=problem,
|
||||
customer_name=None,
|
||||
customer_contact=None,
|
||||
)
|
||||
session = await l1_session_service.start_adhoc_session(
|
||||
db,
|
||||
account_id=account_id,
|
||||
user=user,
|
||||
ticket_id=str(ticket.id),
|
||||
ticket_kind="internal",
|
||||
)
|
||||
await db.commit()
|
||||
return str(session.id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Intake without flow_id → 200 + session_kind='adhoc'
|
||||
# 1. Intake (Phase 2A): build outcome → 200 + session_kind='ai_build'
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_intake_adhoc(client: AsyncClient, test_db: AsyncSession):
|
||||
"""POST /l1/intake without flow_id creates adhoc session."""
|
||||
async def test_intake_build_creates_ai_build_session(client: AsyncClient, test_db: AsyncSession):
|
||||
"""POST /l1/intake with a 'build' outcome creates an ai_build session.
|
||||
|
||||
Phase 2A: intake dispatches via match_or_build; 'adhoc' is no longer a direct
|
||||
intake outcome (it is offered from the out_of_scope prompt on the frontend).
|
||||
"""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
info = await _make_l1_user(client, test_db, email="l1intake@example.com")
|
||||
headers = info["headers"]
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/l1/intake",
|
||||
json={"problem_statement": "Printer won't turn on", "customer_name": "Alice"},
|
||||
headers=headers,
|
||||
)
|
||||
with patch(
|
||||
"app.api.endpoints.l1.match_or_build.match_or_build",
|
||||
new=AsyncMock(return_value={"outcome": "build", "session_kind": "ai_build",
|
||||
"category": "printer"}),
|
||||
):
|
||||
resp = await client.post(
|
||||
"/api/v1/l1/intake",
|
||||
json={"problem_statement": "Printer won't turn on", "customer_name": "Alice"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["session_kind"] == "adhoc"
|
||||
assert body["outcome"] == "build"
|
||||
assert body["session_kind"] == "ai_build"
|
||||
assert body["ticket_kind"] == "internal"
|
||||
assert "session_id" in body
|
||||
assert "ticket_id" in body
|
||||
assert body["session_id"]
|
||||
assert body["ticket_id"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -156,14 +201,7 @@ async def test_step_on_adhoc_returns_400(client: AsyncClient, test_db: AsyncSess
|
||||
info = await _make_l1_user(client, test_db, email="l1step@example.com")
|
||||
headers = info["headers"]
|
||||
|
||||
# Create adhoc session via intake
|
||||
resp = await client.post(
|
||||
"/api/v1/l1/intake",
|
||||
json={"problem_statement": "Adhoc issue"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
session_id = resp.json()["session_id"]
|
||||
session_id = await _create_adhoc_session(test_db, info, problem="Adhoc issue")
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/v1/l1/sessions/{session_id}/step",
|
||||
@@ -184,13 +222,7 @@ async def test_notes_on_adhoc_session(client: AsyncClient, test_db: AsyncSession
|
||||
info = await _make_l1_user(client, test_db, email="l1notes@example.com")
|
||||
headers = info["headers"]
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/l1/intake",
|
||||
json={"problem_statement": "Notes test"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
session_id = resp.json()["session_id"]
|
||||
session_id = await _create_adhoc_session(test_db, info, problem="Notes test")
|
||||
|
||||
notes_payload = [{"text": "Customer called about printer", "ts": "2026-05-28T10:00:00Z"}]
|
||||
resp = await client.post(
|
||||
@@ -213,13 +245,7 @@ async def test_resolve_session(client: AsyncClient, test_db: AsyncSession):
|
||||
info = await _make_l1_user(client, test_db, email="l1resolve@example.com")
|
||||
headers = info["headers"]
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/l1/intake",
|
||||
json={"problem_statement": "Resolve test"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
session_id = resp.json()["session_id"]
|
||||
session_id = await _create_adhoc_session(test_db, info, problem="Resolve test")
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/v1/l1/sessions/{session_id}/resolve",
|
||||
@@ -245,13 +271,7 @@ async def test_escalate_session(client: AsyncClient, test_db: AsyncSession):
|
||||
info = await _make_l1_user(client, test_db, email="l1escalate@example.com")
|
||||
headers = info["headers"]
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/l1/intake",
|
||||
json={"problem_statement": "Escalation test"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
session_id = resp.json()["session_id"]
|
||||
session_id = await _create_adhoc_session(test_db, info, problem="Escalation test")
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/v1/l1/sessions/{session_id}/escalate",
|
||||
@@ -344,15 +364,8 @@ async def test_get_session_cross_account_returns_404(client: AsyncClient, test_d
|
||||
"""GET /l1/sessions/{id} from a different account → 404."""
|
||||
# Account A: creates a session
|
||||
info_a = await _make_l1_user(client, test_db, email="l1tenanta@example.com")
|
||||
headers_a = info_a["headers"]
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/l1/intake",
|
||||
json={"problem_statement": "Account A issue"},
|
||||
headers=headers_a,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
session_id = resp.json()["session_id"]
|
||||
session_id = await _create_adhoc_session(test_db, info_a, problem="Account A issue")
|
||||
|
||||
# Account B: different user in a different account
|
||||
info_b = await _make_l1_user(client, test_db, email="l1tenantb@example.com")
|
||||
|
||||
Reference in New Issue
Block a user