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, MountedSourceActivation, MountedToolCapabilityPolicy, MountedTopicWorkspace, ) from app.models.reply_iteration import JudgeRun, QualityAsset, QualityAssetPair from app.models.training_pipeline import TrainingDataset, TrainingEvaluation, TrainingJob from app.models.training_pipeline import TrainedModelArtifact, TrainedModelDeployment def _use_sqlite_json_types() -> None: for table in ( MountedTopicWorkspace.__table__, MountedDraftWorkspace.__table__, MountedConversationRun.__table__, MountedAgentTrace.__table__, MountedSourceActivation.__table__, MountedToolCapabilityPolicy.__table__, QualityAsset.__table__, QualityAssetPair.__table__, JudgeRun.__table__, TrainingDataset.__table__, TrainingJob.__table__, TrainingEvaluation.__table__, TrainedModelArtifact.__table__, TrainedModelDeployment.__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", "metadata_json", "source_scope", "tenant_scope", "policy_metadata", "source_snapshot", "tags", "findings", "llm_evidence", "selection_rules", "channel_scope", "metrics_json", "evaluation_summary", "effective_scope", }: 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__, MountedSourceActivation.__table__, MountedToolCapabilityPolicy.__table__, QualityAsset.__table__, QualityAssetPair.__table__, JudgeRun.__table__, TrainingDataset.__table__, TrainingJob.__table__, TrainingEvaluation.__table__, TrainedModelArtifact.__table__, TrainedModelDeployment.__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="REVIEWER", 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": "这件事打算谁来负责?"}, tool_trace=[{"tool_name": "knowledge_graph.search", "status": "completed"}], ) policy = MountedToolCapabilityPolicy( tenant_id=1, tool_name="project.create", risk_level="high", confirm_required=True, auto_execute_allowed=False, ) activation = MountedSourceActivation( tenant_id=1, source_system="feishu", scope_type="chat", scope_ref="oc_runtime_lab", status="active", cutover_mode="mounted_only", auto_reply_enabled=True, metadata_json={"allow_private": True}, ) failure_asset = QualityAsset( tenant_id=1, project_id=None, asset_type="failure", status="approved", prompt_key="mounted.runtime.reply", workflow_type="mounted.plan", asked_via="feishu", question_text="帮我判断下这个需求应该谁来接。", answer_text="目前还缺负责人信息。", corrected_answer="建议先给后端负责人 A,再确认业务 owner。", source_snapshot={"run_id": 1}, tags=["mounted", "failure"], ) golden_asset = QualityAsset( tenant_id=1, project_id=None, asset_type="golden", status="approved", prompt_key="mounted.runtime.reply", workflow_type="mounted.plan", asked_via="feishu", question_text="这个需求先别立项,先帮我收敛。", answer_text="我先帮你收敛目标和范围,再决定是否出草稿。", source_snapshot={"run_id": 2}, tags=["mounted", "golden"], ) 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, activation, policy, failure_asset, golden_asset, eval_dataset]) await db_session.flush() db_session.add( QualityAssetPair( tenant_id=1, pair_type="preference", status="approved", chosen_asset_id=golden_asset.id, rejected_asset_id=failure_asset.id, reason_text="golden answer 更符合 mounted runtime 的自然回复目标", ) ) 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() artifact = TrainedModelArtifact( tenant_id=1, training_job_id=eval_job.id, artifact_ref="artifact:eval:1", base_model_family="gpt-5.4", target_slot="high", status="pending_eval", metrics_json={"offline_judge": 91.5}, ) db_session.add(artifact) await db_session.flush() db_session.add( TrainedModelDeployment( tenant_id=1, artifact_id=artifact.id, deployment_status="candidate", bound_tier="high", effective_scope={"sources": ["feishu"]}, decision="review_pending", ) ) 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}, ) ) db_session.add( JudgeRun( tenant_id=1, asset_id=failure_asset.id, prompt_key="mounted.runtime.reply", workflow_type="mounted.plan", asked_via="feishu", judge_status="completed", bucket_key="mounted_runtime.topic_binding", rule_score=78.0, llm_score=82.0, overall_score=80.0, findings=[{"code": "topic_mismatch"}], metadata_json={"run_id": 1}, ) ) 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" assert detail_resp.json()["data"]["agent_traces"][0]["input_summary"]["mode"] == "plan" assert detail_resp.json()["data"]["agent_traces"][0]["output_summary"]["question"] == "这件事打算谁来负责?" assert detail_resp.json()["data"]["agent_traces"][0]["tool_trace"][0]["tool_name"] == "knowledge_graph.search" 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" activations_resp = await client.get("/ai-steward/mounted-ai/source-activations") assert activations_resp.status_code == 200 assert activations_resp.json()["data"]["items"][0]["cutover_mode"] == "mounted_only" assert activations_resp.json()["data"]["items"][0]["auto_reply_enabled"] is True tool_policies_resp = await client.get("/ai-steward/mounted-ai/tool-policies") assert tool_policies_resp.status_code == 200 assert tool_policies_resp.json()["data"]["items"][0]["tool_name"] == "project.create" assert tool_policies_resp.json()["data"]["items"][0]["confirm_required"] is True assert tool_policies_resp.json()["data"]["items"][0]["auto_execute_allowed"] is False activation_upsert_resp = await client.post( "/ai-steward/mounted-ai/admin/source-activation", json={ "source_system": "feishu", "scope_type": "chat", "scope_ref": "oc_runtime_lab", "status": "active", "cutover_mode": "shadow", "auto_reply_enabled": False, "notes": "切回 shadow 做回放验证", }, ) assert activation_upsert_resp.status_code == 200 assert activation_upsert_resp.json()["data"]["item"]["cutover_mode"] == "shadow" assert activation_upsert_resp.json()["data"]["item"]["auto_reply_enabled"] is False tool_policy_upsert_resp = await client.post( "/ai-steward/mounted-ai/admin/tool-policy", json={ "tool_name": "project.create", "risk_level": "critical", "confirm_required": True, "auto_execute_allowed": False, "is_active": True, "source_scope": {"sources": ["feishu"]}, "tenant_scope": {"roles": ["manager"]}, "policy_metadata": {"reviewers": ["pm_owner"]}, "notes": "高风险工具必须保持人工确认", }, ) assert tool_policy_upsert_resp.status_code == 200 assert tool_policy_upsert_resp.json()["data"]["item"]["risk_level"] == "critical" assert tool_policy_upsert_resp.json()["data"]["item"]["tenant_scope"]["roles"] == ["manager"] training_ops_resp = await client.get("/ai-steward/mounted-ai/training-ops") assert training_ops_resp.status_code == 200 assert training_ops_resp.json()["data"]["dataset_count"] == 1 assert training_ops_resp.json()["data"]["candidate_deployment_count"] == 1 assert training_ops_resp.json()["data"]["approved_golden_asset_count"] == 1 assert training_ops_resp.json()["data"]["regression_buckets"][0]["bucket_key"] == "mounted_runtime.topic_binding" hard_cases_resp = await client.get("/ai-steward/mounted-ai/hard-cases") assert hard_cases_resp.status_code == 200 assert hard_cases_resp.json()["data"]["items"][0]["bucket_key"] == "mounted_runtime.topic_binding" assert hard_cases_resp.json()["data"]["items"][0]["question_text"] == "帮我判断下这个需求应该谁来接。" replay_resp = await client.post( "/ai-steward/mounted-ai/admin/replay-run", json={ "run_id": run.id, "bucket_key": "mounted_runtime.replay.manual_review", "asset_type": "failure", "corrected_answer": "建议先澄清负责人,再决定是否出草稿。", "notes": "人工回放后标记为 hard case", }, ) assert replay_resp.status_code == 200 assert replay_resp.json()["data"]["run_id"] == run.id assert replay_resp.json()["data"]["asset"]["asset_type"] == "failure" assert replay_resp.json()["data"]["judge_run"]["bucket_key"] == "mounted_runtime.replay.manual_review"