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

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