feat(l1): advance_ai_build — record answer + generate next node

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 18:22:05 -04:00
parent 0facf2f8c9
commit 68a4b99246
2 changed files with 128 additions and 0 deletions

View File

@@ -13,6 +13,7 @@ from app.core.audit import log_audit
from app.models.flow_proposal import FlowProposal from app.models.flow_proposal import FlowProposal
from app.models.l1_walk_session import L1WalkSession from app.models.l1_walk_session import L1WalkSession
from app.models.user import User from app.models.user import User
from app.services import ai_tree_builder
from app.services import internal_ticket_service from app.services import internal_ticket_service
@@ -120,6 +121,60 @@ async def start_ai_build_session(
return session return session
async def advance_ai_build(
db: AsyncSession,
*,
session_id: UUID,
problem_text: str,
category: str,
node_id: Optional[str] = None,
node_text: Optional[str] = None,
answer: Optional[str] = None,
note: Optional[str] = None,
) -> dict:
"""Append the answered/acked node to walked_path, then generate the next node.
On the first call (node_id is None) nothing is appended — we just generate the
first node. Returns the next node dict (caller persists current_node_id).
Raises ValueError on missing/inactive/non-ai_build session.
``node_text`` is the display text of the node being answered. It is supplied by
the caller/endpoint, which holds the served node. Storing it here ensures that
later nodes receive full prior-step context via ``ai_tree_builder._build_context``
and that captured flywheel trees (``normalize_walked_path``) have meaningful text.
"""
session = await db.get(L1WalkSession, session_id)
if not session:
raise ValueError(f"L1WalkSession {session_id} not found")
if session.session_kind != "ai_build":
raise ValueError("advance_ai_build requires an ai_build session")
if session.status != "active":
raise ValueError(f"Session {session_id} is not active (status={session.status})")
if node_id is not None:
# node_type inferred from the answer: questions are answered yes/no;
# instructions are acknowledged (answer is None) per the next-node endpoint contract.
# Note: entry uses key "id" (not "node_id" as record_step uses) because
# ai_tree_builder.normalize_walked_path reads step.get("id"); the two coexist
# safely because they are segregated by session_kind.
entry = {
"node_type": "question" if answer in ("yes", "no") else "instruction",
"id": node_id,
"text": node_text or "",
"answer": answer,
"l1_note": note,
}
# JSONB requires assigning a new list — in-place mutation isn't tracked
session.walked_path = [*session.walked_path, entry]
next_node = await ai_tree_builder.generate_next_node(
problem_text, category, session.walked_path)
session.current_node_id = next_node.get("id")
session.last_step_at = datetime.now(timezone.utc)
await db.flush()
return next_node
async def record_step( async def record_step(
db: AsyncSession, db: AsyncSession,
*, *,

View File

@@ -796,6 +796,79 @@ async def test_start_ai_build_session(test_db: AsyncSession):
assert s.status == "active" assert s.status == "active"
# ---------------------------------------------------------------------------
# T8: advance_ai_build
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_advance_ai_build_appends_and_returns_next(test_db: AsyncSession, monkeypatch):
from app.services import l1_session_service as svc
from app.services import ai_tree_builder
account = await _make_account(test_db)
l1_user = await _make_user(test_db, account_id=account.id)
s = await svc.start_ai_build_session(
test_db, account_id=account.id, user=l1_user,
ticket_id="t-ai", ticket_kind="internal")
async def fake_next(problem, category, walked):
return {"node_type": "resolved", "id": "done", "text": "Fixed."}
monkeypatch.setattr(ai_tree_builder, "generate_next_node", fake_next)
next_node = await svc.advance_ai_build(
test_db, session_id=s.id, problem_text="printer", category="printer",
node_id="n1", node_text="Powered on?", answer="no", note=None)
assert next_node["node_type"] == "resolved"
refreshed = await test_db.get(type(s), s.id)
assert len(refreshed.walked_path) == 1
assert refreshed.walked_path[0]["answer"] == "no"
assert refreshed.walked_path[0]["text"] == "Powered on?"
@pytest.mark.asyncio
async def test_advance_ai_build_first_call_does_not_append(test_db: AsyncSession, monkeypatch):
from app.services import l1_session_service as svc
from app.services import ai_tree_builder
account = await _make_account(test_db)
l1_user = await _make_user(test_db, account_id=account.id)
s = await svc.start_ai_build_session(
test_db, account_id=account.id, user=l1_user,
ticket_id="t-ai-first", ticket_kind="internal")
async def fake_next(problem, category, walked):
return {"node_type": "question", "id": "q1", "text": "Is it plugged in?"}
monkeypatch.setattr(ai_tree_builder, "generate_next_node", fake_next)
# First call: node_id=None — nothing should be appended
next_node = await svc.advance_ai_build(
test_db, session_id=s.id, problem_text="printer", category="printer",
node_id=None)
assert next_node["node_type"] == "question"
assert next_node["id"] == "q1"
refreshed = await test_db.get(type(s), s.id)
assert len(refreshed.walked_path) == 0
assert refreshed.current_node_id == "q1"
@pytest.mark.asyncio
async def test_advance_ai_build_wrong_session_kind_raises(test_db: AsyncSession, monkeypatch):
from app.services import l1_session_service as svc
from app.services import ai_tree_builder
account = await _make_account(test_db)
l1_user = await _make_user(test_db, account_id=account.id)
# start an adhoc session (not ai_build)
s = await svc.start_adhoc_session(
test_db, account_id=account.id, user=l1_user,
ticket_id="t-adhoc-guard", ticket_kind="internal")
async def fake_next(problem, category, walked): # pragma: no cover
return {"node_type": "question", "id": "q1", "text": "?"}
monkeypatch.setattr(ai_tree_builder, "generate_next_node", fake_next)
with pytest.raises(ValueError, match="ai_build"):
await svc.advance_ai_build(
test_db, session_id=s.id, problem_text="printer", category="printer")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# T14 audit log tests (spec §5.6.1) # T14 audit log tests (spec §5.6.1)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------