feat(reports): 顯示資料源 PlayBook Verifier 缺口
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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、章節、圖表、工作量、實發狀態與下一關。",
|
||||
|
||||
@@ -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、章節、圖表、工作量、實發狀態與下一關。",
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user