feat(reports): 新增報表資料源健康 read model
All checks were successful
Code Review / ai-code-review (push) Successful in 12s
CD Pipeline / tests (push) Successful in 1m33s
CD Pipeline / build-and-deploy (push) Successful in 4m49s
CD Pipeline / post-deploy-checks (push) Successful in 1m30s

This commit is contained in:
Your Name
2026-06-18 19:20:59 +08:00
parent 172d129280
commit 27d9f394e8
6 changed files with 620 additions and 22 deletions

View File

@@ -94,6 +94,9 @@ from src.services.ai_agent_receipt_readback_owner_review import (
from src.services.ai_agent_report_no_write_analysis_runtime import (
load_latest_ai_agent_report_no_write_analysis_runtime,
)
from src.services.ai_agent_report_source_health import (
build_ai_agent_report_source_health,
)
from src.services.ai_agent_low_medium_risk_whitelist import (
load_latest_ai_agent_low_medium_risk_whitelist,
)
@@ -1288,6 +1291,30 @@ async def get_agent_report_status_board() -> dict[str, Any]:
) from exc
@router.get(
"/agent-report-source-health",
response_model=dict[str, Any],
summary="取得 AI Agent 報表資料源健康與 no-send preview",
description=(
"回傳日報 / 週報 / 月報資料源健康、全 0 判讀、no-send preview、"
"KM / PlayBook / 腳本 / 排程 / Verifier 沉澱與 report-source-gap 工作項;"
"此端點只做 redacted readback不送 Telegram、不寫 Gateway queue、不改排程、"
"不啟動 AI runtime、不執行中低風險自動修復、不讀 secret。"
),
)
async def get_agent_report_source_health() -> dict[str, Any]:
"""Return the read-only AI Agent report source health model."""
try:
payload = await build_ai_agent_report_source_health()
return redact_public_lan_topology(payload)
except Exception as exc:
logger.error("ai_agent_report_source_health_failed", 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],

View File

