From 6ab640e4316ca87f06955aed343cda1fb2673e67 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 18 Jun 2026 20:40:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(reports):=20=E9=A1=AF=E7=A4=BA=E8=B3=87?= =?UTF-8?q?=E6=96=99=E6=BA=90=20PlayBook=20Verifier=20=E7=BC=BA=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/ai_agent_report_source_health.py | 68 +++++++++++++++- .../test_ai_agent_report_source_health_api.py | 15 +++- apps/web/messages/en.json | 15 ++++ apps/web/messages/zh-TW.json | 15 ++++ apps/web/src/app/[locale]/reports/page.tsx | 78 +++++++++++++++++++ 5 files changed, 188 insertions(+), 3 deletions(-) diff --git a/apps/api/src/services/ai_agent_report_source_health.py b/apps/api/src/services/ai_agent_report_source_health.py index cf5725ae..4b4d5088 100644 --- a/apps/api/src/services/ai_agent_report_source_health.py +++ b/apps/api/src/services/ai_agent_report_source_health.py @@ -9,13 +9,16 @@ enable schedulers, execute AI repair, mutate incidents, or open runtime gates. from __future__ import annotations import asyncio +from collections.abc import Awaitable, Callable from datetime import datetime -from typing import Any, Awaitable, Callable +from typing import Any from zoneinfo import ZoneInfo import structlog -from src.services.ai_agent_report_status_board import load_latest_ai_agent_report_status_board +from src.services.ai_agent_report_status_board import ( + load_latest_ai_agent_report_status_board, +) logger = structlog.get_logger(__name__) @@ -97,6 +100,10 @@ async def build_ai_agent_report_source_health(days: int = 30) -> dict[str, Any]: no_send_previews = _build_no_send_previews(status_board, ok_count, source_count, gap_sources) work_items = _build_work_items(gap_sources, all_zero) automation_assets = _build_automation_assets(status_board, work_items) + source_gap_playbook_verifier = _build_source_gap_playbook_verifier( + sources, + work_items, + ) return { "schema_version": _SCHEMA_VERSION, @@ -123,6 +130,7 @@ async def build_ai_agent_report_source_health(days: int = 30) -> dict[str, Any]: }, "no_send_previews": no_send_previews, "automation_assets": automation_assets, + "source_gap_playbook_verifier": source_gap_playbook_verifier, "work_items": work_items, "activation_boundaries": { "telegram_send_enabled": False, @@ -140,6 +148,9 @@ async def build_ai_agent_report_source_health(days: int = 30) -> dict[str, Any]: "confidence_percent": confidence_percent, "no_send_preview_count": len(no_send_previews), "report_work_item_count": len(work_items), + "source_gap_playbook_draft_count": len(source_gap_playbook_verifier), + "source_gap_verifier_plan_count": len(source_gap_playbook_verifier), + "source_gap_owner_review_required_count": len(source_gap_playbook_verifier), "live_send_allowed_count": 0, "runtime_gate_count": 0, }, @@ -362,6 +373,59 @@ def _build_automation_assets( ] +def _build_source_gap_playbook_verifier( + sources: list[dict[str, Any]], + work_items: list[dict[str, Any]], +) -> list[dict[str, Any]]: + source_by_work_item = { + source.get("work_item_id"): source + for source in sources + if source.get("work_item_id") + } + cards: list[dict[str, Any]] = [] + for item in work_items: + work_item_id = str(item.get("work_item_id") or "") + if not work_item_id: + continue + source = source_by_work_item.get(work_item_id) or {} + source_id = str(source.get("source_id") or work_item_id.split(":")[-1]) + display_name = str(source.get("display_name") or item.get("title") or source_id) + route = str(source.get("route") or "/api/v1/stats/sre-digest/preview") + cards.append({ + "work_item_id": work_item_id, + "source_id": source_id, + "display_name": display_name, + "route": route, + "playbook_draft_id": f"playbook-draft:{work_item_id}", + "verifier_plan_id": f"verifier-plan:{work_item_id}", + "playbook_state": "draft_required", + "verifier_state": "plan_required", + "script_state": "readback_only", + "schedule_state": "no_send_preview", + "owner_review_required": True, + "runtime_gate_open": False, + "playbook_template_fields": [ + "source_id", + "route", + "expected_metrics", + "freshness_slo", + "fallback_behavior", + "rollback_or_disable_plan", + "owner_review", + "verifier_plan", + ], + "verifier_checks": [ + "source_route_returns_200_or_declared_gap", + "source_ok_semantics_match_metrics", + "all_zero_assessment_not_treated_as_healthy", + "no_send_preview_remains_no_send", + "runtime_gate_count_remains_zero", + ], + "next_action": item.get("next_action") or "補 PlayBook 草案與 Verifier readback。", + }) + return cards + + def _is_all_zero( incident_summary: dict[str, Any], ai_performance: dict[str, Any], diff --git a/apps/api/tests/test_ai_agent_report_source_health_api.py b/apps/api/tests/test_ai_agent_report_source_health_api.py index 61018c2d..4aa5eae6 100644 --- a/apps/api/tests/test_ai_agent_report_source_health_api.py +++ b/apps/api/tests/test_ai_agent_report_source_health_api.py @@ -2,7 +2,6 @@ from fastapi.testclient import TestClient from src.main import app - _PUBLIC_FORBIDDEN_TERMS = [ "工作視窗", "對話內容", @@ -51,6 +50,9 @@ def test_get_ai_agent_report_source_health_api(): assert data["program_status"]["overall_completion_percent"] == 100 assert data["rollups"]["source_count"] == 5 assert data["rollups"]["no_send_preview_count"] == 3 + assert data["rollups"]["source_gap_playbook_draft_count"] >= 0 + assert data["rollups"]["source_gap_verifier_plan_count"] >= 0 + assert data["rollups"]["source_gap_owner_review_required_count"] >= 0 assert data["rollups"]["live_send_allowed_count"] == 0 assert data["rollups"]["runtime_gate_count"] == 0 assert data["activation_boundaries"]["telegram_send_enabled"] is False @@ -69,6 +71,17 @@ def test_get_ai_agent_report_source_health_api(): } asset_labels = {asset["label"] for asset in data["automation_assets"]} assert asset_labels == {"KM", "PlayBook", "腳本", "排程", "Verifier"} + for card in data["source_gap_playbook_verifier"]: + assert card["playbook_state"] == "draft_required" + assert card["verifier_state"] == "plan_required" + assert card["script_state"] == "readback_only" + assert card["schedule_state"] == "no_send_preview" + assert card["owner_review_required"] is True + assert card["runtime_gate_open"] is False + assert card["playbook_draft_id"].startswith("playbook-draft:") + assert card["verifier_plan_id"].startswith("verifier-plan:") + assert "owner_review" in card["playbook_template_fields"] + assert "runtime_gate_count_remains_zero" in card["verifier_checks"] for preview in data["no_send_previews"]: assert preview["delivery_state"] == "no_send_preview" assert preview["live_send_allowed"] is False diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 1fcd533a..de974b8b 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -2266,6 +2266,21 @@ "ok": "可讀", "fail": "缺口" }, + "sourceGapActions": { + "title": "PlayBook / Verifier 缺口處置板", + "subtitle": "每個 report-source-gap 都要有服務專屬 PlayBook 草案、Verifier 計畫、腳本與排程邊界,不能只丟給人工判斷。", + "playbook": "PlayBook 草案", + "verifier": "Verifier 計畫", + "script": "腳本", + "schedule": "排程", + "fields": "必填欄位", + "checks": "Verifier 檢查", + "ownerRequired": "需要 owner review", + "ownerOptional": "Owner review 可後補", + "gateClosed": "runtime gate 0", + "gateOpen": "runtime gate 已開", + "empty": "目前沒有 report-source-gap 處置卡" + }, "cadence": { "title": "日報 / 週報 / 月報", "subtitle": "每個 cadence 都要有 owner、章節、圖表、工作量、實發狀態與下一關。", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 1fcd533a..de974b8b 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -2266,6 +2266,21 @@ "ok": "可讀", "fail": "缺口" }, + "sourceGapActions": { + "title": "PlayBook / Verifier 缺口處置板", + "subtitle": "每個 report-source-gap 都要有服務專屬 PlayBook 草案、Verifier 計畫、腳本與排程邊界,不能只丟給人工判斷。", + "playbook": "PlayBook 草案", + "verifier": "Verifier 計畫", + "script": "腳本", + "schedule": "排程", + "fields": "必填欄位", + "checks": "Verifier 檢查", + "ownerRequired": "需要 owner review", + "ownerOptional": "Owner review 可後補", + "gateClosed": "runtime gate 0", + "gateOpen": "runtime gate 已開", + "empty": "目前沒有 report-source-gap 處置卡" + }, "cadence": { "title": "日報 / 週報 / 月報", "subtitle": "每個 cadence 都要有 owner、章節、圖表、工作量、實發狀態與下一關。", diff --git a/apps/web/src/app/[locale]/reports/page.tsx b/apps/web/src/app/[locale]/reports/page.tsx index aad213ac..917ad392 100644 --- a/apps/web/src/app/[locale]/reports/page.tsx +++ b/apps/web/src/app/[locale]/reports/page.tsx @@ -157,6 +157,24 @@ interface ReportAutomationAsset { next_action: string } +interface ReportSourceGapPlaybookVerifier { + work_item_id: string + source_id: string + display_name: string + route: string + playbook_draft_id: string + verifier_plan_id: string + playbook_state: string + verifier_state: string + script_state: string + schedule_state: string + owner_review_required: boolean + runtime_gate_open: boolean + playbook_template_fields: string[] + verifier_checks: string[] + next_action: string +} + interface ReportSourceHealthSnapshot { source_health: ReportSourceHealthItem[] all_zero_assessment: { @@ -168,12 +186,16 @@ interface ReportSourceHealthSnapshot { } no_send_previews: ReportNoSendPreview[] automation_assets: ReportAutomationAsset[] + source_gap_playbook_verifier: ReportSourceGapPlaybookVerifier[] rollups: { source_count: number source_ok_count: number source_gap_count: number confidence_percent: number report_work_item_count: number + source_gap_playbook_draft_count: number + source_gap_verifier_plan_count: number + source_gap_owner_review_required_count: number live_send_allowed_count: number runtime_gate_count: number } @@ -303,6 +325,7 @@ export default function ReportsPage({ params }: { params: { locale: string } }) () => new Map((sourceHealth?.no_send_previews ?? []).map(preview => [preview.cadence_id, preview])), [sourceHealth] ) + const sourceGapCards = sourceHealth?.source_gap_playbook_verifier ?? [] const pcts = useMemo(() => { if (!ds || ds.total === 0) return null @@ -452,6 +475,35 @@ export default function ReportsPage({ params }: { params: { locale: string } }) +
+ } title={t('sourceGapActions.title')} subtitle={t('sourceGapActions.subtitle')} /> +
+ {sourceGapCards.map(card => ( +
+
+
+

{card.display_name}

+

{card.work_item_id}

+
+ +
+
+ + + + +
+
+ + +
+

{card.next_action}

+
+ ))} + {!sourceGapCards.length && } +
+
+
} title={t('funnel.title')} subtitle={t('funnel.subtitle')} /> @@ -699,6 +751,32 @@ function AssetCard({ label, value, detail, danger = false }: { label: string; va ) } +function AssetMiniLine({ label, value, detail }: { label: string; value: string; detail: string }) { + return ( +
+

{label}

+

{value}

+

{detail}

+
+ ) +} + +function ChecklistBlock({ title, items }: { title: string; items: string[] }) { + return ( +
+

{title}

+
+ {items.map(item => ( +
+ + {item} +
+ ))} +
+
+ ) +} + function AgentCard({ agent, chips }: { agent: AgentStatusReport; chips: string[] }) { const progress = agent.work_units_total > 0 ? Math.round((agent.work_units_done / agent.work_units_total) * 100) : 0 return (