feat(governance): 新增 Agent 報告狀態總覽
This commit is contained in:
@@ -115,6 +115,9 @@ from src.services.ai_agent_report_runtime_fixture_readback import (
|
||||
from src.services.ai_agent_report_runtime_readiness import (
|
||||
load_latest_ai_agent_report_runtime_readiness,
|
||||
)
|
||||
from src.services.ai_agent_report_status_board import (
|
||||
load_latest_ai_agent_report_status_board,
|
||||
)
|
||||
from src.services.ai_agent_report_truth_actionability_review import (
|
||||
load_latest_ai_agent_report_truth_actionability_review,
|
||||
)
|
||||
@@ -947,6 +950,35 @@ async def get_agent_report_automation_review() -> dict[str, Any]:
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/agent-report-status-board",
|
||||
response_model=dict[str, Any],
|
||||
summary="取得 AI Agent 日週月報與工作狀態總覽",
|
||||
description=(
|
||||
"讀取最新已提交的 P2-108 AI Agent 日報、週報、月報完成狀態、"
|
||||
"OpenClaw / Hermes / NemoTron 工作量、圖表化狀態、Telegram 草案與自動優化邊界;"
|
||||
"此端點不排程實發、不送 Telegram、不寫 Gateway queue、不寫讀報回執、"
|
||||
"不啟動 AI 分析 worker、不執行生產優化、不讀 secret、不回傳內部協作內容。"
|
||||
),
|
||||
)
|
||||
async def get_agent_report_status_board() -> dict[str, Any]:
|
||||
"""Return the latest read-only AI Agent report status board."""
|
||||
try:
|
||||
payload = await asyncio.to_thread(load_latest_ai_agent_report_status_board)
|
||||
return redact_public_lan_topology(payload)
|
||||
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_status_board_invalid", error=str(exc))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="AI Agent 日週月報與工作狀態總覽無效",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/agent-report-runtime-readiness",
|
||||
response_model=dict[str, Any],
|
||||
|
||||
273
apps/api/src/services/ai_agent_report_status_board.py
Normal file
273
apps/api/src/services/ai_agent_report_status_board.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
AI Agent report status board snapshot.
|
||||
|
||||
Loads the latest committed P2-108 daily / weekly / monthly report status board.
|
||||
This module exposes a read-only management summary only. It never schedules
|
||||
reports, sends Telegram, writes Gateway queues, records read receipts, starts
|
||||
AI analysis workers, or writes production optimization results.
|
||||
"""
|
||||
|
||||
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_status_board_*.json"
|
||||
_SCHEMA_VERSION = "ai_agent_report_status_board_v1"
|
||||
_RUNTIME_AUTHORITY = "report_status_board_only_no_live_send_or_write"
|
||||
_FORBIDDEN_DISPLAY_TERMS = (
|
||||
"工作視窗",
|
||||
"對話內容",
|
||||
"批准!繼續",
|
||||
"In app browser",
|
||||
"My request for Codex",
|
||||
"browser_context",
|
||||
"codex_user_message",
|
||||
"prompt_text",
|
||||
"raw prompt",
|
||||
"raw_prompt",
|
||||
"private reasoning",
|
||||
"private_reasoning",
|
||||
"chain of thought",
|
||||
"chain_of_thought",
|
||||
"authorization_header",
|
||||
"authorization header",
|
||||
"secret value",
|
||||
"secret_value",
|
||||
"raw payload",
|
||||
"raw_payload",
|
||||
"raw Telegram payload",
|
||||
"raw_telegram_payload",
|
||||
)
|
||||
|
||||
|
||||
def load_latest_ai_agent_report_status_board(
|
||||
evaluations_dir: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Load the newest committed AI Agent report status board snapshot."""
|
||||
directory = evaluations_dir or _DEFAULT_EVALUATIONS_DIR
|
||||
candidates = sorted(directory.glob(_SNAPSHOT_PATTERN))
|
||||
if not candidates:
|
||||
raise FileNotFoundError(f"no AI Agent report status board 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_completion_truth(payload, str(latest))
|
||||
_require_report_cards(payload, str(latest))
|
||||
_require_agent_status_reports(payload, str(latest))
|
||||
_require_visible_charts(payload, str(latest))
|
||||
_require_operator_answers(payload, str(latest))
|
||||
_require_activation_boundaries(payload, str(latest))
|
||||
_require_display_redaction(payload, str(latest))
|
||||
_require_no_forbidden_display_terms(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-108":
|
||||
raise ValueError(f"{label}: current_task_id must be P2-108")
|
||||
if status.get("overall_completion_percent") != 100:
|
||||
raise ValueError(f"{label}: P2-108 status board must be 100 percent complete")
|
||||
|
||||
|
||||
def _require_completion_truth(payload: dict[str, Any], label: str) -> None:
|
||||
truth = payload.get("report_completion_truth") or {}
|
||||
required_true = {
|
||||
"daily_report_visible",
|
||||
"weekly_report_visible",
|
||||
"monthly_report_visible",
|
||||
"per_agent_status_visible",
|
||||
"workload_metrics_visible",
|
||||
"chart_package_visible",
|
||||
"telegram_digest_draft_visible",
|
||||
"high_risk_human_approval_required",
|
||||
}
|
||||
missing = sorted(field for field in required_true if truth.get(field) is not True)
|
||||
if missing:
|
||||
raise ValueError(f"{label}: report visibility truth flags must remain true: {missing}")
|
||||
|
||||
required_false = {
|
||||
"live_report_delivery_enabled",
|
||||
"ai_post_report_analysis_enabled",
|
||||
"medium_low_auto_optimization_enabled",
|
||||
}
|
||||
unsafe = sorted(field for field in required_false if truth.get(field) is not False)
|
||||
if unsafe:
|
||||
raise ValueError(f"{label}: live report automation flags must remain false: {unsafe}")
|
||||
|
||||
zero_counts = {
|
||||
"live_telegram_send_count_24h",
|
||||
"live_auto_optimization_count_24h",
|
||||
}
|
||||
non_zero = sorted(field for field in zero_counts if truth.get(field) != 0)
|
||||
if non_zero:
|
||||
raise ValueError(f"{label}: live report counters must remain zero: {non_zero}")
|
||||
|
||||
|
||||
def _require_report_cards(payload: dict[str, Any], label: str) -> None:
|
||||
cards = payload.get("report_status_cards") or []
|
||||
cadence_ids = {card.get("cadence_id") for card in cards}
|
||||
if cadence_ids != {"daily", "weekly", "monthly"}:
|
||||
raise ValueError(f"{label}: report cards must include daily, weekly, monthly")
|
||||
for card in cards:
|
||||
cadence_id = card.get("cadence_id")
|
||||
if card.get("completion_percent") != 100:
|
||||
raise ValueError(f"{label}: report {cadence_id} must be 100 percent visible")
|
||||
if card.get("contract_state") != "visible_contract_ready":
|
||||
raise ValueError(f"{label}: report {cadence_id} contract_state must be visible_contract_ready")
|
||||
if card.get("delivery_state") != "draft_only":
|
||||
raise ValueError(f"{label}: report {cadence_id} delivery_state must remain draft_only")
|
||||
if card.get("live_delivery_count") != 0:
|
||||
raise ValueError(f"{label}: report {cadence_id} live_delivery_count must remain zero")
|
||||
if not card.get("next_gate"):
|
||||
raise ValueError(f"{label}: report {cadence_id} must include next_gate")
|
||||
|
||||
|
||||
def _require_agent_status_reports(payload: dict[str, Any], label: str) -> None:
|
||||
reports = payload.get("agent_status_reports") or []
|
||||
agent_ids = {report.get("agent_id") for report in reports}
|
||||
if agent_ids != {"openclaw", "hermes", "nemotron"}:
|
||||
raise ValueError(f"{label}: agent status reports must include OpenClaw, Hermes, NemoTron")
|
||||
for report in reports:
|
||||
agent_id = report.get("agent_id")
|
||||
total = report.get("work_units_total")
|
||||
done = report.get("work_units_done")
|
||||
waiting = report.get("work_units_waiting_approval")
|
||||
if not isinstance(total, int) or not isinstance(done, int) or not isinstance(waiting, int):
|
||||
raise ValueError(f"{label}: agent {agent_id} work units must be integers")
|
||||
if done + waiting != total:
|
||||
raise ValueError(f"{label}: agent {agent_id} done + waiting must equal total")
|
||||
if report.get("live_runtime_work_units_24h") != 0:
|
||||
raise ValueError(f"{label}: agent {agent_id} live_runtime_work_units_24h must remain zero")
|
||||
if not report.get("primary_role") or not report.get("status_note"):
|
||||
raise ValueError(f"{label}: agent {agent_id} must include role and status note")
|
||||
|
||||
|
||||
def _require_visible_charts(payload: dict[str, Any], label: str) -> None:
|
||||
charts = payload.get("visible_charts") or []
|
||||
chart_ids = {chart.get("chart_id") for chart in charts}
|
||||
required = {"report_cadence_completion", "agent_workload_status", "runtime_activation_boundary"}
|
||||
if chart_ids != required:
|
||||
raise ValueError(f"{label}: visible charts must match {sorted(required)}")
|
||||
for chart in charts:
|
||||
if not chart.get("series"):
|
||||
raise ValueError(f"{label}: chart {chart.get('chart_id')} must include series")
|
||||
|
||||
|
||||
def _require_operator_answers(payload: dict[str, Any], label: str) -> None:
|
||||
answers = payload.get("operator_answer_cards") or []
|
||||
answer_ids = {answer.get("answer_id") for answer in answers}
|
||||
required = {
|
||||
"daily_weekly_monthly_complete",
|
||||
"per_agent_status_visible",
|
||||
"telegram_and_auto_optimization_boundary",
|
||||
"high_risk_review_policy",
|
||||
}
|
||||
if answer_ids != required:
|
||||
raise ValueError(f"{label}: operator answers must match {sorted(required)}")
|
||||
complete_answers = [answer for answer in answers if answer.get("status") == "complete"]
|
||||
if len(complete_answers) < 2:
|
||||
raise ValueError(f"{label}: at least report and per-agent answers must be complete")
|
||||
|
||||
|
||||
def _require_activation_boundaries(payload: dict[str, Any], label: str) -> None:
|
||||
boundaries = payload.get("activation_boundaries") or {}
|
||||
required_false = {
|
||||
"scheduler_enabled",
|
||||
"gateway_queue_write_enabled",
|
||||
"telegram_send_enabled",
|
||||
"report_receipt_write_enabled",
|
||||
"ai_analysis_run_enabled",
|
||||
"medium_low_auto_execution_enabled",
|
||||
"production_optimization_write_enabled",
|
||||
}
|
||||
unsafe = sorted(field for field in required_false if boundaries.get(field) is not False)
|
||||
if unsafe:
|
||||
raise ValueError(f"{label}: activation boundaries must remain false: {unsafe}")
|
||||
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_display_redaction(payload: dict[str, Any], label: str) -> None:
|
||||
contract = payload.get("display_redaction_contract") or {}
|
||||
if contract.get("redaction_required") is not True:
|
||||
raise ValueError(f"{label}: display redaction is required")
|
||||
forbidden_true = {
|
||||
"raw_prompt_display_allowed",
|
||||
"private_reasoning_display_allowed",
|
||||
"secret_value_display_allowed",
|
||||
"internal_transcript_display_allowed",
|
||||
}
|
||||
unsafe = sorted(field for field in forbidden_true if contract.get(field) is not False)
|
||||
if unsafe:
|
||||
raise ValueError(f"{label}: display redaction fields must remain false: {unsafe}")
|
||||
|
||||
|
||||
def _require_no_forbidden_display_terms(payload: Any, label: str) -> None:
|
||||
strings = _collect_strings(payload)
|
||||
found = sorted({term for term in _FORBIDDEN_DISPLAY_TERMS for value in strings if term in value})
|
||||
if found:
|
||||
raise ValueError(f"{label}: forbidden display terms found: {found}")
|
||||
|
||||
|
||||
def _require_rollup_consistency(payload: dict[str, Any], label: str) -> None:
|
||||
rollups = payload.get("rollups") or {}
|
||||
report_cards = payload.get("report_status_cards") or []
|
||||
agents = payload.get("agent_status_reports") or []
|
||||
charts = payload.get("visible_charts") or []
|
||||
answers = payload.get("operator_answer_cards") or []
|
||||
expected = {
|
||||
"report_card_count": len(report_cards),
|
||||
"agent_status_count": len(agents),
|
||||
"visible_chart_count": len(charts),
|
||||
"operator_answer_count": len(answers),
|
||||
"completed_report_count": len([card for card in report_cards if card.get("completion_percent") == 100]),
|
||||
"workload_unit_total": sum(agent.get("work_units_total", 0) for agent in agents),
|
||||
"workload_done_total": sum(agent.get("work_units_done", 0) for agent in agents),
|
||||
"workload_waiting_approval_total": sum(agent.get("work_units_waiting_approval", 0) for agent in agents),
|
||||
"live_delivery_count": sum(card.get("live_delivery_count", 0) for card in report_cards),
|
||||
"live_telegram_send_count": 0,
|
||||
"live_runtime_work_units": sum(agent.get("live_runtime_work_units_24h", 0) for agent in agents),
|
||||
"live_auto_optimization_count": 0,
|
||||
"high_risk_requires_human_approval": True,
|
||||
}
|
||||
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}")
|
||||
|
||||
|
||||
def _collect_strings(value: Any) -> list[str]:
|
||||
if isinstance(value, str):
|
||||
return [value]
|
||||
if isinstance(value, list):
|
||||
strings: list[str] = []
|
||||
for item in value:
|
||||
strings.extend(_collect_strings(item))
|
||||
return strings
|
||||
if isinstance(value, dict):
|
||||
strings: list[str] = []
|
||||
for item in value.values():
|
||||
strings.extend(_collect_strings(item))
|
||||
return strings
|
||||
return []
|
||||
Reference in New Issue
Block a user