fix(api): 在日報月報 preview 顯示資料源沉澱
Some checks failed
Code Review / ai-code-review (push) Successful in 17s
CD Pipeline / tests (push) Successful in 1m43s
CD Pipeline / build-and-deploy (push) Failing after 8m19s
CD Pipeline / post-deploy-checks (push) Has been skipped

This commit is contained in:
Your Name
2026-06-18 20:01:44 +08:00
parent a8717d52c5
commit 77fe2a85fd
4 changed files with 372 additions and 30 deletions

View File

@@ -26,8 +26,10 @@ Postmortem: Incident resolve 時,由呼叫方 await trigger_postmortem(inciden
from __future__ import annotations
import asyncio
import html
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any
import structlog
@@ -170,7 +172,8 @@ class ReportGenerationService:
async def _collect_alert_stats(self, since: datetime) -> dict:
"""收集告警統計incident 表)"""
from sqlalchemy import func, select, text as sa_text
from sqlalchemy import func, select
from sqlalchemy import text as sa_text
from src.db.base import get_db_context
from src.db.models import IncidentRecord
@@ -296,12 +299,93 @@ class ReportGenerationService:
logger.warning("daily_kpi_playbook_count_failed", error=str(e))
return 0
def format_daily_report(self, kpi: DailyKpi) -> str:
def _format_report_source_health_block(
self,
source_health: dict[str, Any] | None,
) -> list[str]:
"""Format read-only report source health and automation asset state."""
if not source_health:
return []
rollups = source_health.get("rollups") or {}
ok_count = int(rollups.get("source_ok_count") or 0)
total_count = int(rollups.get("source_count") or 0)
confidence = int(rollups.get("confidence_percent") or 0)
gap_ids = [
str(source.get("work_item_id"))
for source in source_health.get("source_health", [])
if source.get("work_item_id")
][:5]
gap_text = ", ".join(gap_ids) if gap_ids else ""
lines = [
"",
"<b>🧾 報表資料源 / 沉澱</b>",
f" 來源: <code>{ok_count}/{total_count}</code> | 信心: <b>{confidence}%</b>",
f" 缺口: {html.escape(gap_text)}",
]
for asset in (source_health.get("automation_assets") or [])[:5]:
label = html.escape(str(asset.get("label") or "資產"))
state = html.escape(str(asset.get("state") or "unknown"))
done = int(asset.get("done_count") or 0)
blocked = int(asset.get("blocked_count") or 0)
total = done + blocked
lines.append(f" {label}: {state} {done}/{total}")
assessment = source_health.get("all_zero_assessment") or {}
if assessment.get("all_zero_observed"):
verdict = html.escape(str(assessment.get("verdict") or "source_gap_requires_review"))
lines.append(f" 全 0 判讀: {verdict}")
lines.append(" 只讀判讀:不自動改排程、不直接發修復、不取代人工批准。")
return lines
def format_monthly_report_preview(
self,
source_health: dict[str, Any] | None,
*,
generated_at: datetime | None = None,
) -> str:
"""Format a monthly no-send preview from the unified report source-health model."""
now = generated_at or now_taipei()
source_health = source_health or {}
previews = source_health.get("no_send_previews") or []
monthly_preview = next(
(preview for preview in previews if preview.get("cadence_id") == "monthly"),
{},
)
gap_ids = monthly_preview.get("gap_source_ids") or []
gap_text = ", ".join(str(gap_id) for gap_id in gap_ids[:5]) if gap_ids else ""
lines = [
"<b>📊 AWOOOI 月報 no-send preview</b>",
f"<b>{now.strftime('%Y-%m')}</b> | {now.strftime('%Y-%m-%d %H:%M')} 台北時間",
"",
"<b>🧭 月報交付狀態</b>",
f" 狀態: {html.escape(str(monthly_preview.get('delivery_state') or 'no_send_preview'))}",
f" Owner: {html.escape(str(monthly_preview.get('owner_agent') or '未指定'))}",
f" 缺口來源: {html.escape(gap_text)}",
" 實發: 0 | Gateway queue write: 0",
]
lines.extend(self._format_report_source_health_block(source_health))
lines += [
"",
"<i>🤖 AWOOOI 月報草案 | no-send preview不代表已授權發送或自動修復</i>",
]
return "\n".join(lines)
def format_daily_report(
self,
kpi: DailyKpi,
source_health: dict[str, Any] | None = None,
) -> str:
"""
組裝日度巡檢報告Telegram HTML 格式)
Args:
kpi: DailyKpi 摘要
source_health: 報表資料源健康與自動化資產沉澱(只讀)
Returns:
Telegram HTML 格式字串
@@ -330,7 +414,7 @@ class ReportGenerationService:
health_label = "需關注"
lines = [
f"<b>📊 AWOOOI 日度巡檢報告</b>",
"<b>📊 AWOOOI 日度巡檢報告</b>",
f"<b>{date_str}</b> | {period_str} 台北時間",
"",
f"<b>{health_icon} 整體健康度: {health_label}</b>",
@@ -355,12 +439,27 @@ class ReportGenerationService:
"<b>🧠 知識積累</b>",
f" 新增 KM 條目: {kpi.km_new_entries}",
f" 活躍 Playbook: {kpi.playbook_count}",
]
lines.extend(self._format_report_source_health_block(source_health))
lines += [
"",
f"<i>🤖 AWOOOI AIOps 自動生成 | {kpi.period_end.strftime('%Y-%m-%d %H:%M')} 台北時間</i>",
]
return "\n".join(lines)
async def collect_report_source_health(self, days: int) -> dict[str, Any] | None:
"""Collect report source health in read-only mode; never send or write."""
try:
from src.services.ai_agent_report_source_health import (
build_ai_agent_report_source_health,
)
return await build_ai_agent_report_source_health(days=days)
except Exception as exc:
logger.warning("daily_report_source_health_failed", error=str(exc))
return None
def format_postmortem(self, data: PostmortemData) -> str:
"""
組裝事後檢討報告Telegram HTML 格式)
@@ -378,7 +477,7 @@ class ReportGenerationService:
resolved_str = data.resolved_at.strftime("%H:%M:%S")
lines = [
f"<b>📋 事後檢討 (Postmortem)</b>",
"<b>📋 事後檢討 (Postmortem)</b>",
f"<b>Incident:</b> {data.incident_id}",
"",
f"<b>⏱ 影響時長:</b> {duration_str}",
@@ -410,7 +509,8 @@ class ReportGenerationService:
"""
try:
kpi = await self.collect_daily_kpi()
report_text = self.format_daily_report(kpi)
source_health = await self.collect_report_source_health(days=1)
report_text = self.format_daily_report(kpi, source_health)
from src.services.telegram_gateway import get_telegram_gateway
gateway = get_telegram_gateway()