PMFlow/backend/tests/test_prompt_assets_service.py

437 lines
15 KiB
Python

from __future__ import annotations
import os
import pytest_asyncio
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
import pytest
os.environ["DATABASE_URL"] = "postgresql+asyncpg://pmflow:pmflow_pass@127.0.0.1:5435/pmflow"
from sqlalchemy import JSON
from app.models.base import Base
from app.models.ai_call_log import AICallLog
from app.models.reply_iteration import PromptGoldenCase, PromptRelease, PromptReleaseCheck, PromptReplayCaseResult, PromptReplayRun
from app.services.prompt_assets_service import PromptAssetService
from app.services.prompt_release.service import PromptReleaseService
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(
PromptRelease.__table__,
PromptReleaseCheck.__table__,
PromptGoldenCase.__table__,
PromptReplayRun.__table__,
PromptReplayCaseResult.__table__,
AICallLog.__table__,
)
async with engine.begin() as conn:
await conn.run_sync(
lambda sync_conn: Base.metadata.create_all(
sync_conn,
tables=[PromptRelease.__table__, PromptReleaseCheck.__table__],
)
)
await conn.run_sync(
lambda sync_conn: Base.metadata.create_all(
sync_conn,
tables=[PromptGoldenCase.__table__, PromptReplayRun.__table__, PromptReplayCaseResult.__table__, AICallLog.__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_prompt_asset_service_lists_registry_with_release_state(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)
payload = await PromptAssetService(db_session).list_assets(tenant_id=1, keyword="PROMPT_FEISHU_DIRECT_QA")
assert payload["total"] == 1
item = payload["items"][0]
assert item["prompt_key"] == "PROMPT_FEISHU_DIRECT_QA"
assert item["default_template_available"] is True
assert item["active_release"]["version"] == release["version"]
assert "question" in item["template_variables"]
@pytest.mark.asyncio
async def test_prompt_asset_service_marks_inline_prompts_as_migration_needed(db_session) -> None:
item = await PromptAssetService(db_session).get_asset(
tenant_id=1,
prompt_key="PROMPT_SIGNAL_WEBPAGE_VALUE_ANALYSIS",
)
assert item["editable_policy"] == "needs_migration"
assert item["runtime_source"] == "inline_discovered"
assert item["default_template_available"] is False
assert item["migration_status"] == "inline_prompt_needs_runtime_migration"
@pytest.mark.asyncio
async def test_prompt_asset_service_creates_activates_and_rolls_back_managed_release(db_session) -> None:
service = PromptAssetService(db_session)
first = await service.create_release(
tenant_id=1,
prompt_key="PROMPT_FEISHU_DIRECT_QA",
body_snapshot="第一版:{question}",
created_by=1001,
)
second = await service.create_release(
tenant_id=1,
prompt_key="PROMPT_FEISHU_DIRECT_QA",
body_snapshot="第二版:{question}",
created_by=1001,
)
await service.activate_release(
tenant_id=1,
prompt_key="PROMPT_FEISHU_DIRECT_QA",
release_id=first["id"],
actor_id=1001,
)
active_second = await service.activate_release(
tenant_id=1,
prompt_key="PROMPT_FEISHU_DIRECT_QA",
release_id=second["id"],
actor_id=1001,
)
rolled_back = await service.rollback_release(
tenant_id=1,
prompt_key="PROMPT_FEISHU_DIRECT_QA",
release_id=first["id"],
actor_id=1001,
)
assert first["version"] == 1
assert second["version"] == 2
assert active_second["status"] == "active"
assert rolled_back["id"] == first["id"]
detail = await service.get_asset(tenant_id=1, prompt_key="PROMPT_FEISHU_DIRECT_QA")
assert detail["active_release"]["id"] == first["id"]
assert detail["active_release"]["body_snapshot"] == "第一版:{question}"
@pytest.mark.asyncio
async def test_prompt_asset_service_runs_guarded_release_gate_and_blocks_activation(db_session) -> None:
service = PromptAssetService(db_session)
release = await service.create_release(
tenant_id=1,
prompt_key="PROMPT_RAG_QUERY_SEMANTICS",
body_snapshot="候选版本:{message}\n{workflow_bundle}\n{planner_message_intent}\n请输出 JSON。",
created_by=1001,
)
with pytest.raises(ValueError, match="blocked by gate"):
await service.activate_release(
tenant_id=1,
prompt_key="PROMPT_RAG_QUERY_SEMANTICS",
release_id=release["id"],
actor_id=1001,
)
checks = await service.run_release_checks(
tenant_id=1,
prompt_key="PROMPT_RAG_QUERY_SEMANTICS",
release_id=release["id"],
actor_id=1001,
)
assert checks["gate"]["status"] == "blocked"
assert any(item["check_type"] == "variable_contract" and item["status"] == "passed" for item in checks["items"])
assert any(item["check_type"] == "golden_replay" and item["status"] == "skipped" for item in checks["items"])
with pytest.raises(ValueError, match="golden"):
await service.activate_release(
tenant_id=1,
prompt_key="PROMPT_RAG_QUERY_SEMANTICS",
release_id=release["id"],
actor_id=1001,
)
@pytest.mark.asyncio
async def test_prompt_asset_service_replay_unblocks_guarded_gate_and_quality_summary(db_session) -> None:
service = PromptAssetService(db_session)
release = await service.create_release(
tenant_id=1,
prompt_key="PROMPT_RAG_QUERY_SEMANTICS",
body_snapshot="候选版本:{message}\n{workflow_bundle}\n{planner_message_intent}\n请输出 JSON。",
created_by=1001,
)
golden_case = await service.create_golden_case(
tenant_id=1,
prompt_key="PROMPT_RAG_QUERY_SEMANTICS",
case_name="基础语义路由样本",
input_payload={
"message": "查一下项目风险",
"workflow_bundle": "项目上下文",
"planner_message_intent": "query",
},
expected_contract={"required_input_keys": ["message"], "required_prompt_contains": ["查一下项目风险"]},
created_by=1001,
)
db_session.add(
AICallLog(
tenant_id=1,
function_name="rag_query_semantics",
prompt="语义判断",
response="[error] schema invalid",
status="structured_invalid",
prompt_key="PROMPT_RAG_QUERY_SEMANTICS",
prompt_release_id=release["id"],
prompt_source="active_release",
routing_meta={
"prompt_key": "PROMPT_RAG_QUERY_SEMANTICS",
"prompt_release_id": release["id"],
"prompt_source": "active_release",
},
latency_ms=120,
cost=0.002,
)
)
await db_session.commit()
checks_before_replay = await service.run_release_checks(
tenant_id=1,
prompt_key="PROMPT_RAG_QUERY_SEMANTICS",
release_id=release["id"],
actor_id=1001,
)
replay = await service.create_replay_run(
tenant_id=1,
prompt_key="PROMPT_RAG_QUERY_SEMANTICS",
release_id=release["id"],
created_by=1001,
)
checks = await service.run_release_checks(
tenant_id=1,
prompt_key="PROMPT_RAG_QUERY_SEMANTICS",
release_id=release["id"],
actor_id=1001,
)
quality = await service.get_quality_summary(
tenant_id=1,
prompt_key="PROMPT_RAG_QUERY_SEMANTICS",
window="7d",
)
cases = await service.list_golden_cases(tenant_id=1, prompt_key="PROMPT_RAG_QUERY_SEMANTICS")
assert golden_case["case_name"] == "基础语义路由样本"
assert checks_before_replay["gate"]["status"] == "blocked"
assert replay["run_status"] == "completed"
assert replay["failed_count"] == 0
assert replay["results"][0]["status"] == "passed"
assert checks["gate"]["status"] == "passed"
assert any(item["check_type"] == "golden_replay" and item["status"] == "passed" for item in checks["items"])
assert quality["total_calls"] == 1
assert quality["failed_calls"] == 1
assert quality["release_breakdown"][0]["prompt_release_id"] == release["id"]
assert cases["active_count"] == 1
@pytest.mark.asyncio
async def test_prompt_asset_service_blocks_guarded_activation_when_replay_failed(db_session) -> None:
service = PromptAssetService(db_session)
release = await service.create_release(
tenant_id=1,
prompt_key="PROMPT_RAG_QUERY_SEMANTICS",
body_snapshot="候选版本:{message}\n{workflow_bundle}\n{planner_message_intent}",
created_by=1001,
)
await service.create_golden_case(
tenant_id=1,
prompt_key="PROMPT_RAG_QUERY_SEMANTICS",
case_name="缺失输入样本",
input_payload={"message": "查一下项目风险"},
expected_contract={"required_input_keys": ["workflow_bundle"]},
created_by=1001,
)
replay = await service.create_replay_run(
tenant_id=1,
prompt_key="PROMPT_RAG_QUERY_SEMANTICS",
release_id=release["id"],
created_by=1001,
)
checks = await service.run_release_checks(
tenant_id=1,
prompt_key="PROMPT_RAG_QUERY_SEMANTICS",
release_id=release["id"],
actor_id=1001,
)
assert replay["failed_count"] == 1
assert replay["results"][0]["status"] == "failed"
assert checks["gate"]["status"] == "blocked"
assert any(item["check_type"] == "golden_replay" and item["status"] == "failed" for item in checks["items"])
with pytest.raises(ValueError, match="blocked by gate"):
await service.activate_release(
tenant_id=1,
prompt_key="PROMPT_RAG_QUERY_SEMANTICS",
release_id=release["id"],
actor_id=1001,
)
@pytest.mark.asyncio
async def test_prompt_asset_service_llm_replay_records_judge_and_call_links(db_session, monkeypatch) -> None:
async def fake_llm_replay(self, *, prompt: str, function_name: str, **kwargs):
return f"{function_name} 输出:{prompt}", 9001 if function_name.endswith("candidate") else 9000
async def fake_judge(self, **kwargs):
return {
"score": 0.91,
"label": "better",
"decision": "recommend_release",
"reason": "候选输出更清晰且未违反边界。",
"risk_flags": [],
}
monkeypatch.setattr(PromptAssetService, "_run_llm_replay_prompt", fake_llm_replay)
monkeypatch.setattr(PromptAssetService, "_judge_replay_case", fake_judge)
service = PromptAssetService(db_session)
release = await service.create_release(
tenant_id=1,
prompt_key="PROMPT_RAG_QUERY_SEMANTICS",
body_snapshot="候选版本:{message}\n{workflow_bundle}\n{planner_message_intent}\n请输出 JSON。",
created_by=1001,
)
await service.create_golden_case(
tenant_id=1,
prompt_key="PROMPT_RAG_QUERY_SEMANTICS",
case_name="LLM 回放样本",
input_payload={
"message": "查一下项目风险",
"workflow_bundle": "项目上下文",
"planner_message_intent": "query",
},
expected_contract={"required_input_keys": ["message"]},
priority="blocking",
created_by=1001,
)
replay = await service.create_replay_run(
tenant_id=1,
prompt_key="PROMPT_RAG_QUERY_SEMANTICS",
release_id=release["id"],
created_by=1001,
include_llm=True,
max_cases=5,
)
assert replay["run_mode"] == "llm_compare"
assert replay["failed_count"] == 0
assert replay["results"][0]["status"] == "passed"
assert replay["results"][0]["candidate_ai_call_log_id"] == 9001
assert replay["results"][0]["judge_score"] == 0.91
assert replay["results"][0]["judge_label"] == "better"
@pytest.mark.asyncio
async def test_prompt_asset_service_non_blocking_case_failure_is_warning(db_session) -> None:
service = PromptAssetService(db_session)
release = await service.create_release(
tenant_id=1,
prompt_key="PROMPT_RAG_QUERY_SEMANTICS",
body_snapshot="候选版本:{message}\n{workflow_bundle}\n{planner_message_intent}",
created_by=1001,
)
await service.create_golden_case(
tenant_id=1,
prompt_key="PROMPT_RAG_QUERY_SEMANTICS",
case_name="观察型失败样本",
input_payload={"message": "查一下项目风险"},
expected_contract={"required_input_keys": ["workflow_bundle"]},
priority="important",
created_by=1001,
)
replay = await service.create_replay_run(
tenant_id=1,
prompt_key="PROMPT_RAG_QUERY_SEMANTICS",
release_id=release["id"],
created_by=1001,
)
assert replay["failed_count"] == 0
assert replay["warning_count"] == 1
assert replay["results"][0]["status"] == "warning"
@pytest.mark.asyncio
async def test_prompt_asset_service_release_watch_summary(db_session) -> None:
service = PromptAssetService(db_session)
release = await 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()
watch = await service.get_release_watch_summary(
tenant_id=1,
prompt_key="PROMPT_FEISHU_DIRECT_QA",
release_id=release["id"],
)
assert watch["recommendation"] in {"needs_review", "rollback_suggested"}
assert watch["windows"]["24h"]["total_calls"] == 1
@pytest.mark.asyncio
async def test_prompt_asset_service_rejects_migration_needed_or_invalid_release(db_session) -> None:
service = PromptAssetService(db_session)
with pytest.raises(ValueError, match="migrated"):
await service.create_release(
tenant_id=1,
prompt_key="PROMPT_SIGNAL_WEBPAGE_VALUE_ANALYSIS",
body_snapshot="不能直接发布",
created_by=1001,
)
with pytest.raises(ValueError, match="unknown variables"):
await service.create_release(
tenant_id=1,
prompt_key="PROMPT_FEISHU_DIRECT_QA",
body_snapshot="未知变量:{unknown_signal}",
created_by=1001,
)