"""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_submit_report_rejects_when_sessions_not_closed(self, client, seeded_db): """评估不应在会话未全部 close 时被标 completed(否则进度 0% 但状态完成)。 复现 t480 真实问题:worker 建会话但未 close,analyst 仍提交报告 → submit_report 无条件 executing→completed,completed_sessions=0。 """ eval_id = await _create_executing_eval(client) await _create_session(client, eval_id) # 建 1 个 running 会话,不 close resp = await client.put(f"/api/intelligent-evals/{eval_id}/report", json={"report": _report_payload()}) # 会话未全部 close → 应拒绝(409),而非 200 直接 completed assert resp.status_code == 409, resp.text async def test_submit_report_allows_when_sessions_closed(self, client, seeded_db): """会话全部 close 后提交报告应成功(正常 analyst 路径不被破坏)。""" 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']}/close", json={"verdict": {"goal_achieved": True, "summary": "完成", "severity": "low"}}, ) assert resp.status_code == 200, resp.text resp = await client.put(f"/api/intelligent-evals/{eval_id}/report", json={"report": _report_payload()}) assert resp.status_code == 200, resp.text assert resp.json()["status"] == "completed" 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