diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index c2b57910..09d80392 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -1078,6 +1078,24 @@ def _callback_reply_evidence_capture_status( } +def _callback_reply_capture_status_from_outbound( + row: AwoooPOutboundMessage, + callback_reply: Mapping[str, Any], +) -> dict[str, Any]: + """Build capture status directly from one outbound source envelope.""" + source_envelope = _as_dict(row.source_envelope) + return _callback_reply_evidence_capture_status( + callback_reply=callback_reply, + persisted_awooop_status_chain=( + _as_dict(source_envelope.get("awooop_status_chain")) or None + ), + persisted_km_stale_completion_summary=( + _as_dict(source_envelope.get("km_stale_completion_summary")) or None + ), + event_at=row.sent_at or row.queued_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")) @@ -1481,6 +1499,13 @@ def _run_callback_reply_summary( "latest_incident_id": None, "latest_at": None, "latest_provider_message_id": None, + "capture_status": "no_callback", + "capture_captured": 0, + "capture_partial": 0, + "capture_not_captured": 0, + "latest_capture_status": None, + "latest_capture_missing": [], + "latest_capture_next_action": None, } sorted_rows = sorted( @@ -1496,6 +1521,26 @@ def _run_callback_reply_summary( failed = statuses.count("callback_reply_failed") latest_status = str(latest_callback.get("status") or "") summary_status = _callback_reply_public_status(latest_callback) + capture_rows = [ + _callback_reply_capture_status_from_outbound(row, callback) + for row, callback in sorted_rows + ] + capture_statuses = [ + str(capture.get("status") or "observed") + for capture in capture_rows + ] + capture_not_captured = capture_statuses.count("not_captured") + capture_partial = capture_statuses.count("partial") + capture_captured = capture_statuses.count("captured") + latest_capture = capture_rows[0] if capture_rows else {} + if capture_not_captured > 0: + capture_status = "not_captured" + elif capture_partial > 0: + capture_status = "partial" + elif capture_captured > 0 and capture_captured == len(capture_rows): + capture_status = "captured" + else: + capture_status = "observed" return { "schema_version": "awooop_run_callback_reply_summary_v1", @@ -1511,6 +1556,13 @@ def _run_callback_reply_summary( "latest_incident_id": latest_callback.get("incident_id"), "latest_at": latest_row.sent_at or latest_row.queued_at, "latest_provider_message_id": latest_row.provider_message_id, + "capture_status": capture_status, + "capture_captured": capture_captured, + "capture_partial": capture_partial, + "capture_not_captured": capture_not_captured, + "latest_capture_status": latest_capture.get("status"), + "latest_capture_missing": latest_capture.get("missing") or [], + "latest_capture_next_action": latest_capture.get("next_action"), } diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py index e4206a57..9b754bbc 100644 --- a/apps/api/tests/test_awooop_operator_timeline_labels.py +++ b/apps/api/tests/test_awooop_operator_timeline_labels.py @@ -270,6 +270,12 @@ def test_run_callback_reply_summary_marks_latest_fallback() -> None: assert summary["latest_incident_id"] == "INC-20260513-79ED5E" assert summary["latest_provider_message_id"] == "101" assert summary["needs_human"] is False + assert summary["capture_status"] == "not_captured" + assert summary["capture_not_captured"] == 2 + assert summary["latest_capture_missing"] == [ + "awooop_status_chain", + "km_stale_completion_summary", + ] def test_run_callback_reply_summary_marks_failed_as_human_attention() -> None: @@ -291,6 +297,54 @@ def test_run_callback_reply_summary_marks_failed_as_human_attention() -> None: assert summary["status"] == "failed" assert summary["failed"] == 1 assert summary["needs_human"] is True + assert summary["capture_status"] == "not_captured" + + +def test_run_callback_reply_summary_counts_capture_statuses() -> None: + summary = _run_callback_reply_summary([ + SimpleNamespace( + source_envelope={ + "callback_reply": { + "status": "callback_reply_sent", + "action": "detail", + "incident_id": "INC-20260513-79ED5E", + }, + "awooop_status_chain": { + "schema_version": "awooop_status_chain_callback_reply_snapshot_v1", + }, + "km_stale_completion_summary": { + "schema_version": ( + "km_stale_owner_review_callback_reply_snapshot_v1" + ), + }, + }, + sent_at=datetime(2026, 5, 18, 6, 3, 0), + queued_at=datetime(2026, 5, 18, 6, 3, 0), + provider_message_id="102", + ), + SimpleNamespace( + source_envelope={ + "callback_reply": { + "status": "callback_reply_sent", + "action": "history", + "incident_id": "INC-20260513-79ED5E", + }, + "awooop_status_chain": { + "schema_version": "awooop_status_chain_callback_reply_snapshot_v1", + }, + }, + sent_at=datetime(2026, 5, 18, 6, 4, 0), + queued_at=datetime(2026, 5, 18, 6, 4, 0), + provider_message_id="103", + ), + ]) + + assert summary["capture_status"] == "partial" + assert summary["capture_captured"] == 1 + assert summary["capture_partial"] == 1 + assert summary["capture_not_captured"] == 0 + assert summary["latest_capture_status"] == "partial" + assert summary["latest_capture_missing"] == ["km_stale_completion_summary"] def test_run_callback_reply_summary_marks_no_callback() -> None: @@ -305,6 +359,7 @@ def test_run_callback_reply_summary_marks_no_callback() -> None: assert summary["status"] == "no_callback" assert summary["total"] == 0 + assert summary["capture_status"] == "no_callback" def test_list_runs_response_preserves_callback_reply_summary() -> None: @@ -336,6 +391,18 @@ def test_list_runs_response_preserves_callback_reply_summary() -> None: "latest_incident_id": "INC-20260513-79ED5E", "latest_at": "2026-05-18T07:31:37", "latest_provider_message_id": "telegram_callback_reply:failed", + "capture_status": "not_captured", + "capture_captured": 0, + "capture_partial": 0, + "capture_not_captured": 1, + "latest_capture_status": "not_captured", + "latest_capture_missing": [ + "awooop_status_chain", + "km_stale_completion_summary", + ], + "latest_capture_next_action": ( + "press_telegram_detail_or_history_after_rollout" + ), }, } ], @@ -347,6 +414,9 @@ def test_list_runs_response_preserves_callback_reply_summary() -> None: dumped = response.model_dump(mode="json") assert dumped["runs"][0]["callback_reply_summary"]["status"] == "failed" assert dumped["runs"][0]["callback_reply_summary"]["needs_human"] is True + assert dumped["runs"][0]["callback_reply_summary"]["capture_status"] == ( + "not_captured" + ) def test_callback_reply_event_item_surfaces_run_link_and_human_flag() -> None: diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 7b86c88a..0777699a 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -2764,6 +2764,18 @@ "emptyShort": "No detail / history callback yet", "latest": "{action} · {incidentId}", "needsHuman": "Callback failure needs human review", + "captureLine": "Snapshot: {status}; captured {captured} / partial {partial} / not captured {notCaptured}", + "captureMissing": "Missing: {items}", + "captureStatuses": { + "captured": "Captured", + "partial": "Partially captured", + "not_captured": "Not captured", + "observed": "Recorded" + }, + "captureItems": { + "awooopStatusChain": "AwoooP status chain", + "kmCompletionSummary": "KM owner-review snapshot" + }, "filters": { "label": "TG Callback filter", "all": "All TG callbacks" diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 5862c254..e8818222 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -2765,6 +2765,18 @@ "emptyShort": "尚無詳情 / 歷史 callback", "latest": "{action} · {incidentId}", "needsHuman": "Callback 失敗需人工確認", + "captureLine": "Snapshot:{status};已捕捉 {captured} / 部分 {partial} / 未捕捉 {notCaptured}", + "captureMissing": "尚缺:{items}", + "captureStatuses": { + "captured": "已捕捉", + "partial": "部分捕捉", + "not_captured": "未捕捉", + "observed": "已記錄" + }, + "captureItems": { + "awooopStatusChain": "AwoooP 狀態鏈", + "kmCompletionSummary": "KM owner-review snapshot" + }, "filters": { "label": "TG Callback 篩選", "all": "所有 TG Callback" diff --git a/apps/web/src/app/[locale]/awooop/runs/page.tsx b/apps/web/src/app/[locale]/awooop/runs/page.tsx index 4d433c07..518313eb 100644 --- a/apps/web/src/app/[locale]/awooop/runs/page.tsx +++ b/apps/web/src/app/[locale]/awooop/runs/page.tsx @@ -117,6 +117,13 @@ interface CallbackReplySummary { latest_incident_id?: string | null; latest_at?: string | null; latest_provider_message_id?: string | null; + capture_status?: CallbackEvidenceCaptureState | "no_callback" | string | null; + capture_captured?: number; + capture_partial?: number; + capture_not_captured?: number; + latest_capture_status?: CallbackEvidenceCaptureState | string | null; + latest_capture_missing?: string[]; + latest_capture_next_action?: string | null; } interface CallbackEvidenceCaptureStatus { @@ -908,8 +915,29 @@ function CallbackReplyCell({ summary }: { summary?: CallbackReplySummary | null const status = normalizeCallbackReplyStatus(summary); const config = CALLBACK_REPLY_CONFIG[status]; const total = summary?.total ?? 0; + const captureStatus = total > 0 + ? normalizeCallbackEvidenceCaptureStatus(summary?.capture_status) + : null; + const captureConfig = captureStatus + ? { + captured: "text-[#17602a]", + partial: "text-[#8a5a08]", + not_captured: "text-[#9f2f25]", + observed: "text-[#1f5b9b]", + }[captureStatus] + : null; const latestAction = summary?.latest_action ? String(summary.latest_action) : null; const latestIncidentId = summary?.latest_incident_id ? String(summary.latest_incident_id) : null; + const captureItemLabels: Record = { + awooop_status_chain: t("captureItems.awooopStatusChain"), + km_stale_completion_summary: t("captureItems.kmCompletionSummary"), + }; + const latestCaptureMissing = Array.isArray(summary?.latest_capture_missing) + ? summary.latest_capture_missing + : []; + const latestCaptureMissingText = latestCaptureMissing.length > 0 + ? latestCaptureMissing.map((item) => captureItemLabels[item] ?? item).join(" / ") + : null; const countText = total > 0 ? t("count", { total, @@ -944,6 +972,19 @@ function CallbackReplyCell({ summary }: { summary?: CallbackReplySummary | null {latestText} )} + {captureStatus && ( + + {t("captureLine", { + status: t(`captureStatuses.${captureStatus}` as never), + captured: summary?.capture_captured ?? 0, + partial: summary?.capture_partial ?? 0, + notCaptured: summary?.capture_not_captured ?? 0, + })} + + )} {summary?.needs_human && ( {t("needsHuman")}