feat(governance): 新增 Agent 日週月報風險自動化審查
All checks were successful
CD Pipeline / tests (push) Successful in 1m28s
Code Review / ai-code-review (push) Successful in 14s
CD Pipeline / build-and-deploy (push) Successful in 3m59s
CD Pipeline / post-deploy-checks (push) Successful in 1m30s

This commit is contained in:
Your Name
2026-06-12 10:46:56 +08:00
parent 867d3e1472
commit a2bcf03124
19 changed files with 1541 additions and 43 deletions

View File

@@ -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],

View 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")

View File

@@ -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"]} == {

View File

@@ -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"])

View 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)

View 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