fix(l1): resolve PR #193 backend review findings (1,4,5,6,7,8,9,10)
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>
This commit is contained in:
@@ -35,6 +35,8 @@ 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,
|
||||
@@ -68,6 +70,17 @@ async def _get_session_or_404(
|
||||
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,
|
||||
@@ -76,18 +89,49 @@ async def intake(
|
||||
):
|
||||
"""L1 intake (Phase 2A): match a published flow, else gate + build.
|
||||
|
||||
Runs the match_or_build orchestrator. Outcomes:
|
||||
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 persisted as a hidden
|
||||
meta entry on walked_path for /next-node), walk an AI-built tree.
|
||||
- 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,
|
||||
ticket_ref="",
|
||||
db=db,
|
||||
force_build=payload.force_build,
|
||||
)
|
||||
@@ -102,14 +146,7 @@ async def intake(
|
||||
)
|
||||
|
||||
# matched OR build → create a ticket and a session
|
||||
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,
|
||||
)
|
||||
ticket = await _create_intake_ticket(db, payload, user)
|
||||
if outcome == "matched":
|
||||
session = await l1_session_service.start_flow_session(
|
||||
db,
|
||||
@@ -126,13 +163,9 @@ async def intake(
|
||||
user=user,
|
||||
ticket_id=str(ticket.id),
|
||||
ticket_kind="internal",
|
||||
category=result.get("category", "unknown"),
|
||||
problem_text=payload.problem_statement,
|
||||
)
|
||||
# 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(
|
||||
@@ -293,27 +326,18 @@ async def next_node(
|
||||
):
|
||||
"""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.
|
||||
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)
|
||||
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",
|
||||
problem_text=session.problem_text or "",
|
||||
category=session.category or "unknown",
|
||||
node_id=payload.node_id,
|
||||
node_text=payload.node_text,
|
||||
answer=payload.answer,
|
||||
|
||||
Reference in New Issue
Block a user