PMFlow/backend/tests/test_ai_routing_governance_...

254 lines
9.5 KiB
Python

from __future__ import annotations
import ast
from pathlib import Path
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
from app.api import ai_routing
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.ai_call_log import AICallLog
from app.models.base import Base
from app.models.integration import Integration
from app.services.ai_routing_governance_service import AIRoutingGovernanceService
def _use_sqlite_json_types(*tables) -> None:
for table in tables:
for column in table.columns:
if column.type.__class__.__name__ in {"JSONB", "ARRAY", "Vector"}:
column.type = JSON()
@pytest_asyncio.fixture
async def db_session():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
tables = [Integration.__table__, AICallLog.__table__]
_use_sqlite_json_types(*tables)
async with engine.begin() as conn:
await conn.run_sync(lambda sync_conn: Base.metadata.create_all(sync_conn, tables=tables))
maker = async_sessionmaker(engine, expire_on_commit=False)
async with maker() as session:
yield session
await engine.dispose()
def _engine_bundle() -> dict:
return {
"active_engine_id": "low-1",
"settings": {"enable_local_kb_qa": False, "local_kb_qa_scope": "kb_qa_only"},
"engines": [
{
"id": "low-1",
"name": "Qwen 低阶引擎",
"provider": "glm",
"model": "qwen-plus",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"api_key": "db-low-key",
},
{
"id": "high-1",
"name": "OpenAI 高阶引擎",
"provider": "openai",
"model": "GLM-5",
"base_url": "https://api.edgefn.net/v1",
"api_key": "db-high-key",
},
],
}
@pytest.mark.asyncio
async def test_overview_masks_keys_and_reports_env_override(db_session, monkeypatch) -> None:
monkeypatch.setenv("DASHSCOPE_API_KEY", "local-placeholder")
db_session.add(Integration(tenant_id=1, type="llm_engine", config=_engine_bundle(), is_active=True))
await db_session.commit()
data = await AIRoutingGovernanceService(db_session, tenant_id=1).get_overview()
assert data["config_version"]
assert data["slots"][0]["label"] == "快速槽"
assert data["slots"][0]["api_key_masked"] == "db-l...-key"
assert any(item["code"] == "env_key_override" for item in data["diagnostics"])
assert "api_env" not in str(data)
assert "local-placeholder" not in str(data)
assert "db-low-key" not in str(data)
@pytest.mark.asyncio
async def test_admin_console_reports_protected_downgrade_and_structured_failures(db_session) -> None:
db_session.add(Integration(tenant_id=1, type="llm_engine", config=_engine_bundle(), is_active=True))
db_session.add(
Integration(
tenant_id=1,
type="model_routing",
config={"mappings": {"adaptive_rag_answer_deep": {"tier_alias": "low", "enabled": True}}},
is_active=True,
)
)
for idx in range(3):
db_session.add(
AICallLog(
tenant_id=1,
function_name="pmflow_launch_draft_v1",
prompt=f"prompt {idx}",
response="",
tokens_used=1,
cost=0,
status="structured_empty",
planned_tier="low",
executed_tier="low",
routing_decision="rule_match",
route_source="default",
route_rule_name="engine_low_fallback",
routing_config_version="v3",
routing_config_updated_at="2026-03-26T21:30:00+08:00",
)
)
await db_session.commit()
data = await AIRoutingGovernanceService(db_session, tenant_id=1).get_admin_console()
codes = {item["code"] for item in data["diagnostics"]}
assert "protected_function_downgraded" in codes
assert "structured_failure_rate" in codes
assert data["recent_failures"][0]["status_label"] == "模型返回为空"
assert data["scene_routes"][0]["label"] == "项目启动草案"
@pytest.mark.asyncio
async def test_team_policy_persists_integration_without_runtime_route_changes(db_session) -> None:
service = AIRoutingGovernanceService(db_session, tenant_id=1)
payload = {
"strategy": "quality_first",
"scenes": [{"key": "project_launch", "policy": "深度起草并进入确认", "mode": "深度优先"}],
"automation_boundary": "所有写操作进入待确认。",
}
saved = await service.update_team_policy(payload=payload, updated_by=7)
await db_session.commit()
loaded = await service.get_team_policy()
assert saved["strategy_label"] == "质量优先"
assert loaded["scenes"][0]["policy"] == "深度起草并进入确认"
assert loaded["automation_boundary"] == "所有写操作进入待确认。"
def _build_app(*, role: str = "REVIEWER") -> FastAPI:
app = FastAPI()
app.include_router(ai_routing.router)
class FakeDb:
async def commit(self) -> None:
return None
async def _fake_get_db():
yield FakeDb()
app.dependency_overrides[get_db] = _fake_get_db
app.dependency_overrides[get_tenant_id] = lambda: 9
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(
id=101,
tenant_id=9,
username="tester",
role=role,
)
return app
def test_api_router_exposes_ai_routing_routes() -> None:
router_source = (Path(__file__).parents[1] / "app" / "api" / "router.py").read_text()
router_tree = ast.parse(router_source)
route_assignments = {}
for node in router_tree.body:
if not isinstance(node, ast.Assign):
continue
for target in node.targets:
if isinstance(target, ast.Name) and target.id in {"CORE_ROUTE_MODULES", "OPTIONAL_ROUTE_GROUPS"}:
route_assignments[target.id] = ast.literal_eval(node.value)
assert "ai_routing" in route_assignments["CORE_ROUTE_MODULES"]
assert "model_routing" in route_assignments["CORE_ROUTE_MODULES"]
assert "ai_routing" not in route_assignments["OPTIONAL_ROUTE_GROUPS"]["ai_control"]
assert "model_routing" not in route_assignments["OPTIONAL_ROUTE_GROUPS"]["ai_control"]
route_paths = {str(getattr(route, "path", "")).strip() for route in ai_routing.router.routes}
assert "/ai-routing/overview" in route_paths
assert "/ai-routing/team-policy" in route_paths
assert "/ai-routing/admin-console" in route_paths
@pytest.mark.asyncio
async def test_ai_routing_overview_requires_governance_role() -> None:
app = _build_app(role="BUILDER")
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.get("/ai-routing/overview")
assert response.status_code == 403
assert response.json()["code"] == 403
@pytest.mark.asyncio
async def test_ai_routing_api_returns_enveloped_payloads(monkeypatch) -> None:
class FakeService:
def __init__(self, db, *, tenant_id: int):
assert tenant_id == 9
_ = db
async def get_overview(self):
return {"slots": [], "diagnostics": []}
async def get_team_policy(self):
return {"strategy": "balanced", "scenes": []}
async def update_team_policy(self, *, payload, updated_by: int):
assert updated_by == 101
return {**payload, "updated_by": updated_by}
async def get_admin_console(self):
return {"providers": [], "scene_routes": []}
async def run_diagnostics(self):
return {"items": [], "summary": {"ok": 1}}
async def fake_log_operation(*args, **kwargs):
_ = args, kwargs
monkeypatch.setattr(ai_routing, "AIRoutingGovernanceService", FakeService)
monkeypatch.setattr(ai_routing, "log_operation", fake_log_operation)
app = _build_app(role="OWNER")
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
overview = await client.get("/ai-routing/overview")
policy = await client.get("/ai-routing/team-policy")
saved = await client.put("/ai-routing/team-policy", json={"strategy": "quality_first", "scenes": []})
console = await client.get("/ai-routing/admin-console")
diagnostics = await client.post("/ai-routing/diagnostics/run")
assert overview.json()["code"] == 0
assert policy.json()["data"]["strategy"] == "balanced"
assert saved.json()["data"]["strategy"] == "quality_first"
assert console.json()["data"]["providers"] == []
assert diagnostics.json()["data"]["summary"]["ok"] == 1
@pytest.mark.asyncio
async def test_reviewer_cannot_save_team_policy(monkeypatch) -> None:
class FakeService:
pass
monkeypatch.setattr(ai_routing, "AIRoutingGovernanceService", FakeService)
app = _build_app(role="REVIEWER")
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.put("/ai-routing/team-policy", json={"strategy": "quality_first", "scenes": []})
assert response.status_code == 403
assert response.json()["message"] == "仅 OWNER 可保存团队 AI 策略"