from __future__ import annotations import os from types import SimpleNamespace import pytest import pytest_asyncio from fastapi import FastAPI from httpx import ASGITransport, AsyncClient from sqlalchemy import JSON from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine os.environ["DATABASE_URL"] = "postgresql+asyncpg://tester:tester@localhost:5432/pmflow_test" from app.api.ai_steward_api import routes_mounted_ai from app.core.database import get_db from app.dependencies.auth import get_current_user from app.dependencies.tenant import get_tenant_id from app.models.base import Base from app.models.mounted_ai_runtime import ( MountedAgentTrace, MountedConversationRun, MountedDraftWorkspace, MountedToolCapabilityPolicy, MountedTopicWorkspace, ) from app.models.training_pipeline import TrainingDataset, TrainingEvaluation, TrainingJob def _use_sqlite_json_types() -> None: for table in ( MountedTopicWorkspace.__table__, MountedDraftWorkspace.__table__, MountedConversationRun.__table__, MountedAgentTrace.__table__, MountedToolCapabilityPolicy.__table__, TrainingDataset.__table__, TrainingJob.__table__, TrainingEvaluation.__table__, ): for column in table.columns: if column.name in { "facts_json", "goals_json", "constraints_json", "options_json", "open_questions", "pending_actions", "bound_entities", "metadata_json", "requirement_card", "draft_payload", "required_confirmations", "context_layers_hit", "tool_calls", "execution_outcome", "input_summary", "output_summary", "tool_trace", "source_scope", "tenant_scope", "policy_metadata", "selection_rules", "channel_scope", "metrics_json", "evaluation_summary", }: column.type = JSON() @pytest_asyncio.fixture async def db_session(): engine = create_async_engine("sqlite+aiosqlite:///:memory:") _use_sqlite_json_types() async with engine.begin() as conn: await conn.run_sync( lambda sync_conn: Base.metadata.create_all( sync_conn, tables=[ MountedTopicWorkspace.__table__, MountedDraftWorkspace.__table__, MountedConversationRun.__table__, MountedAgentTrace.__table__, MountedToolCapabilityPolicy.__table__, TrainingDataset.__table__, TrainingJob.__table__, TrainingEvaluation.__table__, ], ) ) maker = async_sessionmaker(engine, expire_on_commit=False) async with maker() as session: yield session await engine.dispose() def _build_app(db_session): app = FastAPI() app.include_router(routes_mounted_ai.router) async def _fake_get_db(): yield db_session app.dependency_overrides[get_db] = _fake_get_db app.dependency_overrides[get_tenant_id] = lambda: 1 app.dependency_overrides[get_current_user] = lambda: SimpleNamespace( id=9001, tenant_id=1, role="ADMIN", name="管理员", username="admin", ) return app @pytest.mark.asyncio async def test_mounted_ai_admin_routes_return_runtime_views(db_session) -> None: topic = MountedTopicWorkspace( tenant_id=1, topic_key="feishu:chat_1:topic_1", source_system="feishu", channel_ref="chat_1", thread_key="thread_1", mode="plan", status="active", open_questions=["负责人是谁"], readiness_score=0.4, ) db_session.add(topic) await db_session.flush() draft = MountedDraftWorkspace( tenant_id=1, topic_workspace_id=topic.id, state="collecting", draft_version=2, requirement_card={"goals": ["做挂载 AI"]}, ) run = MountedConversationRun( tenant_id=1, trace_id="mounted-run-1", topic_workspace_id=topic.id, source_system="feishu", source_message_id="om_123", mode_before="plan", mode_after="clarify", active_agent="Clarifier", status="completed", trigger_reason="missing_owner", context_layers_hit={"thread": True, "topic": True}, final_reply="还差负责人信息。", ) db_session.add_all([draft, run]) await db_session.flush() trace = MountedAgentTrace( tenant_id=1, conversation_run_id=run.id, agent_name="Clarifier", status="completed", latency_ms=128, input_summary={"mode": "plan"}, output_summary={"question": "这件事打算谁来负责?"}, ) policy = MountedToolCapabilityPolicy( tenant_id=1, tool_name="project.create", risk_level="high", confirm_required=True, auto_execute_allowed=False, ) eval_dataset = TrainingDataset( tenant_id=1, dataset_type="eval", version="teacher-frozen-v1", status="ready", sample_count=18, selection_rules={"suite_kind": "teacher_eval_frozen_set"}, ) db_session.add_all([trace, policy, eval_dataset]) await db_session.flush() eval_job = TrainingJob( tenant_id=1, dataset_id=eval_dataset.id, job_type="sft", base_model_family="gpt-5.4", target_slot="high", status="queued", provider_backend="openai", ) db_session.add(eval_job) await db_session.flush() db_session.add( TrainingEvaluation( tenant_id=1, training_job_id=eval_job.id, evaluation_type="offline_judge", status="completed", score=91.5, evaluation_summary={"pass": True}, ) ) await db_session.commit() app = _build_app(db_session) transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: sources_resp = await client.get("/ai-steward/mounted-ai/sources") assert sources_resp.status_code == 200 assert sources_resp.json()["data"]["items"][0]["source_system"] == "feishu" assert sources_resp.json()["data"]["items"][0]["topic_count"] == 1 topics_resp = await client.get("/ai-steward/mounted-ai/topics") assert topics_resp.status_code == 200 assert topics_resp.json()["data"]["items"][0]["topic_key"] == "feishu:chat_1:topic_1" assert topics_resp.json()["data"]["items"][0]["draft_state"] == "collecting" runs_resp = await client.get("/ai-steward/mounted-ai/runs") assert runs_resp.status_code == 200 assert runs_resp.json()["data"]["items"][0]["active_agent"] == "Clarifier" detail_resp = await client.get(f"/ai-steward/mounted-ai/runs/{run.id}") assert detail_resp.status_code == 200 assert detail_resp.json()["data"]["agent_traces"][0]["agent_name"] == "Clarifier" cutover_resp = await client.get("/ai-steward/mounted-ai/cutover-status") assert cutover_resp.status_code == 200 assert cutover_resp.json()["data"]["mounted_runtime_ready"] is True assert cutover_resp.json()["data"]["tool_policy_count"] == 1 assert cutover_resp.json()["data"]["legacy_cutover_state"] == "retired" assert cutover_resp.json()["data"]["retired_item_count"] >= 4 assert cutover_resp.json()["data"]["active_item_count"] == 0 assert { item["key"]: item["status"] for item in cutover_resp.json()["data"]["legacy_cutover_items"] } == { "ai_pmo_ingress_consultant_capture": "retired", "feishu_mention_consultant_reply": "retired", "signal_capture_legacy_entry": "retired", "legacy_feishu_queue_followup": "retired", } eval_suites_resp = await client.get("/ai-steward/mounted-ai/eval-suites") assert eval_suites_resp.status_code == 200 assert eval_suites_resp.json()["data"]["items"][0]["suite_kind"] == "teacher_eval_frozen_set" assert eval_suites_resp.json()["data"]["items"][0]["latest_score"] == 91.5 eval_suite_detail_resp = await client.get(f"/ai-steward/mounted-ai/eval-suites/{eval_dataset.id}") assert eval_suite_detail_resp.status_code == 200 assert eval_suite_detail_resp.json()["data"]["item"]["sample_count"] == 18 assert eval_suite_detail_resp.json()["data"]["evaluations"][0]["evaluation_type"] == "offline_judge"