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:
@@ -13,6 +13,7 @@ from app.core.audit import log_audit
|
||||
from app.models.flow_proposal import FlowProposal
|
||||
from app.models.l1_walk_session import L1WalkSession
|
||||
from app.models.user import User
|
||||
from app.services import ai_tree_builder
|
||||
from app.services import internal_ticket_service
|
||||
|
||||
|
||||
@@ -120,6 +121,60 @@ async def start_ai_build_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(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
|
||||
@@ -796,6 +796,79 @@ async def test_start_ai_build_session(test_db: AsyncSession):
|
||||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user