feat(reports): 顯示資料源 PlayBook Verifier 缺口
All checks were successful
Code Review / ai-code-review (push) Successful in 13s
CD Pipeline / tests (push) Successful in 1m32s
CD Pipeline / build-and-deploy (push) Successful in 4m35s
CD Pipeline / post-deploy-checks (push) Successful in 1m36s

This commit is contained in:
Your Name
2026-06-18 20:40:01 +08:00
parent 748096c2ce
commit 6ab640e431
5 changed files with 188 additions and 3 deletions

View File

@@ -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],

View File

@@ -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

View File

@@ -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、章節、圖表、工作量、實發狀態與下一關。",

View File

@@ -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、章節、圖表、工作量、實發狀態與下一關。",

View File

@@ -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 } })
</div>
</section>
<section className="rounded-[7px] border border-border bg-card p-4 shadow-sm">
<SectionHeader icon={<ShieldCheck className="h-4 w-4" />} title={t('sourceGapActions.title')} subtitle={t('sourceGapActions.subtitle')} />
<div className="mt-4 grid gap-3 lg:grid-cols-2">
{sourceGapCards.map(card => (
<div key={card.work_item_id} className="rounded-[7px] border border-border bg-background p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">{card.display_name}</p>
<p className="mt-1 break-all text-[11px] leading-5 text-muted-foreground">{card.work_item_id}</p>
</div>
<StatusPill ok={!card.runtime_gate_open} okLabel={t('sourceGapActions.gateClosed')} failLabel={t('sourceGapActions.gateOpen')} />
</div>
<div className="mt-3 grid gap-2 sm:grid-cols-2">
<AssetMiniLine label={t('sourceGapActions.playbook')} value={card.playbook_state} detail={card.playbook_draft_id} />
<AssetMiniLine label={t('sourceGapActions.verifier')} value={card.verifier_state} detail={card.verifier_plan_id} />
<AssetMiniLine label={t('sourceGapActions.script')} value={card.script_state} detail={card.route} />
<AssetMiniLine label={t('sourceGapActions.schedule')} value={card.schedule_state} detail={card.owner_review_required ? t('sourceGapActions.ownerRequired') : t('sourceGapActions.ownerOptional')} />
</div>
<div className="mt-3 grid gap-3 md:grid-cols-2">
<ChecklistBlock title={t('sourceGapActions.fields')} items={card.playbook_template_fields.slice(0, 5)} />
<ChecklistBlock title={t('sourceGapActions.checks')} items={card.verifier_checks.slice(0, 5)} />
</div>
<p className="mt-3 text-xs leading-5 text-muted-foreground">{card.next_action}</p>
</div>
))}
{!sourceGapCards.length && <EmptyPanel label={t('sourceGapActions.empty')} />}
</div>
</section>
<section className="grid gap-4 xl:grid-cols-[1.05fr_0.95fr]">
<div className="rounded-[7px] border border-border bg-card p-4 shadow-sm">
<SectionHeader icon={<Layers3 className="h-4 w-4" />} 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 (
<div className="min-w-0 rounded-[7px] border border-border bg-card p-2">
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">{label}</p>
<p className="mt-1 text-xs font-semibold text-foreground">{value}</p>
<p className="mt-1 break-all text-[10px] leading-4 text-muted-foreground">{detail}</p>
</div>
)
}
function ChecklistBlock({ title, items }: { title: string; items: string[] }) {
return (
<div className="rounded-[7px] border border-border bg-card p-2">
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">{title}</p>
<div className="mt-2 grid gap-1">
{items.map(item => (
<div key={item} className="flex min-w-0 items-start gap-1.5 text-[10px] leading-4 text-muted-foreground">
<CheckCircle2 className="mt-0.5 h-3 w-3 flex-shrink-0 text-blue-500" />
<span className="break-words">{item}</span>
</div>
))}
</div>
</div>
)
}
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 (