feat(web): 在 Work Items 顯示報表缺口 owner review
All checks were successful
Code Review / ai-code-review (push) Successful in 13s
CD Pipeline / tests (push) Successful in 1m33s
CD Pipeline / build-and-deploy (push) Successful in 4m9s
CD Pipeline / post-deploy-checks (push) Successful in 1m35s

This commit is contained in:
Your Name
2026-06-18 21:03:07 +08:00
parent 2c9979321e
commit ca04b49d58
3 changed files with 397 additions and 0 deletions

View File

@@ -7815,6 +7815,47 @@
"remediationHistory": "補救歷史"
}
},
"reportSourceGapOwnerReview": {
"eyebrow": "報表資料源 owner review",
"title": "報表資料源 PlayBook / Verifier 處置板",
"subtitle": "把 report-source-gap 從報表頁接到 Work Items每個缺口都要有 PlayBook 草案、Verifier 計畫、腳本 readback、排程 no-send 與 owner review。",
"loading": "正在讀取 report-source-gap owner review read model。",
"unavailable": "報表資料源 read model 尚未回應;不能把報表全 0 或缺資料判定為健康。",
"empty": "目前沒有 report-source-gap owner review 卡。",
"boundaryTitle": "不可誤讀合約",
"boundary": "live Telegram send={live}runtime gate={gate}。這裡只做草案與 owner review readback不發送、不排程、不執行。",
"openReports": "回報表總控",
"ownerRequired": "需 owner review",
"ownerOptional": "owner review 可後補",
"scheduleBoundary": "排程仍維持 no-send preview",
"fieldsTitle": "PlayBook 必填欄位",
"checksTitle": "Verifier 檢查",
"nextAction": "下一步:{action}",
"metrics": {
"sources": "來源可讀",
"gaps": "資料缺口",
"playbooks": "PlayBook 草案",
"verifiers": "Verifier 計畫",
"confidence": "可信度",
"ownerReview": "需 owner"
},
"assets": {
"playbook": "PlayBook",
"verifier": "Verifier",
"script": "腳本",
"schedule": "排程"
},
"states": {
"draft_required": "需草案",
"plan_required": "需計畫",
"readback_only": "只讀回讀",
"no_send_preview": "no-send preview",
"ok": "正常",
"gap": "缺口",
"blocked": "阻塞",
"unknown": "待確認"
}
},
"status": {
"live": "已完成",
"in_progress": "推進中",
@@ -7850,6 +7891,9 @@
"aiRouteRepairWorkItem": {
"title": "AI Provider primary lane 修復工作項"
},
"reportSourceGapOwnerReview": {
"title": "報表資料源 PlayBook / Verifier owner review"
},
"configDriftFsm": {
"title": "Config Drift fingerprint 狀態機"
},
@@ -7898,6 +7942,7 @@
"autoRepair": "必須同時有 auto_repair、verification_result=success與KM 回寫",
"recurrenceWorkItems": "Run 完成無修復、修復失敗與人工閘門必須進入可追蹤工作項",
"aiRouteRepairWorkItem": "Provider lane 降級時必須顯示 evidence、owner、PlayBook候選與是否可自動修復",
"reportSourceGapOwnerReview": "每個 report-source-gap 必須有 PlayBook 草案、Verifier 計畫、腳本 readback、排程 no-send 與 owner review不得把全 0 當健康或自動執行授權",
"configDriftFsm": "同一 drift fingerprint 必須顯示重複、PR、零 diff、交接與下一步",
"remediationQueue": "每筆 degraded / failed / timeout都必須映射到重跑、重驗、Ticket或人工檢查",
"telegramCallbacks": "按下詳情與歷史不能再只依賴 Redis TTL或舊快照",
@@ -7937,6 +7982,11 @@
"aiRouteRepairSafety": "可安全自動修復:{safe}",
"aiRouteRepairSummary": "AI route 目前由 {selected} 承接;下一步:{action};需人工介入:{human}",
"aiRouteRepairUnavailable": "AI route repair evidence 尚未回傳",
"reportSourceGapOwnerReview": "報表資料源缺口:{gaps}PlayBook 草案 {playbooks}Verifier 計畫 {verifiers};需 owner {owners}",
"reportSourceGapLatest": "最新工作項:{workItem}route={route}",
"reportSourceGapAssets": "資產狀態PlayBook {playbook}Verifier {verifier};排程 {schedule}",
"reportSourceGapBoundary": "live send={live}runtime gate={gate}",
"reportSourceGapEmpty": "目前沒有 report-source-gap 處置卡",
"humanRequired": {
"yes": "是",
"no": "否"

View File

@@ -7815,6 +7815,47 @@
"remediationHistory": "補救歷史"
}
},
"reportSourceGapOwnerReview": {
"eyebrow": "報表資料源 owner review",
"title": "報表資料源 PlayBook / Verifier 處置板",
"subtitle": "把 report-source-gap 從報表頁接到 Work Items每個缺口都要有 PlayBook 草案、Verifier 計畫、腳本 readback、排程 no-send 與 owner review。",
"loading": "正在讀取 report-source-gap owner review read model。",
"unavailable": "報表資料源 read model 尚未回應;不能把報表全 0 或缺資料判定為健康。",
"empty": "目前沒有 report-source-gap owner review 卡。",
"boundaryTitle": "不可誤讀合約",
"boundary": "live Telegram send={live}runtime gate={gate}。這裡只做草案與 owner review readback不發送、不排程、不執行。",
"openReports": "回報表總控",
"ownerRequired": "需 owner review",
"ownerOptional": "owner review 可後補",
"scheduleBoundary": "排程仍維持 no-send preview",
"fieldsTitle": "PlayBook 必填欄位",
"checksTitle": "Verifier 檢查",
"nextAction": "下一步:{action}",
"metrics": {
"sources": "來源可讀",
"gaps": "資料缺口",
"playbooks": "PlayBook 草案",
"verifiers": "Verifier 計畫",
"confidence": "可信度",
"ownerReview": "需 owner"
},
"assets": {
"playbook": "PlayBook",
"verifier": "Verifier",
"script": "腳本",
"schedule": "排程"
},
"states": {
"draft_required": "需草案",
"plan_required": "需計畫",
"readback_only": "只讀回讀",
"no_send_preview": "no-send preview",
"ok": "正常",
"gap": "缺口",
"blocked": "阻塞",
"unknown": "待確認"
}
},
"status": {
"live": "已完成",
"in_progress": "推進中",
@@ -7850,6 +7891,9 @@
"aiRouteRepairWorkItem": {
"title": "AI Provider primary lane 修復工作項"
},
"reportSourceGapOwnerReview": {
"title": "報表資料源 PlayBook / Verifier owner review"
},
"configDriftFsm": {
"title": "Config Drift fingerprint 狀態機"
},
@@ -7898,6 +7942,7 @@
"autoRepair": "必須同時有 auto_repair、verification_result=success與KM 回寫",
"recurrenceWorkItems": "Run 完成無修復、修復失敗與人工閘門必須進入可追蹤工作項",
"aiRouteRepairWorkItem": "Provider lane 降級時必須顯示 evidence、owner、PlayBook候選與是否可自動修復",
"reportSourceGapOwnerReview": "每個 report-source-gap 必須有 PlayBook 草案、Verifier 計畫、腳本 readback、排程 no-send 與 owner review不得把全 0 當健康或自動執行授權",
"configDriftFsm": "同一 drift fingerprint 必須顯示重複、PR、零 diff、交接與下一步",
"remediationQueue": "每筆 degraded / failed / timeout都必須映射到重跑、重驗、Ticket或人工檢查",
"telegramCallbacks": "按下詳情與歷史不能再只依賴 Redis TTL或舊快照",
@@ -7937,6 +7982,11 @@
"aiRouteRepairSafety": "可安全自動修復:{safe}",
"aiRouteRepairSummary": "AI route 目前由 {selected} 承接;下一步:{action};需人工介入:{human}",
"aiRouteRepairUnavailable": "AI route repair evidence 尚未回傳",
"reportSourceGapOwnerReview": "報表資料源缺口:{gaps}PlayBook 草案 {playbooks}Verifier 計畫 {verifiers};需 owner {owners}",
"reportSourceGapLatest": "最新工作項:{workItem}route={route}",
"reportSourceGapAssets": "資產狀態PlayBook {playbook}Verifier {verifier};排程 {schedule}",
"reportSourceGapBoundary": "live send={live}runtime gate={gate}",
"reportSourceGapEmpty": "目前沒有 report-source-gap 處置卡",
"humanRequired": {
"yes": "是",
"no": "否"

View File

@@ -956,6 +956,61 @@ type AiRouteStatusResponse = {
repair_evidence?: AiRouteRepairEvidence | null;
};
type 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;
};
type ReportSourceHealthResponse = {
source_health?: Array<{
source_id?: string | null;
display_name?: string | null;
state?: string | null;
source_ok?: boolean | null;
work_item_id?: string | null;
next_action?: string | null;
}>;
automation_assets?: Array<{
key?: string | null;
label?: string | null;
state?: string | null;
done_count?: number | null;
blocked_count?: number | null;
next_action?: string | null;
}>;
source_gap_playbook_verifier?: ReportSourceGapPlaybookVerifier[];
all_zero_assessment?: {
is_all_zero_signal?: boolean | null;
verdict?: string | null;
next_action?: string | null;
};
rollups?: {
source_count?: number | null;
source_ok_count?: number | null;
source_gap_count?: number | null;
confidence_percent?: number | null;
report_work_item_count?: number | null;
source_gap_playbook_draft_count?: number | null;
source_gap_verifier_plan_count?: number | null;
source_gap_owner_review_required_count?: number | null;
live_send_allowed_count?: number | null;
runtime_gate_count?: number | null;
};
};
type Telemetry = {
quality: AutomationQualitySummary | null;
governanceEvents: GovernanceEventsResponse | null;
@@ -976,6 +1031,7 @@ type Telemetry = {
statusChain: AwoooPStatusChain | null;
incidentTimeline: IncidentTimelineResponse | null;
aiRouteStatus: AiRouteStatusResponse | null;
reportSourceHealth: ReportSourceHealthResponse | null;
};
type AutomationAssetLedgerKey = "km" | "playbook" | "script" | "schedule" | "verifier";
@@ -1291,6 +1347,21 @@ function routeLabel(item?: RemediationHistoryItem | null) {
return route || "--";
}
function reportSourceStateKey(value?: string | null) {
if (
value === "draft_required" ||
value === "plan_required" ||
value === "readback_only" ||
value === "no_send_preview" ||
value === "ok" ||
value === "gap" ||
value === "blocked"
) {
return value;
}
return "unknown";
}
function recurrenceOpenItems(recurrence: RecurrenceResponse | null) {
return (recurrence?.items ?? []).filter((item) => item.work_item?.status === "open");
}
@@ -2382,6 +2453,14 @@ function buildWorkItems(
(governanceUnresolved > 0 && governanceQueuePending === 0);
const mcpGateMissing = hasGateFailure(telemetry.quality, "mcp_gateway_observed");
const timelineGateMissing = hasGateFailure(telemetry.quality, "timeline_recorded");
const reportSourceHealth = telemetry.reportSourceHealth;
const reportSourceCards = reportSourceHealth?.source_gap_playbook_verifier ?? [];
const reportSourceRollups = reportSourceHealth?.rollups;
const reportSourceRuntimeGate = toCount(reportSourceRollups?.runtime_gate_count);
const reportSourceOwnerReview = toCount(
reportSourceRollups?.source_gap_owner_review_required_count
);
const firstReportSourceCard = reportSourceCards[0] ?? null;
return [
{
@@ -2578,6 +2657,48 @@ function buildWorkItems(
: [t("evidence.remediationHistoryEmpty")],
href: "/governance",
},
{
id: "reportSourceGapOwnerReview",
phase: "P2-110D",
status: reportSourceCards.length > 0
? "in_progress"
: reportSourceHealth
? "watching"
: "blocked",
surfaceKey: "workItems",
source: "/api/v1/agents/agent-report-source-health",
gateKey: "reportSourceGapOwnerReview",
evidence: t("evidence.reportSourceGapOwnerReview", {
gaps: toCount(reportSourceRollups?.source_gap_count),
playbooks: toCount(reportSourceRollups?.source_gap_playbook_draft_count),
verifiers: toCount(reportSourceRollups?.source_gap_verifier_plan_count),
owners: reportSourceOwnerReview,
}),
evidenceDetails: firstReportSourceCard
? [
t("evidence.reportSourceGapLatest", {
workItem: firstReportSourceCard.work_item_id,
route: firstReportSourceCard.route,
}),
t("evidence.reportSourceGapAssets", {
playbook: t(
`reportSourceGapOwnerReview.states.${reportSourceStateKey(firstReportSourceCard.playbook_state)}` as never
),
verifier: t(
`reportSourceGapOwnerReview.states.${reportSourceStateKey(firstReportSourceCard.verifier_state)}` as never
),
schedule: t(
`reportSourceGapOwnerReview.states.${reportSourceStateKey(firstReportSourceCard.schedule_state)}` as never
),
}),
t("evidence.reportSourceGapBoundary", {
live: toCount(reportSourceRollups?.live_send_allowed_count),
gate: reportSourceRuntimeGate,
}),
]
: [t("evidence.reportSourceGapEmpty")],
href: `/awooop/work-items?project_id=awoooi#report-source-gap-owner-review`,
},
{
id: "telegramCallbacks",
phase: "T45",
@@ -3193,6 +3314,172 @@ function AutomationAssetLedgerPanel({
);
}
function ReportSourceGapOwnerReviewPanel({
sourceHealth,
loading,
}: {
sourceHealth: ReportSourceHealthResponse | null;
loading: boolean;
}) {
const t = useTranslations("awooop.workItems.reportSourceGapOwnerReview");
const cards = sourceHealth?.source_gap_playbook_verifier ?? [];
const rollups = sourceHealth?.rollups;
const sourceCount = toCount(rollups?.source_count);
const sourceOk = toCount(rollups?.source_ok_count);
const sourceGap = toCount(rollups?.source_gap_count);
const playbookDrafts = toCount(rollups?.source_gap_playbook_draft_count);
const verifierPlans = toCount(rollups?.source_gap_verifier_plan_count);
const ownerReviews = toCount(rollups?.source_gap_owner_review_required_count);
const liveSend = toCount(rollups?.live_send_allowed_count);
const runtimeGate = toCount(rollups?.runtime_gate_count);
const confidence = toCount(rollups?.confidence_percent);
const isUnavailable = !sourceHealth && !loading;
return (
<section
id="report-source-gap-owner-review"
className="border border-[#e0ddd4] bg-white"
data-testid="report-source-gap-owner-review"
>
<div className="grid gap-px bg-[#e0ddd4] lg:grid-cols-[0.92fr_1.08fr]">
<div className="min-w-0 bg-white p-4">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center border border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]">
<FileText className="h-5 w-5" aria-hidden="true" />
</div>
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#8a5a08]">
{t("eyebrow")}
</p>
<h3 className="mt-1 text-lg font-semibold tracking-normal text-[#141413]">
{t("title")}
</h3>
<p className="mt-2 max-w-3xl text-sm leading-6 text-[#5f5b52]">
{t("subtitle")}
</p>
</div>
</div>
<div className="mt-4 grid gap-px bg-[#e0ddd4] sm:grid-cols-2 xl:grid-cols-3">
{[
{ key: "sources", value: loading && !sourceHealth ? "--" : `${sourceOk}/${sourceCount}` },
{ key: "gaps", value: loading && !sourceHealth ? "--" : sourceGap },
{ key: "playbooks", value: loading && !sourceHealth ? "--" : playbookDrafts },
{ key: "verifiers", value: loading && !sourceHealth ? "--" : verifierPlans },
{ key: "confidence", value: loading && !sourceHealth ? "--" : `${confidence}%` },
{ key: "ownerReview", value: loading && !sourceHealth ? "--" : ownerReviews },
].map((metric) => (
<div key={metric.key} className="min-w-0 bg-[#faf9f3] px-3 py-2">
<p className="text-xs font-semibold text-[#77736a]">
{t(`metrics.${metric.key}` as never)}
</p>
<p className="mt-1 break-all font-mono text-xl font-semibold text-[#141413]">
{metric.value}
</p>
</div>
))}
</div>
<div className="mt-4 grid gap-2 text-xs leading-5 text-[#5f5b52]">
<div className="border border-[#e0ddd4] bg-[#faf9f3] px-3 py-2">
<p className="font-semibold text-[#141413]">{t("boundaryTitle")}</p>
<p className="mt-1">{t("boundary", { live: liveSend, gate: runtimeGate })}</p>
</div>
<Link
href="/reports"
className="inline-flex w-fit items-center gap-1.5 border border-[#d8d3c7] bg-white px-2.5 py-1 text-xs font-semibold text-[#141413] hover:border-[#d97757]"
>
{t("openReports")}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
</div>
</div>
<div className="min-w-0 bg-white p-4">
{isUnavailable ? (
<div className="border border-[#e2a29b] bg-[#fff0ef] px-3 py-2 text-sm leading-6 text-[#9f2f25]">
{t("unavailable")}
</div>
) : cards.length === 0 ? (
<div className="border border-[#d8d3c7] bg-[#faf9f3] px-3 py-2 text-sm leading-6 text-[#5f5b52]">
{loading ? t("loading") : t("empty")}
</div>
) : (
<div className="grid gap-3">
{cards.map((card) => (
<article key={card.work_item_id} className="border border-[#e0ddd4] bg-white p-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-[#141413]">
{card.display_name}
</p>
<p className="mt-1 break-all font-mono text-[11px] leading-5 text-[#77736a]">
{card.work_item_id}
</p>
</div>
<span className="shrink-0 border border-[#d9b36f] bg-[#fff7e8] px-2 py-0.5 font-mono text-[11px] font-semibold text-[#8a5a08]">
{card.owner_review_required ? t("ownerRequired") : t("ownerOptional")}
</span>
</div>
<div className="mt-3 grid gap-2 md:grid-cols-4">
{[
["playbook", card.playbook_state, card.playbook_draft_id],
["verifier", card.verifier_state, card.verifier_plan_id],
["script", card.script_state, card.route],
["schedule", card.schedule_state, t("scheduleBoundary")],
].map(([key, state, detail]) => (
<div key={key} className="min-w-0 border border-[#eee9dd] bg-[#faf9f3] px-3 py-2">
<p className="text-[10px] font-semibold uppercase tracking-[0.08em] text-[#77736a]">
{t(`assets.${key}` as never)}
</p>
<p className="mt-1 text-xs font-semibold text-[#141413]">
{t(`states.${reportSourceStateKey(state)}` as never)}
</p>
<p className="mt-1 break-all font-mono text-[10px] leading-4 text-[#5f5b52]">
{detail}
</p>
</div>
))}
</div>
<div className="mt-3 grid gap-3 md:grid-cols-2">
<div className="border border-[#eee9dd] bg-[#faf9f3] px-3 py-2">
<p className="text-xs font-semibold text-[#141413]">{t("fieldsTitle")}</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{card.playbook_template_fields.map((field) => (
<span key={field} className="border border-[#d8d3c7] bg-white px-2 py-0.5 font-mono text-[10px] text-[#5f5b52]">
{field}
</span>
))}
</div>
</div>
<div className="border border-[#eee9dd] bg-[#faf9f3] px-3 py-2">
<p className="text-xs font-semibold text-[#141413]">{t("checksTitle")}</p>
<div className="mt-2 grid gap-1">
{card.verifier_checks.map((check) => (
<div key={check} className="flex min-w-0 items-start gap-1.5 text-[11px] leading-4 text-[#5f5b52]">
<CheckCircle2 className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[#17602a]" aria-hidden="true" />
<span className="break-words font-mono">{check}</span>
</div>
))}
</div>
</div>
</div>
<p className="mt-3 text-xs leading-5 text-[#5f5b52]">
{t("nextAction", { action: card.next_action })}
</p>
</article>
))}
</div>
)}
</div>
</div>
</section>
);
}
function RepairCandidateDraftPanel({
draft,
chain,
@@ -6439,6 +6726,7 @@ export default function AwoooPWorkItemsPage() {
statusChain: null,
incidentTimeline: null,
aiRouteStatus: null,
reportSourceHealth: null,
});
const [loading, setLoading] = useState(true);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
@@ -6487,6 +6775,7 @@ export default function AwoooPWorkItemsPage() {
`${API_BASE}/api/v1/platform/ai-route-status?workload_type=deep_rca`,
projectId
);
const reportSourceHealthUrl = `${API_BASE}/api/v1/agents/agent-report-source-health`;
const [
quality,
@@ -6506,6 +6795,7 @@ export default function AwoooPWorkItemsPage() {
driftFingerprintState,
callbackReplies,
aiRouteStatus,
reportSourceHealth,
] = await Promise.all([
fetchJson<AutomationQualitySummary>(qualityUrl, 15000),
fetchJson<GovernanceEventsResponse>(governanceEventsUrl),
@@ -6524,6 +6814,7 @@ export default function AwoooPWorkItemsPage() {
fetchJson<DriftFingerprintState>(driftFingerprintUrl, 12000),
fetchJson<CallbackRepliesWorkItemResponse>(callbackRepliesUrl, 12000),
fetchJson<AiRouteStatusResponse>(aiRouteStatusUrl, 12000),
fetchJson<ReportSourceHealthResponse>(reportSourceHealthUrl, 12000),
]);
const statusChainIncidentId = selectStatusChainIncidentId(
@@ -6571,6 +6862,7 @@ export default function AwoooPWorkItemsPage() {
statusChain,
incidentTimeline,
aiRouteStatus,
reportSourceHealth,
});
setLastUpdated(new Date());
setLoading(false);
@@ -6674,6 +6966,11 @@ export default function AwoooPWorkItemsPage() {
loading={loading}
/>
<ReportSourceGapOwnerReviewPanel
sourceHealth={telemetry.reportSourceHealth}
loading={loading}
/>
<IncidentEvidenceHeader
projectId={projectId}
incidentIds={visibleIncidentIds}