507 lines
20 KiB
Python
507 lines
20 KiB
Python
"""
|
||
Weekly Report Service - Phase 21.3 定期報告
|
||
============================================
|
||
|
||
整合 StatsService + K3sMonitorService,生成週報並推送 Telegram。
|
||
|
||
數據來源:
|
||
- StatsService (告警/AI 效能統計)
|
||
- K3sMonitorService (K3s 健康)
|
||
- Git API (開發活動)
|
||
|
||
符合 leWOOOgo 積木化規範:
|
||
- Service 層封裝所有邏輯
|
||
- 透過 DI 注入依賴
|
||
|
||
@author Claude Code (首席架構師)
|
||
@version 1.0.0
|
||
@date 2026-03-31 (台北時間)
|
||
@see ADR-041-periodic-reporting-architecture.md
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
import subprocess
|
||
from datetime import datetime, timedelta
|
||
from pathlib import Path
|
||
from urllib.parse import quote, urlencode
|
||
from urllib.request import Request, urlopen
|
||
from typing import Protocol, runtime_checkable
|
||
|
||
import structlog
|
||
from zoneinfo import ZoneInfo
|
||
|
||
from src.core.config import settings
|
||
from src.services.stats_service import StatsService, get_stats_service
|
||
from src.services.k3s_monitor_service import K3sMonitorService, get_k3s_monitor_service
|
||
from src.services.telegram_gateway import WeeklyReportMessage, get_telegram_gateway
|
||
|
||
logger = structlog.get_logger(__name__)
|
||
|
||
# 台北時區
|
||
TZ_TAIPEI = ZoneInfo("Asia/Taipei")
|
||
|
||
|
||
# =============================================================================
|
||
# Protocol (Interface)
|
||
# =============================================================================
|
||
|
||
|
||
@runtime_checkable
|
||
class IWeeklyReportService(Protocol):
|
||
"""
|
||
週報服務介面
|
||
|
||
Phase 21.3: 定義 Protocol 供依賴注入
|
||
"""
|
||
|
||
async def generate_report(self) -> WeeklyReportMessage:
|
||
"""生成週報"""
|
||
...
|
||
|
||
async def send_weekly_report(self) -> bool:
|
||
"""發送週報"""
|
||
...
|
||
|
||
|
||
# =============================================================================
|
||
# Implementation
|
||
# =============================================================================
|
||
|
||
|
||
class WeeklyReportService:
|
||
"""
|
||
週報服務實作
|
||
|
||
整合多個數據來源生成週報
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
stats_service: StatsService | None = None,
|
||
k3s_monitor: K3sMonitorService | None = None,
|
||
):
|
||
self._stats_service = stats_service or get_stats_service()
|
||
self._k3s_monitor = k3s_monitor or get_k3s_monitor_service()
|
||
|
||
def _get_week_range(self) -> tuple[str, datetime, datetime]:
|
||
"""取得本週範圍"""
|
||
now = datetime.now(TZ_TAIPEI)
|
||
# 找到本週一
|
||
monday = now - timedelta(days=now.weekday())
|
||
monday = monday.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
# 本週日
|
||
sunday = monday + timedelta(days=6, hours=23, minutes=59, seconds=59)
|
||
|
||
# ISO 週次
|
||
week_num = now.isocalendar()[1]
|
||
week_range = f"{now.year}-W{week_num:02d}"
|
||
|
||
return week_range, monday, sunday
|
||
|
||
def _get_git_stats(self, since: datetime) -> tuple[int, int, bool]:
|
||
"""取得 Git 統計 (commits, deploys)"""
|
||
git_cwd = "/app"
|
||
if not self._has_local_git_worktree(git_cwd):
|
||
return self._get_gitea_commit_stats(since)
|
||
try:
|
||
# 取得本週 commits 數量
|
||
since_str = since.strftime("%Y-%m-%d")
|
||
result = subprocess.run(
|
||
["git", "log", f"--since={since_str}", "--oneline"],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=10,
|
||
cwd=git_cwd, # K8s 容器內的工作目錄
|
||
)
|
||
if result.returncode != 0:
|
||
logger.warning(
|
||
"git_stats_commits_failed",
|
||
returncode=result.returncode,
|
||
stderr=result.stderr[-300:],
|
||
)
|
||
return self._get_gitea_commit_stats(since)
|
||
commits = len(result.stdout.strip().split("\n")) if result.stdout.strip() else 0
|
||
|
||
# 取得部署次數 (計算含 "deploy" 或 "CD" 的 commits)
|
||
result_deploy = subprocess.run(
|
||
["git", "log", f"--since={since_str}", "--oneline", "--grep=deploy", "--grep=CD", "-i"],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=10,
|
||
cwd=git_cwd,
|
||
)
|
||
if result_deploy.returncode != 0:
|
||
logger.warning(
|
||
"git_stats_deploys_failed",
|
||
returncode=result_deploy.returncode,
|
||
stderr=result_deploy.stderr[-300:],
|
||
)
|
||
return self._get_gitea_commit_stats(since)
|
||
deploys = len(result_deploy.stdout.strip().split("\n")) if result_deploy.stdout.strip() else 0
|
||
|
||
return commits, deploys, True
|
||
except Exception as e:
|
||
logger.warning("git_stats_failed", error=str(e))
|
||
return self._get_gitea_commit_stats(since)
|
||
|
||
def _has_local_git_worktree(self, cwd: str) -> bool:
|
||
"""確認容器內是否真的保留 Git metadata。"""
|
||
return Path(cwd, ".git").exists()
|
||
|
||
def _get_gitea_commit_stats(self, since: datetime) -> tuple[int, int, bool]:
|
||
"""從 Gitea commits API 讀取週報開發活動統計。"""
|
||
owner = quote(str(settings.GITEA_REPO_OWNER), safe="")
|
||
repo = quote(str(settings.GITEA_REPO_NAME), safe="")
|
||
since_utc = since.astimezone(ZoneInfo("UTC")).isoformat().replace("+00:00", "Z")
|
||
header_candidates = self._gitea_read_headers()
|
||
|
||
commits = 0
|
||
deploys = 0
|
||
page = 1
|
||
limit = 50
|
||
max_pages = 1
|
||
try:
|
||
for api_url in self._gitea_api_candidates():
|
||
for headers in header_candidates:
|
||
commits = deploys = 0
|
||
page = 1
|
||
total_commits: int | None = None
|
||
try:
|
||
while page <= max_pages:
|
||
query = urlencode({
|
||
"sha": "main",
|
||
"since": since_utc,
|
||
"page": page,
|
||
"limit": limit,
|
||
})
|
||
url = f"{api_url}/api/v1/repos/{owner}/{repo}/commits?{query}"
|
||
request = Request(url, headers=headers)
|
||
with urlopen(request, timeout=1) as response:
|
||
total_header = response.headers.get("X-Total-Count") or response.headers.get("X-Total")
|
||
if total_header and total_header.isdigit():
|
||
total_commits = int(total_header)
|
||
payload = json.loads(response.read().decode("utf-8"))
|
||
if not isinstance(payload, list):
|
||
logger.warning("gitea_git_stats_unexpected_payload", payload_type=type(payload).__name__)
|
||
break
|
||
commits += len(payload)
|
||
for item in payload:
|
||
commit = item.get("commit") if isinstance(item, dict) else {}
|
||
message = str((commit or {}).get("message") or "")
|
||
subject = message.splitlines()[0].lower()
|
||
if "deploy" in subject or subject.startswith("chore(cd):"):
|
||
deploys += 1
|
||
if len(payload) < limit:
|
||
return total_commits or commits, deploys, True
|
||
page += 1
|
||
if total_commits is not None and total_commits > 0:
|
||
logger.info(
|
||
"gitea_git_stats_sampled",
|
||
total_commits=total_commits,
|
||
sampled_pages=max_pages,
|
||
sampled_deploys=deploys,
|
||
)
|
||
return total_commits, deploys, True
|
||
except Exception as candidate_exc:
|
||
logger.warning(
|
||
"gitea_git_stats_candidate_failed",
|
||
api_url=api_url,
|
||
auth_mode="token" if "Authorization" in headers else "anonymous",
|
||
error=str(candidate_exc),
|
||
)
|
||
continue
|
||
except Exception as exc:
|
||
logger.warning("gitea_git_stats_failed", error=str(exc))
|
||
return 0, 0, False
|
||
|
||
def _gitea_api_candidates(self) -> list[str]:
|
||
"""取得 Git 統計可用的 Gitea read-only API base URLs。"""
|
||
candidates = ["https://gitea.wooo.work", settings.GITEA_API_URL.rstrip("/")]
|
||
deduped: list[str] = []
|
||
for candidate in candidates:
|
||
if candidate and candidate not in deduped:
|
||
deduped.append(candidate)
|
||
return deduped
|
||
|
||
def _gitea_read_headers(self) -> list[dict[str, str]]:
|
||
"""取得 Gitea read-only 統計用 headers,匿名讀優先。"""
|
||
base = {"Accept": "application/json", "User-Agent": "awoooi-weekly-report/1.0"}
|
||
if not settings.GITEA_API_TOKEN:
|
||
return [base]
|
||
token_headers = {**base, "Authorization": f"token {settings.GITEA_API_TOKEN}"}
|
||
return [base, token_headers]
|
||
|
||
async def generate_report(self) -> WeeklyReportMessage:
|
||
"""
|
||
生成週報
|
||
|
||
整合多個數據來源
|
||
"""
|
||
now = datetime.now(TZ_TAIPEI)
|
||
week_range, monday, sunday = self._get_week_range()
|
||
report_date = now.strftime("%Y-%m-%d %H:%M")
|
||
|
||
# 取得統計數據 (7 天)
|
||
stats_source_ok = True
|
||
try:
|
||
incident_summary = await self._stats_service.get_incident_summary(days=7)
|
||
resolution_stats = await self._stats_service.get_resolution_stats(days=7)
|
||
ai_performance = await self._stats_service.get_ai_performance(days=7)
|
||
except Exception as e:
|
||
logger.warning("stats_fetch_failed", error=str(e))
|
||
stats_source_ok = False
|
||
incident_summary = {}
|
||
resolution_stats = {}
|
||
ai_performance = {}
|
||
|
||
# 取得 K3s 狀態
|
||
k3s_source_ok = True
|
||
try:
|
||
k3s_status = await self._k3s_monitor.collect_cluster_status()
|
||
except Exception as e:
|
||
logger.warning("k3s_fetch_failed", error=str(e))
|
||
k3s_source_ok = False
|
||
k3s_status = None
|
||
|
||
# 取得 Git 統計
|
||
commits, deploys, git_source_ok = self._get_git_stats(monday)
|
||
|
||
# 計算指標
|
||
total_incidents = incident_summary.get("total_incidents", 0)
|
||
resolved_rate = incident_summary.get("resolved_rate", 0.0)
|
||
|
||
# 從嚴重度分佈中取得 Critical 數量
|
||
severity_dist = incident_summary.get("severity_distribution", [])
|
||
critical_count = sum(
|
||
s.get("count", 0) for s in severity_dist
|
||
if s.get("severity", "").upper() in ["P0", "CRITICAL"]
|
||
)
|
||
|
||
# AI 效能
|
||
ai_proposals = ai_performance.get("total_proposals", 0)
|
||
ai_executed = ai_performance.get("executed_count", 0)
|
||
ai_success_rate = ai_performance.get("success_rate", 0.0)
|
||
|
||
# 平均回應時間
|
||
avg_response = resolution_stats.get("avg_minutes") or 0.0
|
||
|
||
# K3s 指標
|
||
k3s_uptime = 99.9 # 假設值,實際應從 Prometheus 取得
|
||
pod_restarts = k3s_status.pod_restart_48h if k3s_status else 0
|
||
hpa_events = 0 # 需要從 Prometheus 取得 HPA 事件
|
||
|
||
# 2026-04-07 Claude Code: Sprint 4 F1 — 取得處置分佈
|
||
disp_auto = disp_human = disp_manual = disp_cold = disp_total = 0
|
||
try:
|
||
from src.services.anomaly_counter import get_anomaly_counter
|
||
counter = get_anomaly_counter()
|
||
disp_summary, _ = await counter.get_all_disposition_stats()
|
||
disp_auto = disp_summary.get("auto_repair", 0)
|
||
disp_human = disp_summary.get("human_approved", 0)
|
||
disp_manual = disp_summary.get("manual_resolved", 0)
|
||
disp_cold = disp_summary.get("cold_start_trust", 0)
|
||
disp_total = disp_summary.get("total", 0)
|
||
except Exception as _disp_e:
|
||
logger.warning("weekly_report_disposition_failed", error=str(_disp_e))
|
||
|
||
ai_slo_source_ok = True
|
||
ai_slo_auto_execute_success_rate: float | None = None
|
||
ai_slo_auto_execute_sample_count = 0
|
||
ai_slo_auto_execute_threshold = 0.85
|
||
ai_slo_auto_execute_violated = False
|
||
ai_slo_top_failure = ""
|
||
ai_slo_verifier_coverage_rate: float | None = None
|
||
ai_slo_unverified_auto_count = 0
|
||
try:
|
||
from src.services.adr100_slo_status_service import (
|
||
get_adr100_slo_status_service,
|
||
)
|
||
from src.services.ai_slo_calculator import AiSloCalculator
|
||
|
||
slo_report = await AiSloCalculator(project_id="awoooi").calculate()
|
||
auto_metric = next(
|
||
(metric for metric in slo_report.metrics if metric.name == "auto_execute_success_rate"),
|
||
None,
|
||
)
|
||
if auto_metric is not None:
|
||
ai_slo_auto_execute_success_rate = auto_metric.value
|
||
ai_slo_auto_execute_sample_count = auto_metric.sample_count
|
||
ai_slo_auto_execute_threshold = auto_metric.threshold
|
||
ai_slo_auto_execute_violated = auto_metric.violated
|
||
|
||
diagnostics = slo_report.diagnostics.get("auto_execute_success_rate") or {}
|
||
top_failure = (diagnostics.get("top_failure_groups") or [{}])[0]
|
||
if top_failure:
|
||
ai_slo_top_failure = (
|
||
f"{top_failure.get('alertname') or 'unknown'} / "
|
||
f"{top_failure.get('playbook_id') or 'unknown'} ×"
|
||
f"{int(top_failure.get('count') or 0)}: "
|
||
f"{str(top_failure.get('error_signature') or '')[:90]}"
|
||
)
|
||
|
||
adr100_report = await get_adr100_slo_status_service("awoooi").fetch_report()
|
||
verification = adr100_report.get("verification_coverage") or {}
|
||
ai_slo_verifier_coverage_rate = verification.get("coverage_rate")
|
||
ai_slo_unverified_auto_count = int(verification.get("unverified_auto") or 0)
|
||
except Exception as _slo_e:
|
||
ai_slo_source_ok = False
|
||
logger.warning("weekly_report_ai_slo_failed", error=str(_slo_e))
|
||
|
||
report_source_confidence = 0
|
||
report_source_ok = 0
|
||
report_source_total = 0
|
||
report_source_gap_ids: list[str] = []
|
||
report_asset_state_lines: list[str] = []
|
||
try:
|
||
from src.services.ai_agent_report_source_health import build_ai_agent_report_source_health
|
||
|
||
source_health = await build_ai_agent_report_source_health(days=7)
|
||
source_rollups = source_health.get("rollups") or {}
|
||
report_source_confidence = int(source_rollups.get("confidence_percent") or 0)
|
||
report_source_ok = int(source_rollups.get("source_ok_count") or 0)
|
||
report_source_total = int(source_rollups.get("source_count") or 0)
|
||
report_source_gap_ids = [
|
||
str(source.get("work_item_id"))
|
||
for source in source_health.get("source_health", [])
|
||
if source.get("work_item_id")
|
||
][:5]
|
||
report_asset_state_lines = [
|
||
(
|
||
f"{asset.get('label')}: {asset.get('state')} "
|
||
f"{int(asset.get('done_count') or 0)}/"
|
||
f"{int(asset.get('done_count') or 0) + int(asset.get('blocked_count') or 0)}"
|
||
)
|
||
for asset in source_health.get("automation_assets", [])
|
||
][:5]
|
||
except Exception as _source_e:
|
||
logger.warning("weekly_report_source_health_failed", error=str(_source_e))
|
||
|
||
# 組裝週報
|
||
report = WeeklyReportMessage(
|
||
week_range=week_range,
|
||
report_date=report_date,
|
||
alert_total=total_incidents,
|
||
alert_critical=critical_count,
|
||
alert_resolved=int(total_incidents * resolved_rate / 100) if total_incidents > 0 else 0,
|
||
resolved_rate=resolved_rate,
|
||
ai_proposal_count=ai_proposals,
|
||
ai_executed_count=ai_executed,
|
||
ai_success_rate=ai_success_rate,
|
||
avg_response_minutes=avg_response,
|
||
k3s_uptime_pct=k3s_uptime,
|
||
pod_restart_total=pod_restarts,
|
||
hpa_scale_events=hpa_events,
|
||
commits_count=commits,
|
||
deploy_count=deploys,
|
||
ai_cost_week=0.0, # 需要從 AI 成本追蹤取得
|
||
ai_tokens_week=0, # 需要從 AI 成本追蹤取得
|
||
disposition_auto=disp_auto,
|
||
disposition_human=disp_human,
|
||
disposition_manual=disp_manual,
|
||
disposition_cold_start=disp_cold,
|
||
disposition_total=disp_total,
|
||
stats_source_ok=stats_source_ok,
|
||
k3s_source_ok=k3s_source_ok,
|
||
git_source_ok=git_source_ok,
|
||
cost_source_ok=False,
|
||
ai_slo_source_ok=ai_slo_source_ok,
|
||
ai_slo_auto_execute_success_rate=ai_slo_auto_execute_success_rate,
|
||
ai_slo_auto_execute_sample_count=ai_slo_auto_execute_sample_count,
|
||
ai_slo_auto_execute_threshold=ai_slo_auto_execute_threshold,
|
||
ai_slo_auto_execute_violated=ai_slo_auto_execute_violated,
|
||
ai_slo_top_failure=ai_slo_top_failure,
|
||
ai_slo_verifier_coverage_rate=ai_slo_verifier_coverage_rate,
|
||
ai_slo_unverified_auto_count=ai_slo_unverified_auto_count,
|
||
all_zero_actionable_anomaly=(
|
||
total_incidents == 0
|
||
and ai_proposals == 0
|
||
and ai_executed == 0
|
||
and commits == 0
|
||
and deploys == 0
|
||
and disp_total == 0
|
||
),
|
||
report_source_confidence_percent=report_source_confidence,
|
||
report_source_ok_count=report_source_ok,
|
||
report_source_total_count=report_source_total,
|
||
report_source_gap_ids=report_source_gap_ids,
|
||
report_asset_state_lines=report_asset_state_lines,
|
||
)
|
||
|
||
logger.info(
|
||
"weekly_report_generated",
|
||
week=week_range,
|
||
alerts=total_incidents,
|
||
commits=commits,
|
||
)
|
||
|
||
return report
|
||
|
||
async def send_weekly_report(self) -> bool:
|
||
"""
|
||
發送週報
|
||
|
||
生成週報並推送到 Telegram
|
||
"""
|
||
try:
|
||
# 生成週報
|
||
report = await self.generate_report()
|
||
|
||
# 取得 Telegram Gateway
|
||
gateway = get_telegram_gateway()
|
||
if not gateway._initialized:
|
||
await gateway.initialize()
|
||
|
||
# 發送訊息
|
||
formatted = report.format()
|
||
result = await gateway.send_text(formatted)
|
||
|
||
if result:
|
||
logger.info("weekly_report_sent", week=report.week_range)
|
||
return True
|
||
else:
|
||
logger.error("weekly_report_failed", week=report.week_range)
|
||
return False
|
||
|
||
except Exception as e:
|
||
logger.error("weekly_report_error", error=str(e))
|
||
return False
|
||
|
||
|
||
# =============================================================================
|
||
# Dependency Injection
|
||
# =============================================================================
|
||
|
||
_weekly_report_service: WeeklyReportService | None = None
|
||
|
||
|
||
def get_weekly_report_service() -> WeeklyReportService:
|
||
"""取得 WeeklyReportService 實例 (Singleton)"""
|
||
global _weekly_report_service
|
||
if _weekly_report_service is None:
|
||
_weekly_report_service = WeeklyReportService()
|
||
return _weekly_report_service
|
||
|
||
|
||
# =============================================================================
|
||
# CLI Entry Point (for CronJob)
|
||
# =============================================================================
|
||
|
||
|
||
async def main():
|
||
"""
|
||
CLI 入口點
|
||
|
||
供 K8s CronJob 調用:
|
||
python -m src.services.weekly_report_service
|
||
"""
|
||
service = get_weekly_report_service()
|
||
success = await service.send_weekly_report()
|
||
return 0 if success else 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import sys
|
||
exit_code = asyncio.run(main())
|
||
sys.exit(exit_code)
|