from __future__ import annotations import inspect from collections.abc import Generator from datetime import UTC, datetime, timedelta import pytest from sqlalchemy import create_engine, select, update from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType from app.db.base import Base from app.models.agent_asset import AgentAsset, AgentAssetTestRun from app.models.agent_asset_release_telemetry import AgentAssetReleaseObservation from app.services.agent_asset_release_monitor import AgentAssetReleaseMonitor from app.services.agent_asset_release_review import AgentAssetReleaseReviewService from app.services.agent_asset_release_telemetry import ( AgentAssetReleaseTelemetryService, ReleaseObservationInput, ) @pytest.fixture def db() -> Generator[Session, None, None]: engine = create_engine( "sqlite+pysqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Base.metadata.create_all(engine) factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) with factory() as session: yield session engine.dispose() def _policy( *, min_precision: float = 0.8, max_error_rate: float = 0.25, ) -> dict: return { "shadow_min_samples": 1, "canary_min_samples": 1, "max_error_rate": max_error_rate, "min_precision": min_precision, "max_precision_drop": 0.2, "canary_traffic_percent": 10, } def _seed_asset( db: Session, *, asset_id: str = "release-monitor-asset", tenant_id: str | None = "tenant-a", stage: str = "shadow", candidate_version: str = "v2", min_precision: float = 0.8, max_error_rate: float = 0.25, detail_mode: str = "json_risk", ) -> AgentAsset: previous_config = { "detail_mode": detail_mode, "enabled": True, "stable_marker": f"stable:{asset_id}", } if tenant_id is not None: previous_config["tenant_id"] = tenant_id state = { "release_id": f"release-{asset_id}", "stage": stage, "candidate_version": candidate_version, "previous_version": "v1", "policy": _policy( min_precision=min_precision, max_error_rate=max_error_rate, ), "previous_config": dict(previous_config), "history": [], } asset = AgentAsset( id=asset_id, tenant_id=tenant_id or "platform", scope="tenant" if tenant_id is not None else "platform", asset_type=AgentAssetType.RULE.value, code=f"risk.{asset_id}", name="发布监控规则", description="", domain=AgentAssetDomain.EXPENSE.value, scenario_json=["travel"], owner="finance", status=AgentAssetStatus.ACTIVE.value, current_version="v1", published_version="v1", working_version="v2", config_json={ **previous_config, "release_guard": state, # 外部或客户端放入配置的数字不是发布评测输入。 "untrusted_external_metrics": { "total": 999999, "precision": 1.0, }, }, ) db.add(asset) db.commit() return asset def _record( db: Session, asset: AgentAsset, *, source: str, candidate_hit: bool, baseline_hit: bool | None = True, label: str | None = None, failed: bool = False, ) -> str: state = dict((asset.config_json or {})["release_guard"]) service = AgentAssetReleaseTelemetryService(db) observation = service.record_observation( ReleaseObservationInput( tenant_id=str((asset.config_json or {}).get("tenant_id") or "tenant-a"), asset_id=asset.id, release_id=str(state["release_id"]), stage=str(state["stage"]), # type: ignore[arg-type] version=str(state["candidate_version"]), rule_code=asset.code, source_key=source, candidate_hit=candidate_hit, baseline_hit=baseline_hit, runtime_status="failed" if failed else "completed", failure_code="evaluator_error" if failed else "none", ) ) if label is not None: service.record_review_label( tenant_id=observation.tenant_id, observation_id=observation.id, label=label, # type: ignore[arg-type] request_id=f"review:{source}", actor_id="trusted-auditor", ) db.commit() return observation.id class _NeverCalledGuard: def __init__(self) -> None: self.calls = 0 def record_evaluation(self, *_args, **_kwargs): self.calls += 1 raise AssertionError("collecting telemetry must never call Release Guard") def test_collecting_never_calls_guard_or_rolls_back(db: Session) -> None: asset = _seed_asset(db) _record( db, asset, source="claim-unlabeled", candidate_hit=True, label=None, ) guard = _NeverCalledGuard() monitor = AgentAssetReleaseMonitor(db, guard_service=guard) # type: ignore[arg-type] result = monitor.evaluate_current( tenant_id="tenant-a", asset_id=asset.id, actor="release-monitor", ) refreshed = db.get(AgentAsset, asset.id) assert result["status"] == "collecting" assert result["telemetry_status"] == "collecting" assert result["evaluation_submitted"] is False assert result["metrics"]["candidate_pending_label_count"] == 1 assert result["metrics"]["precision"] is None assert result["rolled_back"] is False assert guard.calls == 0 assert refreshed.config_json["release_guard"]["stage"] == "shadow" assert db.scalar(select(AgentAssetTestRun)) is None def test_ready_metrics_are_evaluated_and_passed(db: Session) -> None: asset = _seed_asset(db) _record( db, asset, source="claim-confirmed", candidate_hit=True, label="confirmed", ) result = AgentAssetReleaseMonitor(db).evaluate_current( tenant_id="tenant-a", asset_id=asset.id, actor="release-monitor", ) run = db.scalar(select(AgentAssetTestRun).where(AgentAssetTestRun.asset_id == asset.id)) assert result["telemetry_status"] == "ready" assert result["status"] == "passed" assert result["evaluation_submitted"] is True assert result["rolled_back"] is False assert result["metrics"]["observed_count"] == 1 assert result["metrics"]["precision"] == 1.0 assert result["metrics"]["baseline_precision"] == 1.0 assert result["metrics"]["recall"] == 1.0 assert result["metrics"]["recall_lower_bound"] == 1.0 assert run is not None assert run.input_json["total"] == 1 assert run.input_json["failure_count"] == 0 assert run.input_json["precision"] == 1.0 assert run.result_json["details"]["metric_source"] == "release_runtime_telemetry" def test_same_ready_snapshot_reuses_test_run(db: Session) -> None: asset = _seed_asset(db) _record( db, asset, source="claim-idempotent", candidate_hit=True, label="confirmed", ) monitor = AgentAssetReleaseMonitor(db) first = monitor.evaluate_current( tenant_id="tenant-a", asset_id=asset.id, actor="release-monitor", ) second = monitor.evaluate_current( tenant_id="tenant-a", asset_id=asset.id, actor="release-monitor-retry", ) runs = list( db.scalars( select(AgentAssetTestRun).where(AgentAssetTestRun.asset_id == asset.id) ).all() ) assert second == first assert len(runs) == 1 def test_real_low_precision_automatically_rolls_back(db: Session) -> None: asset = _seed_asset(db, min_precision=0.9, max_error_rate=1.0) _record(db, asset, source="claim-true", candidate_hit=True, label="confirmed") _record( db, asset, source="claim-false-positive", candidate_hit=True, label="false_positive", ) result = AgentAssetReleaseMonitor(db).evaluate_current( tenant_id="tenant-a", asset_id=asset.id, actor="release-monitor", ) refreshed = db.get(AgentAsset, asset.id) assert result["status"] == "failed" assert result["rolled_back"] is True assert result["release_stage"] == "rolled_back" assert result["metrics"]["precision"] == 0.5 assert "precision_below_threshold" in result["reasons"] assert refreshed.published_version == "v1" assert refreshed.config_json["stable_marker"] == f"stable:{asset.id}" assert refreshed.config_json["release_guard"]["rollback"]["automatic"] is True def test_real_runtime_failure_automatically_rolls_back(db: Session) -> None: asset = _seed_asset(db, min_precision=0.5, max_error_rate=0.1) _record(db, asset, source="claim-good", candidate_hit=True, label="confirmed") _record( db, asset, source="claim-evaluator-failed", candidate_hit=False, baseline_hit=None, failed=True, ) result = AgentAssetReleaseMonitor(db).evaluate_current( tenant_id="tenant-a", asset_id=asset.id, actor="release-monitor", ) assert result["status"] == "failed" assert result["rolled_back"] is True assert result["metrics"]["observed_count"] == 2 assert result["metrics"]["runtime_failure_count"] == 1 assert result["metrics"]["runtime_failure_rate"] == 0.5 assert "error_rate_exceeded" in result["reasons"] assert {item["code"] for item in result["alerts"]} >= { "runtime_failures_detected", "release_auto_rolled_back", } def test_all_runtime_failures_without_hits_automatically_roll_back(db: Session) -> None: asset = _seed_asset(db, min_precision=0.5, max_error_rate=0.1) _record( db, asset, source="claim-only-evaluator-failed", candidate_hit=False, baseline_hit=None, failed=True, ) result = AgentAssetReleaseMonitor(db).evaluate_current( tenant_id="tenant-a", asset_id=asset.id, actor="release-monitor", ) assert result["telemetry_status"] == "ready" assert result["status"] == "failed" assert result["rolled_back"] is True assert result["metrics"]["candidate_hit_count"] == 0 assert result["metrics"]["precision"] is None assert result["metrics"]["runtime_failure_count"] == 1 assert "error_rate_exceeded" in result["reasons"] def test_incomplete_labels_with_tolerated_failure_cannot_pass(db: Session) -> None: asset = _seed_asset(db, min_precision=0.5, max_error_rate=1.0) _record( db, asset, source="claim-unlabeled-hit", candidate_hit=True, label=None, ) _record( db, asset, source="claim-tolerated-evaluator-failed", candidate_hit=False, baseline_hit=None, failed=True, ) result = AgentAssetReleaseMonitor(db).evaluate_current( tenant_id="tenant-a", asset_id=asset.id, actor="release-monitor", ) assert result["telemetry_status"] == "ready" assert result["status"] == "collecting" assert result["rolled_back"] is False assert result["metrics"]["candidate_pending_label_count"] == 1 assert "precision_metric_missing" in result["reasons"] assert "release_labels_pending" in {item["code"] for item in result["alerts"]} def test_cross_tenant_and_global_asset_scope_are_enforced(db: Session) -> None: tenant_asset = _seed_asset(db) _record(db, tenant_asset, source="tenant-a-claim", candidate_hit=True, label="confirmed") monitor = AgentAssetReleaseMonitor(db) with pytest.raises(LookupError): monitor.evaluate_current( tenant_id="tenant-b", asset_id=tenant_asset.id, actor="foreign-monitor", ) assert monitor.batch_evaluate(tenant_id="tenant-b", actor="foreign-monitor")["scanned"] == 0 global_asset = _seed_asset( db, asset_id="global-release-monitor", tenant_id=None, ) with pytest.raises(LookupError): _record(db, global_asset, source="global-claim", candidate_hit=True, label="confirmed") with pytest.raises(LookupError): monitor.evaluate_current( tenant_id="tenant-a", asset_id=global_asset.id, actor="tenant-monitor", ) with pytest.raises(ValueError, match="cross-tenant aggregate"): monitor.evaluate_current( tenant_id="tenant-a", asset_id=global_asset.id, actor="platform-monitor", allow_global_management=True, ) assert global_asset.config_json["release_guard"]["stage"] == "shadow" def test_batch_scan_is_bounded_tenant_safe_and_failure_isolated(db: Session) -> None: collecting_asset = _seed_asset(db, asset_id="batch-collecting") _record(db, collecting_asset, source="collecting", candidate_hit=True) broken_asset = _seed_asset( db, asset_id="batch-error", candidate_version="", ) passed_asset = _seed_asset(db, asset_id="batch-passed") _record(db, passed_asset, source="passed", candidate_hit=True, label="confirmed") rolled_asset = _seed_asset( db, asset_id="batch-rolled-back", min_precision=0.9, max_error_rate=1.0, ) _record(db, rolled_asset, source="roll-true", candidate_hit=True, label="confirmed") _record( db, rolled_asset, source="roll-false", candidate_hit=True, label="false_positive", ) foreign_asset = _seed_asset(db, asset_id="foreign-ready", tenant_id="tenant-b") _record( db, foreign_asset, source="foreign", candidate_hit=True, label="confirmed", ) _seed_asset(db, asset_id="not-json", detail_mode="markdown") _seed_asset(db, asset_id="not-current", stage="rolled_back") monitor = AgentAssetReleaseMonitor(db) result = monitor.batch_evaluate( tenant_id="tenant-a", actor="batch-monitor", limit=10, ) assert result["scanned"] == 4 assert result["evaluated"] == 2 assert result["collecting"] == 1 assert result["rolled_back"] == 1 assert len(result["errors"]) == 1 assert result["errors"][0]["asset_id"] == broken_asset.id assert result["errors"][0]["alerts"][0]["code"] == "release_aggregation_failed" assert {item["asset_id"] for item in result["results"]} == { collecting_asset.id, passed_asset.id, rolled_asset.id, } assert foreign_asset.id not in str(result) assert db.get(AgentAsset, passed_asset.id).config_json["release_guard"]["stage"] == "shadow" assert db.get(AgentAsset, rolled_asset.id).config_json["release_guard"]["stage"] == ( "rolled_back" ) with pytest.raises(ValueError, match="between 1 and 500"): monitor.batch_evaluate(tenant_id="tenant-a", actor="batch-monitor", limit=501) def test_overdue_review_sample_emits_actionable_alert(db: Session) -> None: asset = _seed_asset(db) observation_id = _record( db, asset, source="claim-overdue-review", candidate_hit=True, ) # SQLite 测试库没有 PostgreSQL append-only 触发器;用 Core 语句只调整 # fixture 时间,避免把“运营超时”测试误写成 ORM 可更新契约。 db.execute( update(AgentAssetReleaseObservation) .where(AgentAssetReleaseObservation.id == observation_id) .values(created_at=datetime.now(UTC) - timedelta(hours=25)) ) db.commit() result = AgentAssetReleaseReviewService(db).list_pending( tenant_id="tenant-a", asset_id=asset.id, ) assert result["telemetry_status"] == "collecting" assert result["metrics"]["candidate_oldest_pending_age_seconds"] >= 86_400 assert "release_labels_overdue" in {item["code"] for item in result["alerts"]} def test_monitor_contract_cannot_accept_fabricated_quality_metrics(db: Session) -> None: asset = _seed_asset(db, min_precision=0.9, max_error_rate=1.0) _record( db, asset, source="actual-false-positive", candidate_hit=True, label="false_positive", ) signature = inspect.signature(AgentAssetReleaseMonitor.evaluate_current) assert {"total", "failure_count", "precision", "baseline_precision"}.isdisjoint( signature.parameters ) with pytest.raises(TypeError): AgentAssetReleaseMonitor(db).evaluate_current( tenant_id="tenant-a", asset_id=asset.id, actor="malicious-monitor", precision=1.0, # type: ignore[call-arg] ) result = AgentAssetReleaseMonitor(db).evaluate_current( tenant_id="tenant-a", asset_id=asset.id, actor="release-monitor", ) assert result["metrics"]["precision"] == 0.0 assert result["rolled_back"] is True