diff --git a/apps/api/src/api/v1/stats.py b/apps/api/src/api/v1/stats.py
index ea1375df..6e582f0d 100644
--- a/apps/api/src/api/v1/stats.py
+++ b/apps/api/src/api/v1/stats.py
@@ -27,10 +27,20 @@ from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
from fastapi.responses import PlainTextResponse
from pydantic import BaseModel, Field
-from src.services.stats_service import StatsService, get_stats_service
+from src.services.flywheel_stats_service import (
+ FlywheelStatsService,
+ get_flywheel_stats_service,
+)
from src.services.k3s_monitor_service import K3sMonitorService, get_k3s_monitor_service
-from src.services.weekly_report_service import WeeklyReportService, get_weekly_report_service
-from src.services.flywheel_stats_service import FlywheelStatsService, get_flywheel_stats_service
+from src.services.report_generation_service import (
+ ReportGenerationService,
+ get_report_generation_service,
+)
+from src.services.stats_service import StatsService, get_stats_service
+from src.services.weekly_report_service import (
+ WeeklyReportService,
+ get_weekly_report_service,
+)
router = APIRouter(prefix="/stats", tags=["Statistics"])
@@ -42,6 +52,7 @@ router = APIRouter(prefix="/stats", tags=["Statistics"])
StatsServiceDep = Annotated[StatsService, Depends(get_stats_service)]
K3sMonitorDep = Annotated[K3sMonitorService, Depends(get_k3s_monitor_service)]
WeeklyReportDep = Annotated[WeeklyReportService, Depends(get_weekly_report_service)]
+DailyReportDep = Annotated[ReportGenerationService, Depends(get_report_generation_service)]
# =============================================================================
@@ -367,6 +378,113 @@ class WeeklyReportResponse(BaseModel):
formatted_preview: str = Field(default="", description="Telegram HTML no-send preview")
+class DailyReportPreviewResponse(BaseModel):
+ """日報 no-send preview 回應"""
+
+ report_date: str = Field(description="報告日期時間")
+ alert_total: int = Field(description="24 小時告警總數")
+ auto_repair_success: int = Field(description="自動修復成功次數")
+ auto_repair_failed: int = Field(description="自動修復失敗次數")
+ km_new_entries: int = Field(description="新增 KM 條目")
+ playbook_count: int = Field(description="活躍 PlayBook 數")
+ source_ok_count: int = Field(default=0, description="報表資料源可讀數")
+ source_total_count: int = Field(default=0, description="報表資料源總數")
+ source_confidence_percent: int = Field(default=0, description="報表資料源可信度")
+ source_gap_ids: list[str] = Field(default_factory=list, description="報表資料源缺口工作項")
+ formatted_preview: str = Field(default="", description="Telegram HTML no-send preview")
+
+
+class MonthlyReportPreviewResponse(BaseModel):
+ """月報 no-send preview 回應"""
+
+ report_month: str = Field(description="報告月份")
+ source_ok_count: int = Field(default=0, description="報表資料源可讀數")
+ source_total_count: int = Field(default=0, description="報表資料源總數")
+ source_confidence_percent: int = Field(default=0, description="報表資料源可信度")
+ source_gap_ids: list[str] = Field(default_factory=list, description="報表資料源缺口工作項")
+ no_send_preview_count: int = Field(default=0, description="no-send preview 數量")
+ formatted_preview: str = Field(default="", description="Telegram HTML no-send preview")
+
+
+def _report_source_preview_fields(source_health: dict[str, Any] | None) -> dict[str, Any]:
+ source_health = source_health or {}
+ rollups = source_health.get("rollups") or {}
+ return {
+ "source_ok_count": int(rollups.get("source_ok_count") or 0),
+ "source_total_count": int(rollups.get("source_count") or 0),
+ "source_confidence_percent": int(rollups.get("confidence_percent") or 0),
+ "source_gap_ids": [
+ str(source.get("work_item_id"))
+ for source in source_health.get("source_health", [])
+ if source.get("work_item_id")
+ ][:5],
+ "no_send_preview_count": int(rollups.get("no_send_preview_count") or 0),
+ }
+
+
+@router.get(
+ "/daily/preview",
+ response_model=DailyReportPreviewResponse,
+ summary="預覽日報",
+)
+async def preview_daily_report(
+ service: DailyReportDep = None,
+) -> DailyReportPreviewResponse:
+ """
+ 預覽日報內容 (不發送)
+
+ 這個 endpoint 只讀取 KPI 與 report source-health,不寫 Gateway queue、不發 Telegram。
+ """
+ kpi = await service.collect_daily_kpi()
+ source_health = await service.collect_report_source_health(days=1)
+ preview_fields = _report_source_preview_fields(source_health)
+ return DailyReportPreviewResponse(
+ report_date=kpi.period_end.strftime("%Y-%m-%d %H:%M"),
+ alert_total=kpi.total_alerts,
+ auto_repair_success=kpi.auto_repair_success,
+ auto_repair_failed=kpi.auto_repair_failed,
+ km_new_entries=kpi.km_new_entries,
+ playbook_count=kpi.playbook_count,
+ source_ok_count=preview_fields["source_ok_count"],
+ source_total_count=preview_fields["source_total_count"],
+ source_confidence_percent=preview_fields["source_confidence_percent"],
+ source_gap_ids=preview_fields["source_gap_ids"],
+ formatted_preview=service.format_daily_report(kpi, source_health),
+ )
+
+
+@router.get(
+ "/monthly/preview",
+ response_model=MonthlyReportPreviewResponse,
+ summary="預覽月報",
+)
+async def preview_monthly_report(
+ service: DailyReportDep = None,
+) -> MonthlyReportPreviewResponse:
+ """
+ 預覽月報內容 (不發送)
+
+ 月報目前使用統一 report source-health / no-send preview,不排程、不發送、不寫入。
+ """
+ from src.utils.timezone import now_taipei
+
+ source_health = await service.collect_report_source_health(days=30)
+ preview_fields = _report_source_preview_fields(source_health)
+ now = now_taipei()
+ return MonthlyReportPreviewResponse(
+ report_month=now.strftime("%Y-%m"),
+ source_ok_count=preview_fields["source_ok_count"],
+ source_total_count=preview_fields["source_total_count"],
+ source_confidence_percent=preview_fields["source_confidence_percent"],
+ source_gap_ids=preview_fields["source_gap_ids"],
+ no_send_preview_count=preview_fields["no_send_preview_count"],
+ formatted_preview=service.format_monthly_report_preview(
+ source_health,
+ generated_at=now,
+ ),
+ )
+
+
@router.get(
"/weekly/preview",
response_model=WeeklyReportResponse,
diff --git a/apps/api/src/services/report_generation_service.py b/apps/api/src/services/report_generation_service.py
index 2c1f296c..3a1c39d9 100644
--- a/apps/api/src/services/report_generation_service.py
+++ b/apps/api/src/services/report_generation_service.py
@@ -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 = [
+ "",
+ "🧾 報表資料源 / 沉澱",
+ f" 來源: {ok_count}/{total_count} | 信心: {confidence}%",
+ 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 = [
+ "📊 AWOOOI 月報 no-send preview",
+ f"{now.strftime('%Y-%m')} | {now.strftime('%Y-%m-%d %H:%M')} 台北時間",
+ "",
+ "🧭 月報交付狀態",
+ 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 += [
+ "",
+ "🤖 AWOOOI 月報草案 | no-send preview,不代表已授權發送或自動修復",
+ ]
+ 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"📊 AWOOOI 日度巡檢報告",
+ "📊 AWOOOI 日度巡檢報告",
f"{date_str} | {period_str} 台北時間",
"",
f"{health_icon} 整體健康度: {health_label}",
@@ -355,12 +439,27 @@ class ReportGenerationService:
"🧠 知識積累",
f" 新增 KM 條目: {kpi.km_new_entries} 筆",
f" 活躍 Playbook: {kpi.playbook_count} 個",
+ ]
+ lines.extend(self._format_report_source_health_block(source_health))
+ lines += [
"",
f"🤖 AWOOOI AIOps 自動生成 | {kpi.period_end.strftime('%Y-%m-%d %H:%M')} 台北時間",
]
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"📋 事後檢討 (Postmortem)",
+ "📋 事後檢討 (Postmortem)",
f"Incident: {data.incident_id}",
"",
f"⏱ 影響時長: {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()
diff --git a/apps/api/tests/test_report_generation_service.py b/apps/api/tests/test_report_generation_service.py
index b23b495e..7fe0e467 100644
--- a/apps/api/tests/test_report_generation_service.py
+++ b/apps/api/tests/test_report_generation_service.py
@@ -23,6 +23,7 @@ from types import SimpleNamespace
import pytest
+from src.services import weekly_report_service as weekly_report_module
from src.services.report_generation_service import (
DAILY_REPORT_HOUR_TAIPEI,
POSTMORTEM_MIN_DURATION_MINUTES,
@@ -31,7 +32,6 @@ from src.services.report_generation_service import (
ReportGenerationService,
_seconds_until_next_report,
)
-from src.services import weekly_report_service as weekly_report_module
from src.services.weekly_report_service import WeeklyReportService
_TZ_TAIPEI = timezone(timedelta(hours=8))
@@ -143,15 +143,15 @@ class TestFormatDailyReport:
def _make_kpi(self, **kwargs) -> DailyKpi:
now = datetime.now(_TZ_TAIPEI)
- defaults = dict(
- total_alerts=20,
- auto_resolved=15,
- human_approved=3,
- auto_repair_success=12,
- auto_repair_failed=3,
- km_new_entries=5,
- playbook_count=18,
- )
+ defaults = {
+ "total_alerts": 20,
+ "auto_resolved": 15,
+ "human_approved": 3,
+ "auto_repair_success": 12,
+ "auto_repair_failed": 3,
+ "km_new_entries": 5,
+ "playbook_count": 18,
+ }
defaults.update(kwargs)
return DailyKpi(
period_start=now - timedelta(hours=24),
@@ -239,6 +239,85 @@ class TestFormatDailyReport:
report = svc.format_daily_report(kpi)
assert "" in report
+ def test_contains_report_source_health_assets(self):
+ """日報應顯示資料源健康與自動化資產沉澱"""
+ kpi = self._make_kpi()
+ source_health = {
+ "rollups": {
+ "source_ok_count": 2,
+ "source_count": 5,
+ "confidence_percent": 40,
+ },
+ "source_health": [
+ {"work_item_id": "report-source-gap:incident_summary"},
+ {"work_item_id": "report-source-gap:ai_performance"},
+ ],
+ "automation_assets": [
+ {"label": "KM", "state": "draft_ready", "done_count": 3, "blocked_count": 2},
+ {"label": "PlayBook", "state": "draft_required", "done_count": 0, "blocked_count": 2},
+ {"label": "腳本", "state": "readback_only", "done_count": 1, "blocked_count": 0},
+ {"label": "排程", "state": "no_send_preview", "done_count": 3, "blocked_count": 0},
+ {"label": "Verifier", "state": "source_health_ready", "done_count": 1, "blocked_count": 2},
+ ],
+ "all_zero_assessment": {
+ "all_zero_observed": True,
+ "verdict": "source_gap_or_no_signal_requires_review",
+ },
+ }
+ svc = ReportGenerationService()
+ report = svc.format_daily_report(kpi, source_health)
+
+ assert "報表資料源 / 沉澱" in report
+ assert "來源: 2/5" in report
+ assert "report-source-gap:incident_summary" in report
+ assert "KM: draft_ready 3/5" in report
+ assert "PlayBook: draft_required 0/2" in report
+ assert "腳本: readback_only 1/1" in report
+ assert "排程: no_send_preview 3/3" in report
+ assert "Verifier: source_health_ready 1/3" in report
+ assert "全 0 判讀: source_gap_or_no_signal_requires_review" in report
+ assert "不自動改排程" in report
+
+ def test_monthly_preview_contains_no_send_source_health(self):
+ """月報 preview 應顯示 no-send 邊界與資產沉澱"""
+ source_health = {
+ "rollups": {
+ "source_ok_count": 2,
+ "source_count": 5,
+ "confidence_percent": 40,
+ "no_send_preview_count": 3,
+ },
+ "source_health": [
+ {"work_item_id": "report-source-gap:resolution_stats"},
+ ],
+ "no_send_previews": [
+ {
+ "cadence_id": "monthly",
+ "owner_agent": "Hermes",
+ "delivery_state": "no_send_preview",
+ "gap_source_ids": ["resolution_stats", "ai_performance"],
+ },
+ ],
+ "automation_assets": [
+ {"label": "KM", "state": "draft_ready", "done_count": 3, "blocked_count": 1},
+ {"label": "Verifier", "state": "source_health_ready", "done_count": 1, "blocked_count": 1},
+ ],
+ }
+ svc = ReportGenerationService()
+ report = svc.format_monthly_report_preview(
+ source_health,
+ generated_at=datetime(2026, 6, 18, 10, 0, tzinfo=_TZ_TAIPEI),
+ )
+
+ assert "月報 no-send preview" in report
+ assert "Owner: Hermes" in report
+ assert "實發: 0" in report
+ assert "來源: 2/5" in report
+ assert "resolution_stats" in report
+ assert "KM: draft_ready 3/4" in report
+ assert "Verifier: source_health_ready 1/2" in report
+ assert "不代表已授權發送或自動修復" in report
+
# =============================================================================
# format_postmortem
@@ -250,18 +329,18 @@ class TestFormatPostmortem:
def _make_postmortem(self, **kwargs) -> PostmortemData:
now = datetime.now(_TZ_TAIPEI)
- defaults = dict(
- incident_id="INC-20260414-001",
- title="KubePodOOMKilled on awoooi-api",
- duration_minutes=25.5,
- root_cause="記憶體洩漏導致 OOMKilled",
- resolution_action="kubectl rollout restart deployment/awoooi-api",
- ai_provider="OpenClaw (deepseek-r1:14b)",
- auto_repaired=True,
- retry_count=0,
- created_at=now - timedelta(minutes=25, seconds=30),
- resolved_at=now,
- )
+ defaults = {
+ "incident_id": "INC-20260414-001",
+ "title": "KubePodOOMKilled on awoooi-api",
+ "duration_minutes": 25.5,
+ "root_cause": "記憶體洩漏導致 OOMKilled",
+ "resolution_action": "kubectl rollout restart deployment/awoooi-api",
+ "ai_provider": "OpenClaw (deepseek-r1:14b)",
+ "auto_repaired": True,
+ "retry_count": 0,
+ "created_at": now - timedelta(minutes=25, seconds=30),
+ "resolved_at": now,
+ }
defaults.update(kwargs)
return PostmortemData(**defaults)
diff --git a/apps/api/tests/test_weekly_report_preview_api.py b/apps/api/tests/test_weekly_report_preview_api.py
index 87a7bf08..0d0ff103 100644
--- a/apps/api/tests/test_weekly_report_preview_api.py
+++ b/apps/api/tests/test_weekly_report_preview_api.py
@@ -3,6 +3,29 @@ from fastapi.testclient import TestClient
from src.main import app
+def test_daily_report_preview_exposes_source_health_no_send_preview():
+ client = TestClient(app)
+ response = client.get("/api/v1/stats/daily/preview")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "report_date" in data
+ assert "alert_total" in data
+ assert "km_new_entries" in data
+ assert "playbook_count" in data
+ assert "source_ok_count" in data
+ assert "source_total_count" in data
+ assert "source_confidence_percent" in data
+ assert "source_gap_ids" in data
+ assert "formatted_preview" in data
+
+ preview = data["formatted_preview"]
+ assert "AWOOOI 日度巡檢報告" in preview
+ assert "報表資料源 / 沉澱" in preview
+ assert f"來源: {data['source_ok_count']}/{data['source_total_count']}" in preview
+ assert "不自動改排程" in preview
+
+
def test_weekly_report_preview_exposes_source_health_no_send_preview():
client = TestClient(app)
response = client.get("/api/v1/stats/weekly/preview")
@@ -25,3 +48,25 @@ def test_weekly_report_preview_exposes_source_health_no_send_preview():
assert "報表資料源 / 沉澱" in preview
assert f"來源: {data['source_ok_count']}/{data['source_total_count']}" in preview
assert "不自動改排程" in preview
+
+
+def test_monthly_report_preview_exposes_source_health_no_send_preview():
+ client = TestClient(app)
+ response = client.get("/api/v1/stats/monthly/preview")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "report_month" in data
+ assert "source_ok_count" in data
+ assert "source_total_count" in data
+ assert "source_confidence_percent" in data
+ assert "source_gap_ids" in data
+ assert "no_send_preview_count" in data
+ assert "formatted_preview" in data
+
+ preview = data["formatted_preview"]
+ assert "月報 no-send preview" in preview
+ assert "報表資料源 / 沉澱" in preview
+ assert f"來源: {data['source_ok_count']}/{data['source_total_count']}" in preview
+ assert "實發: 0" in preview
+ assert "不代表已授權發送或自動修復" in preview