From 20d62ee0cf4b2e4f01d7010a3ca4b7a06d8c7767 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 18 May 2026 15:24:39 +0800 Subject: [PATCH] feat(awooop): surface callback replies on run list --- .../src/services/platform_operator_service.py | 67 ++++++++ .../test_awooop_operator_timeline_labels.py | 85 +++++++++- apps/web/messages/en.json | 25 +++ apps/web/messages/zh-TW.json | 25 +++ .../web/src/app/[locale]/awooop/runs/page.tsx | 158 +++++++++++++++++- 5 files changed, 356 insertions(+), 4 deletions(-) diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index 60062df4..6b8bd3a6 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -217,6 +217,9 @@ async def list_runs( "created_at": r.created_at, "timeout_at": r.timeout_at, "remediation_summary": remediation_summaries.get(r.run_id), + "callback_reply_summary": _run_callback_reply_summary( + outbound_by_run.get(r.run_id, []) + ), } for r in rows ] @@ -382,6 +385,69 @@ def _outbound_timeline_metadata( return metadata +def _run_callback_reply_summary( + outbound_messages: list[AwoooPOutboundMessage], +) -> dict[str, Any]: + """Summarize Telegram detail/history callback reply delivery for Run List.""" + callback_rows: list[tuple[AwoooPOutboundMessage, dict[str, Any]]] = [] + for row in outbound_messages: + callback_reply = _outbound_callback_reply(row.source_envelope) + if callback_reply: + callback_rows.append((row, callback_reply)) + + if not callback_rows: + return { + "schema_version": "awooop_run_callback_reply_summary_v1", + "status": "no_callback", + "total": 0, + "sent": 0, + "fallback_sent": 0, + "rescue_sent": 0, + "failed": 0, + "needs_human": False, + "latest_status": None, + "latest_action": None, + "latest_incident_id": None, + "latest_at": None, + "latest_provider_message_id": None, + } + + sorted_rows = sorted( + callback_rows, + key=lambda item: str(item[0].sent_at or item[0].queued_at or ""), + reverse=True, + ) + latest_row, latest_callback = sorted_rows[0] + statuses = [ + str(callback.get("status") or "") + for _, callback in sorted_rows + ] + failed = statuses.count("callback_reply_failed") + latest_status = str(latest_callback.get("status") or "") + summary_status = { + "callback_reply_sent": "sent", + "callback_reply_fallback_sent": "fallback_sent", + "callback_reply_rescue_sent": "rescue_sent", + "callback_reply_failed": "failed", + }.get(latest_status, "observed") + + return { + "schema_version": "awooop_run_callback_reply_summary_v1", + "status": summary_status, + "total": len(sorted_rows), + "sent": statuses.count("callback_reply_sent"), + "fallback_sent": statuses.count("callback_reply_fallback_sent"), + "rescue_sent": statuses.count("callback_reply_rescue_sent"), + "failed": failed, + "needs_human": failed > 0 or latest_status == "callback_reply_failed", + "latest_status": latest_status or None, + "latest_action": latest_callback.get("action"), + "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, + } + + def _mcp_gateway_summary_row(row: AwoooPMcpGatewayAudit) -> dict[str, Any]: """Convert SQLAlchemy audit rows into the truth-chain summary shape.""" return { @@ -441,6 +507,7 @@ def _collect_run_incident_ids( _append_incident_ids_from_text(incident_ids, event.content_redacted) for message in outbound_messages: + _append_incident_ids_from_source_envelope(incident_ids, message.source_envelope) _append_incident_ids_from_text(incident_ids, message.content_preview) _append_incident_ids_from_text(incident_ids, message.send_error) diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py index 250bbb57..fd071e73 100644 --- a/apps/api/tests/test_awooop_operator_timeline_labels.py +++ b/apps/api/tests/test_awooop_operator_timeline_labels.py @@ -12,6 +12,7 @@ from src.services.platform_operator_service import ( _remediation_summary_matches_incident_id, _remediation_summary_matches_status, _remediation_timeline_summary, + _run_callback_reply_summary, _run_remediation_list_summary, _timeline_sort_key, ) @@ -129,6 +130,11 @@ def test_collect_run_incident_ids_reads_source_refs_and_legacy_text() -> None: ] outbound_messages = [ SimpleNamespace( + source_envelope={ + "source_refs": { + "incident_ids": ["INC-20260518-CB0001"], + }, + }, content_preview="詳情:INC-20260513-79ED5E", send_error=None, ) @@ -140,7 +146,84 @@ def test_collect_run_incident_ids_reads_source_refs_and_legacy_text() -> None: outbound_messages=outbound_messages, ) - assert incident_ids == ["INC-20260514-F85F21", "INC-20260513-79ED5E"] + assert incident_ids == [ + "INC-20260514-F85F21", + "INC-20260518-CB0001", + "INC-20260513-79ED5E", + ] + + +def test_run_callback_reply_summary_marks_latest_fallback() -> None: + summary = _run_callback_reply_summary([ + SimpleNamespace( + source_envelope={ + "callback_reply": { + "status": "callback_reply_sent", + "action": "detail", + "incident_id": "INC-20260513-79ED5E", + } + }, + sent_at=datetime(2026, 5, 18, 6, 1, 0), + queued_at=datetime(2026, 5, 18, 6, 1, 0), + provider_message_id="100", + ), + SimpleNamespace( + source_envelope={ + "callback_reply": { + "status": "callback_reply_fallback_sent", + "action": "history", + "incident_id": "INC-20260513-79ED5E", + } + }, + sent_at=datetime(2026, 5, 18, 6, 2, 0), + queued_at=datetime(2026, 5, 18, 6, 2, 0), + provider_message_id="101", + ), + ]) + + assert summary["status"] == "fallback_sent" + assert summary["total"] == 2 + assert summary["sent"] == 1 + assert summary["fallback_sent"] == 1 + assert summary["latest_action"] == "history" + assert summary["latest_incident_id"] == "INC-20260513-79ED5E" + assert summary["latest_provider_message_id"] == "101" + assert summary["needs_human"] is False + + +def test_run_callback_reply_summary_marks_failed_as_human_attention() -> None: + summary = _run_callback_reply_summary([ + SimpleNamespace( + source_envelope={ + "callback_reply": { + "status": "callback_reply_failed", + "action": "detail", + "incident_id": "INC-20260513-79ED5E", + } + }, + sent_at=None, + queued_at=datetime(2026, 5, 18, 6, 3, 0), + provider_message_id="telegram_callback_reply:failed", + ) + ]) + + assert summary["status"] == "failed" + assert summary["failed"] == 1 + assert summary["needs_human"] is True + + +def test_run_callback_reply_summary_marks_no_callback() -> None: + summary = _run_callback_reply_summary([ + SimpleNamespace( + source_envelope={}, + sent_at=datetime(2026, 5, 18, 6, 1, 0), + queued_at=datetime(2026, 5, 18, 6, 1, 0), + provider_message_id="100", + ) + ]) + + assert summary["status"] == "no_callback" + assert summary["total"] == 0 def test_remediation_timeline_summary_surfaces_route_and_write_flags() -> None: diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 42b97165..eac7b3d9 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1820,6 +1820,7 @@ }, "listEvidence": { "column": "AI Evidence", + "callbackColumn": "TG Callback", "count": "{count} dry-runs", "mcpCount": "{count} MCP investigations", "route": "MCP: {route}", @@ -1862,12 +1863,36 @@ "manualGateDetail": "AI is stopped at the approval gate and needs approve / reject", "writeObserved": "Write flags", "writeObservedDetail": "Verify whether this is the expected auto-repair result", + "callbackObserved": "TG Callback", + "callbackObservedDetail": "Detail / history replies are tracked; failed {failed}", "noEvidence": "Missing AI evidence", "noEvidenceDetail": "The list row is not linked to ADR-100 dry-run or MCP evidence yet", "approvalReadOnlyDetail": "Read-only remediation evidence is visible before approval", "approvalNoEvidenceDetail": "Approval still lacks AI evidence; inspect Run Timeline" } }, + "callbackReply": { + "count": "{total} items; fallback {fallback}; failed {failed}", + "emptyShort": "No detail / history callback yet", + "latest": "{action} · {incidentId}", + "needsHuman": "Callback failure needs human review", + "statuses": { + "noCallback": "No callback", + "sent": "Delivered", + "fallbackSent": "Fallback delivered", + "rescueSent": "Rescue delivered", + "failed": "Delivery failed", + "observed": "Recorded" + }, + "details": { + "noCallback": "This run has no detail / history callback reply evidence yet.", + "sent": "The Telegram callback reply was delivered with the original format.", + "fallbackSent": "The Telegram HTML reply failed, then plain-text fallback was delivered.", + "rescueSent": "The Telegram fallback also failed, then rescue plain text was delivered.", + "failed": "The Telegram callback reply ultimately failed to deliver and needs human review.", + "observed": "The Telegram callback reply was recorded with a non-standard status." + } + }, "incidentEvidence": { "title": "Incident Evidence", "subtitle": "Telegram, Run, Approval, and Work Item share the same remediation evidence", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 100a0772..4d57a9fa 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -1821,6 +1821,7 @@ }, "listEvidence": { "column": "AI 證據", + "callbackColumn": "TG Callback", "count": "試跑 {count} 次", "mcpCount": "MCP 調查 {count} 次", "route": "MCP:{route}", @@ -1863,12 +1864,36 @@ "manualGateDetail": "AI 已停在 approval gate,需 approve / reject", "writeObserved": "寫入旗標", "writeObservedDetail": "需確認是否為預期自動修復結果", + "callbackObserved": "TG Callback", + "callbackObservedDetail": "詳情 / 歷史回覆已追蹤;失敗 {failed} 筆", "noEvidence": "缺 AI 證據", "noEvidenceDetail": "列表尚未連到 ADR-100 dry-run 或 MCP evidence", "approvalReadOnlyDetail": "審批前已有只讀補救證據可回看", "approvalNoEvidenceDetail": "審批前仍缺 AI 證據,需進 Run Timeline 檢查" } }, + "callbackReply": { + "count": "{total} 筆;fallback {fallback};失敗 {failed}", + "emptyShort": "尚無詳情 / 歷史 callback", + "latest": "{action} · {incidentId}", + "needsHuman": "Callback 失敗需人工確認", + "statuses": { + "noCallback": "尚無 Callback", + "sent": "已送達", + "fallbackSent": "Fallback 已送達", + "rescueSent": "救援已送達", + "failed": "送達失敗", + "observed": "已記錄" + }, + "details": { + "noCallback": "此 Run 尚未有詳情 / 歷史 callback reply 證據。", + "sent": "Telegram callback reply 已用原格式送達。", + "fallbackSent": "Telegram HTML 回覆失敗後,已用純文字 fallback 送達。", + "rescueSent": "Telegram fallback 仍失敗後,已用救援純文字送達。", + "failed": "Telegram callback reply 最終送達失敗,需人工確認。", + "observed": "Telegram callback reply 已記錄,但狀態不屬於標準分類。" + } + }, "incidentEvidence": { "title": "Incident Evidence", "subtitle": "Telegram、Run、Approval 與 Work Item 共用同一組補救證據", diff --git a/apps/web/src/app/[locale]/awooop/runs/page.tsx b/apps/web/src/app/[locale]/awooop/runs/page.tsx index f6c8b8b0..2ca166e1 100644 --- a/apps/web/src/app/[locale]/awooop/runs/page.tsx +++ b/apps/web/src/app/[locale]/awooop/runs/page.tsx @@ -20,6 +20,7 @@ import { Cpu, ListChecks, SearchCheck, + Send, ShieldCheck, TriangleAlert, } from "lucide-react"; @@ -40,6 +41,13 @@ type RunState = | "timeout"; type RunLane = "intake" | "diagnosis" | "approval" | "execution" | "done" | "manual"; +type CallbackReplyStatus = + | "no_callback" + | "sent" + | "fallback_sent" + | "rescue_sent" + | "failed" + | "observed"; type RemediationStatus = | "no_evidence" | "mcp_observed" @@ -74,6 +82,22 @@ interface RemediationSummary { latest_mcp_server?: string | null; } +interface CallbackReplySummary { + schema_version?: string; + status?: CallbackReplyStatus | string; + total?: number; + sent?: number; + fallback_sent?: number; + rescue_sent?: number; + failed?: number; + needs_human?: boolean; + latest_status?: string | null; + latest_action?: string | null; + latest_incident_id?: string | null; + latest_at?: string | null; + latest_provider_message_id?: string | null; +} + interface Run { run_id: string; project_id: string; @@ -84,6 +108,7 @@ interface Run { step_count: number; created_at: string; remediation_summary?: RemediationSummary | null; + callback_reply_summary?: CallbackReplySummary | null; } interface Tenant { @@ -282,6 +307,46 @@ const REMEDIATION_FILTER_OPTIONS: RemediationStatus[] = [ "no_evidence", ]; +const CALLBACK_REPLY_CONFIG: Record< + CallbackReplyStatus, + { + labelKey: string; + detailKey: string; + className: string; + } +> = { + no_callback: { + labelKey: "statuses.noCallback", + detailKey: "details.noCallback", + className: "border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]", + }, + sent: { + labelKey: "statuses.sent", + detailKey: "details.sent", + className: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]", + }, + fallback_sent: { + labelKey: "statuses.fallbackSent", + detailKey: "details.fallbackSent", + className: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]", + }, + rescue_sent: { + labelKey: "statuses.rescueSent", + detailKey: "details.rescueSent", + className: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]", + }, + failed: { + labelKey: "statuses.failed", + detailKey: "details.failed", + className: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]", + }, + observed: { + labelKey: "statuses.observed", + detailKey: "details.observed", + className: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]", + }, +}; + function getRunLane(state: RunState): RunLane { if (state === "pending") return "intake"; if (state === "waiting_tool") return "diagnosis"; @@ -355,6 +420,20 @@ function normalizeRemediationStatus(summary?: RemediationSummary | null): Remedi return "no_evidence"; } +function normalizeCallbackReplyStatus(summary?: CallbackReplySummary | null): CallbackReplyStatus { + const statusValue = summary?.status; + if ( + statusValue === "sent" || + statusValue === "fallback_sent" || + statusValue === "rescue_sent" || + statusValue === "failed" || + statusValue === "observed" + ) { + return statusValue; + } + return "no_callback"; +} + function RemediationEvidenceCell({ summary }: { summary?: RemediationSummary | null }) { const t = useTranslations("awooop.listEvidence"); const status = normalizeRemediationStatus(summary); @@ -399,6 +478,56 @@ function RemediationEvidenceCell({ summary }: { summary?: RemediationSummary | n ); } +function CallbackReplyCell({ summary }: { summary?: CallbackReplySummary | null }) { + const t = useTranslations("awooop.callbackReply"); + const status = normalizeCallbackReplyStatus(summary); + const config = CALLBACK_REPLY_CONFIG[status]; + const total = summary?.total ?? 0; + const latestAction = summary?.latest_action ? String(summary.latest_action) : null; + const latestIncidentId = summary?.latest_incident_id ? String(summary.latest_incident_id) : null; + const countText = total > 0 + ? t("count", { + total, + failed: summary?.failed ?? 0, + fallback: (summary?.fallback_sent ?? 0) + (summary?.rescue_sent ?? 0), + }) + : t("emptyShort"); + const latestText = latestAction || latestIncidentId + ? t("latest", { + action: latestAction ?? "--", + incidentId: latestIncidentId ?? "--", + }) + : null; + + return ( +
+ + + 0 ? "text-[#5f5b52]" : "text-[#77736a]")}> + {countText} + + {latestText && ( + + {latestText} + + )} + {summary?.needs_human && ( + + {t("needsHuman")} + + )} +
+ ); +} + function linkedIncidentIds(summary?: RemediationSummary | null): string[] { const rawIds = summary?.incident_ids ?? []; return Array.from( @@ -492,6 +621,9 @@ function RunRow({ run }: { run: Run }) { + + + @@ -707,6 +839,12 @@ export default function RunsPage() { (run) => normalizeRemediationStatus(run.remediation_summary) === "no_evidence" ).length, manualGate: runs.filter((run) => run.remediation_summary?.human_gate_open).length, + callbackObserved: runs.filter( + (run) => normalizeCallbackReplyStatus(run.callback_reply_summary) !== "no_callback" + ).length, + callbackFailed: runs.filter( + (run) => normalizeCallbackReplyStatus(run.callback_reply_summary) === "failed" + ).length, }; }, [runs]); @@ -762,7 +900,7 @@ export default function RunsPage() { })} -
+
{[ { label: tEvidence("summary.mcpObserved"), @@ -792,6 +930,17 @@ export default function RunsPage() { icon: TriangleAlert, className: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]", }, + { + label: tEvidence("summary.callbackObserved"), + value: evidenceSummary.callbackObserved, + detail: tEvidence("summary.callbackObservedDetail", { + failed: evidenceSummary.callbackFailed, + }), + icon: Send, + className: evidenceSummary.callbackFailed > 0 + ? "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]" + : "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]", + }, { label: tEvidence("summary.noEvidence"), value: evidenceSummary.noEvidence, @@ -940,6 +1089,9 @@ export default function RunsPage() { {tEvidence("column")} + + {tEvidence("callbackColumn")} + Shadow @@ -955,7 +1107,7 @@ export default function RunsPage() { {loading ? ( Array.from({ length: 8 }).map((_, i) => ( - {Array.from({ length: 10 }).map((_, j) => ( + {Array.from({ length: 11 }).map((_, j) => (
@@ -964,7 +1116,7 @@ export default function RunsPage() { )) ) : runs.length === 0 && !error ? ( - +