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:
@@ -2,6 +2,52 @@ import pytest
|
||||
from app.services import ai_tree_builder as atb
|
||||
|
||||
|
||||
class _FakeProvider:
|
||||
def __init__(self, raw):
|
||||
self._raw = raw
|
||||
|
||||
async def generate_json(self, *, system_prompt, messages, max_tokens):
|
||||
return self._raw, None, None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_next_node_assigns_id_when_model_omits_it(monkeypatch):
|
||||
"""The SYSTEM_PROMPT never asks the model for an id (Finding 1). The server
|
||||
must assign one to every generated node, or the advance protocol — which keys
|
||||
on node_id — can never record an answer and the walk stalls on question 1."""
|
||||
monkeypatch.setattr(
|
||||
atb, "get_ai_provider",
|
||||
lambda *a, **k: _FakeProvider('{"node_type":"question","text":"Plugged in?"}'),
|
||||
)
|
||||
node = await atb.generate_next_node("printer down", "printer", [])
|
||||
assert node["node_type"] == "question"
|
||||
assert node.get("id"), "generated node must carry a server-assigned id"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_next_node_depth_cap_node_has_id(monkeypatch):
|
||||
"""The depth-cap escalate node must also carry an id (it is persisted as
|
||||
current_node_id and may be appended to walked_path)."""
|
||||
walked = [{"node_type": "question", "id": f"n{i}", "text": "?", "answer": "no"}
|
||||
for i in range(atb.MAX_DEPTH)]
|
||||
node = await atb.generate_next_node("x", "printer", walked)
|
||||
assert node["node_type"] == "escalate"
|
||||
assert node.get("id")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_next_node_generation_failed_node_has_id(monkeypatch):
|
||||
"""When both generation attempts fail, the fallback escalate node carries an id."""
|
||||
monkeypatch.setattr(
|
||||
atb, "get_ai_provider",
|
||||
lambda *a, **k: _FakeProvider("not json at all"),
|
||||
)
|
||||
node = await atb.generate_next_node("x", "printer", [])
|
||||
assert node["node_type"] == "escalate"
|
||||
assert node["reason_category"] == "generation_failed"
|
||||
assert node.get("id")
|
||||
|
||||
|
||||
def test_validate_node_rejects_hard_floor_text():
|
||||
node = {"node_type": "instruction", "id": "n1", "text": "Open regedit and change the key", "next": "generate"}
|
||||
with pytest.raises(atb.UnsafeNodeError):
|
||||
|
||||
Reference in New Issue
Block a user