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:
2026-06-09 15:55:45 -04:00
parent 42a4536c63
commit ac89e7b2fa
17 changed files with 592 additions and 80 deletions

View File

@@ -7,6 +7,7 @@ for flywheel capture.
"""
import logging
from typing import Any, Optional
from uuid import uuid4
from app.core.ai_provider import get_ai_provider
from app.core.config import settings
@@ -45,19 +46,21 @@ No prose, no markdown fences.
"""
def _strip_meta(walked_path: list[dict]) -> list[dict]:
"""Drop the hidden ``meta`` entry (category carrier) the intake endpoint seeds.
def _assign_id(node: dict[str, Any]) -> dict[str, Any]:
"""Stamp a stable server-side id on a generated node (Finding 1).
The first walked_path entry on an ai_build session may be a
``{"node_type": "meta", "category": ...}`` marker used to persist the
classified category; it is not a real walk step and must be excluded from
both model context and tree normalization.
The SYSTEM_PROMPT never asks the model for an id — and we must not, since a
model-invented id is neither stable nor trustworthy. But the advance protocol
keys on ``node_id``: without one, the answer to every node is discarded and
the walk can never progress past the first question. So every node the builder
hands back — generated, depth-capped, or generation-failed — gets an id here.
"""
return [s for s in walked_path if s.get("node_type") != "meta"]
if not node.get("id"):
node["id"] = uuid4().hex[:8]
return node
def _build_context(problem_text: str, category: str, walked_path: list[dict]) -> str:
walked_path = _strip_meta(walked_path)
lines = [f"PROBLEM: {problem_text}", f"CATEGORY: {category}", "STEPS SO FAR:"]
if not walked_path:
lines.append("(none yet — produce the first diagnostic question)")
@@ -81,11 +84,11 @@ def validate_node(node: dict[str, Any]) -> dict[str, Any]:
def escalate_if_depth_exceeded(walked_path: list[dict]) -> Optional[dict[str, Any]]:
if len(walked_path) >= MAX_DEPTH:
return {
return _assign_id({
"node_type": "escalate",
"reason_category": "depth_cap",
"text": "Reached the L1 troubleshooting depth limit — escalating to engineering.",
}
})
return None
@@ -108,16 +111,16 @@ async def generate_next_node(
max_tokens=1024,
)
node = parse_llm_json(raw)
return validate_node(node)
return _assign_id(validate_node(node))
except Exception as e:
logger.warning("ai_tree_builder node attempt %d failed: %s", attempt + 1, e)
continue
return {
return _assign_id({
"node_type": "escalate",
"reason_category": "generation_failed",
"text": "Could not generate a safe next step — escalating to engineering.",
}
})
def normalize_walked_path(walked_path: list[dict]) -> dict[str, Any]:
@@ -128,7 +131,6 @@ def normalize_walked_path(walked_path: list[dict]) -> dict[str, Any]:
Returns {id, nodes: {id: node}} — a dict with an id (passes the proposal
approval guard).
"""
walked_path = _strip_meta(walked_path)
nodes: dict[str, Any] = {}
if not walked_path:
root_id = "root"