From e8d5eafb9fcbea300f8885c32a2fa601def7de5c Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 11 Jun 2026 18:40:06 +0800 Subject: [PATCH] =?UTF-8?q?fix(api):=20=E9=80=A3=E7=B5=90=E4=BF=AE?= =?UTF-8?q?=E5=BE=A9=E5=80=99=E9=81=B8=E8=8D=89=E6=A1=88=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E9=A0=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/api/v1/webhooks.py | 22 ++++ apps/api/src/services/awooop_deeplinks.py | 17 +++ .../src/services/repair_candidate_service.py | 105 +++++++++++++++--- apps/api/src/services/telegram_gateway.py | 34 ++++++ .../tests/test_repair_candidate_service.py | 15 +++ .../test_telegram_ai_automation_block.py | 9 ++ 6 files changed, 187 insertions(+), 15 deletions(-) diff --git a/apps/api/src/api/v1/webhooks.py b/apps/api/src/api/v1/webhooks.py index 4a98f2e4..ff78fabf 100644 --- a/apps/api/src/api/v1/webhooks.py +++ b/apps/api/src/api/v1/webhooks.py @@ -599,6 +599,8 @@ async def _push_to_telegram_background( repair_candidate_blocker_summary: str = "", repair_candidate_next_step: str = "", repair_candidate_required_fields: list[str] | None = None, + repair_candidate_work_item_href: str = "", + repair_candidate_work_item_id: str = "", ) -> None: """ 背景任務: 推送待簽核卡片到 Telegram (v7.0 含 SignOz 整合) @@ -695,6 +697,8 @@ async def _push_to_telegram_background( repair_candidate_blocker_summary=repair_candidate_blocker_summary, repair_candidate_next_step=repair_candidate_next_step, repair_candidate_required_fields=repair_candidate_required_fields, + repair_candidate_work_item_href=repair_candidate_work_item_href, + repair_candidate_work_item_id=repair_candidate_work_item_id, ) logger.info( @@ -2492,6 +2496,24 @@ async def _process_new_alert_background( if isinstance(_approval_metadata_cs4.get("repair_candidate_draft_package"), dict) else [] ), + repair_candidate_work_item_href=str( + ( + _approval_metadata_cs4.get("repair_candidate_draft_package", {}) + .get("awooop_work_item", {}) + .get("work_item_url", "") + ) + if isinstance(_approval_metadata_cs4.get("repair_candidate_draft_package"), dict) + else "" + ), + repair_candidate_work_item_id=str( + ( + _approval_metadata_cs4.get("repair_candidate_draft_package", {}) + .get("awooop_work_item", {}) + .get("work_item_id", "") + ) + if isinstance(_approval_metadata_cs4.get("repair_candidate_draft_package"), dict) + else "" + ), ) except Exception as e: diff --git a/apps/api/src/services/awooop_deeplinks.py b/apps/api/src/services/awooop_deeplinks.py index 8e0fcbc8..4f0f9cdc 100644 --- a/apps/api/src/services/awooop_deeplinks.py +++ b/apps/api/src/services/awooop_deeplinks.py @@ -1,6 +1,7 @@ """Shared AwoooP operator deeplinks used by Telegram-facing services.""" from urllib.parse import quote +from urllib.parse import urlencode AWOOOP_WEB_BASE_URL = "https://awoooi.wooo.work" @@ -61,3 +62,19 @@ def incident_truth_chain_reply_markup( if not row: return None return {"inline_keyboard": [row]} + + +def work_item_url( + work_item_id: str, + *, + incident_id: str = "", + project_id: str = "awoooi", + locale: str = "zh-TW", +) -> str: + params = { + "project_id": str(project_id or "awoooi"), + "work_item_id": str(work_item_id or ""), + } + if incident_id: + params["incident_id"] = str(incident_id) + return f"{AWOOOP_WEB_BASE_URL}/{locale}/awooop/work-items?{urlencode(params)}" diff --git a/apps/api/src/services/repair_candidate_service.py b/apps/api/src/services/repair_candidate_service.py index 5264eef1..a89c7da1 100644 --- a/apps/api/src/services/repair_candidate_service.py +++ b/apps/api/src/services/repair_candidate_service.py @@ -9,6 +9,7 @@ from __future__ import annotations from dataclasses import dataclass, field from typing import Any +from urllib.parse import urlencode import structlog @@ -29,6 +30,7 @@ from src.models.playbook import RiskLevel as PlaybookRiskLevel from src.repositories.playbook_repository import get_playbook_repository from src.services.action_parser import ActionKind, parse_kubectl_action from src.services.auto_repair_service import AutoRepairService +from src.services.awooop_deeplinks import work_item_url from src.services.evidence_snapshot import EvidenceSnapshot from src.services.incident_service import get_incident_service from src.services.playbook_match_resolver import resolve_playbook_id_for_alert @@ -137,6 +139,10 @@ class RepairCandidateService: metadata=metadata, evidence=evidence, fallback_action=fallback_action, + incident=incident, + alertname=alertname, + target_resource=target_resource, + namespace=namespace, ) playbook = await self._playbook_repository.get_by_id(playbook_id) @@ -147,6 +153,10 @@ class RepairCandidateService: metadata=metadata, evidence=evidence, fallback_action=fallback_action, + incident=incident, + alertname=alertname, + target_resource=target_resource, + namespace=namespace, ) metadata["playbook_trust"] = { @@ -172,6 +182,10 @@ class RepairCandidateService: evidence=evidence, playbook=playbook, fallback_action=fallback_action, + incident=incident, + alertname=alertname, + target_resource=target_resource, + namespace=namespace, ) metadata["repair_candidate"] = { @@ -316,6 +330,10 @@ class RepairCandidateService: fallback_action: str, evidence: EvidenceSnapshot | None = None, playbook: Playbook | None = None, + incident: Incident | None = None, + alertname: str = "", + target_resource: str = "", + namespace: str = "", ) -> RepairCandidateResult: metadata["repair_candidate_status"] = "blocked" metadata["repair_candidate_blockers"] = list(dict.fromkeys(blockers)) @@ -326,6 +344,10 @@ class RepairCandidateService: blockers=metadata["repair_candidate_blockers"], playbook=playbook, evidence=evidence, + incident=incident, + alertname=alertname, + target_resource=target_resource, + namespace=namespace, ) metadata["playbook_draft_required"] = True metadata["repair_candidate_next_step"] = draft_package["next_step"] @@ -381,6 +403,10 @@ class RepairCandidateService: blockers: list[str], playbook: Playbook | None, evidence: EvidenceSnapshot | None, + incident: Incident | None, + alertname: str = "", + target_resource: str = "", + namespace: str = "", ) -> dict[str, Any]: """Describe the concrete owner-review package needed to unblock repair. @@ -438,7 +464,65 @@ class RepairCandidateService: if evidence and evidence.snapshot_id: evidence_ref = evidence.snapshot_id - return { + required_fields = [ + "alertname", + "target_selector", + "mcp_evidence_refs", + "repair_command", + "rollback_command", + "verifier_plan", + "owner_review", + ] + blocked_operations = [ + "auto_execute", + "approve_no_action_as_repair", + "generic_fallback_repair", + ] + incident_id = getattr(incident, "incident_id", "") if incident else "" + project_id = str(getattr(incident, "project_id", "awoooi") or "awoooi") + work_item: dict[str, Any] | None = None + if incident_id: + work_item_id = f"repair-candidate-draft:{project_id}:{incident_id}:{lane}" + work_item_query = urlencode({ + "project_id": project_id, + "incident_id": incident_id, + "work_item_id": work_item_id, + }) + run_query = urlencode({ + "project_id": project_id, + "incident_id": incident_id, + }) + work_item = { + "schema_version": "awooop_repair_candidate_draft_work_item_v1", + "work_item_id": work_item_id, + "kind": "repair_candidate_playbook_draft", + "status": "open", + "needs_human": True, + "project_id": project_id, + "incident_id": incident_id, + "alertname": alertname or None, + "namespace": namespace or None, + "target_resource": target_resource or None, + "lane": lane, + "reason": ",".join(blockers), + "next_step": next_step, + "required_fields": required_fields, + "blocked_operations": blocked_operations, + "target_href": f"/awooop/runs?{run_query}", + "work_item_href": f"/awooop/work-items?{work_item_query}", + "work_item_url": work_item_url( + work_item_id, + incident_id=incident_id, + project_id=project_id, + ), + "decision_effect": "none", + "safety_level": "read_only_work_item_projection", + "writes_incident_state": False, + "writes_auto_repair_result": False, + "writes_runtime_state": False, + } + + package = { "schema_version": "repair_candidate_draft_package_v1", "status": "draft_required", "lane": lane, @@ -446,21 +530,12 @@ class RepairCandidateService: "matched_playbook_id": playbook.playbook_id if playbook else None, "matched_playbook_name": playbook.name if playbook else None, "evidence_snapshot_id": evidence_ref, - "required_fields": [ - "alertname", - "target_selector", - "mcp_evidence_refs", - "repair_command", - "rollback_command", - "verifier_plan", - "owner_review", - ], - "blocked_operations": [ - "auto_execute", - "approve_no_action_as_repair", - "generic_fallback_repair", - ], + "required_fields": required_fields, + "blocked_operations": blocked_operations, } + if work_item: + package["awooop_work_item"] = work_item + return package def _build_description( self, diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py index 2ebc29ef..10d7247c 100644 --- a/apps/api/src/services/telegram_gateway.py +++ b/apps/api/src/services/telegram_gateway.py @@ -273,6 +273,8 @@ def _format_manual_handoff_package_lines( repair_candidate_blocker_summary: str = "", repair_candidate_next_step: str = "", repair_candidate_required_fields: list[str] | None = None, + repair_candidate_work_item_href: str = "", + repair_candidate_work_item_id: str = "", compact: bool = False, ) -> list[str]: """Build a safe manual handoff package for no-action / degraded alerts. @@ -323,6 +325,30 @@ def _format_manual_handoff_package_lines( insert_at, f"├ PlayBook 草案欄位:{html.escape(field_text)}", ) + insert_at += 1 + if repair_candidate_work_item_href: + work_item_label = "AwoooP 修復候選草案" + if repair_candidate_work_item_id: + work_item_label = ( + f"AwoooP 修復候選草案 " + f"({html.escape(str(repair_candidate_work_item_id)[:80])})" + ) + lines.insert( + insert_at, + ( + "├ 工作項目:" + f"" + f"{work_item_label}" + ), + ) + elif repair_candidate_work_item_id: + lines.insert( + insert_at, + ( + "├ 工作項目:" + f"{html.escape(str(repair_candidate_work_item_id)[:120])}" + ), + ) if not compact: lines.append("按鈕:處置包 看完整證據,重診 重新收集,Runs 追蹤狀態") return lines @@ -1968,6 +1994,8 @@ class TelegramMessage: repair_candidate_blocker_summary: str = "" # 修復候選阻擋原因摘要 repair_candidate_next_step: str = "" # 修復候選阻擋後的下一步 repair_candidate_required_fields: list[str] | None = None # PlayBook 草案必填欄位 + repair_candidate_work_item_href: str = "" # AwoooP 修復候選草案連結 + repair_candidate_work_item_id: str = "" # AwoooP 修復候選草案 ID # ========================================================================== # Phase 22: Nemotron 協作欄位 (ADR-044) @@ -2150,6 +2178,8 @@ class TelegramMessage: repair_candidate_blocker_summary=self.repair_candidate_blocker_summary, repair_candidate_next_step=self.repair_candidate_next_step, repair_candidate_required_fields=self.repair_candidate_required_fields, + repair_candidate_work_item_href=self.repair_candidate_work_item_href, + repair_candidate_work_item_id=self.repair_candidate_work_item_id, ) if not lines: return "" @@ -4163,6 +4193,8 @@ class TelegramGateway: repair_candidate_blocker_summary: str = "", repair_candidate_next_step: str = "", repair_candidate_required_fields: list[str] | None = None, + repair_candidate_work_item_href: str = "", + repair_candidate_work_item_id: str = "", ) -> dict: """ 推送待簽核卡片到 Telegram (v7.0 含 SignOz 整合) @@ -4272,6 +4304,8 @@ class TelegramGateway: repair_candidate_blocker_summary=repair_candidate_blocker_summary, repair_candidate_next_step=repair_candidate_next_step, repair_candidate_required_fields=repair_candidate_required_fields, + repair_candidate_work_item_href=repair_candidate_work_item_href, + repair_candidate_work_item_id=repair_candidate_work_item_id, ) # 格式化訊息 — Phase 22: 如果 Nemotron 啟用,使用雙軌格式 diff --git a/apps/api/tests/test_repair_candidate_service.py b/apps/api/tests/test_repair_candidate_service.py index 8ef9530b..5a1cab5a 100644 --- a/apps/api/tests/test_repair_candidate_service.py +++ b/apps/api/tests/test_repair_candidate_service.py @@ -229,6 +229,17 @@ async def test_candidate_blocked_when_playbook_is_generic_fallback() -> None: ) assert "建立專屬 PlayBook 草案" in result.metadata["repair_candidate_next_step"] assert "repair_command" in result.metadata["repair_candidate_draft_package"]["required_fields"] + work_item = result.metadata["repair_candidate_draft_package"]["awooop_work_item"] + assert work_item["schema_version"] == "awooop_repair_candidate_draft_work_item_v1" + assert work_item["work_item_id"].startswith( + "repair-candidate-draft:awoooi:INC-TEST-REPAIR:" + ) + assert work_item["status"] == "open" + assert work_item["needs_human"] is True + assert work_item["decision_effect"] == "none" + assert work_item["writes_runtime_state"] is False + assert "/awooop/work-items?" in work_item["work_item_href"] + assert "https://awoooi.wooo.work/zh-TW/awooop/work-items?" in work_item["work_item_url"] @pytest.mark.asyncio @@ -268,6 +279,10 @@ async def test_candidate_blocked_observe_only_prompts_repair_playbook_draft() -> "promote_diagnostic_to_repair_playbook" ) assert "診斷命令保留為 MCP evidence collector" in result.metadata["repair_candidate_next_step"] + work_item = result.metadata["repair_candidate_draft_package"]["awooop_work_item"] + assert work_item["target_resource"] == "node-exporter-188" + assert work_item["lane"] == "promote_diagnostic_to_repair_playbook" + assert work_item["safety_level"] == "read_only_work_item_projection" def test_approval_record_data_uses_preallocated_id_without_leaking_metadata() -> None: diff --git a/apps/api/tests/test_telegram_ai_automation_block.py b/apps/api/tests/test_telegram_ai_automation_block.py index 628c464e..048ddbea 100644 --- a/apps/api/tests/test_telegram_ai_automation_block.py +++ b/apps/api/tests/test_telegram_ai_automation_block.py @@ -55,6 +55,12 @@ def test_repair_candidate_missing_card_exposes_manual_handoff_package() -> None: "verifier_plan", "owner_review", ], + repair_candidate_work_item_href=( + "https://awoooi.wooo.work/zh-TW/awooop/work-items?" + "project_id=awoooi&incident_id=INC-20260611-34BBF5" + "&work_item_id=repair-candidate-draft%3Aawoooi%3AINC-20260611-34BBF5" + ), + repair_candidate_work_item_id="repair-candidate-draft:awoooi:INC-20260611-34BBF5", ) body = message.format() @@ -66,6 +72,9 @@ def test_repair_candidate_missing_card_exposes_manual_handoff_package() -> None: assert "建立專屬 PlayBook 草案" in body assert "PlayBook 草案欄位" in body assert "repair_command" in body + assert "工作項目" in body + assert "AwoooP 修復候選草案" in body + assert "https://awoooi.wooo.work/zh-TW/awooop/work-items?" in body assert "補證據:node_exporter target up" in body assert "AwoooP 建立修復候選" in body assert "按鈕:處置包" in body