diff --git a/apps/api/src/services/approval_db.py b/apps/api/src/services/approval_db.py
index 46323f628..40a263209 100644
--- a/apps/api/src/services/approval_db.py
+++ b/apps/api/src/services/approval_db.py
@@ -660,6 +660,8 @@ class ApprovalDBService:
success: bool,
error_message: str | None = None,
execution_kind: str | None = None,
+ repair_executed: bool | None = None,
+ repair_attempted: bool | None = None,
) -> None:
"""
更新執行狀態
@@ -691,7 +693,18 @@ class ApprovalDBService:
# 但前台/報表必須能分辨「未執行修復」而非真正 execution success。
metadata = dict(record.extra_metadata or {})
metadata["execution_kind"] = execution_kind
- metadata["repair_executed"] = execution_kind != "no_action"
+ metadata["repair_executed"] = (
+ repair_executed
+ if repair_executed is not None
+ else execution_kind not in {
+ "no_action",
+ "diagnostic",
+ "parse_failed",
+ "unsupported_action",
+ }
+ )
+ if repair_attempted is not None:
+ metadata["repair_attempted"] = repair_attempted
record.extra_metadata = metadata
logger.info(
diff --git a/apps/api/src/services/approval_execution.py b/apps/api/src/services/approval_execution.py
index 79a6b8ef5..91ae79d34 100644
--- a/apps/api/src/services/approval_execution.py
+++ b/apps/api/src/services/approval_execution.py
@@ -24,8 +24,10 @@ Approval Execution Service - Phase 16 R4.2 瘦身 Router 抽取
"""
import asyncio
+import html
import time
from typing import TYPE_CHECKING, Any
+from urllib.parse import quote
from uuid import UUID
import structlog
@@ -39,6 +41,7 @@ from src.plugins.mcp.interfaces import MCPToolResult
from src.services.approval_action_classifier import is_no_action_approval_action
from src.services.approval_db import get_approval_service, get_timeline_service
from src.services.executor import ExecutionResult, OperationType, get_executor
+from src.services.operator_outcome import build_operator_outcome
from src.services.operation_parser import parse_operation_from_action
if TYPE_CHECKING:
@@ -112,6 +115,12 @@ class ApprovalExecutionService:
"destructive",
"blocked",
)
+ _NON_REPAIR_EXECUTION_KINDS: tuple[str, ...] = (
+ "no_action",
+ "diagnostic",
+ "parse_failed",
+ "unsupported_action",
+ )
@classmethod
def _is_transient_error(cls, error_message: str | None) -> bool:
@@ -136,6 +145,24 @@ class ApprovalExecutionService:
# 瞬態錯誤 → 可重試
return any(kw in lower for kw in cls._TRANSIENT_ERROR_KEYWORDS)
+ @classmethod
+ def _execution_kind_for_result(
+ cls,
+ operation_type: OperationType,
+ result: ExecutionResult | None = None,
+ ) -> str:
+ if operation_type == OperationType.INVESTIGATE:
+ return "diagnostic"
+ if operation_type == OperationType.SSH_HOST:
+ message = str(getattr(result, "message", "") or "").lower()
+ if "ssh_mcp:ssh_diagnose" in message:
+ return "diagnostic"
+ return operation_type.value
+
+ @classmethod
+ def _is_repair_kind(cls, execution_kind: str | None) -> bool:
+ return str(execution_kind or "").lower() not in cls._NON_REPAIR_EXECUTION_KINDS
+
async def execute_approved_action(self, approval: ApprovalRequest) -> bool:
"""
背景執行已批准的操作
@@ -245,6 +272,8 @@ class ApprovalExecutionService:
approval.id,
success=True,
execution_kind="no_action",
+ repair_executed=False,
+ repair_attempted=False,
)
await timeline.add_event(
event_type="exec",
@@ -256,12 +285,6 @@ class ApprovalExecutionService:
approval_id=str(approval.id),
incident_id=approval.incident_id,
)
- # 執行結果 reply 原告警卡片
- asyncio.create_task(
- self._push_execution_result_to_alert(
- approval, success=True, error=None,
- )
- )
# ADR-090 § aol completed (NO_ACTION 視為成功)
await self._log_aol_completed(
op_id=_aol_op_id,
@@ -307,6 +330,14 @@ class ApprovalExecutionService:
approval_id=str(approval.id),
error=str(_resolve_e),
)
+ await self._push_execution_result_to_alert(
+ approval,
+ success=True,
+ error=None,
+ execution_kind="no_action",
+ repair_executed=False,
+ repair_attempted=False,
+ )
return True # NO_ACTION 視為成功完成
# 真解析失敗 (非 NO_ACTION)
@@ -320,6 +351,9 @@ class ApprovalExecutionService:
await service.update_execution_status(
approval.id, success=False,
error_message=f"Could not parse operation type from action: {approval.action[:150]}",
+ execution_kind="parse_failed",
+ repair_executed=False,
+ repair_attempted=False,
)
await timeline.add_event(
event_type="exec",
@@ -356,6 +390,14 @@ class ApprovalExecutionService:
duration_ms=int((time.time() - _aol_started_ms) * 1000),
error_message=f"Could not parse operation type from action: {approval.action[:150]}",
)
+ await self._push_execution_result_to_alert(
+ approval,
+ success=False,
+ error=f"Could not parse operation type from action: {approval.action[:150]}",
+ execution_kind="parse_failed",
+ repair_executed=False,
+ repair_attempted=False,
+ )
return False # 解析失敗 → 執行未發生
executor = get_executor()
@@ -441,12 +483,20 @@ class ApprovalExecutionService:
)
attempt += 1
+ execution_kind = self._execution_kind_for_result(operation_type, result)
+ repair_kind = self._is_repair_kind(execution_kind)
+ repair_executed = bool(result.success and repair_kind)
+ repair_attempted = bool(repair_kind)
+
# Phase 5: 更新資料庫狀態
# 2026-04-18 ADR-090 L5 P0.2: 失敗時帶上 error_message,寫進 rejection_reason
await service.update_execution_status(
approval.id,
success=result.success,
error_message=None if result.success else (result.error or "(executor 未回傳錯誤)"),
+ execution_kind=execution_kind,
+ repair_executed=repair_executed,
+ repair_attempted=repair_attempted,
)
# Update approval status based on result
@@ -484,12 +534,6 @@ class ApprovalExecutionService:
)
)
- # 2026-04-14 Claude Sonnet 4.6: reply_to 原告警卡片顯示執行結果
- # auto_approve 路徑由 _push_auto_repair_result 處理,此處僅處理人工批准
- asyncio.create_task(
- self._push_execution_result_to_alert(approval, success=True, error=None)
- )
-
# Phase 7.6: 觸發 Playbook 自動萃取 (fire-and-forget)
asyncio.create_task(
self._trigger_playbook_extraction(approval)
@@ -576,7 +620,7 @@ class ApprovalExecutionService:
await self._log_alert_execution_completed(
approval,
success=True,
- execution_kind=operation_type.value,
+ execution_kind=execution_kind,
duration_ms=int((time.time() - _aol_started_ms) * 1000),
output={
"operation_type": operation_type.value,
@@ -584,9 +628,18 @@ class ApprovalExecutionService:
"namespace": namespace,
"executor_duration_ms": result.duration_ms,
"total_attempts": total_attempts,
- "repair_executed": True,
+ "repair_attempted": repair_attempted,
+ "repair_executed": repair_executed,
},
)
+ await self._push_execution_result_to_alert(
+ approval,
+ success=True,
+ error=None,
+ execution_kind=execution_kind,
+ repair_executed=repair_executed,
+ repair_attempted=repair_attempted,
+ )
return True # K8s 執行成功
else:
@@ -626,13 +679,6 @@ class ApprovalExecutionService:
)
)
- # 2026-04-14 Claude Sonnet 4.6: reply_to 原告警卡片顯示失敗結果
- asyncio.create_task(
- self._push_execution_result_to_alert(
- approval, success=False, error=result.error
- )
- )
-
# ADR-030 Phase 5 / ADR-083 Phase 3: 觸發學習服務(失敗案例)
# Phase 3 修復:fire-and-forget → await + 30s 熔斷
# 2026-04-15 ogt + Claude Sonnet 4.6(亞太): Phase 3 fire-and-forget 修復
@@ -691,7 +737,7 @@ class ApprovalExecutionService:
await self._log_alert_execution_completed(
approval,
success=False,
- execution_kind=operation_type.value,
+ execution_kind=execution_kind,
duration_ms=int((time.time() - _aol_started_ms) * 1000),
output={
"operation_type": operation_type.value,
@@ -699,11 +745,19 @@ class ApprovalExecutionService:
"namespace": namespace,
"executor_duration_ms": result.duration_ms,
"total_attempts": total_attempts,
- "repair_attempted": True,
+ "repair_attempted": repair_attempted,
"repair_executed": False,
},
error_message=result.error,
)
+ await self._push_execution_result_to_alert(
+ approval,
+ success=False,
+ error=result.error,
+ execution_kind=execution_kind,
+ repair_executed=False,
+ repair_attempted=repair_attempted,
+ )
return False # K8s 執行失敗
async def _execute_ssh_host_action(
@@ -896,6 +950,10 @@ class ApprovalExecutionService:
approval: ApprovalRequest,
success: bool,
error: str | None,
+ *,
+ execution_kind: str | None = None,
+ repair_executed: bool | None = None,
+ repair_attempted: bool | None = None,
) -> None:
"""
執行結果回覆到原告警 Telegram 卡片(reply_to_message_id)
@@ -904,7 +962,8 @@ class ApprovalExecutionService:
- 人工路徑:人類在 Telegram 點批准後,等執行完成,在原告警下 reply 執行結果
- 自動路徑 (requested_by=auto_approve) 由 _push_auto_repair_result 處理,此處 skip
- 透過 Redis tg_msg:{incident_id} 查原告警 message_id,找不到則靜默不發。
+ 透過 Redis tg_msg:{incident_id} 查原告警 message_id。找不到原卡片時,
+ 仍必須發獨立結果通知,避免批准後沒有可追蹤結論。
"""
try:
# 自動執行路徑 skip(避免與 _push_auto_repair_result 重複發訊息)
@@ -917,36 +976,44 @@ class ApprovalExecutionService:
from src.core.redis_client import get_redis
redis = get_redis()
msg_id_raw = await redis.get(f"tg_msg:{approval.incident_id}")
- if not msg_id_raw:
+ orig_msg_id: int | None = None
+ if msg_id_raw:
+ try:
+ orig_msg_id = int(msg_id_raw)
+ except (TypeError, ValueError):
+ orig_msg_id = None
+ if orig_msg_id is None:
logger.debug(
- "push_execution_result_no_msg_id",
+ "push_execution_result_no_msg_id_standalone",
incident_id=approval.incident_id,
approval_id=str(approval.id),
)
- return
-
- try:
- orig_msg_id = int(msg_id_raw)
- except (TypeError, ValueError):
- return
from src.core.config import get_settings
from src.services.telegram_gateway import get_telegram_gateway
settings = get_settings()
gateway = get_telegram_gateway()
target_chat_id = settings.SRE_GROUP_CHAT_ID or settings.OPENCLAW_TG_CHAT_ID
+ if not target_chat_id:
+ logger.warning(
+ "push_execution_result_no_target_chat",
+ incident_id=approval.incident_id,
+ approval_id=str(approval.id),
+ )
+ return
# 2026-04-19 ogt + Claude Opus 4.7 修 AP-2: 除了 reply 外,
# 也 edit 原卡片移除按鈕 + 更新狀態戳記(避免卡片永遠停在「執行中」)
- try:
- await gateway._send_request("editMessageReplyMarkup", {
- "chat_id": target_chat_id,
- "message_id": orig_msg_id,
- "reply_markup": {"inline_keyboard": []},
- })
- except Exception as _edit_e:
- logger.debug("push_execution_edit_buttons_failed",
- approval_id=str(approval.id), error=str(_edit_e))
+ if orig_msg_id is not None:
+ try:
+ await gateway._send_request("editMessageReplyMarkup", {
+ "chat_id": target_chat_id,
+ "message_id": orig_msg_id,
+ "reply_markup": {"inline_keyboard": []},
+ })
+ except Exception as _edit_e:
+ logger.debug("push_execution_edit_buttons_failed",
+ approval_id=str(approval.id), error=str(_edit_e))
# 附加 KM/Playbook 增量(查最近該 incident 的 KM + playbook 使用)
km_info = ""
@@ -969,36 +1036,45 @@ class ApprovalExecutionService:
except Exception:
pass
+ outcome = await self._fetch_operator_outcome_for_result(approval)
no_action = success and is_no_action_approval_action(approval.action)
- if no_action:
- text = (
- f"ℹ️ 已記錄觀察,未執行修復\n"
- f"{(approval.action or '')[:180]}"
- f"{km_info}"
- )
- elif success:
- text = (
- f"✅ 執行成功\n"
- f"{(approval.action or '')[:180]}"
- f"{km_info}"
- )
- else:
- err_short = (error or "未知錯誤")[:150]
- text = (
- f"❌ 執行失敗\n"
- f"{(approval.action or '')[:180]}\n"
- f"原因: {err_short}"
- f"{km_info}"
- )
+ text = self._format_execution_result_message(
+ approval=approval,
+ success=success,
+ error=error,
+ no_action=no_action,
+ km_info=km_info,
+ outcome=outcome,
+ )
+ reply_markup = {
+ "inline_keyboard": [[
+ {
+ "text": "🧭 AwoooP",
+ "url": (
+ "https://awoooi.wooo.work/zh-TW/awooop/runs"
+ f"?project_id=awoooi&incident_id={quote(approval.incident_id or '')}"
+ ),
+ }
+ ]]
+ }
+ payload: dict[str, Any] = {
+ "chat_id": target_chat_id,
+ "text": text,
+ "parse_mode": "HTML",
+ "reply_markup": reply_markup,
+ "disable_web_page_preview": True,
+ }
+ if orig_msg_id is not None:
+ payload["reply_to_message_id"] = orig_msg_id
- await gateway._send_request(
- "sendMessage",
- {
- "chat_id": target_chat_id,
- "text": text,
- "parse_mode": "HTML",
- "reply_to_message_id": orig_msg_id,
- },
+ await gateway._send_request("sendMessage", payload)
+ context_execution_kind = execution_kind or (
+ "no_action" if no_action else "execution"
+ )
+ context_repair_executed = (
+ bool(repair_executed)
+ if repair_executed is not None
+ else bool(success and not no_action)
)
logger.info(
"push_execution_result_sent",
@@ -1007,6 +1083,10 @@ class ApprovalExecutionService:
success=success,
no_action=no_action,
orig_msg_id=orig_msg_id,
+ execution_kind=context_execution_kind,
+ repair_executed=context_repair_executed,
+ operator_outcome_state=outcome.get("state"),
+ needs_human=outcome.get("needs_human"),
)
try:
from src.repositories.alert_operation_log_repository import (
@@ -1023,8 +1103,11 @@ class ApprovalExecutionService:
error_message=error,
context={
"reply_to_message_id": orig_msg_id,
- "execution_kind": "no_action" if no_action else "execution",
- "repair_executed": not no_action and success,
+ "execution_kind": context_execution_kind,
+ "repair_executed": context_repair_executed,
+ "repair_attempted": repair_attempted,
+ "operator_outcome": outcome,
+ "delivery": "reply" if orig_msg_id is not None else "standalone",
},
)
except Exception as _log_e:
@@ -1040,6 +1123,108 @@ class ApprovalExecutionService:
error=str(e),
)
+ async def _fetch_operator_outcome_for_result(
+ self,
+ approval: ApprovalRequest,
+ ) -> dict[str, Any]:
+ incident_id = approval.incident_id
+ if not incident_id:
+ return build_operator_outcome(
+ truth_status=None,
+ automation_quality=None,
+ fetch_error="approval_missing_incident_id",
+ source_id=None,
+ )
+ try:
+ from src.services.awooop_truth_chain_service import fetch_truth_chain
+
+ truth_chain = await asyncio.wait_for(
+ fetch_truth_chain(source_id=incident_id, project_id="awoooi"),
+ timeout=3.0,
+ )
+ outcome = truth_chain.get("operator_outcome")
+ if isinstance(outcome, dict):
+ return outcome
+ quality = truth_chain.get("automation_quality")
+ if isinstance(quality, dict) and isinstance(quality.get("operator_outcome"), dict):
+ return quality["operator_outcome"]
+ return build_operator_outcome(
+ truth_status=truth_chain.get("truth_status")
+ if isinstance(truth_chain.get("truth_status"), dict)
+ else {},
+ automation_quality=quality if isinstance(quality, dict) else {},
+ source_id=incident_id,
+ )
+ except Exception as exc:
+ logger.warning(
+ "execution_result_outcome_fetch_failed",
+ approval_id=str(approval.id),
+ incident_id=incident_id,
+ error=str(exc),
+ )
+ return build_operator_outcome(
+ truth_status=None,
+ automation_quality=None,
+ fetch_error=str(exc),
+ source_id=incident_id,
+ )
+
+ def _format_execution_result_message(
+ self,
+ *,
+ approval: ApprovalRequest,
+ success: bool,
+ error: str | None,
+ no_action: bool,
+ km_info: str,
+ outcome: dict[str, Any],
+ ) -> str:
+ state = str(outcome.get("state") or "unknown")
+ needs_human = bool(outcome.get("needs_human"))
+ if no_action:
+ icon = "ℹ️"
+ title = "已記錄觀察,未執行修復"
+ elif not success:
+ icon = "❌"
+ title = "執行失敗"
+ elif state == "completed_verified":
+ icon = "✅"
+ title = "修復完成並已驗證"
+ elif state == "diagnostic_only_manual_review":
+ icon = "⚠️"
+ title = "已記錄診斷,尚未證明修復"
+ elif needs_human:
+ icon = "⚠️"
+ title = "已執行,但需人工確認"
+ else:
+ icon = "✅"
+ title = "執行完成"
+ notification = (
+ outcome.get("notification")
+ if isinstance(outcome.get("notification"), dict)
+ else {}
+ )
+ channels = notification.get("channels") if isinstance(notification.get("channels"), list) else []
+ lines = [
+ f"{icon} 處置結果 | {html.escape(title)}",
+ f"📋 {html.escape(str(approval.incident_id or '--'))}",
+ f"🧾 Approval: {html.escape(str(approval.id))}",
+ f"🧭 結論: {html.escape(str(outcome.get('summary_zh') or '尚未形成明確結論'))}",
+ f"├ 狀態: {html.escape(str(outcome.get('state') or 'unknown'))}",
+ f"├ 人工: {'yes' if outcome.get('needs_human') else 'no'}",
+ f"├ 通知: {html.escape(','.join(str(item) for item in channels) or 'none')}",
+ f"└ 下一步: {html.escape(str(outcome.get('next_action') or '--'))}",
+ f"⚙️ Action: {html.escape((approval.action or '')[:220])}",
+ ]
+ if error:
+ lines.append(f"原因: {html.escape(str(error)[:240])}")
+ reason = outcome.get("human_action_reason")
+ if reason:
+ lines.append(f"卡點: {html.escape(str(reason)[:240])}")
+ if km_info:
+ lines.append(html.escape(km_info.strip()))
+ return "\n".join(lines)[:3900]
+
async def _get_anomaly_key_from_approval(self, approval: ApprovalRequest) -> str | None:
"""
從 approval → incident → anomaly_key。
diff --git a/apps/api/src/services/awooop_truth_chain_service.py b/apps/api/src/services/awooop_truth_chain_service.py
index d92ec39ba..5c2086ef8 100644
--- a/apps/api/src/services/awooop_truth_chain_service.py
+++ b/apps/api/src/services/awooop_truth_chain_service.py
@@ -22,6 +22,7 @@ from sqlalchemy import text
from src.core.config import settings
from src.db.base import get_db_context
+from src.services.operator_outcome import build_operator_outcome
from src.services.awooop_ansible_check_mode_service import detect_ansible_transport_blockers
from src.services.awooop_ansible_audit_service import build_ansible_truth
from src.services.drift_repeat_state import build_drift_repeat_state
@@ -152,10 +153,10 @@ def _approval_suppresses_repair_execution(approvals: list[dict[str, Any]]) -> bo
if not isinstance(metadata, dict):
continue
execution_kind = str(metadata.get("execution_kind") or "").lower()
- if metadata.get("repair_executed") is False:
- return True
if execution_kind in {"no_action", "diagnostic", "parse_failed", "unsupported_action"}:
return True
+ if metadata.get("repair_executed") is False and not execution_kind:
+ return True
return False
@@ -404,6 +405,12 @@ def _truth_status(
stage = "approval_required"
stage_status = "waiting"
needs_human = True
+ blockers.append("pending_human_approval")
+ elif "EXECUTION_FAILED" in approval_statuses and not has_execution_records:
+ stage = "execution_failed"
+ stage_status = "error"
+ needs_human = True
+ blockers.append("approval_execution_failed_without_execution_record")
elif not has_execution_records and (approval_no_action or "NO_ACTION" in approval_actions):
if approval_statuses:
stage = "manual_required"
@@ -1611,6 +1618,11 @@ async def fetch_truth_chain(source_id: str, project_id: str = "awoooi") -> dict[
km_entries=km_entries,
timeline_events=timeline_events,
)
+ automation_quality["operator_outcome"] = build_operator_outcome(
+ truth_status=truth_status,
+ automation_quality=automation_quality,
+ source_id=source_id,
+ )
result = {
"project_id": project_id,
@@ -1660,6 +1672,7 @@ async def fetch_truth_chain(source_id: str, project_id: str = "awoooi") -> dict[
"ansible": build_ansible_truth(automation_ops, incident=incident, drift=drift),
},
"automation_quality": automation_quality,
+ "operator_outcome": automation_quality["operator_outcome"],
"reconciliation": reconciliation,
"learning": {
"knowledge_entries": km_entries,
diff --git a/apps/api/src/services/operator_outcome.py b/apps/api/src/services/operator_outcome.py
new file mode 100644
index 000000000..6af37cb40
--- /dev/null
+++ b/apps/api/src/services/operator_outcome.py
@@ -0,0 +1,230 @@
+"""Operator-facing alert outcome contract.
+
+This module is intentionally generic: it converts the existing truth-chain
+stage, automation quality verdict, and remediation evidence state into one
+small contract that Telegram, AwoooP, and result notifications can all share.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+_ACTION_REQUIRED_CHANNELS = ("telegram_sre_war_room", "awooop_operator_console")
+_RESULT_ONLY_CHANNELS = ("telegram_result_reply", "awooop_operator_console")
+
+
+def _safe_int(value: Any) -> int:
+ try:
+ return int(value or 0)
+ except (TypeError, ValueError):
+ return 0
+
+
+def _first_text(values: list[Any]) -> str | None:
+ for value in values:
+ if value:
+ return str(value)
+ return None
+
+
+def _build_notification(
+ *,
+ mode: str,
+ channels: tuple[str, ...],
+ reason: str,
+ source_id: str | None,
+) -> dict[str, Any]:
+ return {
+ "mode": mode,
+ "channels": list(channels),
+ "reason": reason,
+ "source_id": source_id,
+ "telegram": (
+ "reply_to_original_or_standalone_action_required"
+ if mode == "action_required"
+ else "reply_to_original_or_standalone_result"
+ ),
+ "awooop": "status_chain_panel",
+ }
+
+
+def build_operator_outcome(
+ *,
+ truth_status: dict[str, Any] | None = None,
+ automation_quality: dict[str, Any] | None = None,
+ remediation_state: str | None = None,
+ fetch_error: str | None = None,
+ source_id: str | None = None,
+) -> dict[str, Any]:
+ """Build a normalized operator outcome for an alert/incident.
+
+ The output deliberately answers three questions:
+ 1. What happened?
+ 2. Does a human need to intervene?
+ 3. How will that human be notified / where should they act?
+ """
+ truth_status = truth_status or {}
+ automation_quality = automation_quality or {}
+ facts = automation_quality.get("facts")
+ if not isinstance(facts, dict):
+ facts = {}
+
+ verdict = str(automation_quality.get("verdict") or "unknown")
+ stage = str(truth_status.get("current_stage") or "unknown")
+ stage_status = str(truth_status.get("stage_status") or "unknown")
+ blockers = [
+ str(item)
+ for item in [
+ *(truth_status.get("blockers") if isinstance(truth_status.get("blockers"), list) else []),
+ *(automation_quality.get("blockers") if isinstance(automation_quality.get("blockers"), list) else []),
+ ]
+ if item
+ ]
+ verification = str(facts.get("verification_result") or "missing")
+ has_repair_execution = _safe_int(facts.get("effective_execution_records")) > 0 or _safe_int(
+ facts.get("auto_repair_execution_records")
+ ) > 0
+ has_nonrepair_operation = (
+ _safe_int(facts.get("automation_operation_records")) > 0
+ and not has_repair_execution
+ )
+ needs_human_from_truth = bool(truth_status.get("needs_human"))
+ first_blocker = _first_text(blockers)
+
+ if fetch_error:
+ state = "truth_chain_unavailable"
+ severity = "warning"
+ needs_human = True
+ next_action = "open_awooop_and_review_source_records"
+ summary = "真相鏈查詢失敗,需人工確認處置結果"
+ reason = str(fetch_error)[:240]
+ elif verdict == "auto_repaired_verified":
+ state = "completed_verified"
+ severity = "success"
+ needs_human = False
+ next_action = "monitor_for_regression"
+ summary = "已驗證自動修復完成"
+ reason = "execution_and_verification_succeeded"
+ elif verdict == "execution_failed" or stage == "execution_failed":
+ state = "execution_failed_manual_required"
+ severity = "critical"
+ needs_human = True
+ next_action = "manual_fix_or_rollback"
+ summary = "執行失敗,需人工介入"
+ reason = first_blocker or "execution_failed"
+ elif verdict == "manual_required_diagnostic_only" or has_nonrepair_operation:
+ state = "diagnostic_only_manual_review"
+ severity = "warning"
+ needs_human = True
+ next_action = "manual_review_or_collect_repair_evidence"
+ summary = "只完成診斷/觀察,尚未證明修復"
+ reason = first_blocker or "diagnostic_or_audit_only"
+ elif verdict == "auto_repaired_verification_degraded":
+ state = "verification_degraded_manual_required"
+ severity = "warning"
+ needs_human = True
+ next_action = "manual_verify_or_repair"
+ summary = "已執行但驗證退化,需人工確認"
+ reason = first_blocker or f"verification={verification}"
+ elif verdict == "execution_unverified" or (
+ has_repair_execution and verification == "missing"
+ ):
+ state = "execution_unverified_manual_required"
+ severity = "warning"
+ needs_human = True
+ next_action = "run_or_review_post_execution_verification"
+ summary = "已執行但缺少驗證結果,需人工確認"
+ reason = first_blocker or "execution_without_verification_result"
+ elif verdict == "manual_required_no_action":
+ state = "no_action_manual_review"
+ severity = "warning"
+ needs_human = True
+ next_action = "manual_review_no_action_decision"
+ summary = "AI 選擇不執行修復,需人工判斷是否接手"
+ reason = first_blocker or "no_action_or_observe"
+ elif remediation_state == "read_only":
+ state = "read_only_dry_run_manual_gate"
+ severity = "warning"
+ needs_human = True
+ next_action = "approve_or_escalate_from_awooop"
+ summary = "只讀試跑完成,等待人工放行或轉交"
+ reason = first_blocker or "read_only_dry_run"
+ elif remediation_state == "write_observed":
+ state = "write_observed_manual_review"
+ severity = "critical"
+ needs_human = True
+ next_action = "review_write_evidence"
+ summary = "補救證據出現寫入旗標,需人工確認"
+ reason = first_blocker or "write_observed"
+ elif remediation_state in {"blocked", "fetch_failed"}:
+ state = "blocked_manual_required"
+ severity = "critical" if remediation_state == "blocked" else "warning"
+ needs_human = True
+ next_action = "manual_investigation"
+ summary = "自動化流程受阻,需人工處理"
+ reason = first_blocker or remediation_state
+ elif verdict == "approval_required" or stage == "approval_required":
+ state = "approval_required"
+ severity = "warning"
+ needs_human = True
+ next_action = "approve_reject_or_escalate"
+ summary = "等待人工審批,尚未執行"
+ reason = first_blocker or "pending_human_approval"
+ elif needs_human_from_truth:
+ state = "manual_required"
+ severity = "warning"
+ needs_human = True
+ next_action = "manual_investigation"
+ summary = "真相鏈判定需人工介入"
+ reason = first_blocker or f"{stage}/{stage_status}"
+ elif verdict in {"observed_not_executed", "received_only"}:
+ state = "observed_not_executed"
+ severity = "info"
+ needs_human = False
+ next_action = "collect_evidence_or_wait"
+ summary = "已收到/觀測,尚未進入修復執行"
+ reason = first_blocker or verdict
+ else:
+ state = "unknown_pending_observation"
+ severity = "warning"
+ needs_human = bool(blockers)
+ next_action = "review_status_chain"
+ summary = "處置結果尚未形成明確結論"
+ reason = first_blocker or f"{verdict}:{stage}/{stage_status}"
+
+ mode = "action_required" if needs_human else "result_only"
+ channels = _ACTION_REQUIRED_CHANNELS if needs_human else _RESULT_ONLY_CHANNELS
+ return {
+ "schema_version": "operator_outcome_v1",
+ "state": state,
+ "severity": severity,
+ "summary_zh": summary,
+ "needs_human": needs_human,
+ "human_action_required": needs_human,
+ "human_action_reason": reason,
+ "next_action": next_action,
+ "notification": _build_notification(
+ mode=mode,
+ channels=channels,
+ reason=reason,
+ source_id=source_id,
+ ),
+ "evidence": {
+ "verdict": verdict,
+ "current_stage": stage,
+ "stage_status": stage_status,
+ "verification": verification,
+ "auto_repair_execution_records": _safe_int(
+ facts.get("auto_repair_execution_records")
+ ),
+ "effective_execution_records": _safe_int(
+ facts.get("effective_execution_records")
+ ),
+ "automation_operation_records": _safe_int(
+ facts.get("automation_operation_records")
+ ),
+ "mcp_gateway_total": _safe_int(facts.get("mcp_gateway_total")),
+ "knowledge_entries": _safe_int(facts.get("knowledge_entries")),
+ },
+ "blockers": blockers[:8],
+ }
diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py
index 126a23edc..9389e38ae 100644
--- a/apps/api/src/services/platform_operator_service.py
+++ b/apps/api/src/services/platform_operator_service.py
@@ -57,6 +57,7 @@ from src.services.ollama_failover_manager import (
get_ollama_failover_manager,
)
from src.services.ollama_health_monitor import HealthReport, HealthStatus
+from src.services.operator_outcome import build_operator_outcome
from src.services.run_state_machine import transition
logger = structlog.get_logger(__name__)
@@ -3581,6 +3582,20 @@ def _build_awooop_status_chain(
]
if fetch_error:
blockers.append("truth_chain_fetch_failed")
+ outcome = {}
+ if isinstance(quality.get("operator_outcome"), dict):
+ outcome = dict(quality["operator_outcome"])
+ else:
+ outcome = build_operator_outcome(
+ truth_status=truth_status,
+ automation_quality=quality,
+ remediation_state=remediation_state,
+ fetch_error=fetch_error,
+ source_id=source_id,
+ )
+ if outcome:
+ needs_human = bool(needs_human or outcome.get("needs_human"))
+ next_step = str(outcome.get("next_action") or next_step)
return {
"schema_version": "awooop_status_chain_v1",
@@ -3594,6 +3609,7 @@ def _build_awooop_status_chain(
"verification": str(verification),
"needs_human": needs_human,
"next_step": next_step,
+ "operator_outcome": outcome,
"blockers": blockers[:8],
"fetch_error": fetch_error,
"evidence": {
diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py
index ce722ff67..bb6e55573 100644
--- a/apps/api/src/services/telegram_gateway.py
+++ b/apps/api/src/services/telegram_gateway.py
@@ -39,6 +39,7 @@ from opentelemetry import trace
from src.core.config import settings
from src.core.redis_client import get_redis
+from src.services.operator_outcome import build_operator_outcome
from src.services.security_interceptor import (
NonceReplayError,
UserNotWhitelistedError,
@@ -169,6 +170,42 @@ def _format_automation_quality_lines(quality: dict[str, object] | None) -> list[
return lines
+def _operator_outcome_from_blocks(
+ *,
+ truth_status: dict[str, object] | None,
+ quality: dict[str, object] | None,
+ remediation_state: str | None = None,
+ source_id: str | None = None,
+) -> dict[str, object]:
+ if isinstance(quality, dict) and isinstance(quality.get("operator_outcome"), dict):
+ return dict(quality["operator_outcome"])
+ return build_operator_outcome(
+ truth_status=truth_status if isinstance(truth_status, dict) else {},
+ automation_quality=quality if isinstance(quality, dict) else {},
+ remediation_state=remediation_state,
+ source_id=source_id,
+ )
+
+
+def _format_operator_outcome_lines(outcome: dict[str, object] | None) -> list[str]:
+ if not outcome:
+ return []
+ notification = (
+ outcome.get("notification")
+ if isinstance(outcome.get("notification"), dict)
+ else {}
+ )
+ channels = notification.get("channels") if isinstance(notification.get("channels"), list) else []
+ return [
+ "👤 處置結論",
+ f"├ 結果:{html.escape(str(outcome.get('summary_zh') or '尚未形成明確結論'))}",
+ f"├ 狀態:{html.escape(str(outcome.get('state') or 'unknown'))}",
+ f"├ 人工:{'yes' if outcome.get('needs_human') else 'no'} | "
+ f"通知:{html.escape(','.join(str(item) for item in channels) or 'none')}",
+ f"└ 下一步:{html.escape(str(outcome.get('next_action') or '--'))}",
+ ]
+
+
def _format_remediation_history_lines(history: dict[str, object] | None) -> list[str]:
if not history or int(history.get("total") or 0) <= 0:
return []
@@ -413,6 +450,14 @@ def _format_awooop_status_chain_lines(
and repair_state != "auto_repaired_verified"
):
needs_human = True
+ outcome = _operator_outcome_from_blocks(
+ truth_status=truth_status,
+ quality=quality,
+ remediation_state=remediation_state,
+ )
+ if outcome:
+ needs_human = bool(needs_human or outcome.get("needs_human"))
+ next_step = str(outcome.get("next_action") or next_step)
lines = [
"",
@@ -450,6 +495,9 @@ def _format_awooop_status_chain_lines(
),
f"下一步: {html.escape(next_step)}",
]
+ outcome_lines = _format_operator_outcome_lines(outcome)
+ if outcome_lines:
+ lines.extend(["", *outcome_lines])
blockers = [str(item) for item in [*truth_blockers, *quality_blockers] if item]
if blockers:
@@ -1485,6 +1533,15 @@ def _callback_reply_awooop_status_chain_snapshot(
and repair_state != "auto_repaired_verified"
):
needs_human = True
+ outcome = _operator_outcome_from_blocks(
+ truth_status=truth_status,
+ quality=quality,
+ remediation_state=remediation_state,
+ source_id=incident_id,
+ )
+ if outcome:
+ needs_human = bool(needs_human or outcome.get("needs_human"))
+ next_step = str(outcome.get("next_action") or next_step)
truth_blockers = (
truth_status.get("blockers") if isinstance(truth_status.get("blockers"), list) else []
@@ -1511,6 +1568,7 @@ def _callback_reply_awooop_status_chain_snapshot(
"verification": str(verification),
"needs_human": needs_human,
"next_step": next_step,
+ "operator_outcome": outcome,
"blockers": blockers[:8],
"evidence": {
"auto_repair_records": auto_repair_records,
@@ -1782,6 +1840,13 @@ class TelegramMessage:
text = f"{self.root_cause} {self.suggested_action}".lower()
state = (self.automation_state or "").lower()
quality = self.automation_quality or {}
+ outcome = (
+ quality.get("operator_outcome")
+ if isinstance(quality.get("operator_outcome"), dict)
+ else None
+ )
+ if outcome and outcome.get("summary_zh"):
+ return str(outcome["summary_zh"])
facts = quality.get("facts") if isinstance(quality.get("facts"), dict) else {}
verdict = str(quality.get("verdict") or "")
has_repair_execution = _has_repair_execution_evidence(facts)
@@ -1828,6 +1893,25 @@ class TelegramMessage:
return "🟡 AI 已提出修復建議,等待人工批准"
return "🟡 安全閘門待審批"
+ def _operator_outcome(self) -> dict[str, object] | None:
+ quality = self.automation_quality if isinstance(self.automation_quality, dict) else {}
+ if isinstance(quality.get("operator_outcome"), dict):
+ return dict(quality["operator_outcome"])
+ if not quality:
+ return None
+ return build_operator_outcome(
+ truth_status=None,
+ automation_quality=quality,
+ remediation_state=_remediation_evidence_state(self.remediation_summary),
+ source_id=self.incident_id or self.approval_id,
+ )
+
+ def _format_operator_outcome_block(self) -> str:
+ lines = _format_operator_outcome_lines(self._operator_outcome())
+ if not lines:
+ return ""
+ return "\n".join(lines) + "\n"
+
def _format_automation_block(self) -> str:
"""Visible AI automation chain for every ACTION REQUIRED card.
2026-05-04 ogt: 加入 Token 用量 + 具體 Ollama 伺服器顯示
@@ -2080,6 +2164,7 @@ class TelegramMessage:
playbook_line = f"📖 Playbook:{html.escape(self.playbook_name)}\n"
remediation_evidence_block = self._format_remediation_evidence_block()
flow_progress_block = self._format_flow_progress_block()
+ operator_outcome_block = self._format_operator_outcome_block()
automation_block = self._format_automation_block()
# ADR-075 TYPE-3 格式組裝
@@ -2092,6 +2177,7 @@ class TelegramMessage:
f"🧭 處置狀態:{safe_automation_summary}\n"
f"{remediation_evidence_block}\n"
f"{flow_progress_block}\n"
+ f"{operator_outcome_block}"
f"{automation_block}"
f"\n"
f"🧠 AI 深度診斷\n"
diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py
index 1d4d24191..90e4cfa21 100644
--- a/apps/api/tests/test_awooop_operator_timeline_labels.py
+++ b/apps/api/tests/test_awooop_operator_timeline_labels.py
@@ -1361,6 +1361,8 @@ def test_awooop_status_chain_marks_verified_repair() -> None:
assert chain["verification"] == "healthy"
assert chain["needs_human"] is False
assert chain["next_step"] == "monitor_for_regression"
+ assert chain["operator_outcome"]["state"] == "completed_verified"
+ assert chain["operator_outcome"]["needs_human"] is False
assert chain["evidence"]["latest_route"] == "auto_repair_executor/rollout_restart/write"
assert chain["mcp"]["gateway"]["success"] == 1
assert chain["mcp"]["gateway"]["failed"] == 1
@@ -1643,6 +1645,9 @@ def test_awooop_status_chain_does_not_treat_audit_ops_as_repair() -> None:
assert chain["repair_state"] == "diagnostic_or_audit_recorded"
assert chain["next_step"] == "manual_review_or_collect_repair_evidence"
+ assert chain["needs_human"] is True
+ assert chain["operator_outcome"]["state"] == "diagnostic_only_manual_review"
+ assert chain["operator_outcome"]["notification"]["mode"] == "action_required"
assert chain["evidence"]["operation_records"] == 1
assert chain["evidence"]["auto_repair_records"] == 0
diff --git a/apps/api/tests/test_operator_outcome.py b/apps/api/tests/test_operator_outcome.py
new file mode 100644
index 000000000..3240c43ba
--- /dev/null
+++ b/apps/api/tests/test_operator_outcome.py
@@ -0,0 +1,159 @@
+from __future__ import annotations
+
+import inspect
+from types import SimpleNamespace
+from uuid import UUID
+
+from src.services.approval_execution import ApprovalExecutionService
+from src.services.operator_outcome import build_operator_outcome
+
+
+def test_operator_outcome_marks_diagnostic_only_as_manual_action_required() -> None:
+ outcome = build_operator_outcome(
+ truth_status={
+ "current_stage": "execution_succeeded",
+ "stage_status": "success",
+ "needs_human": False,
+ "blockers": [],
+ },
+ automation_quality={
+ "verdict": "auto_repaired_verification_degraded",
+ "facts": {
+ "automation_operation_records": 1,
+ "effective_execution_records": 0,
+ "auto_repair_execution_records": 0,
+ "verification_result": "degraded",
+ "mcp_gateway_total": 22,
+ "knowledge_entries": 4,
+ },
+ "blockers": ["verification_recorded"],
+ },
+ source_id="INC-20260530-88D960",
+ )
+
+ assert outcome["schema_version"] == "operator_outcome_v1"
+ assert outcome["state"] == "diagnostic_only_manual_review"
+ assert outcome["needs_human"] is True
+ assert outcome["notification"]["mode"] == "action_required"
+ assert "telegram_sre_war_room" in outcome["notification"]["channels"]
+ assert outcome["next_action"] == "manual_review_or_collect_repair_evidence"
+
+
+def test_operator_outcome_marks_unverified_execution_as_human_review() -> None:
+ outcome = build_operator_outcome(
+ truth_status={
+ "current_stage": "execution_succeeded",
+ "stage_status": "success",
+ "needs_human": False,
+ "blockers": [],
+ },
+ automation_quality={
+ "verdict": "execution_unverified",
+ "facts": {
+ "effective_execution_records": 1,
+ "auto_repair_execution_records": 0,
+ "verification_result": None,
+ },
+ "blockers": ["verification_recorded"],
+ },
+ )
+
+ assert outcome["state"] == "execution_unverified_manual_required"
+ assert outcome["needs_human"] is True
+ assert outcome["next_action"] == "run_or_review_post_execution_verification"
+
+
+def test_operator_outcome_marks_verified_repair_as_result_only() -> None:
+ outcome = build_operator_outcome(
+ truth_status={
+ "current_stage": "execution_succeeded",
+ "stage_status": "success",
+ "needs_human": False,
+ "blockers": [],
+ },
+ automation_quality={
+ "verdict": "auto_repaired_verified",
+ "facts": {
+ "effective_execution_records": 1,
+ "auto_repair_execution_records": 1,
+ "verification_result": "success",
+ },
+ "blockers": [],
+ },
+ )
+
+ assert outcome["state"] == "completed_verified"
+ assert outcome["needs_human"] is False
+ assert outcome["notification"]["mode"] == "result_only"
+
+
+def test_execution_result_message_includes_operator_outcome_and_human_channels() -> None:
+ service = ApprovalExecutionService()
+ approval = SimpleNamespace(
+ id=UUID("11111111-1111-4111-8111-111111111111"),
+ incident_id="INC-20260531-ABC123",
+ action="OBSERVE",
+ )
+ text = service._format_execution_result_message(
+ approval=approval,
+ success=True,
+ error=None,
+ no_action=True,
+ km_info="",
+ outcome=build_operator_outcome(
+ truth_status={"needs_human": True, "blockers": ["manual_gate"]},
+ automation_quality={
+ "verdict": "manual_required_no_action",
+ "facts": {},
+ "blockers": [],
+ },
+ ),
+ )
+
+ assert "處置結果" in text
+ assert "人工: yes" in text
+ assert "telegram_sre_war_room" in text
+ assert "manual_review_no_action_decision" in text
+
+
+def test_execution_result_message_does_not_call_diagnostic_success_repair_done() -> None:
+ service = ApprovalExecutionService()
+ approval = SimpleNamespace(
+ id=UUID("22222222-2222-4222-8222-222222222222"),
+ incident_id="INC-20260531-DIAG01",
+ action="ssh diagnose disk usage",
+ )
+
+ text = service._format_execution_result_message(
+ approval=approval,
+ success=True,
+ error=None,
+ no_action=False,
+ km_info="",
+ outcome=build_operator_outcome(
+ truth_status={"needs_human": False, "blockers": []},
+ automation_quality={
+ "verdict": "auto_repaired_verification_degraded",
+ "facts": {
+ "automation_operation_records": 1,
+ "effective_execution_records": 0,
+ "auto_repair_execution_records": 0,
+ "verification_result": "degraded",
+ },
+ "blockers": ["diagnostic_only"],
+ },
+ ),
+ )
+
+ assert "已記錄診斷,尚未證明修復" in text
+ assert "執行成功" not in text
+ assert "修復完成" not in text
+
+
+def test_execution_result_sender_has_standalone_fallback_when_original_card_missing() -> None:
+ source = inspect.getsource(ApprovalExecutionService._push_execution_result_to_alert)
+
+ assert "push_execution_result_no_msg_id_standalone" in source
+ assert "reply_to_message_id" in source
+ assert "delivery" in source
+ assert "standalone" in source
diff --git a/apps/api/tests/test_telegram_message_templates.py b/apps/api/tests/test_telegram_message_templates.py
index 388ae0159..7bb1c3f30 100644
--- a/apps/api/tests/test_telegram_message_templates.py
+++ b/apps/api/tests/test_telegram_message_templates.py
@@ -130,6 +130,8 @@ def test_awooop_status_chain_lines_show_verified_auto_repair_stage() -> None:
assert "auto_repair_executor/rollout_restart/write" in joined
assert "人工: no" in joined
assert "monitor_for_regression" in joined
+ assert "處置結論" in joined
+ assert "已驗證自動修復完成" in joined
def test_awooop_status_chain_lines_show_read_only_manual_gate() -> None:
@@ -206,6 +208,9 @@ def test_awooop_status_chain_lines_do_not_treat_audit_ops_as_repair() -> None:
assert "diagnostic_or_audit_recorded" in joined
assert "manual_review_or_collect_repair_evidence" in joined
assert "executed_pending_verification" not in joined
+ assert "處置結論" in joined
+ assert "只完成診斷/觀察,尚未證明修復" in joined
+ assert "通知:telegram_sre_war_room,awooop_operator_console" in joined
def test_awooop_agent_evidence_lines_show_mcp_source_execution_playbook_km() -> None:
diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
index 1f1d50ffc..8f8e8acaf 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -3656,6 +3656,11 @@
"km": "KM",
"adr100": "ADR-100 Route"
},
+ "outcome": {
+ "summary": "Outcome",
+ "notification": "Human Notification Channels",
+ "reason": "Human Reason"
+ },
"toolchain": {
"title": "AI Agent Evidence Chain",
"mcp": "MCP / Custom MCP",
diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json
index 7b32c2c68..c03cade00 100644
--- a/apps/web/messages/zh-TW.json
+++ b/apps/web/messages/zh-TW.json
@@ -3657,6 +3657,11 @@
"km": "KM",
"adr100": "ADR-100 Route"
},
+ "outcome": {
+ "summary": "處置結論",
+ "notification": "人工通知通道",
+ "reason": "人工原因"
+ },
"toolchain": {
"title": "AI Agent 證據鏈",
"mcp": "MCP / 自建 MCP",
diff --git a/apps/web/src/components/awooop/status-chain.tsx b/apps/web/src/components/awooop/status-chain.tsx
index 1af6f3288..8493204ce 100644
--- a/apps/web/src/components/awooop/status-chain.tsx
+++ b/apps/web/src/components/awooop/status-chain.tsx
@@ -17,6 +17,22 @@ export interface AwoooPStatusChain {
verification?: string | null;
needs_human?: boolean | null;
next_step?: string | null;
+ operator_outcome?: {
+ schema_version?: string;
+ state?: string | null;
+ severity?: string | null;
+ summary_zh?: string | null;
+ needs_human?: boolean | null;
+ human_action_required?: boolean | null;
+ human_action_reason?: string | null;
+ next_action?: string | null;
+ notification?: {
+ mode?: string | null;
+ channels?: string[];
+ telegram?: string | null;
+ awooop?: string | null;
+ };
+ } | null;
blockers?: string[];
fetch_error?: string | null;
evidence?: {
@@ -199,6 +215,7 @@ export function AwoooPStatusChainPanel({
const tone = toneClass(chain);
const emptyLabel = t("emptyValue");
const evidence = chain?.evidence ?? {};
+ const outcome = chain?.operator_outcome;
const blockers = chain?.blockers ?? [];
const sourceCorrelation = chain?.source_refs?.correlation;
@@ -222,7 +239,7 @@ export function AwoooPStatusChainPanel({
{ label: t("fields.stage"), value: `${valueOrEmpty(chain.current_stage, emptyLabel)} / ${valueOrEmpty(chain.stage_status, emptyLabel)}` },
{ label: t("fields.repair"), value: valueOrEmpty(chain.repair_state, emptyLabel) },
{ label: t("fields.verification"), value: valueOrEmpty(chain.verification, emptyLabel) },
- { label: t("fields.nextStep"), value: valueOrEmpty(chain.next_step, emptyLabel) },
+ { label: t("fields.nextStep"), value: valueOrEmpty(outcome?.next_action ?? chain.next_step, emptyLabel) },
];
const evidenceMetrics = [
{ label: t("evidence.autoRepair"), value: evidence.auto_repair_records ?? 0 },
@@ -457,6 +474,29 @@ export function AwoooPStatusChainPanel({
))}
+ {outcome && (
+
{t("outcome.summary")}
++ {valueOrEmpty(outcome.summary_zh, emptyLabel)} +
+{t("outcome.notification")}
++ {(outcome.notification?.channels ?? []).join(", ") || emptyLabel} +
+{t("outcome.reason")}
++ {valueOrEmpty(outcome.human_action_reason, emptyLabel)} +
+