diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index 6654f2e5..60062df4 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -271,10 +271,26 @@ def _outbound_timeline_title( channel_type: str, message_type: str, content_preview: str | None, + callback_reply: dict[str, Any] | None = None, ) -> str: """將 legacy Telegram outbound 分類成 Operator 看得懂的語義標題。""" channel = channel_type.upper() preview = content_preview or "" + if callback_reply: + action = str(callback_reply.get("action") or "").strip() + status = str(callback_reply.get("status") or "").strip() + action_label = { + "detail": "詳情", + "history": "歷史", + }.get(action, action or "Callback") + status_label = { + "callback_reply_sent": "已送出", + "callback_reply_fallback_sent": " fallback 已送出", + "callback_reply_rescue_sent": "救援已送出", + "callback_reply_failed": "送出失敗", + }.get(status, status or "已記錄") + return f"{channel}:{action_label}回覆{status_label}" + if "RUNBOOK REVIEW" in preview: return f"{channel}:Runbook 待人工審核" if "[AWOOOI CI/CD]" in preview or "AWOOOI CI/CD" in preview: @@ -299,6 +315,73 @@ def _outbound_timeline_title( return f"{channel}:{fallback}" +def _outbound_callback_reply(source_envelope: Any) -> dict[str, Any] | None: + """Extract Telegram callback reply evidence from outbound source envelope.""" + if not isinstance(source_envelope, dict): + return None + callback_reply = source_envelope.get("callback_reply") + return callback_reply if isinstance(callback_reply, dict) else None + + +def _outbound_timeline_status( + send_status: str, + callback_reply: dict[str, Any] | None, +) -> str: + """Prefer callback delivery status when the outbound row records one.""" + if callback_reply: + status = callback_reply.get("status") + if isinstance(status, str) and status: + return status + return send_status + + +def _outbound_timeline_summary( + *, + content_preview: str | None, + send_error: str | None, + callback_reply: dict[str, Any] | None, +) -> str | None: + """Summarize callback reply state without forcing operators to inspect raw JSON.""" + if not callback_reply: + return content_preview or send_error + + parts = [ + f"callback={callback_reply.get('action') or '--'}", + f"incident={callback_reply.get('incident_id') or '--'}", + f"status={callback_reply.get('status') or '--'}", + ] + parse_mode = callback_reply.get("parse_mode") + if parse_mode: + parts.append(f"parse_mode={parse_mode}") + error = callback_reply.get("error") + if error: + parts.append(f"error={error}") + if content_preview: + parts.append(str(content_preview)) + return " · ".join(parts) + + +def _outbound_timeline_metadata( + row: AwoooPOutboundMessage, + callback_reply: dict[str, Any] | None, +) -> dict[str, Any]: + """Build compact outbound metadata with callback fields first when present.""" + metadata: dict[str, Any] = {} + if callback_reply: + metadata.update({ + "callback_status": callback_reply.get("status"), + "callback_action": callback_reply.get("action"), + "callback_incident_id": callback_reply.get("incident_id"), + "callback_parse_mode": callback_reply.get("parse_mode"), + }) + metadata.update({ + "message_type": row.message_type, + "provider_message_id": row.provider_message_id, + "triggered_by_state": row.triggered_by_state, + }) + return metadata + + def _mcp_gateway_summary_row(row: AwoooPMcpGatewayAudit) -> dict[str, Any]: """Convert SQLAlchemy audit rows into the truth-chain summary shape.""" return { @@ -971,8 +1054,10 @@ async def get_run_detail( for row in inbound_events ] - outbound_items = [ - { + outbound_items = [] + for row in outbound_messages: + callback_reply = _outbound_callback_reply(row.source_envelope) + outbound_items.append({ "message_id": row.message_id, "channel_type": row.channel_type, "message_type": row.message_type, @@ -983,9 +1068,8 @@ async def get_run_detail( "queued_at": row.queued_at, "sent_at": row.sent_at, "triggered_by_state": row.triggered_by_state, - } - for row in outbound_messages - ] + "callback_reply": callback_reply, + }) def _mcp_item(row: AwoooPMcpGatewayAudit) -> dict[str, Any]: gate_result = row.gate_result if isinstance(row.gate_result, dict) else {} @@ -1136,6 +1220,7 @@ async def get_run_detail( ) ) for row in outbound_messages: + callback_reply = _outbound_callback_reply(row.source_envelope) timeline.append( _timeline_item( ts=row.sent_at or row.queued_at, @@ -1144,14 +1229,15 @@ async def get_run_detail( row.channel_type, row.message_type, row.content_preview, + callback_reply, ), - status=row.send_status, - summary=row.content_preview or row.send_error, - metadata={ - "message_type": row.message_type, - "provider_message_id": row.provider_message_id, - "triggered_by_state": row.triggered_by_state, - }, + status=_outbound_timeline_status(row.send_status, callback_reply), + summary=_outbound_timeline_summary( + content_preview=row.content_preview, + send_error=row.send_error, + callback_reply=callback_reply, + ), + metadata=_outbound_timeline_metadata(row, callback_reply), ) ) if run.completed_at: diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py index 50c3ce46..250bbb57 100644 --- a/apps/api/tests/test_awooop_operator_timeline_labels.py +++ b/apps/api/tests/test_awooop_operator_timeline_labels.py @@ -7,6 +7,8 @@ from src.services.platform_operator_service import ( _legacy_mcp_timeline_summary, _list_filter_context_limit, _outbound_timeline_title, + _outbound_timeline_status, + _outbound_timeline_summary, _remediation_summary_matches_incident_id, _remediation_summary_matches_status, _remediation_timeline_summary, @@ -61,6 +63,54 @@ def test_outbound_timeline_title_falls_back_to_human_label() -> None: assert title == "TELEGRAM:漸進式狀態回饋" +def test_outbound_timeline_title_labels_callback_reply_fallback() -> None: + callback_reply = { + "status": "callback_reply_fallback_sent", + "action": "history", + "incident_id": "INC-20260513-79ED5E", + "parse_mode": "plain_text", + } + + title = _outbound_timeline_title( + "telegram", + "final", + "事件歷史統計 INC-20260513-79ED5E", + callback_reply, + ) + summary = _outbound_timeline_summary( + content_preview="事件歷史統計 INC-20260513-79ED5E", + send_error=None, + callback_reply=callback_reply, + ) + + assert title == "TELEGRAM:歷史回覆 fallback 已送出" + assert _outbound_timeline_status("sent", callback_reply) == ( + "callback_reply_fallback_sent" + ) + assert "callback=history" in summary + assert "incident=INC-20260513-79ED5E" in summary + assert "parse_mode=plain_text" in summary + + +def test_outbound_timeline_title_labels_callback_reply_failure() -> None: + callback_reply = { + "status": "callback_reply_failed", + "action": "detail", + "incident_id": "INC-20260513-79ED5E", + "error": "HTTP error: 400", + } + + assert _outbound_timeline_title("telegram", "error", None, callback_reply) == ( + "TELEGRAM:詳情回覆送出失敗" + ) + assert _outbound_timeline_status("failed", callback_reply) == "callback_reply_failed" + assert "error=HTTP error: 400" in _outbound_timeline_summary( + content_preview=None, + send_error="HTTP error: 400", + callback_reply=callback_reply, + ) + + def test_collect_run_incident_ids_reads_source_refs_and_legacy_text() -> None: run = SimpleNamespace( trigger_ref="not-an-incident", diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index c15ee00c..42b97165 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -2034,6 +2034,10 @@ "received": "Received", "running": "Running", "sent": "Sent", + "callbackReplySent": "Callback sent", + "callbackReplyFallbackSent": "Callback fallback", + "callbackReplyRescueSent": "Callback rescue", + "callbackReplyFailed": "Callback failed", "shadow": "Shadow", "success": "Success", "timeout": "Timed out", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 580c0013..100a0772 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -2035,6 +2035,10 @@ "received": "已接收", "running": "執行中", "sent": "已送出", + "callbackReplySent": "Callback 已送出", + "callbackReplyFallbackSent": "Callback fallback", + "callbackReplyRescueSent": "Callback 救援", + "callbackReplyFailed": "Callback 失敗", "shadow": "Shadow", "success": "成功", "timeout": "已超時", diff --git a/apps/web/src/app/[locale]/awooop/runs/[run_id]/page.tsx b/apps/web/src/app/[locale]/awooop/runs/[run_id]/page.tsx index 3aecd97b..74910ae5 100644 --- a/apps/web/src/app/[locale]/awooop/runs/[run_id]/page.tsx +++ b/apps/web/src/app/[locale]/awooop/runs/[run_id]/page.tsx @@ -213,6 +213,10 @@ const STATUS_STYLE: Record = { completed: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]", success: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]", sent: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]", + callback_reply_sent: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]", + callback_reply_fallback_sent: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]", + callback_reply_rescue_sent: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]", + callback_reply_failed: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]", running: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]", received: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]", waiting_approval: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]", @@ -236,6 +240,10 @@ const STATUS_TRANSLATION_KEYS: Record = { received: "statuses.received", running: "statuses.running", sent: "statuses.sent", + callback_reply_sent: "statuses.callbackReplySent", + callback_reply_fallback_sent: "statuses.callbackReplyFallbackSent", + callback_reply_rescue_sent: "statuses.callbackReplyRescueSent", + callback_reply_failed: "statuses.callbackReplyFailed", shadow: "statuses.shadow", success: "statuses.success", timeout: "statuses.timeout",