feat(awooop): show callback evidence capture status
All checks were successful
CD Pipeline / tests (push) Successful in 1m5s
Code Review / ai-code-review (push) Successful in 14s
CD Pipeline / build-and-deploy (push) Successful in 4m30s
CD Pipeline / post-deploy-checks (push) Successful in 1m55s

This commit is contained in:
Your Name
2026-05-25 10:54:39 +08:00
parent 1c8ebdf283
commit 04684eef5f
6 changed files with 299 additions and 6 deletions

View File

@@ -98,6 +98,7 @@ class CallbackReplyItem(BaseModel):
persisted_awooop_status_chain: dict[str, Any] | None = None
km_stale_completion_summary: dict[str, Any] | None = None
persisted_km_stale_completion_summary: dict[str, Any] | None = None
evidence_capture_status: dict[str, Any] | None = None
run_detail_href: str | None = None

View File

@@ -107,6 +107,7 @@ _SOURCE_CORRELATION_PRE_WINDOW_HOURS = 2
_KM_STALE_COMPLETION_CALLBACK_SCHEMA_VERSION = (
"km_stale_owner_review_completion_callback_summary_v1"
)
_CALLBACK_EVIDENCE_CAPTURE_STATUS_SCHEMA_VERSION = "callback_evidence_capture_status_v1"
# =============================================================================
# Tenants
@@ -1027,6 +1028,56 @@ def _callback_reply_public_status(callback_reply: dict[str, Any]) -> str:
}.get(raw_status, "observed")
def _callback_reply_evidence_capture_status(
*,
callback_reply: Mapping[str, Any],
persisted_awooop_status_chain: dict[str, Any] | None,
persisted_km_stale_completion_summary: dict[str, Any] | None,
event_at: Any,
) -> dict[str, Any]:
"""Explain whether callback-time evidence snapshots were persisted."""
captured: list[str] = []
missing: list[str] = []
if persisted_awooop_status_chain:
captured.append("awooop_status_chain")
else:
missing.append("awooop_status_chain")
if persisted_km_stale_completion_summary:
captured.append("km_stale_completion_summary")
else:
missing.append("km_stale_completion_summary")
if not missing:
status_value = "captured"
reason = "ok"
next_action = "none"
elif captured:
status_value = "partial"
reason = "partial_snapshot_rollout_transition"
next_action = "press_telegram_detail_or_history_after_rollout"
else:
status_value = "not_captured"
raw_status = str(callback_reply.get("status") or "")
reason = (
"callback_reply_delivery_failed_snapshot_missing"
if raw_status == "callback_reply_failed"
else "legacy_callback_before_snapshot_rollout"
)
next_action = "press_telegram_detail_or_history_after_rollout"
return {
"schema_version": _CALLBACK_EVIDENCE_CAPTURE_STATUS_SCHEMA_VERSION,
"status": status_value,
"reason": reason,
"action": str(callback_reply.get("action") or "").strip() or None,
"captured": captured,
"missing": missing,
"snapshot_rollout": "t167_t169",
"next_action": next_action,
"event_at": event_at,
}
def _callback_reply_event_item(row: Mapping[str, Any]) -> dict[str, Any]:
"""Convert one callback reply outbound row into a read-only evidence item."""
callback_reply = _as_dict(row.get("callback_reply"))
@@ -1036,6 +1087,12 @@ def _callback_reply_event_item(row: Mapping[str, Any]) -> dict[str, Any]:
run_id = row.get("run_id")
status_value = _callback_reply_public_status(callback_reply)
event_at = row.get("sent_at") or row.get("queued_at")
persisted_awooop_status_chain = _as_dict(
row.get("persisted_awooop_status_chain"),
) or None
persisted_km_stale_completion_summary = _as_dict(
row.get("persisted_km_stale_completion_summary"),
) or None
return {
"message_id": row.get("message_id"),
@@ -1057,12 +1114,18 @@ def _callback_reply_event_item(row: Mapping[str, Any]) -> dict[str, Any]:
"agent_id": row.get("agent_id"),
"run_created_at": row.get("run_created_at"),
"callback_reply": callback_reply,
"persisted_awooop_status_chain": _as_dict(
row.get("persisted_awooop_status_chain"),
) or None,
"persisted_km_stale_completion_summary": _as_dict(
row.get("persisted_km_stale_completion_summary"),
) or None,
"persisted_awooop_status_chain": persisted_awooop_status_chain,
"persisted_km_stale_completion_summary": (
persisted_km_stale_completion_summary
),
"evidence_capture_status": _callback_reply_evidence_capture_status(
callback_reply=callback_reply,
persisted_awooop_status_chain=persisted_awooop_status_chain,
persisted_km_stale_completion_summary=(
persisted_km_stale_completion_summary
),
event_at=event_at,
),
"run_detail_href": (
f"/awooop/runs/{run_id}?project_id={project_id}"
if run_id and project_id

View File

@@ -407,6 +407,56 @@ def test_callback_reply_event_item_surfaces_run_link_and_human_flag() -> None:
assert item["persisted_awooop_status_chain"]["repair_state"] == (
"blocked_manual_required"
)
assert item["evidence_capture_status"]["status"] == "captured"
assert item["evidence_capture_status"]["captured"] == [
"awooop_status_chain",
"km_stale_completion_summary",
]
assert item["evidence_capture_status"]["missing"] == []
assert item["evidence_capture_status"]["next_action"] == "none"
def test_callback_reply_event_item_marks_legacy_snapshot_missing() -> None:
run_id = UUID("5c0306e0-591a-5445-9a33-80f499426b38")
message_id = UUID("56cdb6ad-46a4-48f5-9d3b-b1ac9c0b2e92")
item = _callback_reply_event_item({
"message_id": message_id,
"run_id": run_id,
"project_id": "awoooi",
"channel_type": "telegram",
"message_type": "final",
"send_status": "sent",
"send_error": None,
"provider_message_id": "123",
"queued_at": datetime(2026, 5, 18, 7, 31, 37),
"sent_at": datetime(2026, 5, 18, 7, 31, 38),
"triggered_by_state": "callback_reply",
"content_preview": "事件詳情",
"run_state": "completed",
"agent_id": "legacy-telegram-gateway",
"run_created_at": datetime(2026, 5, 18, 7, 30, 0),
"callback_reply": {
"status": "callback_reply_sent",
"action": "detail",
"incident_id": "INC-20260513-79ED5E",
},
"persisted_awooop_status_chain": None,
"persisted_km_stale_completion_summary": None,
})
capture_status = item["evidence_capture_status"]
assert capture_status["schema_version"] == "callback_evidence_capture_status_v1"
assert capture_status["status"] == "not_captured"
assert capture_status["reason"] == "legacy_callback_before_snapshot_rollout"
assert capture_status["missing"] == [
"awooop_status_chain",
"km_stale_completion_summary",
]
assert capture_status["captured"] == []
assert capture_status["next_action"] == (
"press_telegram_detail_or_history_after_rollout"
)
def test_list_callback_replies_response_preserves_callback_evidence() -> None:
@@ -513,6 +563,20 @@ def test_list_callback_replies_response_preserves_callback_evidence() -> None:
"automation_state": "manual_owner_review_required",
},
},
"evidence_capture_status": {
"schema_version": "callback_evidence_capture_status_v1",
"status": "captured",
"reason": "ok",
"action": "detail",
"captured": [
"awooop_status_chain",
"km_stale_completion_summary",
],
"missing": [],
"snapshot_rollout": "t167_t169",
"next_action": "none",
"event_at": datetime(2026, 5, 18, 7, 31, 37),
},
"run_detail_href": (
"/awooop/runs/5c0306e0-591a-5445-9a33-80f499426b38"
"?project_id=awoooi"
@@ -538,6 +602,7 @@ def test_list_callback_replies_response_preserves_callback_evidence() -> None:
assert dumped["items"][0]["persisted_km_stale_completion_summary"]["triage"][
"ai_lead_agent"
] == "Hermes"
assert dumped["items"][0]["evidence_capture_status"]["status"] == "captured"
assert dumped["items"][0]["run_detail_href"].endswith("project_id=awoooi")

View File

@@ -2800,6 +2800,29 @@
"awooopSnapshotMcp": "MCP: total {total} / success {success} / failed {failed} / blocked {blocked}; top {topTool}",
"awooopSnapshotExecution": "Execution: executor {executor}; playbook {playbook}; Ansible considered={ansible} / candidates={candidates}",
"awooopSnapshotSource": "Source: {status}; direct {direct} / candidate {candidate} / applied {applied}; {providers}",
"capture": {
"title": "Evidence Capture Status",
"captured": "Captured: {items}",
"missing": "Missing: {items}",
"nextAction": "Next action: {action}",
"reason": "reason={reason}; rollout={rollout}",
"none": "None",
"statuses": {
"captured": "Captured",
"partial": "Partially captured",
"not_captured": "Not captured",
"observed": "Recorded"
},
"items": {
"awooopStatusChain": "AwoooP status chain",
"kmCompletionSummary": "KM owner-review snapshot"
},
"nextActions": {
"none": "No follow-up needed",
"press_telegram_detail_or_history_after_rollout": "Press Telegram Detail / History again to create a new callback snapshot",
"observed": "Wait for the next callback evidence"
}
},
"kmCompletion": {
"title": "KM Owner Review",
"status": "Status: {status}",

View File

@@ -2801,6 +2801,29 @@
"awooopSnapshotMcp": "MCPtotal {total} / success {success} / failed {failed} / blocked {blocked}top {topTool}",
"awooopSnapshotExecution": "Executionexecutor {executor}playbook {playbook}Ansible considered={ansible} / candidates={candidates}",
"awooopSnapshotSource": "Source{status}direct {direct} / candidate {candidate} / applied {applied}{providers}",
"capture": {
"title": "Evidence Capture 狀態",
"captured": "已捕捉:{items}",
"missing": "尚缺:{items}",
"nextAction": "下一步:{action}",
"reason": "reason={reason}rollout={rollout}",
"none": "無",
"statuses": {
"captured": "已捕捉",
"partial": "部分捕捉",
"not_captured": "未捕捉",
"observed": "已記錄"
},
"items": {
"awooopStatusChain": "AwoooP 狀態鏈",
"kmCompletionSummary": "KM owner-review snapshot"
},
"nextActions": {
"none": "不需補動作",
"press_telegram_detail_or_history_after_rollout": "重新按 Telegram 詳情 / 歷史,產生新版 callback snapshot",
"observed": "等待下一次 callback evidence"
}
},
"kmCompletion": {
"title": "KM Owner Review",
"status": "狀態:{status}",

View File

@@ -53,6 +53,11 @@ type CallbackReplyStatus =
| "rescue_sent"
| "failed"
| "observed";
type CallbackEvidenceCaptureState =
| "captured"
| "partial"
| "not_captured"
| "observed";
type RemediationStatus =
| "no_evidence"
| "mcp_observed"
@@ -114,6 +119,18 @@ interface CallbackReplySummary {
latest_provider_message_id?: string | null;
}
interface CallbackEvidenceCaptureStatus {
schema_version?: string;
status?: CallbackEvidenceCaptureState | string | null;
reason?: string | null;
action?: string | null;
captured?: string[];
missing?: string[];
snapshot_rollout?: string | null;
next_action?: string | null;
event_at?: string | null;
}
interface Run {
run_id: string;
project_id: string;
@@ -373,6 +390,7 @@ interface CallbackReplyEvent {
persisted_awooop_status_chain?: AwoooPStatusChain | null;
km_stale_completion_summary?: KmStaleCompletionSummary | null;
persisted_km_stale_completion_summary?: KmStaleCompletionSummary | null;
evidence_capture_status?: CallbackEvidenceCaptureStatus | null;
}
interface CallbackRepliesResponse {
@@ -732,6 +750,19 @@ function normalizeCallbackReplyEventStatus(statusValue?: string | null): Callbac
return "observed";
}
function normalizeCallbackEvidenceCaptureStatus(
statusValue?: string | null
): CallbackEvidenceCaptureState {
if (
statusValue === "captured" ||
statusValue === "partial" ||
statusValue === "not_captured"
) {
return statusValue;
}
return "observed";
}
function normalizeKmCompletionStatus(statusValue?: string | null) {
if (
statusValue === "matched_owner_review" ||
@@ -744,6 +775,90 @@ function normalizeKmCompletionStatus(statusValue?: string | null) {
return "observed";
}
function CallbackEvidenceCaptureStatusPanel({
captureStatus,
}: {
captureStatus?: CallbackEvidenceCaptureStatus | null;
}) {
const t = useTranslations("awooop.callbackReply.events.capture");
if (!captureStatus) return null;
const state = normalizeCallbackEvidenceCaptureStatus(captureStatus.status);
const config = {
captured: {
className: "border-[#8ec58e] bg-[#edf8ed] text-[#17602a]",
icon: ShieldCheck,
},
partial: {
className: "border-[#e1c36a] bg-[#fff7df] text-[#8a5a08]",
icon: AlertCircle,
},
not_captured: {
className: "border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]",
icon: BellOff,
},
observed: {
className: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]",
icon: SearchCheck,
},
}[state];
const Icon = config.icon;
const itemLabels: Record<string, string> = {
awooop_status_chain: t("items.awooopStatusChain"),
km_stale_completion_summary: t("items.kmCompletionSummary"),
};
const formatItems = (items?: string[]) => (
items && items.length > 0
? items.map((item) => itemLabels[item] ?? item).join(" / ")
: t("none")
);
const nextActionRaw = captureStatus.next_action ?? "observed";
const nextActionKey = nextActionRaw === "none"
|| nextActionRaw === "press_telegram_detail_or_history_after_rollout"
? nextActionRaw
: "observed";
return (
<div className="mt-3 border-t border-[#e0ddd4] pt-3">
<div className="flex flex-wrap items-center gap-2">
<Icon className="h-3.5 w-3.5 text-[#1f5b9b]" aria-hidden="true" />
<p className="text-xs font-semibold text-[#141413]">{t("title")}</p>
<span
className={cn(
"inline-flex items-center border px-2 py-0.5 text-xs font-semibold",
config.className
)}
>
{t(`statuses.${state}` as never)}
</span>
</div>
<div className="mt-2 grid gap-1 text-xs leading-5 text-[#5f5b52]">
<p>
{t("captured", {
items: formatItems(captureStatus.captured),
})}
</p>
<p>
{t("missing", {
items: formatItems(captureStatus.missing),
})}
</p>
<p>
{t("nextAction", {
action: t(`nextActions.${nextActionKey}` as never),
})}
</p>
<p className="truncate font-mono text-[#77736a]">
{t("reason", {
reason: captureStatus.reason ?? "--",
rollout: captureStatus.snapshot_rollout ?? "--",
})}
</p>
</div>
</div>
);
}
function RemediationEvidenceCell({ summary }: { summary?: RemediationSummary | null }) {
const t = useTranslations("awooop.listEvidence");
const status = normalizeRemediationStatus(summary);
@@ -1796,6 +1911,9 @@ function CallbackReplyEvidencePanel({
compact
className="mt-3"
/>
<CallbackEvidenceCaptureStatusPanel
captureStatus={event.evidence_capture_status}
/>
<CallbackAwoooPStatusChainSnapshot
chain={event.persisted_awooop_status_chain}
/>