PMFlow/backend/tests/test_prompt_assets_api.py

389 lines
17 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 text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
os.environ["DATABASE_URL"] = "postgresql+asyncpg://pmflow:pmflow_pass@127.0.0.1:5435/pmflow"
from app.api import prompt_assets_api
from app.core.database import get_db
from app.dependencies.permissions import require_platform_ai_ops
from app.dependencies.tenant import get_tenant_id
from app.models.base import Base
from app.models.ai_call_log import AICallLog
from app.models.reply_iteration import PromptRelease, PromptReleaseCheck
from app.services.prompt_release.service import PromptReleaseService
@pytest_asyncio.fixture
async def db_session():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(
lambda sync_conn: Base.metadata.create_all(
sync_conn,
tables=[PromptRelease.__table__],
)
)
await conn.run_sync(
lambda sync_conn: Base.metadata.create_all(
sync_conn,
tables=[AICallLog.__table__],
)
)
await conn.execute(
text(
"""
CREATE TABLE prompt_release_checks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id BIGINT NOT NULL,
prompt_key VARCHAR(128) NOT NULL,
release_id INTEGER NOT NULL,
check_type VARCHAR(48) NOT NULL,
status VARCHAR(24) NOT NULL,
severity VARCHAR(16) NOT NULL DEFAULT 'info',
summary TEXT NOT NULL,
details_json JSON,
created_by BIGINT,
completed_at VARCHAR(64),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
)
"""
)
)
await conn.execute(
text(
"""
CREATE TABLE prompt_golden_cases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id BIGINT NOT NULL,
prompt_key VARCHAR(128) NOT NULL,
case_name VARCHAR(255) NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'active',
input_payload JSON,
expected_contract JSON,
expected_contains TEXT,
source VARCHAR(48) NOT NULL DEFAULT 'manual',
source_ai_call_log_id INTEGER,
case_type VARCHAR(32) NOT NULL DEFAULT 'regression',
priority VARCHAR(24) NOT NULL DEFAULT 'blocking',
scenario_key VARCHAR(128),
tags JSON,
last_run_status VARCHAR(24),
last_failed_at VARCHAR(64),
created_by BIGINT,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
)
"""
)
)
await conn.execute(
text(
"""
CREATE TABLE prompt_replay_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id BIGINT NOT NULL,
prompt_key VARCHAR(128) NOT NULL,
release_id INTEGER NOT NULL,
baseline_release_id INTEGER,
run_status VARCHAR(24) NOT NULL DEFAULT 'pending',
run_mode VARCHAR(32) NOT NULL DEFAULT 'contract_smoke',
case_count INTEGER NOT NULL DEFAULT 0,
passed_count INTEGER NOT NULL DEFAULT 0,
failed_count INTEGER NOT NULL DEFAULT 0,
warning_count INTEGER NOT NULL DEFAULT 0,
score_delta FLOAT,
summary TEXT,
risk_flags_json JSON,
created_by BIGINT,
started_at VARCHAR(64),
completed_at VARCHAR(64),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
)
"""
)
)
await conn.execute(
text(
"""
CREATE TABLE prompt_replay_case_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id BIGINT NOT NULL,
run_id INTEGER NOT NULL,
golden_case_id INTEGER NOT NULL,
prompt_key VARCHAR(128) NOT NULL,
release_id INTEGER NOT NULL,
baseline_output TEXT,
candidate_output TEXT,
status VARCHAR(24) NOT NULL,
contract_result_json JSON,
diff_summary TEXT,
risk_flags_json JSON,
baseline_ai_call_log_id INTEGER,
candidate_ai_call_log_id INTEGER,
judge_score FLOAT,
judge_label VARCHAR(32),
judge_reason TEXT,
latency_ms INTEGER,
estimated_cost FLOAT,
error_summary TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
)
"""
)
)
maker = async_sessionmaker(engine, expire_on_commit=False)
async with maker() as session:
yield session
await engine.dispose()
def _build_app(db_session) -> FastAPI:
app = FastAPI()
app.include_router(prompt_assets_api.router)
async def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[get_tenant_id] = lambda: 1
app.dependency_overrides[require_platform_ai_ops] = lambda: SimpleNamespace(id=1001, tenant_id=1, role="AI_OPS")
return app
@pytest.mark.asyncio
async def test_prompt_assets_api_lists_and_reads_detail(db_session) -> None:
release_service = PromptReleaseService(db_session)
release = await release_service.create_release(
tenant_id=1,
prompt_key="PROMPT_FEISHU_DIRECT_QA",
body_snapshot="运营定制:{question}",
created_by=1001,
)
await release_service.activate_release(tenant_id=1, release_id=release["id"], actor_id=1001)
app = _build_app(db_session)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
list_response = await client.get("/prompt-assets?keyword=PROMPT_FEISHU_DIRECT_QA")
detail_response = await client.get("/prompt-assets/PROMPT_FEISHU_DIRECT_QA")
assert list_response.status_code == 200
payload = list_response.json()["data"]
assert payload["total"] == 1
assert payload["items"][0]["active_release"]["version"] == release["version"]
assert detail_response.status_code == 200
detail = detail_response.json()["data"]
assert detail["prompt_key"] == "PROMPT_FEISHU_DIRECT_QA"
assert detail["default_template_available"] is True
assert "default_template_body" in detail
assert detail["releases"][0]["body_snapshot"] == "运营定制:{question}"
@pytest.mark.asyncio
async def test_prompt_assets_api_manages_release_lifecycle(db_session) -> None:
app = _build_app(db_session)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
first_response = await client.post(
"/prompt-assets/PROMPT_FEISHU_DIRECT_QA/releases",
json={"body_snapshot": "第一版:{question}", "notes": "initial"},
)
second_response = await client.post(
"/prompt-assets/PROMPT_FEISHU_DIRECT_QA/releases",
json={"body_snapshot": "第二版:{question}", "notes": "improve tone"},
)
assert first_response.status_code == 200
assert second_response.status_code == 200
first = first_response.json()["data"]
second = second_response.json()["data"]
assert first["version"] == 1
assert second["version"] == 2
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
activate_first = await client.post(f"/prompt-assets/PROMPT_FEISHU_DIRECT_QA/releases/{first['id']}/activate")
activate_second = await client.post(f"/prompt-assets/PROMPT_FEISHU_DIRECT_QA/releases/{second['id']}/activate")
rollback_first = await client.post(f"/prompt-assets/PROMPT_FEISHU_DIRECT_QA/releases/{first['id']}/rollback")
detail_response = await client.get("/prompt-assets/PROMPT_FEISHU_DIRECT_QA")
assert activate_first.status_code == 200
assert activate_second.status_code == 200
assert activate_second.json()["data"]["status"] == "active"
assert rollback_first.status_code == 200
assert rollback_first.json()["data"]["id"] == first["id"]
detail = detail_response.json()["data"]
assert detail["active_release"]["id"] == first["id"]
statuses = {item["id"]: item["status"] for item in detail["releases"]}
assert statuses[first["id"]] == "active"
assert statuses[second["id"]] == "archived"
@pytest.mark.asyncio
async def test_prompt_assets_api_runs_guarded_release_gate_and_blocks_activation(db_session) -> None:
app = _build_app(db_session)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
create_response = await client.post(
"/prompt-assets/PROMPT_RAG_QUERY_SEMANTICS/releases",
json={"body_snapshot": "门禁候选:{message}\\n{workflow_bundle}\\n{planner_message_intent}\\n请输出 JSON。", "notes": "guarded candidate"},
)
assert create_response.status_code == 200
release = create_response.json()["data"]
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
activate_before_check = await client.post(f"/prompt-assets/PROMPT_RAG_QUERY_SEMANTICS/releases/{release['id']}/activate")
checks_response = await client.post(f"/prompt-assets/PROMPT_RAG_QUERY_SEMANTICS/releases/{release['id']}/checks/run")
list_checks_response = await client.get(f"/prompt-assets/PROMPT_RAG_QUERY_SEMANTICS/releases/{release['id']}/checks")
activate_after_check = await client.post(f"/prompt-assets/PROMPT_RAG_QUERY_SEMANTICS/releases/{release['id']}/activate")
assert activate_before_check.status_code == 400
assert "blocked by gate" in activate_before_check.json()["message"]
assert checks_response.status_code == 200
checks = checks_response.json()["data"]
assert checks["gate"]["status"] == "blocked"
assert {item["check_type"] for item in checks["items"]} == {"variable_contract", "schema_smoke", "golden_replay"}
assert any(item["check_type"] == "golden_replay" and item["status"] == "skipped" for item in checks["items"])
assert list_checks_response.status_code == 200
assert list_checks_response.json()["data"]["gate"]["status"] == "blocked"
assert activate_after_check.status_code == 400
@pytest.mark.asyncio
async def test_prompt_assets_api_creates_golden_case_from_call_and_quality_summary(db_session) -> None:
release_service = PromptReleaseService(db_session)
release = await release_service.create_release(
tenant_id=1,
prompt_key="PROMPT_FEISHU_DIRECT_QA",
body_snapshot="运营定制:{question}",
created_by=1001,
)
db_session.add(
AICallLog(
tenant_id=1,
function_name="adaptive_rag_answer_normal",
prompt="运营定制:这周项目风险是什么?",
response="[error] provider timeout",
status="failed",
prompt_key="PROMPT_FEISHU_DIRECT_QA",
prompt_release_id=release["id"],
prompt_source="active_release",
latency_ms=300,
cost=0.004,
)
)
await db_session.commit()
app = _build_app(db_session)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
quality_response = await client.get("/prompt-assets/PROMPT_FEISHU_DIRECT_QA/quality-summary?window=7d")
case_response = await client.post(
"/prompt-assets/PROMPT_FEISHU_DIRECT_QA/golden-cases/from-call/1",
json={"case_name": "失败调用回归样本"},
)
list_response = await client.get("/prompt-assets/PROMPT_FEISHU_DIRECT_QA/golden-cases")
watch_response = await client.get(f"/prompt-assets/PROMPT_FEISHU_DIRECT_QA/releases/{release['id']}/watch-summary")
assert quality_response.status_code == 200
quality = quality_response.json()["data"]
assert quality["total_calls"] == 1
assert quality["failed_calls"] == 1
assert quality["recent_failures"][0]["id"] == 1
assert case_response.status_code == 200
assert case_response.json()["data"]["source"] == "ai_call_log"
assert case_response.json()["data"]["priority"] == "important"
assert list_response.status_code == 200
assert list_response.json()["data"]["active_count"] == 1
assert watch_response.status_code == 200
assert watch_response.json()["data"]["windows"]["24h"]["total_calls"] == 1
@pytest.mark.asyncio
async def test_prompt_assets_api_runs_replay_and_guarded_gate_allows_activation(db_session) -> None:
app = _build_app(db_session)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
create_response = await client.post(
"/prompt-assets/PROMPT_RAG_QUERY_SEMANTICS/releases",
json={"body_snapshot": "门禁候选:{message}\\n{workflow_bundle}\\n{planner_message_intent}\\n请输出 JSON。", "notes": "guarded candidate"},
)
assert create_response.status_code == 200
release = create_response.json()["data"]
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
case_response = await client.post(
"/prompt-assets/PROMPT_RAG_QUERY_SEMANTICS/golden-cases",
json={
"case_name": "基础语义路由样本",
"input_payload": {
"message": "查一下项目风险",
"workflow_bundle": "项目上下文",
"planner_message_intent": "query",
},
"expected_contract": {
"required_input_keys": ["message"],
"required_prompt_contains": ["查一下项目风险"],
},
"priority": "blocking",
"case_type": "regression",
"tags": ["smoke"],
},
)
replay_response = await client.post(f"/prompt-assets/PROMPT_RAG_QUERY_SEMANTICS/releases/{release['id']}/replay-runs", json={"max_cases": 5})
runs_response = await client.get(f"/prompt-assets/PROMPT_RAG_QUERY_SEMANTICS/releases/{release['id']}/replay-runs")
run_id = replay_response.json()["data"]["id"]
run_detail_response = await client.get(f"/prompt-assets/PROMPT_RAG_QUERY_SEMANTICS/replay-runs/{run_id}")
checks_response = await client.post(f"/prompt-assets/PROMPT_RAG_QUERY_SEMANTICS/releases/{release['id']}/checks/run")
activate_response = await client.post(f"/prompt-assets/PROMPT_RAG_QUERY_SEMANTICS/releases/{release['id']}/activate")
assert case_response.status_code == 200
assert case_response.json()["data"]["priority"] == "blocking"
assert replay_response.status_code == 200
replay = replay_response.json()["data"]
assert replay["run_status"] == "completed"
assert replay["failed_count"] == 0
assert replay["results"][0]["status"] == "passed"
assert runs_response.status_code == 200
assert runs_response.json()["data"]["total"] == 1
assert run_detail_response.status_code == 200
assert run_detail_response.json()["data"]["id"] == run_id
assert checks_response.status_code == 200
assert checks_response.json()["data"]["gate"]["status"] == "passed"
assert activate_response.status_code == 200
assert activate_response.json()["data"]["status"] == "active"
@pytest.mark.asyncio
async def test_prompt_assets_api_rejects_migration_needed_and_unknown_variables(db_session) -> None:
app = _build_app(db_session)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
migration_response = await client.post(
"/prompt-assets/PROMPT_SIGNAL_WEBPAGE_VALUE_ANALYSIS/releases",
json={"body_snapshot": "不能直接运营"},
)
unknown_var_response = await client.post(
"/prompt-assets/PROMPT_FEISHU_DIRECT_QA/releases",
json={"body_snapshot": "未知变量:{unknown_signal}"},
)
assert migration_response.status_code == 400
assert "must be migrated" in migration_response.json()["message"]
assert unknown_var_response.status_code == 400
assert "unknown variables" in unknown_var_response.json()["message"]