254 lines
9.5 KiB
Python
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 策略"
|