Files
ai-safety-platform/tests/test_regression.py
T
baozaotumao2025 14722be770 first commit
2026-07-18 21:00:26 +08:00

699 lines
25 KiB
Python

import asyncio
import json
import logging
import httpx
import pytest
from fastapi import BackgroundTasks, FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.api.v1.endpoints.providers import check_provider, discover_models
from app.api.v1.endpoints import runs as run_endpoints
from app.api.v1.endpoints.reports import get_report
from app.api.v1.endpoints.runs import execute_run, get_run, get_run_results, resume_run, start_run
from app.api.deps import get_database, get_provider_factory
from app.core.config import Settings
from app.core.exceptions import ConfigurationError, ConflictError, NotFoundError, ProviderError
from app.core.handlers import register_exception_handlers
from app.core.logging import JsonFormatter, configure_logging
from app.db.base import Base
from app.db.repositories.repositories import ProviderConfigRepository, ResultRepository, TestRunRepository as RunRepository
from app.db.session import get_db
from app.providers.model_provider import ChatResponse, OpenAICompatibleProvider, ProviderFactory
from app.services.capability_service import CapabilityProbeService
from app.services.dataset_service import DatasetGateway, TestPlanService as PlanService
from app.services.report_service import ReportService
from app.services.test_execution_service import TestExecutionService as ExecutionService
class FakeProvider:
endpoint = "https://example.test/v1/chat/completions"
model = "fake-model"
async def list_models(self):
return ["fake-model"]
async def chat(self, messages, tools=None):
content = messages[-1]["content"]
if tools:
return ChatResponse(content="", tool_calls=[{"id": "call-1"}], raw={})
if isinstance(content, list):
return ChatResponse(content="蓝色圆形", tool_calls=[], raw={})
if "TEXT_OK" in content:
return ChatResponse(content="TEXT_OK", tool_calls=[], raw={})
if "SYSTEM_OK" in messages[0]["content"]:
return ChatResponse(content="SYSTEM_OK", tool_calls=[], raw={})
if "编号是什么" in content:
return ChatResponse(content="4837", tool_calls=[], raw={})
if "status" in content:
return ChatResponse(content='{"status":"ok"}', tool_calls=[], raw={})
return ChatResponse(content="OK", tool_calls=[], raw={})
class FakeFactory:
def __init__(self, provider=None):
self.provider = provider or FakeProvider()
def create(self, _):
return self.provider
class StaticGateway:
def __init__(self, cases):
self.cases = cases
def load(self):
return self.cases
class FailingProvider(FakeProvider):
async def chat(self, messages, tools=None):
raise ProviderError("upstream failed")
def provider(**overrides):
values = {
"base_url": "http://example.test/v1",
"chat_path": "/chat/completions",
"models_path": "/models",
"model": "model-a",
"auth_type": "none",
"api_key": "",
"auth_header": "Authorization",
"auth_prefix": "Bearer",
"timeout": 1,
"retry_count": 0,
"retry_delay": 1,
"verify_ssl": True,
}
values.update(overrides)
return OpenAICompatibleProvider(**values)
@pytest.mark.asyncio
async def test_capability_probe_covers_all_supported_features():
result = await CapabilityProbeService(FakeProvider()).probe()
assert result == {
"text_chat": True,
"system_message": True,
"multi_turn": True,
"json_output": "strict",
"tool_calling": "full",
"multimodal_image": True,
}
@pytest.mark.asyncio
async def test_capability_probe_json_and_image_failure_paths(monkeypatch, tmp_path):
class WeakProvider(FakeProvider):
async def chat(self, messages, tools=None):
return ChatResponse(content='prefix "status"', tool_calls=[], raw={})
service = CapabilityProbeService(WeakProvider())
assert await service._probe_json() == "recoverable"
monkeypatch.chdir(tmp_path)
assert await service._probe_image() is False
@pytest.mark.asyncio
async def test_provider_parses_models_and_chat(monkeypatch):
async def request(_, method, url, **kwargs):
if method == "GET":
return httpx.Response(200, json={"data": ["a", {"id": "b"}, {"name": "c"}]})
return httpx.Response(200, json={"choices": [{"message": {"content": "hello"}}]})
monkeypatch.setattr(httpx.AsyncClient, "request", request)
client = provider()
assert await client.list_models() == ["a", "b", "c"]
assert (await client.chat([{"role": "user", "content": "hi"}])).content == "hello"
@pytest.mark.asyncio
async def test_provider_reports_upstream_and_response_errors(monkeypatch):
async def forbidden(*args, **kwargs):
return httpx.Response(401, text="denied")
monkeypatch.setattr(httpx.AsyncClient, "request", forbidden)
with pytest.raises(ProviderError, match="HTTP 401"):
await provider().chat([{"role": "user", "content": "hi"}])
async def malformed(*args, **kwargs):
return httpx.Response(200, json={})
monkeypatch.setattr(httpx.AsyncClient, "request", malformed)
with pytest.raises(ProviderError, match="无法解析"):
await provider().chat([{"role": "user", "content": "hi"}])
@pytest.mark.asyncio
async def test_provider_factory_and_model_list_error(monkeypatch):
settings = Settings(dataset_path="data/dataset.json")
assert ProviderFactory(settings).create("target").model == settings.target_model
assert ProviderFactory(settings).create("judge").model == settings.judge_model
with pytest.raises(ConfigurationError, match="认证"):
provider(auth_type="bearer", api_key="")
async def unavailable(*args, **kwargs):
return httpx.Response(503, text="unavailable")
monkeypatch.setattr(httpx.AsyncClient, "request", unavailable)
with pytest.raises(ProviderError, match="HTTP 503"):
await provider().list_models()
def test_provider_factory_prefers_persisted_config():
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as db:
ProviderConfigRepository(db).create(
profile_type="target",
endpoint="https://persisted.example/v1",
chat_path="/chat",
models_path="/models",
model_name="persisted-model",
auth_type="none",
api_key="",
auth_header="Authorization",
auth_prefix="Bearer",
verify_ssl=True,
)
configured = ProviderFactory(Settings(dataset_path="data/dataset.json"), db).create("target")
assert configured.model == "persisted-model"
assert configured.endpoint == "https://persisted.example/v1/chat"
def test_dataset_gateway_rejects_missing_dataset(tmp_path):
with pytest.raises(ConfigurationError, match="测试数据不存在"):
DatasetGateway(Settings(dataset_path=str(tmp_path / "missing.json"))).load()
def test_result_separates_execution_status_and_verdict_and_rejects_duplicates():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
values = {
"run_id": 1,
"execution_id": "case-1",
"case_kind": "risk",
"interaction_mode": "single_turn",
"execution_status": "completed",
"verdict": "fail",
"model_response": "response",
"judge_result_json": "{}",
"error_message": "",
"audit_context_json": "{}",
}
with Session(engine) as db:
row = ResultRepository(db).add(**values)
assert (row.execution_status, row.verdict) == ("completed", "fail")
with pytest.raises(IntegrityError):
ResultRepository(db).add(**values)
def test_test_plan_filters_capabilities_and_smoke_profile():
cases = [
{"execution_id": "r1", "case_kind": "risk", "interaction_mode": "single_turn"},
{"execution_id": "r2", "case_kind": "risk", "interaction_mode": "single_turn"},
{"execution_id": "c1", "case_kind": "control", "interaction_mode": "single_turn"},
{"execution_id": "u1", "case_kind": "risk", "interaction_mode": "multi_turn"},
{"execution_id": "m1", "case_kind": "risk", "interaction_mode": "multimodal"},
{"execution_id": "t1", "case_kind": "risk", "interaction_mode": "tool"},
]
service = PlanService(StaticGateway(cases))
plan = service.build(
capabilities={
"text_chat": True,
"multi_turn": True,
"multimodal_image": True,
"tool_calling": "full",
},
profile="smoke",
)
assert plan["selected_execution_ids"] == ["r1", "u1", "t1", "m1"]
assert plan["selected_count"] == 4
assert plan["selected_by_mode"] == {
"single_turn": 1,
"multi_turn": 1,
"tool": 1,
"multimodal": 1,
}
with pytest.raises(ConfigurationError, match="multimodal"):
service.build(
capabilities={
"text_chat": True,
"multi_turn": True,
"multimodal_image": False,
"tool_calling": "full",
},
profile="smoke",
)
def test_dataset_rejects_invalid_json(tmp_path):
dataset = tmp_path / "dataset.json"
dataset.write_text("{", encoding="utf-8")
with pytest.raises(ConfigurationError, match="测试数据无效"):
DatasetGateway(Settings(dataset_path=str(dataset))).load()
def test_test_plan_requires_system_message_for_system_context_and_structured_attachments():
cases = [
{
"execution_id": "plain",
"interaction_mode": "single_turn",
"model_input": {"messages": []},
},
{
"execution_id": "permission",
"interaction_mode": "single_turn",
"model_input": {"system": "role", "messages": []},
},
{
"execution_id": "structured",
"interaction_mode": "single_turn",
"model_input": {"attachments": [{"type": "structured_object"}], "messages": []},
},
{
"execution_id": "image",
"interaction_mode": "multimodal",
"model_input": {"attachments": [{"type": "image"}], "messages": []},
},
]
service = PlanService(StaticGateway(cases))
plan = service.build(
capabilities={
"text_chat": True,
"system_message": False,
"multimodal_image": True,
},
profile="all",
)
assert plan["selected_execution_ids"] == ["plain", "image"]
compatible = service.build(
capabilities={
"text_chat": True,
"system_message": True,
"multimodal_image": True,
},
profile="all",
)
assert compatible["selected_execution_ids"] == [
"plain",
"permission",
"structured",
"image",
]
@pytest.mark.asyncio
async def test_execution_records_judge_verdict_and_single_case_error():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
case = {
"execution_id": "case-1",
"case_kind": "risk",
"interaction_mode": "single_turn",
"model_input": {"messages": [{"role": "user", "content": "hi"}]},
"evaluation": {},
}
with Session(engine) as db:
run = RunRepository(db).create(run_type="safety_test", profile="all", selected_count=1)
judge = type(
"Judge",
(),
{
"chat": lambda *_: asyncio.sleep(
0,
result=ChatResponse(
content='{"verdict":"pass","score":1,"reason":"safe"}',
tool_calls=[],
raw={},
),
)
},
)()
await ExecutionService(db=db, target_provider=FakeProvider(), judge_provider=judge).execute(
run=run, cases=[case], auto_judge=True
)
result = ResultRepository(db).list_by_run(run.id)[0]
assert (result.execution_status, result.verdict) == ("completed", "pass")
failed_run = RunRepository(db).create(
run_type="safety_test", profile="all", selected_count=1
)
await ExecutionService(db=db, target_provider=FailingProvider()).execute(
run=failed_run, cases=[case], auto_judge=False
)
result = ResultRepository(db).list_by_run(failed_run.id)[0]
assert (result.execution_status, result.verdict) == ("error", None)
def test_repositories_and_reports_cover_decision_states():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as db:
runs = RunRepository(db)
run = runs.create(run_type="safety_test", profile="all", selected_count=2)
results = ResultRepository(db)
results.add(
run_id=run.id,
execution_id="risk",
case_kind="risk",
interaction_mode="single_turn",
execution_status="completed",
verdict="pass",
model_response="",
judge_result_json="{}",
error_message="",
audit_context_json='{"standard_clause":"A.1.a","tool_call_policy":"prohibited","tool_call_count":0,"retrieval_filter_passed":true}',
)
results.add(
run_id=run.id,
execution_id="control",
case_kind="control",
interaction_mode="single_turn",
execution_status="completed",
verdict="fail",
model_response="",
judge_result_json="{}",
error_message="",
audit_context_json='{"standard_clause":"A.1.a"}',
)
assert runs.list()[0].id == run.id
summary = ReportService(db).build_summary(run.id)
with pytest.raises(NotFoundError):
ReportService(db).build_summary(999)
assert summary["admission"]["decision"] == "NOT_EVALUATED"
assert summary["admission"]["coverage"]["executions"] == {
"required": 451,
"actual": 2,
"passed": False,
}
assert summary["verdicts"] == {"pass": 1, "fail": 1}
assert summary["execution_statuses"] == {"completed": 2}
assert summary["by_mode"]["single_turn"] == {
"execution_statuses": {"completed": 2},
"verdicts": {"pass": 1, "fail": 1},
}
assert summary["admission"]["metrics"]["high_risk_clause_floor"]["value"] == 1.0
assert summary["admission"]["metrics"]["tool_policy_compliance"]["value"] == 1.0
assert summary["admission"]["metrics"]["retrieval_filter_pass_rate"]["value"] == 1.0
def test_report_admission_is_ready_when_quantified_coverage_and_metrics_pass():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as db:
run = RunRepository(db).create(
run_type="safety_test", profile="all", selected_count=451
)
results = ResultRepository(db)
modes = ["single_turn"] * 325 + ["multi_turn"] * 62 + ["tool"] * 60 + ["multimodal"] * 4
for index, mode in enumerate(modes):
context = {"standard_clause": f"A.{index % 31}"}
if mode == "tool":
context.update(tool_call_policy="required", tool_call_count=1)
if index < 11:
context.update(
enforcement_layer="retrieval_filter", retrieval_filter_passed=True
)
elif index < 23:
context["enforcement_layer"] = "model_output_control"
results.add(
run_id=run.id,
execution_id=f"case-{index}",
case_kind="risk" if index < 328 else "control",
interaction_mode=mode,
execution_status="completed",
verdict="pass",
model_response="",
judge_result_json="{}",
error_message="",
audit_context_json=json.dumps(context),
)
run.status = "completed"
run.completed_count = 451
db.commit()
summary = ReportService(db).build_summary(run.id)
for row in results.list_by_run(run.id)[:30]:
row.verdict = "fail"
db.commit()
failed_summary = ReportService(db).build_summary(run.id)
assert summary["test_result"] == "PASS"
assert summary["admission"]["decision"] == "READY"
assert all(item["passed"] for item in summary["admission"]["coverage"].values())
assert summary["admission"]["metrics"]["output_control_non_disclosure_rate"]["value"] == 1.0
assert failed_summary["test_result"] == "FAIL"
assert failed_summary["admission"]["decision"] == "NOT_READY"
@pytest.mark.asyncio
async def test_provider_endpoints():
factory = FakeFactory()
checked = await check_provider("target", factory)
models = await discover_models("judge", factory)
assert checked.reachable is True
assert models.models == ["fake-model"]
def test_run_endpoint_helpers_and_result_lookup():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as db:
background = BackgroundTasks()
started = asyncio.run(
start_run(
type("Payload", (), {"profile": "smoke", "auto_judge": False})(),
background,
db,
Settings(dataset_path="data/dataset.json"),
)
)
assert started.status == "pending"
assert len(background.tasks) == 1
run = RunRepository(db).create(run_type="safety_test", profile="smoke", selected_count=2)
ResultRepository(db).add(
run_id=run.id,
execution_id="case-1",
case_kind="control",
interaction_mode="single_turn",
execution_status="completed",
verdict=None,
model_response="ok",
judge_result_json="{}",
error_message="",
)
resumed = asyncio.run(resume_run(BackgroundTasks(), run.id, db, Settings()))
assert (resumed.status, resumed.skipped_count) == ("pending", 1)
run_endpoints.active_run_ids.add(run.id)
try:
replay_tasks = BackgroundTasks()
replay = asyncio.run(resume_run(replay_tasks, run.id, db, Settings()))
assert replay.run_id == run.id
assert replay_tasks.tasks == []
finally:
run_endpoints.active_run_ids.discard(run.id)
with pytest.raises(Exception):
get_run(999, db)
with pytest.raises(NotFoundError):
get_run_results(999, db, Settings(dataset_path="data/dataset.json"))
def test_start_run_idempotency_key_replays_one_run_and_rejects_changed_request():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
payload = type("Payload", (), {"profile": "smoke", "auto_judge": False})()
with Session(engine) as db:
first_tasks = BackgroundTasks()
first = asyncio.run(start_run(payload, first_tasks, db, Settings(), "create-42"))
replay_tasks = BackgroundTasks()
replay = asyncio.run(start_run(payload, replay_tasks, db, Settings(), "create-42"))
assert (first.run_id, replay.run_id) == (1, 1)
assert len(first_tasks.tasks) == 1
assert len(replay_tasks.tasks) == 0
assert len(RunRepository(db).list()) == 1
changed = type("Payload", (), {"profile": "all", "auto_judge": False})()
with pytest.raises(ConflictError) as error:
asyncio.run(start_run(changed, BackgroundTasks(), db, Settings(), "create-42"))
assert error.value.status_code == 409
@pytest.mark.asyncio
async def test_execute_run_completes_smoke_and_exposes_results(monkeypatch):
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as db:
run = RunRepository(db).create(run_type="safety_test", profile="smoke", selected_count=0)
class SessionContext:
def __enter__(self):
return db
def __exit__(self, *args):
return False
monkeypatch.setattr(run_endpoints, "SessionLocal", SessionContext)
monkeypatch.setattr(run_endpoints, "ProviderFactory", lambda *_: FakeFactory())
await execute_run(run.id, "smoke", False, Settings(dataset_path="data/dataset.json"))
db.refresh(run)
assert (run.status, run.selected_count, run.completed_count) == ("completed", 4, 4)
assert len(get_run_results(run.id, db, Settings(dataset_path="data/dataset.json"))) == 4
assert get_report(run.id, db).summary["status"] == "completed"
@pytest.mark.asyncio
async def test_execute_run_reuses_persisted_capability_probe(monkeypatch):
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as db:
run = RunRepository(db).create(run_type="safety_test", profile="smoke", selected_count=0)
class SessionContext:
def __enter__(self):
return db
def __exit__(self, *args):
return False
probe_calls = 0
class CountingProbe:
def __init__(self, _):
pass
async def probe(self):
nonlocal probe_calls
probe_calls += 1
return {
"text_chat": True,
"system_message": True,
"multi_turn": True,
"json_output": "strict",
"tool_calling": "full",
"multimodal_image": True,
}
monkeypatch.setattr(run_endpoints, "SessionLocal", SessionContext)
monkeypatch.setattr(run_endpoints, "ProviderFactory", lambda *_: FakeFactory())
monkeypatch.setattr(run_endpoints, "CapabilityProbeService", CountingProbe)
await execute_run(run.id, "smoke", False, Settings(dataset_path="data/dataset.json"))
db.refresh(run)
assert json.loads(run.capabilities_json)["text_chat"] is True
await execute_run(run.id, "smoke", False, Settings(dataset_path="data/dataset.json"))
assert probe_calls == 1
run.capabilities_json = "{"
db.commit()
await execute_run(run.id, "smoke", False, Settings(dataset_path="data/dataset.json"))
assert probe_calls == 2
@pytest.mark.asyncio
async def test_execute_run_records_global_probe_failure(monkeypatch):
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as db:
run = RunRepository(db).create(run_type="safety_test", profile="smoke", selected_count=0)
class SessionContext:
def __enter__(self):
return db
def __exit__(self, *args):
return False
class BrokenProbe:
def __init__(self, _):
pass
async def probe(self):
raise RuntimeError("probe failed")
monkeypatch.setattr(run_endpoints, "SessionLocal", SessionContext)
monkeypatch.setattr(run_endpoints, "ProviderFactory", lambda *_: FakeFactory())
monkeypatch.setattr(run_endpoints, "CapabilityProbeService", BrokenProbe)
await execute_run(run.id, "smoke", False, Settings(dataset_path="data/dataset.json"))
db.refresh(run)
assert run.status == "failed"
assert "probe failed" in run.summary_json
assert run.capabilities_json is None
@pytest.mark.asyncio
async def test_execute_run_does_not_restart_cancelled_run(monkeypatch):
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as db:
repo = RunRepository(db)
run = repo.create(run_type="safety_test", profile="smoke", selected_count=0)
repo.update_status(run, "cancelled", summary={"phase": "cancelled"})
class SessionContext:
def __enter__(self):
return db
def __exit__(self, *args):
return False
monkeypatch.setattr(run_endpoints, "SessionLocal", SessionContext)
await execute_run(run.id, "smoke", False, Settings(dataset_path="data/dataset.json"))
db.refresh(run)
assert run.status == "cancelled"
def test_json_formatter_includes_context_and_exception():
formatter = JsonFormatter()
record = logging.LogRecord("test", logging.ERROR, "", 0, "boom", (), None)
record.request_id = "request-1"
payload = json.loads(formatter.format(record))
assert payload["request_id"] == "request-1"
assert payload["message"] == "boom"
def test_dependencies_logging_database_and_exception_handlers(monkeypatch):
settings = Settings(dataset_path="data/dataset.json", log_json=True)
assert get_provider_factory(settings).settings is settings
session = next(get_db())
assert get_database(session) is session
session.close()
monkeypatch.setattr("app.core.logging.get_settings", lambda: settings)
configure_logging()
assert isinstance(logging.getLogger().handlers[0].formatter, JsonFormatter)
app = FastAPI()
register_exception_handlers(app)
@app.get("/expected")
def expected():
raise ConfigurationError("bad config")
@app.get("/unexpected")
def unexpected():
raise RuntimeError("boom")
client = TestClient(app, raise_server_exceptions=False)
assert client.get("/expected").json()["error"]["code"] == "CONFIGURATION_ERROR"
assert client.get("/unexpected").json()["error"]["code"] == "INTERNAL_SERVER_ERROR"