feat(governance): 新增報表 runtime dry-run 證據包
This commit is contained in:
@@ -88,6 +88,9 @@ from src.services.ai_agent_redis_dry_run_gate import (
|
||||
from src.services.ai_agent_report_automation_review import (
|
||||
load_latest_ai_agent_report_automation_review,
|
||||
)
|
||||
from src.services.ai_agent_report_runtime_dry_run import (
|
||||
load_latest_ai_agent_report_runtime_dry_run,
|
||||
)
|
||||
from src.services.ai_agent_report_runtime_readiness import (
|
||||
load_latest_ai_agent_report_runtime_readiness,
|
||||
)
|
||||
@@ -942,6 +945,34 @@ async def get_agent_report_runtime_readiness() -> dict[str, Any]:
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/agent-report-runtime-dry-run",
|
||||
response_model=dict[str, Any],
|
||||
summary="取得 AI Agent 報表 runtime no-write dry-run 證據包",
|
||||
description=(
|
||||
"讀取最新已提交的 P2-403M 報表 runtime no-write dry-run、Telegram Gateway queue 草案、"
|
||||
"讀報回執 redaction 與 readback verifier 草案;此端點不排程實發、不送 Telegram、"
|
||||
"不寫 Gateway queue、不寫讀報回執、不啟動 AI runtime worker、不啟動中低風險 auto worker、"
|
||||
"不執行 verifier live readback、不讀 secret、不回傳內部對話內容。"
|
||||
),
|
||||
)
|
||||
async def get_agent_report_runtime_dry_run() -> dict[str, Any]:
|
||||
"""Return the latest read-only AI Agent report runtime dry-run package."""
|
||||
try:
|
||||
return await asyncio.to_thread(load_latest_ai_agent_report_runtime_dry_run)
|
||||
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_runtime_dry_run_invalid", error=str(exc))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="AI Agent 報表 runtime no-write dry-run 證據包無效",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/agent-owner-approved-fixture-dry-run",
|
||||
response_model=dict[str, Any],
|
||||
|
||||
219
apps/api/src/services/ai_agent_report_runtime_dry_run.py
Normal file
219
apps/api/src/services/ai_agent_report_runtime_dry_run.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
AI Agent report runtime no-write dry-run snapshot.
|
||||
|
||||
Loads the latest committed P2-403M report runtime dry-run contract. This
|
||||
module only validates repo-committed dry-run evidence and never writes Telegram
|
||||
Gateway queues, sends Telegram messages, starts AI workers, or reads secrets.
|
||||
"""
|
||||
|
||||
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_runtime_dry_run_*.json"
|
||||
_SCHEMA_VERSION = "ai_agent_report_runtime_dry_run_v1"
|
||||
_RUNTIME_AUTHORITY = "report_runtime_no_write_dry_run_only_no_gateway_write_or_delivery"
|
||||
|
||||
|
||||
def load_latest_ai_agent_report_runtime_dry_run(
|
||||
evaluations_dir: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Load the newest committed AI Agent report runtime dry-run snapshot."""
|
||||
directory = evaluations_dir or _DEFAULT_EVALUATIONS_DIR
|
||||
candidates = sorted(directory.glob(_SNAPSHOT_PATTERN))
|
||||
if not candidates:
|
||||
raise FileNotFoundError(f"no AI Agent report runtime dry-run 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_no_write_boundaries(payload, str(latest))
|
||||
_require_artifact_contract(payload, str(latest))
|
||||
_require_gateway_draft_contract(payload, str(latest))
|
||||
_require_verifier_contract(payload, str(latest))
|
||||
_require_agent_roles(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") != _RUNTIME_AUTHORITY:
|
||||
raise ValueError(f"{label}: runtime_authority must remain {_RUNTIME_AUTHORITY}")
|
||||
if status.get("current_task_id") != "P2-403M":
|
||||
raise ValueError(f"{label}: current_task_id must be P2-403M")
|
||||
|
||||
|
||||
def _require_no_write_boundaries(payload: dict[str, Any], label: str) -> None:
|
||||
truth = payload.get("dry_run_truth") or {}
|
||||
required_true = {
|
||||
"no_write_dry_run_package_ready",
|
||||
"report_snapshot_dry_run_ready",
|
||||
"telegram_gateway_queue_draft_ready",
|
||||
"readback_verifier_plan_ready",
|
||||
"failure_only_telegram_draft_ready",
|
||||
}
|
||||
missing = sorted(field for field in required_true if truth.get(field) is not True)
|
||||
if missing:
|
||||
raise ValueError(f"{label}: dry-run readiness flags must remain true: {missing}")
|
||||
|
||||
required_false = {
|
||||
"production_delivery_enabled",
|
||||
"telegram_gateway_queue_write_enabled",
|
||||
"telegram_bot_api_call_enabled",
|
||||
"delivery_receipt_write_enabled",
|
||||
"ai_runtime_worker_enabled",
|
||||
"medium_low_auto_worker_enabled",
|
||||
"post_action_verifier_live_readback_enabled",
|
||||
"production_write_enabled",
|
||||
"secret_value_read_enabled",
|
||||
"work_window_transcript_display_allowed",
|
||||
}
|
||||
unsafe = sorted(field for field in required_false if truth.get(field) is not False)
|
||||
if unsafe:
|
||||
raise ValueError(f"{label}: live write/send/runtime flags must remain false: {unsafe}")
|
||||
|
||||
zero_counts = {
|
||||
"live_report_delivery_count_24h",
|
||||
"telegram_gateway_queue_write_count_24h",
|
||||
"telegram_bot_api_call_count_24h",
|
||||
"delivery_receipt_write_count_24h",
|
||||
"ai_runtime_worker_run_count_24h",
|
||||
"medium_low_auto_execution_count_24h",
|
||||
"post_action_verifier_live_readback_count_24h",
|
||||
"production_write_count_24h",
|
||||
}
|
||||
non_zero = sorted(field for field in zero_counts if truth.get(field) != 0)
|
||||
if non_zero:
|
||||
raise ValueError(f"{label}: live write/send/runtime counts must remain zero: {non_zero}")
|
||||
|
||||
|
||||
def _require_artifact_contract(payload: dict[str, Any], label: str) -> None:
|
||||
artifacts = payload.get("dry_run_artifacts") or []
|
||||
artifact_ids = {artifact.get("artifact_id") for artifact in artifacts}
|
||||
required = {
|
||||
"report_run_snapshot_preview",
|
||||
"telegram_digest_payload_preview",
|
||||
"ai_post_report_analysis_packet",
|
||||
"medium_low_auto_noop_plan",
|
||||
"post_action_verifier_readback_plan",
|
||||
}
|
||||
if artifact_ids != required:
|
||||
raise ValueError(f"{label}: dry-run artifacts must match {sorted(required)}")
|
||||
|
||||
for artifact in artifacts:
|
||||
artifact_id = artifact.get("artifact_id")
|
||||
if artifact.get("mode") != "repo_only_no_write":
|
||||
raise ValueError(f"{label}: artifact {artifact_id} mode must remain repo_only_no_write")
|
||||
if artifact.get("writes_production") is not False:
|
||||
raise ValueError(f"{label}: artifact {artifact_id} must not write production")
|
||||
if artifact.get("contains_secret") is not False:
|
||||
raise ValueError(f"{label}: artifact {artifact_id} must not contain secrets")
|
||||
|
||||
|
||||
def _require_gateway_draft_contract(payload: dict[str, Any], label: str) -> None:
|
||||
drafts = payload.get("telegram_gateway_queue_drafts") or []
|
||||
draft_ids = {draft.get("draft_id") for draft in drafts}
|
||||
if draft_ids != {"daily_report_digest", "weekly_report_digest", "monthly_report_digest"}:
|
||||
raise ValueError(f"{label}: Telegram queue drafts must cover daily, weekly, monthly")
|
||||
|
||||
for draft in drafts:
|
||||
draft_id = draft.get("draft_id")
|
||||
if draft.get("recipient_room") != "AwoooI SRE 戰情室":
|
||||
raise ValueError(f"{label}: draft {draft_id} must target AwoooI SRE 戰情室")
|
||||
if draft.get("secret_ref") != "SRE_GROUP_CHAT_ID":
|
||||
raise ValueError(f"{label}: draft {draft_id} must only reference SRE_GROUP_CHAT_ID")
|
||||
if draft.get("gateway_queue_write_enabled") is not False:
|
||||
raise ValueError(f"{label}: draft {draft_id} must not write Gateway queue")
|
||||
if draft.get("telegram_send_enabled") is not False:
|
||||
raise ValueError(f"{label}: draft {draft_id} must not send Telegram")
|
||||
if draft.get("direct_bot_api_allowed") is not False:
|
||||
raise ValueError(f"{label}: draft {draft_id} must not allow direct Bot API")
|
||||
if draft.get("payload_contains_secret") is not False:
|
||||
raise ValueError(f"{label}: draft {draft_id} must not contain secret payload")
|
||||
|
||||
|
||||
def _require_verifier_contract(payload: dict[str, Any], label: str) -> None:
|
||||
cases = payload.get("readback_verifier_cases") or []
|
||||
case_ids = {case.get("case_id") for case in cases}
|
||||
required = {
|
||||
"report_snapshot_readback",
|
||||
"gateway_queue_preview_readback",
|
||||
"receipt_redaction_readback",
|
||||
"medium_low_noop_readback",
|
||||
}
|
||||
if case_ids != required:
|
||||
raise ValueError(f"{label}: readback verifier cases must match {sorted(required)}")
|
||||
|
||||
for case in cases:
|
||||
case_id = case.get("case_id")
|
||||
if case.get("live_readback_enabled") is not False:
|
||||
raise ValueError(f"{label}: verifier case {case_id} must not run live readback")
|
||||
if case.get("writes_result") is not False:
|
||||
raise ValueError(f"{label}: verifier case {case_id} must not write result")
|
||||
if case.get("requires_secret_value") is not False:
|
||||
raise ValueError(f"{label}: verifier case {case_id} must not require secret value")
|
||||
|
||||
|
||||
def _require_agent_roles(payload: dict[str, Any], label: str) -> None:
|
||||
roles = payload.get("agent_dry_run_roles") or []
|
||||
agents = {role.get("agent_id") for role in roles}
|
||||
if agents != {"openclaw", "hermes", "nemotron"}:
|
||||
raise ValueError(f"{label}: dry-run roles must include OpenClaw, Hermes, and NemoTron")
|
||||
for role in roles:
|
||||
if role.get("live_action_count_24h") != 0:
|
||||
raise ValueError(f"{label}: agent {role.get('agent_id')} live_action_count_24h must remain zero")
|
||||
|
||||
|
||||
def _require_rollup_consistency(payload: dict[str, Any], label: str) -> None:
|
||||
rollups = payload.get("rollups") or {}
|
||||
truth = payload.get("dry_run_truth") or {}
|
||||
artifacts = payload.get("dry_run_artifacts") or []
|
||||
drafts = payload.get("telegram_gateway_queue_drafts") or []
|
||||
cases = payload.get("readback_verifier_cases") or []
|
||||
roles = payload.get("agent_dry_run_roles") or []
|
||||
checkpoints = payload.get("operator_checkpoints") or []
|
||||
|
||||
expected = {
|
||||
"dry_run_artifact_count": len(artifacts),
|
||||
"gateway_queue_draft_count": len(drafts),
|
||||
"readback_verifier_case_count": len(cases),
|
||||
"agent_role_count": len(roles),
|
||||
"operator_checkpoint_count": len(checkpoints),
|
||||
"live_report_delivery_count": truth.get("live_report_delivery_count_24h"),
|
||||
"telegram_gateway_queue_write_count": truth.get("telegram_gateway_queue_write_count_24h"),
|
||||
"telegram_bot_api_call_count": truth.get("telegram_bot_api_call_count_24h"),
|
||||
"delivery_receipt_write_count": truth.get("delivery_receipt_write_count_24h"),
|
||||
"ai_runtime_worker_run_count": truth.get("ai_runtime_worker_run_count_24h"),
|
||||
"medium_low_auto_execution_count": truth.get("medium_low_auto_execution_count_24h"),
|
||||
"post_action_verifier_live_readback_count": truth.get("post_action_verifier_live_readback_count_24h"),
|
||||
"production_write_count": truth.get("production_write_count_24h"),
|
||||
}
|
||||
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(
|
||||
checkpoint.get("checkpoint_id")
|
||||
for checkpoint in checkpoints
|
||||
if checkpoint.get("approval_required") is True
|
||||
)
|
||||
if sorted(rollups.get("approval_required_checkpoint_ids") or []) != approval_required:
|
||||
raise ValueError(f"{label}: approval_required_checkpoint_ids mismatch")
|
||||
Reference in New Issue
Block a user