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)} +

+
+
+ )} + {!compact && (
{evidenceMetrics.map((item) => ( diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 88ed23237..3d00d883a 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,47 @@ +## 2026-05-31|Telegram / AwoooP 處置結果與人工通知契約補齊 + +**背景**: + +- 使用者指出 Telegram 告警在批准後、已處理、執行中、degraded 等狀態之間仍缺少專業結論:看不出最後處置結果、是否仍需人工介入、以及人工介入要透過什麼通知方式接手。 +- Production 抽查顯示部分 approval 已有 `execution_success` 或 `execution_failed`,但 Telegram result delivery 可能是 0;若找不到原始 Telegram message id,舊邏輯會靜默放棄回報,造成 operator 只能看到「已批准 / 執行中」卻看不到終局。 +- 診斷型 operation、OBSERVE / NO_ACTION、verification degraded 與真正 repair execution 仍有顯示層混淆風險。 + +**本次調整**: + +- 新增 `operator_outcome_v1` 共用契約,統一輸出 `state`、`summary_zh`、`needs_human`、`human_action_required`、`human_action_reason`、`next_action`、`notification.channels`、`evidence`、`blockers`。 +- AwoooP truth-chain、platform status-chain、Telegram 首屏卡、Telegram callback snapshot 與前台 status-chain 全部讀同一份 operator outcome,不再各自推論處置結果。 +- approval execution completion 改為等待最終驗證、KM / AOL / incident log 寫入後才送 Telegram result;若 Redis 找不到 `tg_msg:{incident_id}`,改送 standalone 群組結果通知並記錄 `TELEGRAM_RESULT_SENT`,不再靜默失聯。 +- `OBSERVE` / `NO_ACTION` / diagnostic / parse_failed / unsupported_action 明確寫入 `execution_kind`、`repair_executed=false`、`repair_attempted=false`,避免被統計或前台誤讀為自動修復完成。 +- `PENDING / WAITING_APPROVAL`、diagnostic-only、verification degraded、execution failed、read-only dry-run、write-observed、blocked 等狀態都會明確標示是否需要人工、通知方式與下一步。 +- 前台 `/awooop` status-chain 新增處置結論區塊,顯示處置摘要、人工通知通道與人工原因;中英文 i18n 已同步。 + +**驗證**: + +```text +python3 -m py_compile apps/api/src/services/operator_outcome.py apps/api/src/services/approval_db.py apps/api/src/services/approval_execution.py apps/api/src/services/awooop_truth_chain_service.py apps/api/src/services/platform_operator_service.py apps/api/src/services/telegram_gateway.py apps/api/tests/test_operator_outcome.py apps/api/tests/test_awooop_operator_timeline_labels.py apps/api/tests/test_telegram_message_templates.py + -> pass +python3 -m json.tool apps/web/messages/zh-TW.json + -> pass +python3 -m json.tool apps/web/messages/en.json + -> pass +cd apps/api && ruff check --select E9,F401,F821,F841 src/services/operator_outcome.py src/services/approval_db.py src/services/approval_execution.py src/services/awooop_truth_chain_service.py src/services/platform_operator_service.py src/services/telegram_gateway.py tests/test_operator_outcome.py tests/test_awooop_operator_timeline_labels.py tests/test_telegram_message_templates.py + -> pass +cd apps/api && DATABASE_URL=postgresql+asyncpg://test:test@localhost:5432/test pytest tests/test_operator_outcome.py tests/test_awooop_truth_chain_service.py tests/test_awooop_operator_timeline_labels.py tests/test_telegram_message_templates.py tests/test_incident_timeline_service.py -q + -> 159 passed +pnpm --dir apps/web exec tsc --noEmit --tsBuildInfoFile /tmp/awoooi-operator-outcome-20260531.tsbuildinfo + -> pass +NEXT_PUBLIC_API_URL=https://awoooi.wooo.work pnpm --dir apps/web run build + -> pass +git diff --check + -> pass +``` + +**進度邊界**: + +- 本輪補齊「結果語意與通知契約」,不是新增更高權限的自動修復能力。 +- 歷史 incident 不會被批次重跑修復;舊資料若缺原始 Telegram message id,新版後續會送 standalone 結果通知,歷史全量補發需另開 backfill 策略避免洗版。 +- 自動化要宣稱 repair completed,仍必須有有效 repair execution 或 auto-repair execution 證據;診斷、觀察、dry-run、degraded 不再被顯示成修復完成。 + ## 2026-05-31|側邊欄 nav 全語系繁體中文收斂 **背景**: diff --git a/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md b/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md index 3c829e6f8..7aa81b833 100644 --- a/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md +++ b/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md @@ -2684,6 +2684,13 @@ Phase 6 完成後 - Verification:`py_compile` pass;targeted `ruff --select E9,F401,F821,F841` pass;`test_incident_timeline_service.py` + `test_awooop_truth_chain_service.py` + `test_telegram_ai_automation_block.py` + `test_telegram_message_templates.py` -> 101 passed;`test_awooop_operator_timeline_labels.py` covers status-chain diagnostic/audit-only repair_state。Production DB readback confirms both approvals have `repair_executed=false` plus execution/postmortem/KM backfill rows. - 判讀:可宣稱 repair 的條件是 `effective_execution_records > 0` 或 `auto_repair_execution_records > 0`,不是 operation log 數量。舊資料修正後,operator 看到「已記錄診斷/觀察」時不能再視為自動修復完成。 +**T154c Telegram / AwoooP operator outcome contract(2026-05-31 台北)**: +- 觸發:使用者指出批准後、已處理、執行中、degraded 等狀態仍缺少終局結論,operator 看不出「處理結果是什麼、是否需要人工介入、人工介入透過什麼通知方式接手」。Production 抽查也看到部分 approval 的 `telegram_result_total=0`,舊 `_push_execution_result_to_alert()` 在找不到 Redis 原始 Telegram message id 時會靜默 return。 +- 修正:新增 `operator_outcome_v1` 共用契約,集中輸出 `state`、`summary_zh`、`needs_human`、`human_action_required`、`human_action_reason`、`next_action`、`notification.channels`、`evidence`、`blockers`。AwoooP truth-chain、platform status-chain、Telegram 首屏、Telegram callback snapshot 與前台 status-chain 全部讀同一份 outcome。approval execution result 改在最終驗證、AOL / KM / incident log 寫入後送出;若缺原始 Telegram message id,改送 standalone 群組結果通知並寫 `TELEGRAM_RESULT_SENT`,不再靜默失聯。 +- 語意收斂:`OBSERVE` / `NO_ACTION` / diagnostic / parse_failed / unsupported_action 會寫入 `execution_kind`、`repair_executed=false`、`repair_attempted=false`。diagnostic-only、verification degraded、execution failed、read-only dry-run、write-observed、blocked、PENDING / WAITING_APPROVAL 都會明確標示人工需求、通知通道與下一步,不再把診斷或觀察顯示成修復完成。 +- Verification:API `py_compile` pass;i18n JSON parse pass;targeted `ruff --select E9,F401,F821,F841` pass;`test_operator_outcome.py` + `test_awooop_truth_chain_service.py` + `test_awooop_operator_timeline_labels.py` + `test_telegram_message_templates.py` + `test_incident_timeline_service.py` -> 159 passed;web `tsc --noEmit` pass;production API URL build pass;`git diff --check` pass。 +- 判讀:T154c 補的是「operator-facing disposition contract」與「result notification reliability」,不是擴權自動修復。新版後新 incident 的批准結果會落到 Telegram / DB / AwoooP 同一份 outcome;歷史全量補發需另開 backfill,避免一次洗版。 + **T152 Ansible runtime readiness surfaced(2026-05-24 台北)**: - 觸發:T151 已讓首頁看到 execution backend / Ansible attribution,但 operator 仍看不到 runtime 端缺什麼,容易把「Ansible 有候選」誤解成「Ansible 已能自動修復」。 - 修正:API image 複製 `infra/ansible/` 作 read-only catalog;`truth-chain/quality/summary` 新增 `ansible_runtime`,回報 playbook binary、catalog、inventory、playbook_count、can_run_check_mode、blockers。首頁 execution evidence 同步顯示 runtime 狀態;目前 production 顯示 `runtime 未就緒:ansible_playbook_binary_missing`。未安裝 `ansible-core`、未啟用 check-mode / apply。