PMFlow/backend/tests/test_mounted_ai_runtime_api.py

254 lines
8.7 KiB
Python

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"