PMFlow/backend/tests/test_ai_call_traces_api.py

303 lines
13 KiB
Python
Raw 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 datetime import datetime
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_call_traces_api
from app.core.database import get_db
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
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:")
_use_sqlite_json_types(AICallLog.__table__, Integration.__table__)
async with engine.begin() as conn:
await conn.run_sync(lambda sync_conn: Base.metadata.create_all(sync_conn, tables=[AICallLog.__table__, Integration.__table__]))
maker = async_sessionmaker(engine, expire_on_commit=False)
async with maker() as session:
yield session
await engine.dispose()
@pytest.mark.asyncio
async def test_ai_call_trace_list_and_detail_redact_payload(db_session) -> None:
db_session.add(
Integration(
tenant_id=1,
type="llm_engine",
is_active=True,
config={
"engines": [
{
"id": "engine-qwen-plus",
"name": "阿里云-qwen-plus",
"provider": "dashscope",
"model": "qwen-plus",
}
]
},
)
)
db_session.add(
AICallLog(
tenant_id=1,
user_id=7,
function_name="pmflow_launch_draft_v1",
prompt="用户手机号 13800138000,key=sk-secretsecretsecretsecret,请生成项目草案",
response='{"ok":true,"routing_meta":{"scene_key":"project_launch","slot_key":"structured"}}',
tokens_used=128,
cost=0.001,
status="success",
trace_id="trace-1",
provider="dashscope",
model="qwen-plus",
latency_ms=345,
routing_meta={"scene_key": "project_launch", "slot_key": "structured", "route_source": "active_slot"},
prompt_key="PROMPT_PM_FLOW_LAUNCH_DRAFT",
prompt_release_id=12,
prompt_source="active_release",
provider_request_payload={
"model": "qwen-plus",
"messages": [
{"role": "system", "content": "你是项目助手"},
{"role": "user", "content": "手机号 13800138000,key=sk-secretsecretsecretsecret"},
],
"temperature": 0.0,
"max_tokens": 5600,
},
provider_response_payload={
"id": "cmpl-test",
"choices": [{"message": {"content": "{\"ok\":true}"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 90, "completion_tokens": 38, "total_tokens": 128},
},
provider_attempts=[
{
"provider": "dashscope",
"model": "qwen-plus",
"request_payload": {"model": "qwen-plus", "messages": [{"role": "user", "content": "sk-secretsecretsecretsecret"}]},
"response_payload": {"choices": [{"message": {"content": "{\"ok\":true}"}}]},
"status": "success",
}
],
planned_tier="structured",
executed_tier="structured",
routing_decision="slot",
route_source="active_slot",
route_rule_name="project_launch",
created_at=datetime(2026, 4, 29, 10, 0, 0),
)
)
await db_session.commit()
app = FastAPI()
app.include_router(ai_call_traces_api.router)
async def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[require_platform_ai_ops] = lambda: SimpleNamespace(id=1, role="OWNER")
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
list_response = await client.get("/ai-call-traces?provider=dashscope&scene_key=project_launch&prompt_key=PROMPT_PM_FLOW_LAUNCH_DRAFT")
detail_response = await client.get("/ai-call-traces/trace-1")
assert list_response.status_code == 200
payload = list_response.json()["data"]
assert payload["summary"]["total_calls"] == 1
assert payload["items"][0]["provider"] == "dashscope"
assert payload["items"][0]["provider_code"] == "dashscope"
assert payload["items"][0]["provider_label"] == "Qwen / DashScope"
assert payload["items"][0]["runtime_label"] == "阿里云-qwen-plus"
assert payload["items"][0]["engine_name"] == "阿里云-qwen-plus"
assert payload["items"][0]["function_label"] == "新项目草案生成"
assert payload["items"][0]["scene_key"] == "project_launch"
assert payload["items"][0]["slot_key"] == "structured"
assert payload["items"][0]["prompt_key"] == "PROMPT_PM_FLOW_LAUNCH_DRAFT"
assert payload["items"][0]["prompt_release_id"] == 12
assert payload["items"][0]["prompt_source"] == "active_release"
assert payload["items"][0]["prompt_tokens"] == 90
assert payload["items"][0]["completion_tokens"] == 38
assert payload["items"][0]["request_max_tokens"] == 5600
assert payload["items"][0]["finish_reason"] == "stop"
assert "13800138000" not in payload["items"][0]["prompt_preview"]
assert "sk-secret" not in payload["items"][0]["prompt_preview"]
assert detail_response.status_code == 200
detail = detail_response.json()["data"]
assert detail["request"]["prompt_chars"] > 0
assert detail["runtime_label"] == "阿里云-qwen-plus"
assert "[REDACTED]" in detail["request"]["prompt_redacted"]
assert detail["request"]["provider_request_payload_chars"] > 0
assert "qwen-plus" in detail["request"]["provider_request_payload_redacted"]
assert "13800138000" not in detail["request"]["provider_request_payload_redacted"]
assert "sk-secret" not in detail["request"]["provider_request_payload_redacted"]
assert detail["response"]["provider_response_payload_chars"] > 0
assert detail["response"]["provider_attempts_count"] == 1
assert detail["routing"]["routing_meta"]["slot_key"] == "structured"
assert detail["audit"]["raw_payload_available"] is True
@pytest.mark.asyncio
async def test_ai_call_trace_list_shows_real_capability_probe_calls_by_default(db_session) -> None:
db_session.add_all(
[
AICallLog(
tenant_id=1,
user_id=7,
function_name="probe_engine_capabilities",
prompt="basic_chat,structured_output",
response='{"basic_chat":{"status":"verified"}}',
tokens_used=100,
cost=0.001,
status="capability_probe_partial",
trace_id="trace-probe-summary",
provider="dashscope",
model="qwen-plus",
routing_meta={
"scene_key": "platform_ai_ops",
"routing_decision": "direct_capability_probe",
"route_source": "provider_console",
"probe_run_id": "cap-1",
"probe_targets": ["basic_chat", "structured_output"],
},
provider_attempts=[
{"probe_target": "basic_chat", "status": "success"},
{"probe_target": "structured_output", "status": "success"},
],
created_at=datetime(2026, 4, 29, 10, 1, 0),
),
AICallLog(
tenant_id=1,
user_id=7,
function_name="probe_capability_basic_chat",
prompt="发送最小连通 prompt,要求模型只返回 OK。",
response="OK",
tokens_used=6,
cost=0.00001,
status="capability_verified",
trace_id="trace-probe-basic-chat",
provider="dashscope",
model="qwen-plus",
routing_meta={
"scene_key": "platform_ai_ops",
"routing_decision": "direct_capability_probe",
"route_source": "provider_console",
"probe_run_id": "cap-1",
"probe_target": "basic_chat",
},
created_at=datetime(2026, 4, 29, 10, 1, 1),
),
]
)
await db_session.commit()
app = FastAPI()
app.include_router(ai_call_traces_api.router)
async def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[require_platform_ai_ops] = lambda: SimpleNamespace(id=1, role="OWNER")
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
default_response = await client.get("/ai-call-traces?provider=dashscope")
legacy_summary_response = await client.get("/ai-call-traces?provider=dashscope&function_name=probe_engine_capabilities")
assert default_response.status_code == 200
default_payload = default_response.json()["data"]
assert default_payload["total"] == 1
assert default_payload["summary"]["total_calls"] == 1
assert default_payload["items"][0]["function_name"] == "probe_capability_basic_chat"
assert default_payload["items"][0]["function_label"] == "能力探测:基础聊天"
assert legacy_summary_response.status_code == 200
legacy_summary_payload = legacy_summary_response.json()["data"]
assert legacy_summary_payload["total"] == 1
assert legacy_summary_payload["items"][0]["function_name"] == "probe_engine_capabilities"
assert legacy_summary_payload["items"][0]["function_label"] == "供应商能力探测汇总(历史兼容)"
@pytest.mark.asyncio
async def test_ai_call_trace_structured_timeout_has_specific_diagnosis(db_session) -> None:
db_session.add(
AICallLog(
tenant_id=1,
user_id=7,
function_name="pmflow_launch_draft_v1",
prompt="请生成项目草案",
response="模型调用失败:function=pmflow_launch_draft_v1, reason=upstream_empty_or_invalid_response",
tokens_used=128,
cost=0.001,
status="structured_timeout",
trace_id="trace-timeout",
provider="dashscope",
model="qwen-plus",
routing_meta={
"scene_key": "project_launch",
"slot_key": "structured",
"provider_timeout_seconds": 90,
"job_timeout_seconds": 180,
"input_bucket": "normal",
"scene_timeout_policy_id": "project_launch:pmflow_launch_draft_v1:normal",
"scene_timeout_policy_version": "scene-timeout-v1",
},
provider_attempts=[
{"status": "request_error", "error_type": "ReadTimeout", "error": ""},
{"status": "request_error", "error_type": "ReadTimeout", "error": ""},
],
planned_tier="structured",
executed_tier="structured",
route_source="slot_binding",
created_at=datetime(2026, 5, 1, 19, 37, 42),
)
)
await db_session.commit()
app = FastAPI()
app.include_router(ai_call_traces_api.router)
async def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[require_platform_ai_ops] = lambda: SimpleNamespace(id=1, role="OWNER")
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
list_response = await client.get("/ai-call-traces?status=structured_timeout")
detail_response = await client.get("/ai-call-traces/trace-timeout")
assert list_response.status_code == 200
payload = list_response.json()["data"]
assert payload["summary"]["failed_calls"] == 1
assert payload["items"][0]["status_label"] == "模型超时"
assert payload["items"][0]["error_diagnosis"]["code"] == "model_read_timeout"
assert payload["items"][0]["error_diagnosis"]["attempt_count"] == 2
assert detail_response.status_code == 200
detail = detail_response.json()["data"]
assert detail["runtime_policy"]["provider_timeout_seconds"] == 90
assert detail["runtime_policy"]["job_timeout_seconds"] == 180
assert detail["runtime_policy"]["input_bucket"] == "normal"
assert detail["error_diagnosis"]["attempt_errors"][0]["error_type"] == "ReadTimeout"
assert "fallback runtime" in " ".join(detail["error_diagnosis"]["recommendation"]["recommended_actions"])