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):
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.account import Account
|
||||
from app.models.flow_proposal import FlowProposal
|
||||
from app.models.l1_walk_session import L1WalkSession
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def test_flow_proposal_accepts_l1_session_id_without_source_session():
|
||||
@@ -14,3 +22,44 @@ def test_flow_proposal_accepts_l1_session_id_without_source_session():
|
||||
status="pending",
|
||||
)
|
||||
assert p.l1_session_id is not None and p.source_session_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deleting_l1_session_cascades_proposal_not_check_violation(test_db: AsyncSession):
|
||||
"""Finding 6: an L1-sourced proposal has source_session_id NULL by the exactly-one
|
||||
CHECK. With ondelete=CASCADE the proposal dies with its session; the old SET NULL
|
||||
would have NULLed both columns and aborted the DELETE on the CHECK (time bomb)."""
|
||||
s = str(uuid.uuid4())[:8]
|
||||
account = Account(id=uuid.uuid4(), name=f"Acct {s}", display_code=s.upper())
|
||||
test_db.add(account)
|
||||
await test_db.flush()
|
||||
user = User(
|
||||
id=uuid.uuid4(), email=f"u-{uuid.uuid4()}@example.com", name="U",
|
||||
account_id=account.id, account_role="l1_tech", role="engineer", is_active=True,
|
||||
)
|
||||
test_db.add(user)
|
||||
await test_db.flush()
|
||||
session = L1WalkSession(
|
||||
account_id=account.id, created_by_user_id=user.id,
|
||||
ticket_id="t-cascade", ticket_kind="internal", session_kind="ai_build",
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.flush()
|
||||
proposal = FlowProposal(
|
||||
account_id=account.id, l1_session_id=session.id, source_session_id=None,
|
||||
proposal_type="new_flow", title="AI L1 draft",
|
||||
proposed_flow_data={"tree_structure": {"id": "root"}},
|
||||
source="ai_realtime_l1", status="pending",
|
||||
)
|
||||
test_db.add(proposal)
|
||||
await test_db.flush()
|
||||
pid = proposal.id
|
||||
|
||||
# Delete the session — must succeed and cascade to the proposal.
|
||||
await test_db.delete(session)
|
||||
await test_db.flush()
|
||||
|
||||
remaining = (await test_db.execute(
|
||||
select(FlowProposal).where(FlowProposal.id == pid)
|
||||
)).scalar_one_or_none()
|
||||
assert remaining is None
|
||||
|
||||
@@ -155,3 +155,73 @@ async def test_escalations_forbidden_for_l1_tech(client: AsyncClient, test_db: A
|
||||
info = await _make_user(client, test_db, email="aib_l1@example.com", account_role="l1_tech")
|
||||
r = await client.get("/api/v1/l1/escalations", headers=info["headers"])
|
||||
assert r.status_code == 403, r.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_intake_with_flow_id_starts_flow_directly(client: AsyncClient, test_db: AsyncSession):
|
||||
"""Finding 4: an explicit flow_id bypasses the matcher and starts that flow."""
|
||||
from app.models.tree import Tree
|
||||
info = await _make_user(client, test_db, email="aib_flowid@example.com", account_role="l1_tech")
|
||||
tree = Tree(
|
||||
id=uuid.uuid4(), name="VPN Flow", account_id=info["account_id"],
|
||||
author_id=info["user_id"], tree_type="troubleshooting",
|
||||
tree_structure={"nodes": [], "edges": []}, visibility="team", status="published",
|
||||
)
|
||||
test_db.add(tree)
|
||||
await test_db.commit()
|
||||
|
||||
# match_or_build must NOT be called when flow_id is supplied.
|
||||
with patch(
|
||||
"app.api.endpoints.l1.match_or_build.match_or_build",
|
||||
new=AsyncMock(side_effect=AssertionError("matcher should be bypassed")),
|
||||
):
|
||||
r = await client.post(
|
||||
"/api/v1/l1/intake",
|
||||
json={"problem_statement": "vpn down", "flow_id": str(tree.id)},
|
||||
headers=info["headers"],
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["outcome"] == "matched"
|
||||
assert body["session_kind"] == "flow"
|
||||
assert body["flow_id"] == str(tree.id)
|
||||
assert body["session_id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_intake_adhoc_starts_adhoc_session(client: AsyncClient, test_db: AsyncSession):
|
||||
"""Finding 5: adhoc=True starts a free-form ad-hoc walk (out_of_scope fallback)."""
|
||||
info = await _make_user(client, test_db, email="aib_adhoc@example.com", account_role="l1_tech")
|
||||
with patch(
|
||||
"app.api.endpoints.l1.match_or_build.match_or_build",
|
||||
new=AsyncMock(side_effect=AssertionError("matcher should be bypassed")),
|
||||
):
|
||||
r = await client.post(
|
||||
"/api/v1/l1/intake",
|
||||
json={"problem_statement": "weird thing", "adhoc": True},
|
||||
headers=info["headers"],
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["outcome"] == "adhoc"
|
||||
assert body["session_kind"] == "adhoc"
|
||||
assert body["session_id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_intake_build_persists_category_and_problem_text(client: AsyncClient, test_db: AsyncSession):
|
||||
"""Root cause B: build stores category + problem_text on the session (no meta entry)."""
|
||||
info = await _make_user(client, test_db, email="aib_cols@example.com", account_role="l1_tech")
|
||||
with patch(
|
||||
"app.api.endpoints.l1.match_or_build.match_or_build",
|
||||
new=AsyncMock(return_value={"outcome": "build", "session_kind": "ai_build",
|
||||
"category": "printer"}),
|
||||
):
|
||||
r = await client.post("/api/v1/l1/intake",
|
||||
json={"problem_statement": "printer jam"}, headers=info["headers"])
|
||||
sid = r.json()["session_id"]
|
||||
sess = await test_db.get(L1WalkSession, uuid.UUID(sid))
|
||||
assert sess.category == "printer"
|
||||
assert sess.problem_text == "printer jam"
|
||||
# No hidden meta entry smuggled into walked_path.
|
||||
assert sess.walked_path == []
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Tests for the account L1 AI-build category settings API (Phase 2A).
|
||||
|
||||
GET /accounts/me/l1-categories — readable by L1-or-above.
|
||||
GET /accounts/me/l1-categories — owner/admin only (Finding 7: read and write agree).
|
||||
PATCH /accounts/me/l1-categories — owner/admin only; drops unknown/hard-floored keys.
|
||||
"""
|
||||
import uuid
|
||||
@@ -65,12 +65,22 @@ async def test_get_categories_returns_enabled_available_hard_floor(client: Async
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_categories_readable_by_l1_tech(client: AsyncClient, test_db: AsyncSession):
|
||||
info = await _make_user(client, test_db, email="cat_l1_get@example.com", account_role="l1_tech")
|
||||
async def test_get_categories_readable_by_admin(client: AsyncClient, test_db: AsyncSession):
|
||||
"""Finding 7: account admins can READ (previously 403 on GET while they could PATCH)."""
|
||||
info = await _make_user(client, test_db, email="cat_admin_get@example.com", account_role="admin")
|
||||
r = await client.get("/api/v1/accounts/me/l1-categories", headers=info["headers"])
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_categories_forbidden_for_l1_tech(client: AsyncClient, test_db: AsyncSession):
|
||||
"""Finding 7: GET now matches PATCH (owner/admin only). The walker gates
|
||||
server-side and never fetches this, so l1_tech read access was unused."""
|
||||
info = await _make_user(client, test_db, email="cat_l1_get@example.com", account_role="l1_tech")
|
||||
r = await client.get("/api/v1/accounts/me/l1-categories", headers=info["headers"])
|
||||
assert r.status_code == 403, r.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_categories_owner_can_set(client: AsyncClient, test_db: AsyncSession):
|
||||
info = await _make_user(client, test_db, email="cat_owner_patch@example.com", account_role="owner")
|
||||
|
||||
@@ -124,8 +124,9 @@ async def _create_adhoc_session(db: AsyncSession, info: dict, *, problem: str =
|
||||
async def test_intake_build_creates_ai_build_session(client: AsyncClient, test_db: AsyncSession):
|
||||
"""POST /l1/intake with a 'build' outcome creates an ai_build session.
|
||||
|
||||
Phase 2A: intake dispatches via match_or_build; 'adhoc' is no longer a direct
|
||||
intake outcome (it is offered from the out_of_scope prompt on the frontend).
|
||||
Phase 2A: intake dispatches via match_or_build. An explicit adhoc=True (the
|
||||
out_of_scope prompt's "Walk it ad-hoc") starts an ad-hoc session directly —
|
||||
see test_l1_api_ai_build.test_intake_adhoc_starts_adhoc_session.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
info = await _make_l1_user(client, test_db, email="l1intake@example.com")
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.models.user import User
|
||||
from app.models.tree import Tree
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.flow_proposal import FlowProposal
|
||||
from app.models.l1_walk_session import L1WalkSession
|
||||
from app.services.l1_session_service import (
|
||||
start_flow_session,
|
||||
start_proposal_session,
|
||||
@@ -1073,3 +1074,138 @@ async def test_escalate_without_walk_writes_audit_log(test_db: AsyncSession):
|
||||
)
|
||||
row = result.scalar_one()
|
||||
assert row.account_id == account.id
|
||||
# Audit coverage: the reason category must be recorded (restored — a prior
|
||||
# edit dropped this assertion, weakening the audit guarantee).
|
||||
assert row.details["escalation_reason_category"] == "no_kb_content"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Finding 1 (server-assigned node ids) + Finding 8 (pending-node replay)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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_ai_build_first_node_carries_id_and_advance_grows_walk(
|
||||
test_db: AsyncSession, monkeypatch,
|
||||
):
|
||||
"""Finding 1 contract: the SYSTEM_PROMPT never asks for an id, yet the first
|
||||
generated node must carry one — and advancing with that id must grow walked_path
|
||||
(the original showstopper: node_id was always None, so the walk never advanced)."""
|
||||
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-contract", ticket_kind="internal",
|
||||
category="printer", problem_text="printer offline")
|
||||
|
||||
# Real generator + a provider that omits id (the shape the model produces).
|
||||
monkeypatch.setattr(
|
||||
ai_tree_builder, "get_ai_provider",
|
||||
lambda *a, **k: _FakeProvider('{"node_type":"question","text":"Plugged in?"}'))
|
||||
|
||||
first = await svc.advance_ai_build(
|
||||
test_db, session_id=s.id, problem_text="printer offline",
|
||||
category="printer", node_id=None)
|
||||
assert first.get("id"), "first node must carry a server-assigned id"
|
||||
|
||||
# Answer it with the id we were handed; walked_path must grow by one.
|
||||
await svc.advance_ai_build(
|
||||
test_db, session_id=s.id, problem_text="printer offline", category="printer",
|
||||
node_id=first["id"], node_text=first["text"], answer="no")
|
||||
refreshed = await test_db.get(L1WalkSession, s.id)
|
||||
assert len(refreshed.walked_path) == 1
|
||||
assert refreshed.walked_path[0]["id"] == first["id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advance_ai_build_replays_pending_node_without_regenerating(
|
||||
test_db: AsyncSession, monkeypatch,
|
||||
):
|
||||
"""Finding 8: a re-mount (node_id=None) replays the served-but-unanswered node
|
||||
instead of firing a fresh paid LLM call (which could also swap the question)."""
|
||||
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-replay", ticket_kind="internal",
|
||||
category="printer", problem_text="printer offline")
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
async def fake_next(problem, category, walked):
|
||||
calls["n"] += 1
|
||||
return {"node_type": "question", "id": f"q{calls['n']}", "text": "?"}
|
||||
monkeypatch.setattr(ai_tree_builder, "generate_next_node", fake_next)
|
||||
|
||||
first = await svc.advance_ai_build(
|
||||
test_db, session_id=s.id, problem_text="p", category="printer", node_id=None)
|
||||
# Re-mount without answering — must NOT regenerate.
|
||||
replay = await svc.advance_ai_build(
|
||||
test_db, session_id=s.id, problem_text="p", category="printer", node_id=None)
|
||||
assert calls["n"] == 1
|
||||
assert replay["id"] == first["id"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Finding 10: escalation recipient resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_escalate_skips_soft_deleted_engineer(test_db: AsyncSession, monkeypatch):
|
||||
"""A soft-deleted engineer must not be paged (is_active alone misses them)."""
|
||||
from datetime import datetime, timezone
|
||||
from app.services import l1_session_service as svc
|
||||
calls = {}
|
||||
|
||||
async def fake_notify(event, account_id, payload, db, target_user_ids=None):
|
||||
calls["target_user_ids"] = target_user_ids
|
||||
monkeypatch.setattr(svc, "notify", fake_notify)
|
||||
|
||||
account = await _make_account(test_db)
|
||||
l1_user = await _make_user(test_db, account_id=account.id)
|
||||
live_eng = await _make_user(test_db, account_id=account.id, account_role="engineer")
|
||||
dead_eng = await _make_user(test_db, account_id=account.id, account_role="engineer")
|
||||
dead_eng.deleted_at = datetime.now(timezone.utc)
|
||||
await test_db.flush()
|
||||
ticket = await _make_internal_ticket(test_db, account_id=account.id, user_id=l1_user.id)
|
||||
s = await svc.start_ai_build_session(
|
||||
test_db, account_id=account.id, user=l1_user,
|
||||
ticket_id=str(ticket.id), ticket_kind="internal")
|
||||
await svc.escalate(test_db, session_id=s.id, reason="x", reason_category="exhausted_safe_steps")
|
||||
assert live_eng.id in calls["target_user_ids"]
|
||||
assert dead_eng.id not in calls["target_user_ids"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_escalate_with_no_engineers_falls_back_to_default_recipients(
|
||||
test_db: AsyncSession, monkeypatch,
|
||||
):
|
||||
"""Finding 10: when no eligible engineer exists, pass None (not []) so notify()
|
||||
falls back to the default owner/admin set instead of silently dropping it."""
|
||||
from app.services import l1_session_service as svc
|
||||
calls = {}
|
||||
|
||||
async def fake_notify(event, account_id, payload, db, target_user_ids=None):
|
||||
calls["target_user_ids"] = target_user_ids
|
||||
monkeypatch.setattr(svc, "notify", fake_notify)
|
||||
|
||||
account = await _make_account(test_db)
|
||||
# Only an l1_tech exists — not in the owner/admin/engineer recipient query.
|
||||
l1_user = await _make_user(test_db, account_id=account.id)
|
||||
ticket = await _make_internal_ticket(test_db, account_id=account.id, user_id=l1_user.id)
|
||||
s = await svc.start_ai_build_session(
|
||||
test_db, account_id=account.id, user=l1_user,
|
||||
ticket_id=str(ticket.id), ticket_kind="internal")
|
||||
await svc.escalate(test_db, session_id=s.id, reason="x", reason_category="exhausted_safe_steps")
|
||||
assert calls["target_user_ids"] is None
|
||||
|
||||
@@ -10,7 +10,7 @@ async def test_match_wins_before_category_gate():
|
||||
with patch.object(mob.flow_matching_engine, "find_matches", new=AsyncMock(
|
||||
return_value=[{"tree_id": str(uuid.uuid4()), "tree_name": "VPN", "score": 0.9}])), \
|
||||
patch.object(mob, "get_enabled_categories", new=AsyncMock(return_value=[])):
|
||||
res = await mob.match_or_build(uuid.uuid4(), "vpn down", None, "t1", db=AsyncMock(), force_build=False)
|
||||
res = await mob.match_or_build(uuid.uuid4(), "vpn down", None, db=AsyncMock(), force_build=False)
|
||||
assert res["outcome"] == "matched"
|
||||
assert res["session_kind"] == "flow"
|
||||
|
||||
@@ -19,7 +19,7 @@ async def test_match_wins_before_category_gate():
|
||||
async def test_suggest_band():
|
||||
with patch.object(mob.flow_matching_engine, "find_matches", new=AsyncMock(
|
||||
return_value=[{"tree_id": str(uuid.uuid4()), "tree_name": "X", "score": 0.66}])):
|
||||
res = await mob.match_or_build(uuid.uuid4(), "p", None, "t1", db=AsyncMock(), force_build=False)
|
||||
res = await mob.match_or_build(uuid.uuid4(), "p", None, db=AsyncMock(), force_build=False)
|
||||
assert res["outcome"] == "suggest"
|
||||
assert res["near_miss"]["flow_name"] == "X"
|
||||
assert "flow_id" in res["near_miss"] and isinstance(res["near_miss"]["flow_id"], str)
|
||||
@@ -32,7 +32,7 @@ async def test_out_of_scope_when_category_disabled_on_build_path():
|
||||
with patch.object(mob.flow_matching_engine, "find_matches", new=AsyncMock(return_value=[])), \
|
||||
patch.object(mob, "classify", new=AsyncMock(return_value="printer")), \
|
||||
patch.object(mob, "get_enabled_categories", new=AsyncMock(return_value=["vpn_connect"])):
|
||||
res = await mob.match_or_build(uuid.uuid4(), "printer jam", None, "t1", db=AsyncMock(), force_build=False)
|
||||
res = await mob.match_or_build(uuid.uuid4(), "printer jam", None, db=AsyncMock(), force_build=False)
|
||||
assert res["outcome"] == "out_of_scope"
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ async def test_build_when_enabled_and_no_match():
|
||||
with patch.object(mob.flow_matching_engine, "find_matches", new=AsyncMock(return_value=[])), \
|
||||
patch.object(mob, "classify", new=AsyncMock(return_value="printer")), \
|
||||
patch.object(mob, "get_enabled_categories", new=AsyncMock(return_value=["printer"])):
|
||||
res = await mob.match_or_build(uuid.uuid4(), "printer jam", None, "t1", db=AsyncMock(), force_build=False)
|
||||
res = await mob.match_or_build(uuid.uuid4(), "printer jam", None, db=AsyncMock(), force_build=False)
|
||||
assert res["outcome"] == "build"
|
||||
assert res["session_kind"] == "ai_build"
|
||||
|
||||
@@ -52,7 +52,7 @@ async def test_force_build_skips_match_but_still_gates():
|
||||
with patch.object(mob.flow_matching_engine, "find_matches", new=fm), \
|
||||
patch.object(mob, "classify", new=AsyncMock(return_value="printer")), \
|
||||
patch.object(mob, "get_enabled_categories", new=AsyncMock(return_value=["printer"])):
|
||||
res = await mob.match_or_build(uuid.uuid4(), "p", None, "t1", db=AsyncMock(), force_build=True)
|
||||
res = await mob.match_or_build(uuid.uuid4(), "p", None, db=AsyncMock(), force_build=True)
|
||||
fm.assert_not_called()
|
||||
assert res["outcome"] == "build"
|
||||
|
||||
@@ -61,7 +61,7 @@ async def test_force_build_skips_match_but_still_gates():
|
||||
async def test_score_exactly_match_threshold_is_matched():
|
||||
with patch.object(mob.flow_matching_engine, "find_matches", new=AsyncMock(
|
||||
return_value=[{"tree_id": str(uuid.uuid4()), "tree_name": "X", "score": 0.75}])):
|
||||
res = await mob.match_or_build(uuid.uuid4(), "p", None, "t1", db=AsyncMock(), force_build=False)
|
||||
res = await mob.match_or_build(uuid.uuid4(), "p", None, db=AsyncMock(), force_build=False)
|
||||
assert res["outcome"] == "matched"
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ async def test_score_exactly_match_threshold_is_matched():
|
||||
async def test_score_exactly_suggest_threshold_is_suggest():
|
||||
with patch.object(mob.flow_matching_engine, "find_matches", new=AsyncMock(
|
||||
return_value=[{"tree_id": str(uuid.uuid4()), "tree_name": "X", "score": 0.60}])):
|
||||
res = await mob.match_or_build(uuid.uuid4(), "p", None, "t1", db=AsyncMock(), force_build=False)
|
||||
res = await mob.match_or_build(uuid.uuid4(), "p", None, db=AsyncMock(), force_build=False)
|
||||
assert res["outcome"] == "suggest"
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ async def test_score_below_suggest_falls_through_to_build_path():
|
||||
return_value=[{"tree_id": str(uuid.uuid4()), "tree_name": "X", "score": 0.4}])), \
|
||||
patch.object(mob, "classify", new=AsyncMock(return_value="printer")), \
|
||||
patch.object(mob, "get_enabled_categories", new=AsyncMock(return_value=["printer"])):
|
||||
res = await mob.match_or_build(uuid.uuid4(), "printer", None, "t1", db=AsyncMock(), force_build=False)
|
||||
res = await mob.match_or_build(uuid.uuid4(), "printer", None, db=AsyncMock(), force_build=False)
|
||||
assert res["outcome"] == "build"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user