diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py index d81bf2de..afe2a34c 100644 --- a/apps/api/src/services/telegram_gateway.py +++ b/apps/api/src/services/telegram_gateway.py @@ -3621,10 +3621,27 @@ class WeeklyReportMessage: actionable_all_zero = self.all_zero_actionable_anomaly or all_zero report_trust = "低可信" if actionable_all_zero or source_ok_count < 4 else "可參考" source_status = ( - f"Stats={'ok' if self.stats_source_ok else 'fail'} / " - f"K3s={'ok' if self.k3s_source_ok else 'fail'} / " - f"Git={'ok' if self.git_source_ok else 'fail'} / " - f"Cost={'ok' if self.cost_source_ok else 'missing'}" + f"統計={'正常' if self.stats_source_ok else '失效'} / " + f"K3s={'正常' if self.k3s_source_ok else '失效'} / " + f"Git={'正常' if self.git_source_ok else '失效'} / " + f"成本={'正常' if self.cost_source_ok else '缺資料'}" + ) + source_gaps: list[str] = [] + if not self.stats_source_ok: + source_gaps.append("告警 / AI 統計資料源失效:建立 report-source-gap:stats_api") + if not self.k3s_source_ok: + source_gaps.append("K3s 指標資料源失效:建立 report-source-gap:k3s_metrics") + if not self.git_source_ok: + source_gaps.append("開發活動資料源失效:建立 report-source-gap:gitea_activity") + if not self.cost_source_ok: + source_gaps.append("AI 成本資料源缺資料:建立 report-source-gap:ai_cost_ledger") + if actionable_all_zero: + source_gaps.insert(0, "全 0 不是健康:必須追查資料鏈路 freshness / confidence") + if not source_gaps: + source_gaps.append("資料源通過;持續比對趨勢、異常與修復沉澱") + gap_lines = "\n".join( + f"├ {html.escape(item)}" if index < len(source_gaps) - 1 else f"└ {html.escape(item)}" + for index, item in enumerate(source_gaps[:5]) ) message = ( @@ -3662,6 +3679,10 @@ class WeeklyReportMessage: f"💰 AI 成本\n" f"├ 費用: ${self.ai_cost_week:.2f}\n" f"└ Tokens: {self.ai_tokens_week:,}\n" + f"━━━━━━━━━━━━━━━━━━━\n" + f"🧩 資料缺口 / 下一步\n" + f"{gap_lines}\n" + f"只讀判讀:不自動改排程、不直接發修復、不取代人工批准。\n" ) # Sprint 4 F1: 處置分佈(有資料才加) @@ -3678,7 +3699,7 @@ class WeeklyReportMessage: f"└ 自動化率: {auto_rate}%" ) - return message[:1800] + return message[:2400] @dataclass diff --git a/apps/api/src/services/weekly_report_service.py b/apps/api/src/services/weekly_report_service.py index a484423d..b6b5f9e5 100644 --- a/apps/api/src/services/weekly_report_service.py +++ b/apps/api/src/services/weekly_report_service.py @@ -106,6 +106,13 @@ class WeeklyReportService: timeout=10, cwd="/app", # K8s 容器內的工作目錄 ) + if result.returncode != 0: + logger.warning( + "git_stats_commits_failed", + returncode=result.returncode, + stderr=result.stderr[-300:], + ) + return 0, 0, False commits = len(result.stdout.strip().split("\n")) if result.stdout.strip() else 0 # 取得部署次數 (計算含 "deploy" 或 "CD" 的 commits) @@ -116,6 +123,13 @@ class WeeklyReportService: timeout=10, cwd="/app", ) + if result_deploy.returncode != 0: + logger.warning( + "git_stats_deploys_failed", + returncode=result_deploy.returncode, + stderr=result_deploy.stderr[-300:], + ) + return commits, 0, False deploys = len(result_deploy.stdout.strip().split("\n")) if result_deploy.stdout.strip() else 0 return commits, deploys, True diff --git a/apps/api/tests/test_report_generation_service.py b/apps/api/tests/test_report_generation_service.py index 532a9707..b23b495e 100644 --- a/apps/api/tests/test_report_generation_service.py +++ b/apps/api/tests/test_report_generation_service.py @@ -31,10 +31,66 @@ 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)) +# ============================================================================= +# WeeklyReportService Git 資料源可信度 +# ============================================================================= + + +class TestWeeklyReportGitStats: + """週報不能把 Git 資料源失敗偽裝成 0 commits / 0 deploys。""" + + def test_git_log_failure_marks_source_failed(self, monkeypatch): + class Result: + returncode = 128 + stdout = "" + stderr = "fatal: not a git repository" + + monkeypatch.setattr( + weekly_report_module.subprocess, + "run", + lambda *args, **kwargs: Result(), + ) + + svc = WeeklyReportService(stats_service=object(), k3s_monitor=object()) + commits, deploys, source_ok = svc._get_git_stats(datetime.now(_TZ_TAIPEI)) + + assert commits == 0 + assert deploys == 0 + assert source_ok is False + + def test_git_deploy_log_failure_marks_source_failed_after_commits(self, monkeypatch): + class CommitResult: + returncode = 0 + stdout = "abc123 feat: one\nbcd234 fix: two\n" + stderr = "" + + class DeployResult: + returncode = 128 + stdout = "" + stderr = "fatal: bad revision" + + calls = [] + + def fake_run(*args, **kwargs): + calls.append(args[0]) + return CommitResult() if len(calls) == 1 else DeployResult() + + monkeypatch.setattr(weekly_report_module.subprocess, "run", fake_run) + + svc = WeeklyReportService(stats_service=object(), k3s_monitor=object()) + commits, deploys, source_ok = svc._get_git_stats(datetime.now(_TZ_TAIPEI)) + + assert commits == 2 + assert deploys == 0 + assert source_ok is False + + # ============================================================================= # DailyKpi 計算屬性 # ============================================================================= diff --git a/apps/api/tests/test_telegram_message_templates.py b/apps/api/tests/test_telegram_message_templates.py index dac27d7d..4ec9eb43 100644 --- a/apps/api/tests/test_telegram_message_templates.py +++ b/apps/api/tests/test_telegram_message_templates.py @@ -265,10 +265,14 @@ def test_weekly_report_marks_all_zero_as_low_trust_anomaly() -> None: assert "報表資料信任度" in body assert "判定: 低可信" in body - assert "Stats=fail" in body - assert "Git=fail" in body - assert "Cost=missing" in body + assert "統計=失效" in body + assert "Git=失效" in body + assert "成本=缺資料" in body assert "全 0: actionable_anomaly" in body + assert "資料缺口 / 下一步" in body + assert "全 0 不是健康" in body + assert "report-source-gap:stats_api" in body + assert "report-source-gap:gitea_activity" in body assert "告警統計" in body @@ -291,6 +295,7 @@ def test_weekly_report_keeps_nonzero_source_status_visible() -> None: assert "判定: 可參考" in body assert "全 0: no" in body + assert "資料源通過" in body assert "Commits: 5" in body assert "Tokens: 1,200" in body