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:
@@ -3,33 +3,54 @@ from datetime import datetime
|
||||
from typing import Any, Literal, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class IntakeRequest(BaseModel):
|
||||
problem_statement: str = Field(..., min_length=1)
|
||||
customer_name: Optional[str] = None
|
||||
customer_contact: Optional[str] = None
|
||||
# When set, bypass the matcher and start this published flow directly (the
|
||||
# suggest card's "Use this flow" — the client already holds the flow id).
|
||||
flow_id: Optional[UUID] = None
|
||||
# When True, start an ad-hoc free-form walk (the out_of_scope prompt's
|
||||
# "Walk it ad-hoc" fallback). Mutually informative with flow_id/force_build;
|
||||
# flow_id takes precedence if both are somehow set.
|
||||
adhoc: bool = False
|
||||
force_build: bool = False
|
||||
|
||||
|
||||
# Outcomes that start a session (and therefore must carry session_id + ticket).
|
||||
_SESSION_OUTCOMES = {"matched", "build", "adhoc"}
|
||||
|
||||
|
||||
class IntakeResponse(BaseModel):
|
||||
outcome: Literal["matched", "suggest", "out_of_scope", "build"]
|
||||
outcome: Literal["matched", "suggest", "out_of_scope", "build", "adhoc"]
|
||||
session_id: Optional[UUID] = None
|
||||
session_kind: Optional[Literal["flow", "proposal", "adhoc", "ai_build"]] = None
|
||||
ticket_id: Optional[str] = None
|
||||
ticket_kind: Optional[str] = None
|
||||
ticket_kind: Optional[Literal["psa", "internal"]] = None
|
||||
flow_id: Optional[UUID] = None # for 'matched'
|
||||
near_miss: Optional[dict] = None # for 'suggest'
|
||||
category: Optional[str] = None # for 'out_of_scope'
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_outcome_invariants(self) -> "IntakeResponse":
|
||||
"""Restore the per-outcome contract the frontend depends on: a session
|
||||
outcome MUST carry the session_id + ticket the walker navigates to, so a
|
||||
backend regression surfaces here instead of as /l1/walk/undefined."""
|
||||
if self.outcome in _SESSION_OUTCOMES:
|
||||
if self.session_id is None or self.ticket_id is None:
|
||||
raise ValueError(
|
||||
f"intake outcome '{self.outcome}' requires session_id + ticket_id"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class NextNodeRequest(BaseModel):
|
||||
node_id: Optional[str] = None
|
||||
node_text: Optional[str] = None # rendered text of the node being answered (carry-forward Task 8)
|
||||
answer: Optional[str] = None # 'yes' | 'no' for questions
|
||||
acknowledged: Optional[bool] = None
|
||||
answer: Optional[str] = None # 'yes' | 'no' for questions; None acks an instruction
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
@@ -70,6 +91,8 @@ class EscalateWithoutWalkRequest(BaseModel):
|
||||
class WalkSessionResponse(BaseModel):
|
||||
id: UUID
|
||||
session_kind: str
|
||||
category: Optional[str] = None
|
||||
problem_text: Optional[str] = None
|
||||
flow_id: Optional[UUID]
|
||||
flow_proposal_id: Optional[UUID]
|
||||
current_node_id: Optional[str]
|
||||
|
||||
Reference in New Issue
Block a user