From 68a4b99246d4bab5c67e477ae96e6a96bf484f11 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Fri, 29 May 2026 18:22:05 -0400 Subject: [PATCH] =?UTF-8?q?feat(l1):=20advance=5Fai=5Fbuild=20=E2=80=94=20?= =?UTF-8?q?record=20answer=20+=20generate=20next=20node?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- backend/app/services/l1_session_service.py | 55 ++++++++++++++++ backend/tests/test_l1_session_service.py | 73 ++++++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/backend/app/services/l1_session_service.py b/backend/app/services/l1_session_service.py index b0eb107e..c86cd239 100644 --- a/backend/app/services/l1_session_service.py +++ b/backend/app/services/l1_session_service.py @@ -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, *, diff --git a/backend/tests/test_l1_session_service.py b/backend/tests/test_l1_session_service.py index 072060f4..bf4e2669 100644 --- a/backend/tests/test_l1_session_service.py +++ b/backend/tests/test_l1_session_service.py @@ -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) # ---------------------------------------------------------------------------