diff --git a/apps/api/src/api/v1/platform/operator_runs.py b/apps/api/src/api/v1/platform/operator_runs.py index 088aa835..a88327eb 100644 --- a/apps/api/src/api/v1/platform/operator_runs.py +++ b/apps/api/src/api/v1/platform/operator_runs.py @@ -102,11 +102,37 @@ class CallbackReplyItem(BaseModel): run_detail_href: str | None = None +class CallbackReplyAuditSummary(BaseModel): + schema_version: str + project_id: str + outbound_total: int + outbound_source_envelope_total: int + outbound_source_refs_total: int + outbound_incident_ref_total: int + outbound_failed_total: int + callback_total: int + callback_sent_total: int + callback_fallback_total: int + callback_rescue_total: int + callback_failed_total: int + callback_detail_total: int + callback_history_total: int + callback_snapshot_captured_total: int + callback_snapshot_partial_total: int + callback_snapshot_missing_total: int + callback_incident_total: int + snapshot_status: str + next_action: str + latest_outbound_at: datetime | None = None + latest_callback_at: datetime | None = None + + class ListCallbackRepliesResponse(BaseModel): items: list[CallbackReplyItem] total: int page: int per_page: int + summary: CallbackReplyAuditSummary | None = None class CicdEventItem(BaseModel): diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index c4046a12..a4cd1d90 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -110,6 +110,9 @@ _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" +_CALLBACK_REPLY_AUDIT_SUMMARY_SCHEMA_VERSION = ( + "telegram_callback_reply_audit_summary_v1" +) # ============================================================================= # Tenants @@ -398,6 +401,10 @@ async def list_callback_replies( total = count_result.scalar_one() rows_result = await db.execute(list_sql, params) rows = list(rows_result.mappings().all()) + summary = await _fetch_callback_reply_audit_summary( + db, + project_id=project_id or "awoooi", + ) items = [_callback_reply_event_item(row) for row in rows] status_chain_cache: dict[tuple[str, str], dict[str, Any]] = {} @@ -454,6 +461,165 @@ async def list_callback_replies( "total": total, "page": page, "per_page": per_page, + "summary": summary, + } + + +async def _fetch_callback_reply_audit_summary( + db: Any, + *, + project_id: str, +) -> dict[str, Any]: + """Summarize Telegram outbound mirror and callback evidence capture coverage.""" + result = await db.execute( + text(""" + SELECT + COUNT(*) AS outbound_total, + COUNT(*) FILTER ( + WHERE source_envelope <> '{}'::jsonb + ) AS outbound_source_envelope_total, + COUNT(*) FILTER ( + WHERE source_envelope ? 'source_refs' + ) AS outbound_source_refs_total, + COUNT(*) FILTER ( + WHERE COALESCE( + source_envelope #> '{source_refs,incident_ids}', + '[]'::jsonb + ) <> '[]'::jsonb + ) AS outbound_incident_ref_total, + COUNT(*) FILTER ( + WHERE send_status = 'failed' + ) AS outbound_failed_total, + COUNT(*) FILTER ( + WHERE source_envelope ? 'callback_reply' + ) AS callback_total, + COUNT(*) FILTER ( + WHERE source_envelope #>> '{callback_reply,status}' + = 'callback_reply_sent' + ) AS callback_sent_total, + COUNT(*) FILTER ( + WHERE source_envelope #>> '{callback_reply,status}' + = 'callback_reply_fallback_sent' + ) AS callback_fallback_total, + COUNT(*) FILTER ( + WHERE source_envelope #>> '{callback_reply,status}' + = 'callback_reply_rescue_sent' + ) AS callback_rescue_total, + COUNT(*) FILTER ( + WHERE source_envelope #>> '{callback_reply,status}' + = 'callback_reply_failed' + ) AS callback_failed_total, + COUNT(*) FILTER ( + WHERE LOWER(source_envelope #>> '{callback_reply,action}') + = 'detail' + ) AS callback_detail_total, + COUNT(*) FILTER ( + WHERE LOWER(source_envelope #>> '{callback_reply,action}') + = 'history' + ) AS callback_history_total, + COUNT(*) FILTER ( + WHERE source_envelope ? 'callback_reply' + AND source_envelope ? 'awooop_status_chain' + AND source_envelope ? 'km_stale_completion_summary' + ) AS callback_snapshot_captured_total, + COUNT(*) FILTER ( + WHERE source_envelope ? 'callback_reply' + AND ( + source_envelope ? 'awooop_status_chain' + OR source_envelope ? 'km_stale_completion_summary' + ) + AND NOT ( + source_envelope ? 'awooop_status_chain' + AND source_envelope ? 'km_stale_completion_summary' + ) + ) AS callback_snapshot_partial_total, + COUNT(*) FILTER ( + WHERE source_envelope ? 'callback_reply' + AND NOT ( + source_envelope ? 'awooop_status_chain' + OR source_envelope ? 'km_stale_completion_summary' + ) + ) AS callback_snapshot_missing_total, + COUNT(DISTINCT source_envelope #>> '{callback_reply,incident_id}') + FILTER ( + WHERE source_envelope ? 'callback_reply' + AND COALESCE( + source_envelope #>> '{callback_reply,incident_id}', + '' + ) <> '' + ) AS callback_incident_total, + MAX(COALESCE(sent_at, queued_at)) AS latest_outbound_at, + MAX(COALESCE(sent_at, queued_at)) FILTER ( + WHERE source_envelope ? 'callback_reply' + ) AS latest_callback_at + FROM awooop_outbound_message + WHERE project_id = :project_id + AND channel_type = 'telegram' + """), + {"project_id": project_id}, + ) + return _callback_reply_audit_summary_from_row( + result.mappings().one(), + project_id=project_id, + ) + + +def _callback_reply_audit_summary_from_row( + row: Mapping[str, Any], + *, + project_id: str, +) -> dict[str, Any]: + """Convert aggregate SQL row into the public callback evidence audit summary.""" + outbound_total = _safe_int(row.get("outbound_total")) + callback_total = _safe_int(row.get("callback_total")) + captured = _safe_int(row.get("callback_snapshot_captured_total")) + partial = _safe_int(row.get("callback_snapshot_partial_total")) + missing = _safe_int(row.get("callback_snapshot_missing_total")) + outbound_incident_refs = _safe_int(row.get("outbound_incident_ref_total")) + + if callback_total <= 0: + snapshot_status = "no_callback" + next_action = "press_telegram_detail_or_history" + elif missing > 0: + snapshot_status = "not_captured" + next_action = "press_telegram_detail_or_history_after_rollout" + elif partial > 0: + snapshot_status = "partial" + next_action = "press_telegram_detail_or_history_after_rollout" + elif outbound_total > 0 and outbound_incident_refs == 0: + snapshot_status = "captured" + next_action = "review_outbound_source_refs" + else: + snapshot_status = "captured" + next_action = "none" + + return { + "schema_version": _CALLBACK_REPLY_AUDIT_SUMMARY_SCHEMA_VERSION, + "project_id": project_id, + "outbound_total": outbound_total, + "outbound_source_envelope_total": _safe_int( + row.get("outbound_source_envelope_total") + ), + "outbound_source_refs_total": _safe_int( + row.get("outbound_source_refs_total") + ), + "outbound_incident_ref_total": outbound_incident_refs, + "outbound_failed_total": _safe_int(row.get("outbound_failed_total")), + "callback_total": callback_total, + "callback_sent_total": _safe_int(row.get("callback_sent_total")), + "callback_fallback_total": _safe_int(row.get("callback_fallback_total")), + "callback_rescue_total": _safe_int(row.get("callback_rescue_total")), + "callback_failed_total": _safe_int(row.get("callback_failed_total")), + "callback_detail_total": _safe_int(row.get("callback_detail_total")), + "callback_history_total": _safe_int(row.get("callback_history_total")), + "callback_snapshot_captured_total": captured, + "callback_snapshot_partial_total": partial, + "callback_snapshot_missing_total": missing, + "callback_incident_total": _safe_int(row.get("callback_incident_total")), + "snapshot_status": snapshot_status, + "next_action": next_action, + "latest_outbound_at": row.get("latest_outbound_at"), + "latest_callback_at": row.get("latest_callback_at"), } diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py index e85adcdb..1c44f895 100644 --- a/apps/api/tests/test_awooop_operator_timeline_labels.py +++ b/apps/api/tests/test_awooop_operator_timeline_labels.py @@ -23,6 +23,7 @@ from src.services.platform_operator_service import ( _ai_route_policy_order, _ai_route_repair_evidence_item, _build_awooop_status_chain, + _callback_reply_audit_summary_from_row, _callback_reply_event_item, _callback_reply_summary_matches_status, _cicd_duration_seconds, @@ -658,6 +659,30 @@ def test_list_callback_replies_response_preserves_callback_evidence() -> None: "total": 1, "page": 1, "per_page": 20, + "summary": { + "schema_version": "telegram_callback_reply_audit_summary_v1", + "project_id": "awoooi", + "outbound_total": 120, + "outbound_source_envelope_total": 118, + "outbound_source_refs_total": 100, + "outbound_incident_ref_total": 80, + "outbound_failed_total": 1, + "callback_total": 3, + "callback_sent_total": 1, + "callback_fallback_total": 1, + "callback_rescue_total": 0, + "callback_failed_total": 1, + "callback_detail_total": 2, + "callback_history_total": 1, + "callback_snapshot_captured_total": 1, + "callback_snapshot_partial_total": 1, + "callback_snapshot_missing_total": 1, + "callback_incident_total": 2, + "snapshot_status": "not_captured", + "next_action": "press_telegram_detail_or_history_after_rollout", + "latest_outbound_at": datetime(2026, 5, 18, 7, 40, 0), + "latest_callback_at": datetime(2026, 5, 18, 7, 31, 37), + }, }) dumped = response.model_dump(mode="json") @@ -676,6 +701,42 @@ def test_list_callback_replies_response_preserves_callback_evidence() -> None: ] == "Hermes" assert dumped["items"][0]["evidence_capture_status"]["status"] == "captured" assert dumped["items"][0]["run_detail_href"].endswith("project_id=awoooi") + assert dumped["summary"]["outbound_total"] == 120 + assert dumped["summary"]["callback_snapshot_missing_total"] == 1 + assert dumped["summary"]["snapshot_status"] == "not_captured" + + +def test_callback_reply_audit_summary_marks_missing_snapshots() -> None: + summary = _callback_reply_audit_summary_from_row( + { + "outbound_total": 5256, + "outbound_source_envelope_total": 5256, + "outbound_source_refs_total": 5000, + "outbound_incident_ref_total": 3200, + "outbound_failed_total": 0, + "callback_total": 2, + "callback_sent_total": 2, + "callback_fallback_total": 0, + "callback_rescue_total": 0, + "callback_failed_total": 0, + "callback_detail_total": 0, + "callback_history_total": 2, + "callback_snapshot_captured_total": 0, + "callback_snapshot_partial_total": 0, + "callback_snapshot_missing_total": 2, + "callback_incident_total": 1, + "latest_outbound_at": datetime(2026, 5, 25, 8, 42, 22), + "latest_callback_at": datetime(2026, 5, 24, 14, 38, 4), + }, + project_id="awoooi", + ) + + assert summary["schema_version"] == "telegram_callback_reply_audit_summary_v1" + assert summary["outbound_total"] == 5256 + assert summary["callback_total"] == 2 + assert summary["callback_snapshot_missing_total"] == 2 + assert summary["snapshot_status"] == "not_captured" + assert summary["next_action"] == "press_telegram_detail_or_history_after_rollout" @pytest.mark.asyncio diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 9ec77a24..1898a33f 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -2831,6 +2831,32 @@ "total": "{count} items", "empty": "No callback reply evidence yet.", "error": "Callback evidence failed to load: {error}", + "summary": { + "outbound": "Outbound mirror", + "outboundDetail": "source_refs {sourceRefs}; incident refs {incidentRefs}; coverage {coverage}", + "callbacks": "Callback replies", + "callbackDetail": "detail {detail} / history {history}; incidents {incidents}", + "snapshots": "Evidence snapshots", + "snapshotDetail": "captured {captured} / partial {partial} / missing {missing}; coverage {coverage}", + "delivery": "Delivery failures", + "deliveryDetail": "sent {sent}; fallback {fallback}; outbound failed {outboundFailed}", + "next": "Next", + "latest": "Latest callback: {time}", + "statuses": { + "captured": "Captured", + "partial": "Partially captured", + "not_captured": "Not captured", + "no_callback": "No callback yet", + "observed": "Recorded" + }, + "nextActions": { + "none": "No follow-up needed", + "press_telegram_detail_or_history": "Press Telegram Detail / History once to create callback evidence", + "press_telegram_detail_or_history_after_rollout": "Press Telegram Detail / History again to capture the new snapshot", + "review_outbound_source_refs": "Review outbound source_refs gaps", + "observed": "Wait for the next callback evidence" + } + }, "action": "Action: {action}", "incident": "Incident: {incidentId}", "sendStatus": "Send status: {status}", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index aebdc5e6..5d17550c 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -2832,6 +2832,32 @@ "total": "{count} 筆", "empty": "目前尚無 callback reply evidence。", "error": "Callback evidence 載入失敗:{error}", + "summary": { + "outbound": "出站鏡像", + "outboundDetail": "source_refs {sourceRefs};incident refs {incidentRefs};覆蓋 {coverage}", + "callbacks": "Callback replies", + "callbackDetail": "detail {detail} / history {history};Incident {incidents}", + "snapshots": "Evidence snapshots", + "snapshotDetail": "captured {captured} / partial {partial} / missing {missing};覆蓋 {coverage}", + "delivery": "送達失敗", + "deliveryDetail": "sent {sent};fallback {fallback};outbound failed {outboundFailed}", + "next": "下一步", + "latest": "最新 callback:{time}", + "statuses": { + "captured": "已捕捉", + "partial": "部分捕捉", + "not_captured": "未捕捉", + "no_callback": "尚無 callback", + "observed": "已記錄" + }, + "nextActions": { + "none": "不需補動作", + "press_telegram_detail_or_history": "按一次 Telegram 詳情 / 歷史產生 callback evidence", + "press_telegram_detail_or_history_after_rollout": "重新按 Telegram 詳情 / 歷史補新版 snapshot", + "review_outbound_source_refs": "檢查 outbound source_refs 缺口", + "observed": "等待下一次 callback evidence" + } + }, "action": "動作:{action}", "incident": "Incident:{incidentId}", "sendStatus": "送訊狀態:{status}", diff --git a/apps/web/src/app/[locale]/awooop/runs/page.tsx b/apps/web/src/app/[locale]/awooop/runs/page.tsx index 432154e9..7de32284 100644 --- a/apps/web/src/app/[locale]/awooop/runs/page.tsx +++ b/apps/web/src/app/[locale]/awooop/runs/page.tsx @@ -138,6 +138,31 @@ interface CallbackEvidenceCaptureStatus { event_at?: string | null; } +interface CallbackReplyAuditSummary { + schema_version?: string; + project_id?: string; + outbound_total?: number; + outbound_source_envelope_total?: number; + outbound_source_refs_total?: number; + outbound_incident_ref_total?: number; + outbound_failed_total?: number; + callback_total?: number; + callback_sent_total?: number; + callback_fallback_total?: number; + callback_rescue_total?: number; + callback_failed_total?: number; + callback_detail_total?: number; + callback_history_total?: number; + callback_snapshot_captured_total?: number; + callback_snapshot_partial_total?: number; + callback_snapshot_missing_total?: number; + callback_incident_total?: number; + snapshot_status?: CallbackEvidenceCaptureState | "no_callback" | string | null; + next_action?: string | null; + latest_outbound_at?: string | null; + latest_callback_at?: string | null; +} + interface Run { run_id: string; project_id: string; @@ -405,6 +430,7 @@ interface CallbackRepliesResponse { total: number; page: number; per_page: number; + summary?: CallbackReplyAuditSummary | null; } interface AiRoutePolicyItem { @@ -1908,13 +1934,148 @@ function CallbackAwoooPStatusChainSnapshot({ ); } +function formatCoveragePercent(value: number, total: number) { + if (total <= 0) return "0%"; + return `${Math.round((value / total) * 100)}%`; +} + +function normalizeCallbackAuditSnapshotStatus(statusValue?: string | null) { + if ( + statusValue === "captured" || + statusValue === "partial" || + statusValue === "not_captured" || + statusValue === "no_callback" + ) { + return statusValue; + } + return "observed"; +} + +function CallbackReplyAuditSummaryPanel({ + summary, +}: { + summary?: CallbackReplyAuditSummary | null; +}) { + const t = useTranslations("awooop.callbackReply.events.summary"); + if (!summary) return null; + + const outboundTotal = summary.outbound_total ?? 0; + const callbackTotal = summary.callback_total ?? 0; + const snapshotStatus = normalizeCallbackAuditSnapshotStatus(summary.snapshot_status); + const nextActionRaw = summary.next_action ?? "observed"; + const nextActionKey = ( + nextActionRaw === "none" || + nextActionRaw === "press_telegram_detail_or_history" || + nextActionRaw === "press_telegram_detail_or_history_after_rollout" || + nextActionRaw === "review_outbound_source_refs" + ) ? nextActionRaw : "observed"; + const sourceRefCoverage = formatCoveragePercent( + summary.outbound_incident_ref_total ?? 0, + outboundTotal + ); + const snapshotCoverage = formatCoveragePercent( + summary.callback_snapshot_captured_total ?? 0, + callbackTotal + ); + const latestCallback = summary.latest_callback_at + ? new Date(summary.latest_callback_at).toLocaleString("zh-TW", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }) + : "--"; + const snapshotClass = { + captured: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]", + partial: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]", + not_captured: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]", + no_callback: "border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]", + observed: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]", + }[snapshotStatus]; + + return ( +
{t("outbound")}
++ {outboundTotal} +
++ {t("outboundDetail", { + sourceRefs: summary.outbound_source_refs_total ?? 0, + incidentRefs: summary.outbound_incident_ref_total ?? 0, + coverage: sourceRefCoverage, + })} +
+{t("callbacks")}
++ {callbackTotal} +
++ {t("callbackDetail", { + detail: summary.callback_detail_total ?? 0, + history: summary.callback_history_total ?? 0, + incidents: summary.callback_incident_total ?? 0, + })} +
+{t("snapshots")}
+ + {t(`statuses.${snapshotStatus}` as never)} + ++ {t("snapshotDetail", { + captured: summary.callback_snapshot_captured_total ?? 0, + partial: summary.callback_snapshot_partial_total ?? 0, + missing: summary.callback_snapshot_missing_total ?? 0, + coverage: snapshotCoverage, + })} +
+{t("delivery")}
++ {summary.callback_failed_total ?? 0} +
++ {t("deliveryDetail", { + sent: summary.callback_sent_total ?? 0, + fallback: (summary.callback_fallback_total ?? 0) + + (summary.callback_rescue_total ?? 0), + outboundFailed: summary.outbound_failed_total ?? 0, + })} +
+{t("next")}
++ {t(`nextActions.${nextActionKey}` as never)} +
++ {t("latest", { time: latestCallback })} +
+