544 lines
22 KiB
Python
544 lines
22 KiB
Python
"""Integration tests for intelligent eval lifecycle + session + report API (tickets 02, 03, 04).
|
|
|
|
Uses httpx.AsyncClient with app= to drive the FastAPI app in-process.
|
|
Covers the full lifecycle (create → plan → approve → execute → cancel),
|
|
reject/resubmit flow, illegal transition rejections (409), the session
|
|
lifecycle (create → message → close) with channel I/O stubbed via MockChannel,
|
|
and the report flow (submit → completed → read → Markdown export).
|
|
"""
|
|
|
|
import pytest
|
|
from agenteval.models import ChannelType, EvalTarget, PlatformType, TargetStatus
|
|
from agenteval.storage.repository import TargetRepository
|
|
from agenteval.web.app import app
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
|
|
@pytest.fixture()
|
|
def seeded_db(db_session, monkeypatch):
|
|
"""Patch get_session/get_db to the test session and seed a target."""
|
|
from agenteval.storage import db as db_module
|
|
from agenteval.storage import repository as repo_module
|
|
from agenteval.web import app as app_module
|
|
|
|
monkeypatch.setattr(app_module, "init_db", lambda: None)
|
|
|
|
def _test_get_session():
|
|
return db_session
|
|
|
|
monkeypatch.setattr(db_module, "get_session", _test_get_session)
|
|
monkeypatch.setattr(repo_module, "get_session", _test_get_session)
|
|
|
|
from agenteval.web.deps import get_db
|
|
|
|
def _test_get_db():
|
|
try:
|
|
yield db_session
|
|
finally:
|
|
pass
|
|
|
|
app.dependency_overrides[get_db] = _test_get_db
|
|
|
|
target = EvalTarget(
|
|
id="t-1",
|
|
name="mock-target",
|
|
platform=PlatformType.AI_DIGITAL_EMPLOYEE,
|
|
channel_type=ChannelType.TUTU_API,
|
|
channel_config={"base_url": "http://mock", "token": "x"},
|
|
status=TargetStatus.ACTIVE,
|
|
)
|
|
TargetRepository(db_session).create(target)
|
|
|
|
yield db_session
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
@pytest.fixture()
|
|
async def client():
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
|
yield c
|
|
|
|
|
|
async def _create_eval(client, **overrides) -> dict:
|
|
payload = {
|
|
"name": "客服助手智能评估",
|
|
"target_id": "t-1",
|
|
"goal": "评估退货流程处理能力",
|
|
"seeds": {"personas": ["急躁老客户"], "goals": ["完成退货"]},
|
|
"intent": "考察退货全流程",
|
|
"role_description": "模拟真实用户",
|
|
"time_window_hours": 24,
|
|
}
|
|
payload.update(overrides)
|
|
resp = await client.post("/api/intelligent-evals", json=payload)
|
|
assert resp.status_code == 200, resp.text
|
|
return resp.json()
|
|
|
|
|
|
async def _submit_plan(client, eval_id: str, plan: dict | None = None) -> dict:
|
|
if plan is None:
|
|
plan = {
|
|
"dimensions": ["退货流程", "投诉处理"],
|
|
"virtual_users": [{"persona": {"background": "老客户"}, "goal": "完成退货"}],
|
|
"time_distribution": [{"time_slot": "0-2h", "sessions": 1, "scenario": "早间咨询"}],
|
|
"estimated_sessions": 3,
|
|
"budget": {"max_turns_per_session": 12, "total_max_turns": 36},
|
|
"completion_criteria": "每个维度至少一个会话",
|
|
}
|
|
resp = await client.put(f"/api/intelligent-evals/{eval_id}/plan", json={"plan": plan})
|
|
assert resp.status_code == 200, resp.text
|
|
return resp.json()
|
|
|
|
|
|
class TestCreateEval:
|
|
async def test_create_transitions_to_planning(self, client, seeded_db):
|
|
data = await _create_eval(client)
|
|
assert data["status"] == "planning"
|
|
assert data["name"] == "客服助手智能评估"
|
|
assert data["goal"] == "评估退货流程处理能力"
|
|
assert data["time_window_hours"] == 24
|
|
assert "id" in data
|
|
|
|
async def test_create_requires_name(self, client, seeded_db):
|
|
resp = await client.post(
|
|
"/api/intelligent-evals",
|
|
json={
|
|
"name": "",
|
|
"target_id": "t-1",
|
|
"goal": "test",
|
|
},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
async def test_create_requires_goal(self, client, seeded_db):
|
|
resp = await client.post(
|
|
"/api/intelligent-evals",
|
|
json={
|
|
"name": "test",
|
|
"target_id": "t-1",
|
|
"goal": "",
|
|
},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
async def test_create_requires_existing_target(self, client, seeded_db):
|
|
resp = await client.post(
|
|
"/api/intelligent-evals",
|
|
json={"name": "test", "target_id": "missing", "goal": "evaluate"},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
|
|
class TestPlanApproval:
|
|
async def test_submit_plan_transitions_to_pending_approval(self, client, seeded_db):
|
|
ev = await _create_eval(client)
|
|
data = await _submit_plan(client, ev["id"])
|
|
assert data["status"] == "pending_approval"
|
|
assert data["plan"]["dimensions"] == ["退货流程", "投诉处理"]
|
|
|
|
async def test_approve_transitions_to_executing(self, client, seeded_db):
|
|
ev = await _create_eval(client)
|
|
await _submit_plan(client, ev["id"])
|
|
resp = await client.post(f"/api/intelligent-evals/{ev['id']}/approve")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["status"] == "executing"
|
|
assert data["started_at"] is not None
|
|
|
|
async def test_reject_transitions_back_to_planning(self, client, seeded_db):
|
|
ev = await _create_eval(client)
|
|
await _submit_plan(client, ev["id"])
|
|
resp = await client.post(f"/api/intelligent-evals/{ev['id']}/reject", json={"feedback": "缺少投诉维度"})
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["status"] == "planning"
|
|
assert data["plan_feedback"] == "缺少投诉维度"
|
|
|
|
async def test_reject_then_resubmit(self, client, seeded_db):
|
|
ev = await _create_eval(client)
|
|
await _submit_plan(client, ev["id"], plan={"dimensions": ["A"]})
|
|
await client.post(f"/api/intelligent-evals/{ev['id']}/reject", json={"feedback": "加B"})
|
|
data = await _submit_plan(client, ev["id"], plan={"dimensions": ["A", "B"]})
|
|
assert data["status"] == "pending_approval"
|
|
assert data["plan"]["dimensions"] == ["A", "B"]
|
|
assert data["plan_feedback"] is None
|
|
|
|
|
|
class TestCancel:
|
|
async def _create_executing(self, client) -> str:
|
|
ev = await _create_eval(client)
|
|
await _submit_plan(client, ev["id"])
|
|
await client.post(f"/api/intelligent-evals/{ev['id']}/approve")
|
|
return ev["id"]
|
|
|
|
async def test_cancel_executing(self, client, seeded_db):
|
|
eval_id = await self._create_executing(client)
|
|
resp = await client.post(f"/api/intelligent-evals/{eval_id}/cancel")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["status"] == "cancelled"
|
|
assert data["completed_at"] is not None
|
|
|
|
async def test_cancel_pending_approval(self, client, seeded_db):
|
|
ev = await _create_eval(client)
|
|
await _submit_plan(client, ev["id"])
|
|
resp = await client.post(f"/api/intelligent-evals/{ev['id']}/cancel")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "cancelled"
|
|
|
|
|
|
class TestIllegalTransitions:
|
|
async def test_approve_from_planning_returns_409(self, client, seeded_db):
|
|
ev = await _create_eval(client)
|
|
resp = await client.post(f"/api/intelligent-evals/{ev['id']}/approve")
|
|
assert resp.status_code == 409
|
|
|
|
async def test_double_cancel_returns_409(self, client, seeded_db):
|
|
ev = await _create_eval(client)
|
|
await _submit_plan(client, ev["id"])
|
|
await client.post(f"/api/intelligent-evals/{ev['id']}/approve")
|
|
await client.post(f"/api/intelligent-evals/{ev['id']}/cancel")
|
|
resp = await client.post(f"/api/intelligent-evals/{ev['id']}/cancel")
|
|
assert resp.status_code == 409
|
|
|
|
async def test_submit_plan_from_executing_returns_409(self, client, seeded_db):
|
|
ev = await _create_eval(client)
|
|
await _submit_plan(client, ev["id"])
|
|
await client.post(f"/api/intelligent-evals/{ev['id']}/approve")
|
|
resp = await client.put(f"/api/intelligent-evals/{ev['id']}/plan", json={"plan": {"new": True}})
|
|
assert resp.status_code == 409
|
|
|
|
async def test_not_found_returns_404(self, client, seeded_db):
|
|
resp = await client.get("/api/intelligent-evals/nonexistent")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
class TestListAndGet:
|
|
async def test_list_empty(self, client, seeded_db):
|
|
resp = await client.get("/api/intelligent-evals")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["intelligent_evals"] == []
|
|
|
|
async def test_list_returns_created(self, client, seeded_db):
|
|
await _create_eval(client, name="e1")
|
|
await _create_eval(client, name="e2")
|
|
resp = await client.get("/api/intelligent-evals")
|
|
assert len(resp.json()["intelligent_evals"]) == 2
|
|
|
|
async def test_get_by_id(self, client, seeded_db):
|
|
ev = await _create_eval(client, name="e1")
|
|
resp = await client.get(f"/api/intelligent-evals/{ev['id']}")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["name"] == "e1"
|
|
|
|
|
|
async def _create_executing_eval(client) -> str:
|
|
ev = await _create_eval(client)
|
|
await _submit_plan(client, ev["id"])
|
|
await client.post(f"/api/intelligent-evals/{ev['id']}/approve")
|
|
return ev["id"]
|
|
|
|
|
|
async def _create_session(client, eval_id: str, **overrides) -> dict:
|
|
payload = {
|
|
"persona": {"background": "急躁老客户", "style": "直接"},
|
|
"goal": "完成退货",
|
|
"dimension": "退货流程",
|
|
}
|
|
payload.update(overrides)
|
|
resp = await client.post(f"/api/intelligent-evals/{eval_id}/sessions", json=payload)
|
|
assert resp.status_code == 200, resp.text
|
|
return resp.json()
|
|
|
|
|
|
class TestCreateSession:
|
|
async def test_create_session_in_executing(self, client, seeded_db):
|
|
eval_id = await _create_executing_eval(client)
|
|
data = await _create_session(client, eval_id)
|
|
assert data["eval_id"] == eval_id
|
|
assert data["target_id"] == "t-1"
|
|
assert data["status"] == "running"
|
|
assert data["persona"]["background"] == "急躁老客户"
|
|
assert data["dimension"] == "退货流程"
|
|
assert data["turn_count"] == 0
|
|
|
|
async def test_create_session_in_planning_returns_409(self, client, seeded_db):
|
|
ev = await _create_eval(client)
|
|
resp = await client.post(f"/api/intelligent-evals/{ev['id']}/sessions", json={"goal": "g"})
|
|
assert resp.status_code == 409
|
|
|
|
async def test_create_session_requires_goal(self, client, seeded_db):
|
|
eval_id = await _create_executing_eval(client)
|
|
resp = await client.post(f"/api/intelligent-evals/{eval_id}/sessions", json={"goal": ""})
|
|
assert resp.status_code == 422
|
|
|
|
async def test_create_session_unknown_eval_returns_404(self, client, seeded_db):
|
|
resp = await client.post("/api/intelligent-evals/nope/sessions", json={"goal": "g"})
|
|
assert resp.status_code == 404
|
|
|
|
async def test_eval_response_counts_sessions(self, client, seeded_db):
|
|
eval_id = await _create_executing_eval(client)
|
|
await _create_session(client, eval_id)
|
|
resp = await client.get(f"/api/intelligent-evals/{eval_id}")
|
|
data = resp.json()
|
|
assert data["session_count"] == 1
|
|
assert data["completed_sessions"] == 0
|
|
assert len(data["sessions"]) == 1
|
|
assert "messages" not in data["sessions"][0]
|
|
|
|
|
|
class TestConductTurn:
|
|
@pytest.fixture()
|
|
def mock_channel(self, monkeypatch):
|
|
from agenteval.intelligent_eval import lifecycle as lifecycle_module
|
|
|
|
from tests.unit.mock_channel import MockChannel
|
|
|
|
channel = MockChannel(reply_text="好的,已为您发起退货申请")
|
|
|
|
class _StubFactory:
|
|
@staticmethod
|
|
def create(target):
|
|
return channel
|
|
|
|
monkeypatch.setattr(lifecycle_module, "ChannelFactory", _StubFactory)
|
|
return channel
|
|
|
|
async def test_turn_round_trip_persists_messages(self, client, seeded_db, mock_channel):
|
|
eval_id = await _create_executing_eval(client)
|
|
session = await _create_session(client, eval_id)
|
|
resp = await client.post(
|
|
f"/api/intelligent-evals/{eval_id}/sessions/{session['id']}/messages",
|
|
json={"content": "我要退货"},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
data = resp.json()
|
|
assert data["reply"] == "好的,已为您发起退货申请"
|
|
assert data["turn_count"] == 1
|
|
|
|
msgs = await client.get(f"/api/intelligent-evals/{eval_id}/sessions/{session['id']}/messages")
|
|
messages = msgs.json()["messages"]
|
|
assert len(messages) == 2
|
|
assert messages[0]["role"] == "user"
|
|
assert messages[0]["content"] == "我要退货"
|
|
assert messages[1]["role"] == "assistant"
|
|
assert messages[1]["content"] == "好的,已为您发起退货申请"
|
|
assert messages[1]["latency_ms"] is not None
|
|
|
|
detail = await client.get(f"/api/intelligent-evals/{eval_id}/sessions")
|
|
assert detail.json()["sessions"][0]["turn_count"] == 1
|
|
|
|
async def test_turn_increments_across_rounds(self, client, seeded_db, mock_channel):
|
|
eval_id = await _create_executing_eval(client)
|
|
session = await _create_session(client, eval_id)
|
|
for expected in (1, 2, 3):
|
|
resp = await client.post(
|
|
f"/api/intelligent-evals/{eval_id}/sessions/{session['id']}/messages",
|
|
json={"content": f"第{expected}轮"},
|
|
)
|
|
assert resp.json()["turn_count"] == expected
|
|
|
|
async def test_turn_unknown_session_returns_404(self, client, seeded_db, mock_channel):
|
|
resp = await client.post("/api/intelligent-evals/nope/sessions/nope/messages", json={"content": "hi"})
|
|
assert resp.status_code == 404
|
|
|
|
async def test_channel_send_failure_returns_502(self, client, seeded_db, monkeypatch):
|
|
from agenteval.intelligent_eval import lifecycle as lifecycle_module
|
|
|
|
from tests.unit.mock_channel import MockChannel
|
|
|
|
channel = MockChannel(send_ok=False)
|
|
|
|
class _StubFactory:
|
|
@staticmethod
|
|
def create(target):
|
|
return channel
|
|
|
|
monkeypatch.setattr(lifecycle_module, "ChannelFactory", _StubFactory)
|
|
eval_id = await _create_executing_eval(client)
|
|
session = await _create_session(client, eval_id)
|
|
resp = await client.post(
|
|
f"/api/intelligent-evals/{eval_id}/sessions/{session['id']}/messages",
|
|
json={"content": "我要退货"},
|
|
)
|
|
assert resp.status_code == 502
|
|
|
|
|
|
class TestCloseSession:
|
|
async def test_close_records_verdict(self, client, seeded_db):
|
|
eval_id = await _create_executing_eval(client)
|
|
session = await _create_session(client, eval_id)
|
|
verdict = {"passed": True, "score": 0.85, "notes": "退货流程顺畅"}
|
|
resp = await client.post(
|
|
f"/api/intelligent-evals/{eval_id}/sessions/{session['id']}/close",
|
|
json={"verdict": verdict},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
data = resp.json()
|
|
assert data["status"] == "completed"
|
|
assert data["verdict"] == verdict
|
|
assert data["closed_at"] is not None
|
|
|
|
eval_resp = await client.get(f"/api/intelligent-evals/{eval_id}")
|
|
assert eval_resp.json()["completed_sessions"] == 1
|
|
|
|
async def test_double_close_returns_409(self, client, seeded_db):
|
|
eval_id = await _create_executing_eval(client)
|
|
session = await _create_session(client, eval_id)
|
|
await client.post(
|
|
f"/api/intelligent-evals/{eval_id}/sessions/{session['id']}/close",
|
|
json={"verdict": {"passed": True}},
|
|
)
|
|
resp = await client.post(
|
|
f"/api/intelligent-evals/{eval_id}/sessions/{session['id']}/close",
|
|
json={"verdict": {"passed": True}},
|
|
)
|
|
assert resp.status_code == 409
|
|
|
|
async def test_message_after_close_returns_409(self, client, seeded_db, monkeypatch):
|
|
from agenteval.intelligent_eval import lifecycle as lifecycle_module
|
|
|
|
from tests.unit.mock_channel import MockChannel
|
|
|
|
class _StubFactory:
|
|
@staticmethod
|
|
def create(target):
|
|
return MockChannel()
|
|
|
|
monkeypatch.setattr(lifecycle_module, "ChannelFactory", _StubFactory)
|
|
eval_id = await _create_executing_eval(client)
|
|
session = await _create_session(client, eval_id)
|
|
await client.post(
|
|
f"/api/intelligent-evals/{eval_id}/sessions/{session['id']}/close",
|
|
json={"verdict": {"passed": True}},
|
|
)
|
|
resp = await client.post(
|
|
f"/api/intelligent-evals/{eval_id}/sessions/{session['id']}/messages",
|
|
json={"content": "还在吗"},
|
|
)
|
|
assert resp.status_code == 409
|
|
|
|
async def test_close_unknown_session_returns_404(self, client, seeded_db):
|
|
resp = await client.post(
|
|
"/api/intelligent-evals/nope/sessions/nope/close",
|
|
json={"verdict": {"passed": True}},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
|
|
class TestListSessions:
|
|
async def test_list_sessions_unknown_eval_returns_404(self, client, seeded_db):
|
|
resp = await client.get("/api/intelligent-evals/nope/sessions")
|
|
assert resp.status_code == 404
|
|
|
|
async def test_messages_unknown_session_returns_404(self, client, seeded_db):
|
|
resp = await client.get("/api/intelligent-evals/nope/sessions/nope/messages")
|
|
assert resp.status_code == 404
|
|
|
|
async def test_session_accessed_via_wrong_eval_returns_404(self, client, seeded_db):
|
|
eval_id = await _create_executing_eval(client)
|
|
session = await _create_session(client, eval_id)
|
|
other = await _create_eval(client, name="other")
|
|
resp = await client.get(f"/api/intelligent-evals/{other['id']}/sessions/{session['id']}/messages")
|
|
assert resp.status_code == 404
|
|
|
|
async def test_session_mutation_via_wrong_eval_returns_404(self, client, seeded_db):
|
|
eval_id = await _create_executing_eval(client)
|
|
session = await _create_session(client, eval_id)
|
|
other = await _create_executing_eval(client)
|
|
|
|
message = await client.post(
|
|
f"/api/intelligent-evals/{other}/sessions/{session['id']}/messages",
|
|
json={"content": "越权消息"},
|
|
)
|
|
assert message.status_code == 404
|
|
|
|
closed = await client.post(
|
|
f"/api/intelligent-evals/{other}/sessions/{session['id']}/close",
|
|
json={"verdict": {"passed": False}},
|
|
)
|
|
assert closed.status_code == 404
|
|
|
|
|
|
def _report_payload() -> dict:
|
|
return {
|
|
"summary": "整体表现良好。",
|
|
"scores": {"退货流程": 0.7},
|
|
"findings": [
|
|
{
|
|
"issue": "未主动确认订单号",
|
|
"severity": "high",
|
|
"dimension": "退货流程",
|
|
"evidence": [
|
|
{
|
|
"session_id": "s-1",
|
|
"turn_index": 3,
|
|
"user_said": "我要退货",
|
|
"assistant_replied": "好的",
|
|
}
|
|
],
|
|
"suggestion": "增加确认步骤",
|
|
}
|
|
],
|
|
"highlights": [{"description": "上下文连贯", "dimension": "多轮追问"}],
|
|
"priority_recommendations": ["先修退货确认"],
|
|
}
|
|
|
|
|
|
class TestReport:
|
|
async def test_submit_report_completes_eval(self, client, seeded_db):
|
|
eval_id = await _create_executing_eval(client)
|
|
resp = await client.put(f"/api/intelligent-evals/{eval_id}/report", json={"report": _report_payload()})
|
|
assert resp.status_code == 200, resp.text
|
|
data = resp.json()
|
|
assert data["status"] == "completed"
|
|
assert data["completed_at"] is not None
|
|
assert data["report"]["summary"] == "整体表现良好。"
|
|
|
|
async def test_get_report_round_trip(self, client, seeded_db):
|
|
eval_id = await _create_executing_eval(client)
|
|
await client.put(f"/api/intelligent-evals/{eval_id}/report", json={"report": _report_payload()})
|
|
resp = await client.get(f"/api/intelligent-evals/{eval_id}/report")
|
|
assert resp.status_code == 200
|
|
report = resp.json()
|
|
assert report["summary"] == "整体表现良好。"
|
|
assert report["findings"][0]["issue"] == "未主动确认订单号"
|
|
|
|
async def test_markdown_export(self, client, seeded_db):
|
|
eval_id = await _create_executing_eval(client)
|
|
await client.put(f"/api/intelligent-evals/{eval_id}/report", json={"report": _report_payload()})
|
|
resp = await client.get(f"/api/intelligent-evals/{eval_id}/report/markdown")
|
|
assert resp.status_code == 200
|
|
assert resp.headers["content-type"].startswith("text/markdown")
|
|
assert "# 客服助手智能评估" in resp.text
|
|
assert "未主动确认订单号" in resp.text
|
|
|
|
async def test_submit_report_requires_executing(self, client, seeded_db):
|
|
ev = await _create_eval(client) # planning state
|
|
resp = await client.put(f"/api/intelligent-evals/{ev['id']}/report", json={"report": _report_payload()})
|
|
assert resp.status_code == 409
|
|
|
|
async def test_submit_report_rejects_invalid_structure(self, client, seeded_db):
|
|
eval_id = await _create_executing_eval(client)
|
|
bad = {"summary": "缺 findings"}
|
|
resp = await client.put(f"/api/intelligent-evals/{eval_id}/report", json={"report": bad})
|
|
assert resp.status_code == 422
|
|
|
|
async def test_submit_report_rejects_finding_missing_fields(self, client, seeded_db):
|
|
eval_id = await _create_executing_eval(client)
|
|
payload = _report_payload()
|
|
payload["findings"] = [{"issue": "只有 issue"}]
|
|
resp = await client.put(f"/api/intelligent-evals/{eval_id}/report", json={"report": payload})
|
|
assert resp.status_code == 422
|
|
|
|
async def test_get_report_before_submission_returns_404(self, client, seeded_db):
|
|
eval_id = await _create_executing_eval(client)
|
|
resp = await client.get(f"/api/intelligent-evals/{eval_id}/report")
|
|
assert resp.status_code == 404
|
|
|
|
async def test_markdown_before_submission_returns_404(self, client, seeded_db):
|
|
eval_id = await _create_executing_eval(client)
|
|
resp = await client.get(f"/api/intelligent-evals/{eval_id}/report/markdown")
|
|
assert resp.status_code == 404
|