PMFlow/backend/tests/test_ai_readiness_api.py

358 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
from types import SimpleNamespace
import pytest
import pytest_asyncio
from fastapi import FastAPI, HTTPException
from httpx import ASGITransport, AsyncClient
from sqlalchemy import JSON
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.api import ai_readiness_api
from app.core.database import get_db
from app.dependencies.auth import get_current_user
from app.dependencies.permissions import require_platform_ai_ops
from app.models.ai_call_log import AICallLog
from app.models.base import Base
from app.models.integration import Integration
from app.services import ai_readiness_service as ai_readiness_module
from app.services.ai_readiness_service import AIReadinessService
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": "high-1",
"engines": [
{
"id": "low-1",
"name": "Qwen 低阶引擎",
"provider": "dashscope",
"model": "qwen-plus",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"api_key": "low-key",
"enabled": True,
"connection_status": "available",
"last_test_status": "success",
"capabilities": {
"structured_output": True,
"json_schema": True,
"capability_probe_status": "passed",
"capability_evidence": {
"structured_output": {"status": "verified", "source": "probe"},
"json_schema": {"status": "verified", "source": "probe"},
},
},
},
{
"id": "high-1",
"name": "OpenAI 高阶引擎",
"provider": "openai",
"model": "GLM-5",
"base_url": "https://api.edgefn.net/v1",
"api_key": "high-key",
"enabled": True,
"connection_status": "available",
"last_test_status": "success",
},
],
}
def _slot_bundle(*, include_structured: bool) -> dict:
slots = {
"low": {"primary_engine_id": "low-1", "failure_policy": "upgrade_to_high"},
"high": {"primary_engine_id": "high-1", "failure_policy": "explicit_fail"},
}
if include_structured:
slots["structured"] = {"primary_engine_id": "low-1", "failure_policy": "try_fallback_then_fail"}
return {"active": {"slots": slots}, "active_version": "slot-v-test"}
async def _seed_platform_config(session, *, include_structured: bool) -> None:
session.add(Integration(tenant_id=1, type="llm_engine", config=_engine_bundle(), is_active=True))
session.add(Integration(tenant_id=1, type="ai_capability_slots", config=_slot_bundle(include_structured=include_structured), is_active=True))
await session.commit()
@pytest.mark.asyncio
async def test_project_launch_readiness_blocks_when_structured_slot_unpublished(db_session) -> None:
await _seed_platform_config(db_session, include_structured=False)
payload = await AIReadinessService(db_session, tenant_id=12).get_project_launch_readiness()
assert payload["status"] == "blocked"
assert payload["can_generate_project_draft"] is False
assert payload["platform"]["checks"]["active_slot"] == "failed"
assert any(item["code"] == "structured_slot_unpublished" for item in payload["platform"]["blockers"])
assert payload["title"] == "AI 项目草案暂不可用"
@pytest.mark.asyncio
async def test_project_launch_readiness_ready_after_structured_slot_and_success(db_session) -> None:
await _seed_platform_config(db_session, include_structured=True)
db_session.add(
AICallLog(
tenant_id=12,
function_name="pmflow_launch_draft_v1",
prompt="prompt",
response="{}",
tokens_used=1,
cost=0,
status="success",
planned_tier="structured",
executed_tier="structured",
routing_decision="capability_slot",
route_source="slot_binding",
route_rule_name="structured",
)
)
await db_session.commit()
payload = await AIReadinessService(db_session, tenant_id=12).get_project_launch_readiness()
assert payload["status"] == "ready"
assert payload["can_generate_project_draft"] is True
assert payload["platform"]["checks"]["capability_probe"] == "passed"
assert payload["platform"]["checks"]["recent_smoke"] == "passed"
@pytest.mark.asyncio
async def test_project_launch_readiness_uses_platform_smoke_when_newer_than_tenant_failure(db_session) -> None:
await _seed_platform_config(db_session, include_structured=True)
db_session.add_all(
[
AICallLog(
tenant_id=12,
function_name="pmflow_launch_draft_v1",
prompt="prompt",
response="old failure",
tokens_used=1,
cost=0,
status="structured_http_error",
planned_tier="low",
executed_tier="low",
routing_decision="rule_match",
route_source="default",
route_rule_name="engine_low_fallback",
),
AICallLog(
tenant_id=1,
function_name="pmflow_launch_draft_v1",
prompt="prompt",
response="{}",
tokens_used=1,
cost=0,
status="success",
planned_tier="structured",
executed_tier="structured",
routing_decision="capability_slot",
route_source="slot_binding",
route_rule_name="structured",
),
]
)
await db_session.commit()
payload = await AIReadinessService(db_session, tenant_id=12).get_project_launch_readiness()
assert payload["status"] == "ready"
assert payload["can_generate_project_draft"] is True
assert payload["platform"]["checks"]["recent_smoke"] == "passed"
@pytest.mark.asyncio
async def test_project_launch_readiness_keeps_ready_after_single_recent_failure_with_recent_success(db_session) -> None:
await _seed_platform_config(db_session, include_structured=True)
db_session.add_all(
[
AICallLog(
tenant_id=12,
function_name="pmflow_launch_draft_v1",
prompt="prompt",
response="{}",
tokens_used=1,
cost=0,
status="success",
planned_tier="structured",
executed_tier="structured",
routing_decision="capability_slot",
route_source="slot_binding",
route_rule_name="structured",
),
AICallLog(
tenant_id=12,
function_name="pmflow_launch_draft_v1",
prompt="prompt",
response="模型调用失败:structured_empty",
tokens_used=1,
cost=0,
status="structured_empty",
planned_tier="structured",
executed_tier="structured",
routing_decision="capability_slot",
route_source="slot_binding",
route_rule_name="structured",
),
]
)
await db_session.commit()
payload = await AIReadinessService(db_session, tenant_id=12).get_project_launch_readiness()
assert payload["status"] == "ready"
assert payload["can_generate_project_draft"] is True
assert payload["platform"]["checks"]["recent_smoke"] == "passed"
assert payload["platform"]["checks"]["recent_failures"] == "warning"
assert any(item["code"] == "recent_generation_failed" for item in payload["platform"]["blockers"])
@pytest.mark.asyncio
async def test_project_launch_readiness_recovers_after_three_consecutive_recent_failures(db_session) -> None:
await _seed_platform_config(db_session, include_structured=True)
db_session.add_all(
[
AICallLog(
tenant_id=12,
function_name="pmflow_launch_draft_v1",
prompt="prompt",
response="{}",
tokens_used=1,
cost=0,
status="success",
planned_tier="structured",
executed_tier="structured",
routing_decision="capability_slot",
route_source="slot_binding",
route_rule_name="structured",
),
AICallLog(
tenant_id=12,
function_name="pmflow_launch_draft_v1",
prompt="prompt",
response="structured_empty",
tokens_used=1,
cost=0,
status="structured_empty",
planned_tier="structured",
executed_tier="structured",
routing_decision="capability_slot",
route_source="slot_binding",
route_rule_name="structured",
),
AICallLog(
tenant_id=12,
function_name="pmflow_launch_draft_v1",
prompt="prompt",
response="structured_empty",
tokens_used=1,
cost=0,
status="structured_empty",
planned_tier="structured",
executed_tier="structured",
routing_decision="capability_slot",
route_source="slot_binding",
route_rule_name="structured",
),
AICallLog(
tenant_id=12,
function_name="pmflow_launch_draft_v1",
prompt="prompt",
response="structured_empty",
tokens_used=1,
cost=0,
status="structured_empty",
planned_tier="structured",
executed_tier="structured",
routing_decision="capability_slot",
route_source="slot_binding",
route_rule_name="structured",
),
]
)
await db_session.commit()
payload = await AIReadinessService(db_session, tenant_id=12).get_project_launch_readiness()
assert payload["status"] == "recovering"
assert payload["can_generate_project_draft"] is False
assert payload["platform"]["checks"]["recent_smoke"] == "failed"
assert payload["platform"]["checks"]["recent_failures"] == "failed"
@pytest.mark.asyncio
async def test_project_launch_smoke_runs_against_platform_runtime_tenant(db_session, monkeypatch) -> None:
await _seed_platform_config(db_session, include_structured=True)
captured: dict[str, int] = {}
class FakePMFlowLaunchService:
def __init__(self, db, *, tenant_id: int):
_ = db
captured["tenant_id"] = int(tenant_id)
async def generate_draft_payload_for_readiness_check(self, *, user_id: int, profile_stage: str = "active"):
captured["user_id"] = int(user_id)
captured["profile_stage"] = profile_stage
return SimpleNamespace()
monkeypatch.setattr(ai_readiness_module, "PMFlowLaunchService", FakePMFlowLaunchService)
payload = await AIReadinessService(db_session, tenant_id=12).run_project_launch_check(user_id=101)
assert captured == {"tenant_id": 1, "user_id": 101, "profile_stage": "active"}
assert payload["platform"]["checks"]["provider_runtime"] == "passed"
def _build_app(session, *, role: str = "OWNER") -> FastAPI:
app = FastAPI()
app.include_router(ai_readiness_api.router)
fake_user = SimpleNamespace(id=101, tenant_id=12, username="tester", role=role)
async def _fake_get_db():
yield session
async def _fake_platform_ai_ops():
if role != "OWNER":
raise HTTPException(status_code=403, detail="Platform capability required: platform.ai_routing.manage")
return fake_user
app.dependency_overrides[get_db] = _fake_get_db
app.dependency_overrides[get_current_user] = lambda: fake_user
app.dependency_overrides[require_platform_ai_ops] = _fake_platform_ai_ops
return app
@pytest.mark.asyncio
async def test_ai_readiness_api_returns_enveloped_payload(db_session) -> None:
await _seed_platform_config(db_session, include_structured=False)
app = _build_app(db_session)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.get("/ai-readiness/project-launch")
assert response.status_code == 200
body = response.json()
assert body["code"] == 0
assert body["data"]["scene"] == "project_launch"
assert body["data"]["status"] == "blocked"