feat(governance): 新增 Agent 日週月報風險自動化審查
This commit is contained in:
@@ -85,6 +85,9 @@ from src.services.ai_agent_proactive_operations_contract import (
|
||||
from src.services.ai_agent_redis_dry_run_gate import (
|
||||
load_latest_ai_agent_redis_dry_run_gate,
|
||||
)
|
||||
from src.services.ai_agent_report_automation_review import (
|
||||
load_latest_ai_agent_report_automation_review,
|
||||
)
|
||||
from src.services.ai_agent_report_truth_actionability_review import (
|
||||
load_latest_ai_agent_report_truth_actionability_review,
|
||||
)
|
||||
@@ -881,6 +884,33 @@ async def get_agent_report_truth_actionability_review() -> dict[str, Any]:
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/agent-report-automation-review",
|
||||
response_model=dict[str, Any],
|
||||
summary="取得 AI Agent 日週月報與風險自動化 review",
|
||||
description=(
|
||||
"讀取最新已提交的 AI Agent 日報、週報、月報、Agent 工作量、圖表化報告、"
|
||||
"AI 分析建議與高/中/低風險自動化政策;此端點不排程實發、不送 Telegram、"
|
||||
"不啟動中低風險自動執行器、不執行生產優化、不讀 secret、不回傳內部工作視窗對話。"
|
||||
),
|
||||
)
|
||||
async def get_agent_report_automation_review() -> dict[str, Any]:
|
||||
"""Return the latest read-only AI Agent report automation review."""
|
||||
try:
|
||||
return await asyncio.to_thread(load_latest_ai_agent_report_automation_review)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
logger.error("ai_agent_report_automation_review_invalid", error=str(exc))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="AI Agent 日週月報與風險自動化 review 無效",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/agent-owner-approved-fixture-dry-run",
|
||||
response_model=dict[str, Any],
|
||||
|
||||
154
apps/api/src/services/ai_agent_report_automation_review.py
Normal file
154
apps/api/src/services/ai_agent_report_automation_review.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
AI Agent report automation review snapshot.
|
||||
|
||||
Loads the latest committed P2-403J daily / weekly / monthly report, workload,
|
||||
chart, and risk-tier automation policy review. This module never schedules a
|
||||
live report, sends Telegram, writes optimization changes, or starts an
|
||||
automation worker.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from src.services.snapshot_paths import default_evaluations_dir
|
||||
|
||||
_DEFAULT_EVALUATIONS_DIR = default_evaluations_dir(Path(__file__))
|
||||
_SNAPSHOT_PATTERN = "ai_agent_report_automation_review_*.json"
|
||||
_SCHEMA_VERSION = "ai_agent_report_automation_review_v1"
|
||||
|
||||
|
||||
def load_latest_ai_agent_report_automation_review(
|
||||
evaluations_dir: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Load the newest committed AI Agent report automation review snapshot."""
|
||||
directory = evaluations_dir or _DEFAULT_EVALUATIONS_DIR
|
||||
candidates = sorted(directory.glob(_SNAPSHOT_PATTERN))
|
||||
if not candidates:
|
||||
raise FileNotFoundError(f"no AI Agent report automation review snapshots found in {directory}")
|
||||
|
||||
latest = candidates[-1]
|
||||
with latest.open(encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError(f"{latest}: expected JSON object")
|
||||
_require_schema(payload, str(latest))
|
||||
_require_report_contract(payload, str(latest))
|
||||
_require_runtime_boundaries(payload, str(latest))
|
||||
_require_rollup_consistency(payload, str(latest))
|
||||
return payload
|
||||
|
||||
|
||||
def _require_schema(payload: dict[str, Any], label: str) -> None:
|
||||
if payload.get("schema_version") != _SCHEMA_VERSION:
|
||||
raise ValueError(f"{label}: expected schema_version={_SCHEMA_VERSION}")
|
||||
status = payload.get("program_status") or {}
|
||||
if status.get("read_only_mode") is not True:
|
||||
raise ValueError(f"{label}: program_status.read_only_mode must be true")
|
||||
if status.get("runtime_authority") != "reporting_and_risk_policy_review_only_no_live_execution":
|
||||
raise ValueError(
|
||||
f"{label}: runtime_authority must remain reporting_and_risk_policy_review_only_no_live_execution"
|
||||
)
|
||||
|
||||
|
||||
def _require_report_contract(payload: dict[str, Any], label: str) -> None:
|
||||
cadences = payload.get("report_cadences") or []
|
||||
cadence_ids = {cadence.get("cadence_id") for cadence in cadences}
|
||||
if cadence_ids != {"daily", "weekly", "monthly"}:
|
||||
raise ValueError(f"{label}: report cadences must include daily, weekly, monthly")
|
||||
|
||||
agent_ids = {agent.get("agent_id") for agent in payload.get("agent_workload_metrics") or []}
|
||||
if agent_ids != {"openclaw", "hermes", "nemotron"}:
|
||||
raise ValueError(f"{label}: workload metrics must include OpenClaw, Hermes, NemoTron")
|
||||
|
||||
if not payload.get("report_charts"):
|
||||
raise ValueError(f"{label}: report charts must not be empty")
|
||||
if not payload.get("analysis_recommendations"):
|
||||
raise ValueError(f"{label}: analysis recommendations must not be empty")
|
||||
|
||||
risk_ids = {tier.get("risk_id") for tier in (payload.get("risk_tier_policy") or {}).get("risk_tiers") or []}
|
||||
if not {"low", "medium", "high", "critical"}.issubset(risk_ids):
|
||||
raise ValueError(f"{label}: risk tier policy must include low, medium, high, critical")
|
||||
|
||||
|
||||
def _require_runtime_boundaries(payload: dict[str, Any], label: str) -> None:
|
||||
truth = payload.get("report_truth") or {}
|
||||
if truth.get("high_risk_requires_approval") is not True:
|
||||
raise ValueError(f"{label}: high risk approval gate must remain true")
|
||||
if truth.get("medium_low_auto_policy_defined") is not True:
|
||||
raise ValueError(f"{label}: medium / low auto policy must be defined")
|
||||
|
||||
zero_counts = {
|
||||
"report_delivery_count_24h",
|
||||
"report_read_receipt_count_24h",
|
||||
"live_auto_optimization_count_24h",
|
||||
"live_medium_low_auto_execution_count_24h",
|
||||
}
|
||||
non_zero = sorted(key for key in zero_counts if truth.get(key) != 0)
|
||||
if non_zero:
|
||||
raise ValueError(f"{label}: live report / automation counts must remain zero: {non_zero}")
|
||||
|
||||
false_flags = {
|
||||
"report_delivery_enabled",
|
||||
"ai_analysis_after_report_enabled",
|
||||
"medium_low_auto_execution_enabled",
|
||||
}
|
||||
unsafe_truth = sorted(flag for flag in false_flags if truth.get(flag) is not False)
|
||||
if unsafe_truth:
|
||||
raise ValueError(f"{label}: live report automation flags must remain false: {unsafe_truth}")
|
||||
|
||||
boundaries = payload.get("approval_boundaries") or {}
|
||||
unsafe_boundaries = sorted(
|
||||
key
|
||||
for key, value in boundaries.items()
|
||||
if key != "high_risk_requires_human_approval" and value is not False
|
||||
)
|
||||
if unsafe_boundaries:
|
||||
raise ValueError(f"{label}: approval boundaries must remain false: {unsafe_boundaries}")
|
||||
if boundaries.get("high_risk_requires_human_approval") is not True:
|
||||
raise ValueError(f"{label}: high_risk_requires_human_approval must remain true")
|
||||
|
||||
|
||||
def _require_rollup_consistency(payload: dict[str, Any], label: str) -> None:
|
||||
rollups = payload.get("rollups") or {}
|
||||
workload = payload.get("agent_workload_metrics") or []
|
||||
recommendations = payload.get("analysis_recommendations") or []
|
||||
charts = payload.get("report_charts") or []
|
||||
cadences = payload.get("report_cadences") or []
|
||||
|
||||
expected = {
|
||||
"report_cadence_count": len(cadences),
|
||||
"agent_count": len(workload),
|
||||
"chart_count": len(charts),
|
||||
"recommendation_count": len(recommendations),
|
||||
"workload_unit_total": sum(item.get("work_units_total", 0) for item in workload),
|
||||
"workload_done_total": sum(item.get("work_units_done", 0) for item in workload),
|
||||
"workload_waiting_approval_total": sum(item.get("work_units_waiting_approval", 0) for item in workload),
|
||||
"live_report_delivery_count": 0,
|
||||
"live_auto_optimization_count": 0,
|
||||
}
|
||||
for risk in ("low", "medium", "high", "critical"):
|
||||
expected[f"{risk}_risk_recommendation_count"] = len(
|
||||
[item for item in recommendations if item.get("risk_tier") == risk]
|
||||
)
|
||||
|
||||
mismatched = {
|
||||
key: {"expected": value, "actual": rollups.get(key)}
|
||||
for key, value in expected.items()
|
||||
if rollups.get(key) != value
|
||||
}
|
||||
if mismatched:
|
||||
raise ValueError(f"{label}: rollup counts must match payload sections: {mismatched}")
|
||||
|
||||
approval_required = sorted(
|
||||
item.get("recommendation_id")
|
||||
for item in recommendations
|
||||
if item.get("approval_required") is True
|
||||
)
|
||||
if sorted(rollups.get("approval_required_recommendation_ids") or []) != approval_required:
|
||||
raise ValueError(f"{label}: approval_required_recommendation_ids mismatch")
|
||||
if rollups.get("current_auto_execution_enabled_count") != 0:
|
||||
raise ValueError(f"{label}: current auto execution count must remain zero")
|
||||
@@ -13,7 +13,7 @@ def test_load_latest_ai_agent_interaction_learning_proof_reads_committed_snapsho
|
||||
data = load_latest_ai_agent_interaction_learning_proof()
|
||||
|
||||
assert data["schema_version"] == "ai_agent_interaction_learning_proof_v1"
|
||||
assert data["program_status"]["overall_completion_percent"] == 99
|
||||
assert data["program_status"]["overall_completion_percent"] == 100
|
||||
assert data["program_status"]["current_task_id"] == "P2-403J"
|
||||
assert data["program_status"]["next_task_id"] == "P2-403K"
|
||||
assert data["program_status"]["read_only_mode"] is True
|
||||
@@ -26,10 +26,10 @@ def test_load_latest_ai_agent_interaction_learning_proof_reads_committed_snapsho
|
||||
assert data["frontend_redaction"]["raw_prompt_display_allowed"] is False
|
||||
assert data["approval_boundaries"]["runtime_worker_allowed"] is False
|
||||
assert data["approval_boundaries"]["telegram_direct_send_allowed"] is False
|
||||
assert data["rollups"]["proof_level_count"] == len(data["proof_ladder"]) == 8
|
||||
assert data["rollups"]["signal_count"] == len(data["proof_signals"]) == 10
|
||||
assert data["rollups"]["proof_level_count"] == len(data["proof_ladder"]) == 9
|
||||
assert data["rollups"]["signal_count"] == len(data["proof_signals"]) == 11
|
||||
assert data["rollups"]["operator_surface_count"] == len(data["operator_surfaces"]) == 5
|
||||
assert data["rollups"]["runtime_gate_count"] == len(data["runtime_gates"]) == 6
|
||||
assert data["rollups"]["runtime_gate_count"] == len(data["runtime_gates"]) == 7
|
||||
assert data["rollups"]["live_signal_count"] == 0
|
||||
assert data["rollups"]["live_pending_level_ids"] == []
|
||||
assert {lane["agent_id"] for lane in data["agent_lanes"]} == {
|
||||
|
||||
@@ -16,7 +16,7 @@ def test_ai_agent_interaction_learning_proof_endpoint_returns_committed_snapshot
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["schema_version"] == "ai_agent_interaction_learning_proof_v1"
|
||||
assert data["program_status"]["overall_completion_percent"] == 99
|
||||
assert data["program_status"]["overall_completion_percent"] == 100
|
||||
assert data["program_status"]["current_task_id"] == "P2-403J"
|
||||
assert data["program_status"]["next_task_id"] == "P2-403K"
|
||||
assert data["program_status"]["read_only_mode"] is True
|
||||
@@ -26,9 +26,9 @@ def test_ai_agent_interaction_learning_proof_endpoint_returns_committed_snapshot
|
||||
assert data["frontend_redaction"]["operator_conversation_display_allowed"] is False
|
||||
assert data["approval_boundaries"]["conversation_transcript_display_allowed"] is False
|
||||
assert data["approval_boundaries"]["telegram_direct_send_allowed"] is False
|
||||
assert data["rollups"]["proof_level_count"] == 8
|
||||
assert data["rollups"]["contract_ready_level_count"] == 6
|
||||
assert data["rollups"]["signal_count"] == 10
|
||||
assert data["rollups"]["proof_level_count"] == 9
|
||||
assert data["rollups"]["contract_ready_level_count"] == 7
|
||||
assert data["rollups"]["signal_count"] == 11
|
||||
assert data["rollups"]["live_signal_count"] == 0
|
||||
assert data["rollups"]["operator_surface_count"] == 5
|
||||
assert any(lane["agent_id"] == "openclaw" for lane in data["agent_lanes"])
|
||||
|
||||
91
apps/api/tests/test_ai_agent_report_automation_review.py
Normal file
91
apps/api/tests/test_ai_agent_report_automation_review.py
Normal file
@@ -0,0 +1,91 @@
|
||||
import copy
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.ai_agent_report_automation_review import (
|
||||
load_latest_ai_agent_report_automation_review,
|
||||
)
|
||||
|
||||
|
||||
def _write_snapshot(tmp_path, payload):
|
||||
path = tmp_path / "ai_agent_report_automation_review_2026-06-12.json"
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def test_load_latest_ai_agent_report_automation_review():
|
||||
data = load_latest_ai_agent_report_automation_review()
|
||||
|
||||
assert data["schema_version"] == "ai_agent_report_automation_review_v1"
|
||||
assert data["program_status"]["current_task_id"] == "P2-403J"
|
||||
assert data["program_status"]["next_task_id"] == "P2-403K"
|
||||
assert data["program_status"]["overall_completion_percent"] == 100
|
||||
assert data["report_truth"]["daily_report_ready"] is True
|
||||
assert data["report_truth"]["weekly_report_ready"] is True
|
||||
assert data["report_truth"]["monthly_report_ready"] is True
|
||||
assert data["report_truth"]["medium_low_auto_policy_defined"] is True
|
||||
assert data["report_truth"]["medium_low_auto_execution_enabled"] is False
|
||||
assert data["report_truth"]["high_risk_requires_approval"] is True
|
||||
assert data["report_truth"]["live_auto_optimization_count_24h"] == 0
|
||||
assert data["rollups"]["report_cadence_count"] == 3
|
||||
assert data["rollups"]["agent_count"] == 3
|
||||
assert data["rollups"]["chart_count"] == 4
|
||||
assert data["rollups"]["recommendation_count"] == 5
|
||||
assert data["rollups"]["workload_unit_total"] == 91
|
||||
assert data["rollups"]["current_auto_execution_enabled_count"] == 0
|
||||
assert data["rollups"]["live_report_delivery_count"] == 0
|
||||
assert data["rollups"]["live_auto_optimization_count"] == 0
|
||||
|
||||
|
||||
def test_rejects_missing_monthly_report(tmp_path):
|
||||
data = load_latest_ai_agent_report_automation_review()
|
||||
bad = copy.deepcopy(data)
|
||||
bad["report_cadences"] = [
|
||||
cadence for cadence in bad["report_cadences"] if cadence["cadence_id"] != "monthly"
|
||||
]
|
||||
bad["rollups"]["report_cadence_count"] = len(bad["report_cadences"])
|
||||
_write_snapshot(tmp_path, bad)
|
||||
|
||||
with pytest.raises(ValueError, match="daily, weekly, monthly"):
|
||||
load_latest_ai_agent_report_automation_review(tmp_path)
|
||||
|
||||
|
||||
def test_rejects_medium_low_auto_execution_enabled(tmp_path):
|
||||
data = load_latest_ai_agent_report_automation_review()
|
||||
bad = copy.deepcopy(data)
|
||||
bad["report_truth"]["medium_low_auto_execution_enabled"] = True
|
||||
_write_snapshot(tmp_path, bad)
|
||||
|
||||
with pytest.raises(ValueError, match="live report automation flags"):
|
||||
load_latest_ai_agent_report_automation_review(tmp_path)
|
||||
|
||||
|
||||
def test_rejects_high_risk_approval_disabled(tmp_path):
|
||||
data = load_latest_ai_agent_report_automation_review()
|
||||
bad = copy.deepcopy(data)
|
||||
bad["report_truth"]["high_risk_requires_approval"] = False
|
||||
_write_snapshot(tmp_path, bad)
|
||||
|
||||
with pytest.raises(ValueError, match="high risk approval gate"):
|
||||
load_latest_ai_agent_report_automation_review(tmp_path)
|
||||
|
||||
|
||||
def test_rejects_report_delivery_count(tmp_path):
|
||||
data = load_latest_ai_agent_report_automation_review()
|
||||
bad = copy.deepcopy(data)
|
||||
bad["report_truth"]["report_delivery_count_24h"] = 1
|
||||
_write_snapshot(tmp_path, bad)
|
||||
|
||||
with pytest.raises(ValueError, match="live report / automation counts"):
|
||||
load_latest_ai_agent_report_automation_review(tmp_path)
|
||||
|
||||
|
||||
def test_rejects_rollup_mismatch(tmp_path):
|
||||
data = load_latest_ai_agent_report_automation_review()
|
||||
bad = copy.deepcopy(data)
|
||||
bad["rollups"]["workload_unit_total"] = 999
|
||||
_write_snapshot(tmp_path, bad)
|
||||
|
||||
with pytest.raises(ValueError, match="rollup counts"):
|
||||
load_latest_ai_agent_report_automation_review(tmp_path)
|
||||
28
apps/api/tests/test_ai_agent_report_automation_review_api.py
Normal file
28
apps/api/tests/test_ai_agent_report_automation_review_api.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.main import app
|
||||
|
||||
|
||||
def test_get_ai_agent_report_automation_review_api():
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/agents/agent-report-automation-review")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["schema_version"] == "ai_agent_report_automation_review_v1"
|
||||
assert data["program_status"]["current_task_id"] == "P2-403J"
|
||||
assert data["program_status"]["next_task_id"] == "P2-403K"
|
||||
assert data["program_status"]["overall_completion_percent"] == 100
|
||||
assert data["report_truth"]["daily_report_ready"] is True
|
||||
assert data["report_truth"]["weekly_report_ready"] is True
|
||||
assert data["report_truth"]["monthly_report_ready"] is True
|
||||
assert data["report_truth"]["medium_low_auto_policy_defined"] is True
|
||||
assert data["report_truth"]["medium_low_auto_execution_enabled"] is False
|
||||
assert data["report_truth"]["high_risk_requires_approval"] is True
|
||||
assert data["rollups"]["report_cadence_count"] == 3
|
||||
assert data["rollups"]["agent_count"] == 3
|
||||
assert data["rollups"]["chart_count"] == 4
|
||||
assert data["rollups"]["recommendation_count"] == 5
|
||||
assert data["rollups"]["workload_unit_total"] == 91
|
||||
assert data["rollups"]["current_auto_execution_enabled_count"] == 0
|
||||
assert data["rollups"]["live_auto_optimization_count"] == 0
|
||||
Reference in New Issue
Block a user