feat(reports): 新增報表資料源健康 read model
This commit is contained in:
@@ -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],
|
||||
|
||||
388
apps/api/src/services/ai_agent_report_source_health.py
Normal file
388
apps/api/src/services/ai_agent_report_source_health.py
Normal 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
|
||||
Reference in New Issue
Block a user