@@ -0,0 +1,388 @@
"""
AI Agent report source health read model.
This module builds a redacted, read-only source-health view for daily, weekly,
monthly reports. It intentionally does not send Telegram, write Gateway queues,
enable schedulers, execute AI repair, mutate incidents, or open runtime gates.
"""
from __future__ import annotations
import asyncio
from datetime import datetime
from typing import Any, Awaitable, Callable
from zoneinfo import ZoneInfo
import structlog
from src.services.ai_agent_report_status_board import load_latest_ai_agent_report_status_board
logger = structlog.get_logger(__name__)
_SCHEMA_VERSION = "ai_agent_report_source_health_v1"
_CURRENT_TASK_ID = "P2-109"
_NEXT_TASK_ID = "P2-110"
_RUNTIME_AUTHORITY = "report_source_health_read_model_only_no_send_or_write"
_TAIPEI = ZoneInfo("Asia/Taipei")
async def build_ai_agent_report_source_health(days: int = 30) -> dict[str, Any]:
"""Build the report source health read model."""
generated_at = datetime.now(_TAIPEI).isoformat()
status_board = await _load_status_board()
incident_summary = await _read_source(
source_id="incident_summary",
display_name="事件統計 read model",
route="/api/v1/stats/incidents/summary",
work_item_id="report-source-gap:incident_summary",
reader=lambda: _read_incident_summary(days),
extractor=lambda payload: {
"total": _as_int(payload.get("total_incidents") or payload.get("total")),
"resolved_rate": _as_float(payload.get("resolved_rate")),
},
next_action="接入 redacted public incident summary確認 Alertmanager 入庫、recurrence mirror 與 freshness。",
)
resolution_stats = await _read_source(
source_id="resolution_stats",
display_name="解決率 read model",
route="/api/v1/stats/incidents/resolution",
work_item_id="report-source-gap:resolution_stats",
reader=lambda: _read_resolution_stats(days),
extractor=lambda payload: {
"avg_minutes": payload.get("avg_minutes"),
"resolution_rate": payload.get("resolutionRate") or payload.get("resolution_rate"),
},
next_action="接入 redacted public resolution stats確認 resolved_at、duration 與 postmortem 寫回。",
)
ai_performance = await _read_source(
source_id="ai_performance",
display_name="AI 效能 read model",
route="/api/v1/stats/ai-performance",
work_item_id="report-source-gap:ai_performance",
reader=lambda: _read_ai_performance(days),
extractor=lambda payload: {
"proposal_count": _as_int(payload.get("total_proposals")),
"executed_count": _as_int(payload.get("executed_count")),
"success_rate": _as_float(payload.get("success_rate")),
},
next_action="接入 redacted public AI performance stats確認提案、執行、成功率與 fallback reason。",
)
disposition_stats = await _read_source(
source_id="disposition_stats",
display_name="處置統計 read model",
route="/api/v1/stats/disposition",
work_item_id="report-source-gap:disposition_stats",
reader=_read_disposition_stats,
extractor=lambda payload: payload,
next_action="處置統計可讀時仍需追蹤 auto repair、manual handoff、cold-start trust 的占比。",
)
status_board_source = _build_status_board_source(status_board)
sources = [
incident_summary,
resolution_stats,
ai_performance,
disposition_stats,
status_board_source,
]
ok_count = sum(1 for source in sources if source["source_ok"])
gap_sources = [source for source in sources if not source["source_ok"]]
all_zero = _is_all_zero(incident_summary, ai_performance, disposition_stats)
source_count = len(sources)
confidence_percent = round(ok_count / source_count * 100) if source_count else 0
if all_zero and gap_sources:
confidence_percent = min(confidence_percent, 40)
no_send_previews = _build_no_send_previews(status_board, ok_count, source_count, gap_sources)
work_items = _build_work_items(gap_sources, all_zero)
automation_assets = _build_automation_assets(status_board, work_items)
return {
"schema_version": _SCHEMA_VERSION,
"generated_at": generated_at,
"program_status": {
"current_task_id": _CURRENT_TASK_ID,
"next_task_id": _NEXT_TASK_ID,
"overall_completion_percent": 100,
"read_only_mode": True,
"runtime_authority": _RUNTIME_AUTHORITY,
"status_note": "日報 / 週報 / 月報資料源健康與 no-send preview 已可由 API 統一讀回。",
},
"source_health": sources,
"all_zero_assessment": {
"all_zero_observed": all_zero,
"verdict": "source_gap_or_no_signal_requires_review" if all_zero else "signals_available_or_not_all_zero",
"confidence_percent": confidence_percent,
"blocking_reason": "資料源缺口存在時,全 0 不可視為健康。" if all_zero and gap_sources else "",
"next_action": (
"先處理 report-source-gap再產生日報 / 週報 / 月報草案。"
if gap_sources
else "持續用趨勢、recurrence 與 verifier 判讀是否需要 AI 接手。"
),
},
"no_send_previews": no_send_previews,
"automation_assets": automation_assets,
"work_items": work_items,
"activation_boundaries": {
"telegram_send_enabled": False,
"gateway_queue_write_enabled": False,
"scheduler_change_enabled": False,
"ai_runtime_execution_enabled": False,
"medium_low_auto_execution_enabled": False,
"production_write_enabled": False,
"secret_read_enabled": False,
},
"rollups": {
"source_count": source_count,
"source_ok_count": ok_count,
"source_gap_count": len(gap_sources),
"confidence_percent": confidence_percent,
"no_send_preview_count": len(no_send_previews),
"report_work_item_count": len(work_items),
"live_send_allowed_count": 0,
"runtime_gate_count": 0,
},
}
async def _load_status_board() -> dict[str, Any] | None:
try:
return await asyncio.to_thread(load_latest_ai_agent_report_status_board)
except Exception as exc:
logger.warning("report_source_health_status_board_failed", error=str(exc))
return None
async def _read_source(
*,
source_id: str,
display_name: str,
route: str,
work_item_id: str,
reader: Callable[[], Awaitable[dict[str, Any]]],
extractor: Callable[[dict[str, Any]], dict[str, Any]],
next_action: str,
) -> dict[str, Any]:
try:
payload = await reader()
metrics = extractor(payload or {})
return {
"source_id": source_id,
"display_name": display_name,
"route": route,
"source_ok": True,
"state": "ok",
"freshness": "live_readback",
"confidence_percent": 100,
"metrics": metrics,
"work_item_id": "",
"next_action": next_action,
}
except Exception as exc:
logger.warning("report_source_health_reader_failed", source_id=source_id, error=str(exc))
return {
"source_id": source_id,
"display_name": display_name,
"route": route,
"source_ok": False,
"state": "gap",
"freshness": "unavailable",
"confidence_percent": 0,
"metrics": {},
"work_item_id": work_item_id,
"next_action": next_action,
}
async def _read_incident_summary(days: int) -> dict[str, Any]:
from src.services.stats_service import get_stats_service
return await get_stats_service().get_incident_summary(days=days)
async def _read_resolution_stats(days: int) -> dict[str, Any]:
from src.services.stats_service import get_stats_service
return await get_stats_service().get_resolution_stats(days=days)
async def _read_ai_performance(days: int) -> dict[str, Any]:
from src.services.stats_service import get_stats_service
return await get_stats_service().get_ai_performance(days=days)
async def _read_disposition_stats() -> dict[str, Any]:
from src.services.anomaly_counter import get_anomaly_counter
summary, _ = await get_anomaly_counter().get_all_disposition_stats()
total = _as_int(summary.get("total"))
auto = _as_int(summary.get("auto_repair")) + _as_int(summary.get("cold_start_trust"))
return {
"total": total,
"auto_repair": _as_int(summary.get("auto_repair")),
"human_approved": _as_int(summary.get("human_approved")),
"manual_resolved": _as_int(summary.get("manual_resolved")),
"cold_start_trust": _as_int(summary.get("cold_start_trust")),
"auto_rate": round(auto / total, 4) if total else 0,
}
def _build_status_board_source(status_board: dict[str, Any] | None) -> dict[str, Any]:
if not status_board:
return {
"source_id": "report_status_board",
"display_name": "日報 / 週報 / 月報狀態板",
"route": "/api/v1/agents/agent-report-status-board",
"source_ok": False,
"state": "gap",
"freshness": "unavailable",
"confidence_percent": 0,
"metrics": {},
"work_item_id": "report-source-gap:status_board",
"next_action": "確認 committed snapshot、API route 與 no-send preview contract。",
}
rollups = status_board.get("rollups") or {}
return {
"source_id": "report_status_board",
"display_name": "日報 / 週報 / 月報狀態板",
"route": "/api/v1/agents/agent-report-status-board",
"source_ok": True,
"state": "ok",
"freshness": "committed_snapshot",
"confidence_percent": 100,
"metrics": {
"report_card_count": _as_int(rollups.get("report_card_count")),
"agent_status_count": _as_int(rollups.get("agent_status_count")),
"workload_done_total": _as_int(rollups.get("workload_done_total")),
"workload_unit_total": _as_int(rollups.get("workload_unit_total")),
"live_delivery_count": _as_int(rollups.get("live_delivery_count")),
},
"work_item_id": "",
"next_action": "狀態板可讀;下一步接 no-send preview freshness 與 SRE 戰情室 digest route。",
}
def _build_no_send_previews(
status_board: dict[str, Any] | None,
ok_count: int,
source_count: int,
gap_sources: list[dict[str, Any]],
) -> list[dict[str, Any]]:
cards = (status_board or {}).get("report_status_cards") or []
gap_ids = [source["source_id"] for source in gap_sources]
previews = []
for card in cards:
previews.append({
"cadence_id": card.get("cadence_id"),
"display_name": card.get("display_name"),
"owner_agent": card.get("owner_agent"),
"delivery_state": "no_send_preview",
"source_ready_count": ok_count,
"source_total_count": source_count,
"blocked_by_source_gap": bool(gap_sources),
"gap_source_ids": gap_ids,
"live_send_allowed": False,
"gateway_queue_write_allowed": False,
"next_gate": "補齊 source health 與 receipt readback 後,仍需人工批准才可實發。",
})
return previews
def _build_work_items(gap_sources: list[dict[str, Any]], all_zero: bool) -> list[dict[str, Any]]:
work_items = [
{
"work_item_id": source["work_item_id"],
"title": f"補齊 {source['display_name']} 資料鏈路",
"state": "open",
"blocking_reason": "report source unavailable",
"next_action": source["next_action"],
}
for source in gap_sources
if source.get("work_item_id")
]
if all_zero:
work_items.append({
"work_item_id": "report-source-gap:all_zero_truth",
"title": "全 0 報表真相判讀",
"state": "open",
"blocking_reason": "all-zero report cannot be treated as healthy",
"next_action": "比對事件、處置、AI 效能、Git / deploy 與成本來源 freshness。",
})
return work_items
def _build_automation_assets(
status_board: dict[str, Any] | None,
work_items: list[dict[str, Any]],
) -> list[dict[str, Any]]:
rollups = (status_board or {}).get("rollups") or {}
return [
{
"asset_id": "km_report_digest",
"label": "KM",
"state": "draft_ready",
"done_count": _as_int(rollups.get("agent_status_count")),
"blocked_count": len(work_items),
"next_action": "把資料缺口與 digest 結論回寫到 owner review 後的 KM 草稿。",
},
{
"asset_id": "playbook_report_source_gap",
"label": "PlayBook",
"state": "draft_required" if work_items else "candidate_ready",
"done_count": 0 if work_items else 1,
"blocked_count": len(work_items),
"next_action": "建立 report-source-gap 專屬 PlayBook不可用通用兜底命令。",
},
{
"asset_id": "script_report_readback",
"label": "腳本",
"state": "readback_only",
"done_count": 1,
"blocked_count": 0,
"next_action": "保留 read-only API / no-send preview不寫排程、不呼叫 Bot API。",
},
{
"asset_id": "schedule_report_no_send",
"label": "排程",
"state": "no_send_preview",
"done_count": _as_int(rollups.get("report_card_count")),
"blocked_count": 0,
"next_action": "日報 / 週報 / 月報先產生草案live delivery 仍維持 0。",
},
{
"asset_id": "verifier_report_source_health",
"label": "Verifier",
"state": "source_health_ready",
"done_count": 1,
"blocked_count": len(work_items),
"next_action": "Verifier 需檢查 source_ok、all_zero_assessment 與 no_send_previews。",
},
]
def _is_all_zero(
incident_summary: dict[str, Any],
ai_performance: dict[str, Any],
disposition_stats: dict[str, Any],
) -> bool:
incident_total = _as_int(incident_summary.get("metrics", {}).get("total"))
proposals = _as_int(ai_performance.get("metrics", {}).get("proposal_count"))
executed = _as_int(ai_performance.get("metrics", {}).get("executed_count"))
disposition_total = _as_int(disposition_stats.get("metrics", {}).get("total"))
return incident_total == 0 and proposals == 0 and executed == 0 and disposition_total == 0
def _as_int(value: Any) -> int:
try:
return int(value or 0)
except (TypeError, ValueError):
return 0
def _as_float(value: Any) -> float:
try:
return float(value or 0.0)
except (TypeError, ValueError):
return 0.0