fix(api): 在日報月報 preview 顯示資料源沉澱
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user