diff --git a/apps/api/src/api/v1/platform/operator_runs.py b/apps/api/src/api/v1/platform/operator_runs.py index 6b905c67..3b3f80d2 100644 --- a/apps/api/src/api/v1/platform/operator_runs.py +++ b/apps/api/src/api/v1/platform/operator_runs.py @@ -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 diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index 334aa41f..c2b57910 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -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 diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py index 848674af..e4206a57 100644 --- a/apps/api/tests/test_awooop_operator_timeline_labels.py +++ b/apps/api/tests/test_awooop_operator_timeline_labels.py @@ -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") diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index b1572d78..7b86c88a 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -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}", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 070415e9..5862c254 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -2801,6 +2801,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 狀態", + "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}", diff --git a/apps/web/src/app/[locale]/awooop/runs/page.tsx b/apps/web/src/app/[locale]/awooop/runs/page.tsx index ba3c9a36..4d433c07 100644 --- a/apps/web/src/app/[locale]/awooop/runs/page.tsx +++ b/apps/web/src/app/[locale]/awooop/runs/page.tsx @@ -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 = { + 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 ( +
+
+
+
+

+ {t("captured", { + items: formatItems(captureStatus.captured), + })} +

+

+ {t("missing", { + items: formatItems(captureStatus.missing), + })} +

+

+ {t("nextAction", { + action: t(`nextActions.${nextActionKey}` as never), + })} +

+

+ {t("reason", { + reason: captureStatus.reason ?? "--", + rollout: captureStatus.snapshot_rollout ?? "--", + })} +

+
+
+ ); +} + 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" /> +