From 77fe2a85fdfe79fe69ead2bf015d0b49f927bb7e Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 18 Jun 2026 20:01:44 +0800 Subject: [PATCH] =?UTF-8?q?fix(api):=20=E5=9C=A8=E6=97=A5=E5=A0=B1?= =?UTF-8?q?=E6=9C=88=E5=A0=B1=20preview=20=E9=A1=AF=E7=A4=BA=E8=B3=87?= =?UTF-8?q?=E6=96=99=E6=BA=90=E6=B2=89=E6=BE=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/api/v1/stats.py | 124 +++++++++++++++++- .../src/services/report_generation_service.py | 110 +++++++++++++++- .../tests/test_report_generation_service.py | 123 +++++++++++++---- .../tests/test_weekly_report_preview_api.py | 45 +++++++ 4 files changed, 372 insertions(+), 30 deletions(-) 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