From 4e81439386d13ca6d7741cc41f74a06ed8aed5b7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 25 Jun 2026 23:19:26 +0800 Subject: [PATCH] fix(api): surface Work Item handoff in Telegram cards --- apps/api/src/services/telegram_gateway.py | 134 ++++++++++++++++-- .../test_telegram_ai_automation_block.py | 4 +- .../tests/test_telegram_message_templates.py | 47 ++++++ 3 files changed, 173 insertions(+), 12 deletions(-) diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py index efba14c5..743df2ce 100644 --- a/apps/api/src/services/telegram_gateway.py +++ b/apps/api/src/services/telegram_gateway.py @@ -40,10 +40,12 @@ from src.core.config import settings from src.core.redis_client import get_redis from src.services.awooop_ansible_audit_service import summarize_ansible_execution from src.services.awooop_deeplinks import ( + AWOOOP_WEB_BASE_URL, incident_alerts_url, incident_runs_url, incident_truth_chain_button_row, incident_truth_chain_reply_markup, + work_item_url, ) from src.services.approval_action_classifier import ( is_executable_repair_approval_action, @@ -983,6 +985,58 @@ def _format_manual_asset_ledger_lines(*, compact: bool = False) -> list[str]: ] +def _normalize_awooop_url(url: str) -> str: + raw = str(url or "").strip() + if not raw: + return "" + if raw.startswith(("https://", "http://")): + return raw + if raw.startswith("/awooop/"): + return f"{AWOOOP_WEB_BASE_URL}/zh-TW{raw}" + if raw.startswith("/"): + return f"{AWOOOP_WEB_BASE_URL}{raw}" + return raw + + +def _repair_candidate_work_item_url( + *, + incident_id: str = "", + repair_candidate_work_item_href: str = "", + repair_candidate_work_item_id: str = "", +) -> str: + normalized_href = _normalize_awooop_url(repair_candidate_work_item_href) + if normalized_href: + return normalized_href + if repair_candidate_work_item_id: + return work_item_url( + repair_candidate_work_item_id, + incident_id=incident_id, + project_id="awoooi", + ) + return "" + + +def _repair_candidate_work_item_refs_from_metadata(metadata: object) -> tuple[str, str]: + if not isinstance(metadata, dict): + return "", "" + package = metadata.get("repair_candidate_draft_package") + if not isinstance(package, dict): + package = {} + work_item = package.get("awooop_work_item") + if not isinstance(work_item, dict): + work_item = {} + href = ( + str(work_item.get("work_item_url") or "") + or str(work_item.get("work_item_href") or "") + or str(metadata.get("repair_candidate_work_item_href") or "") + ) + work_item_id = ( + str(work_item.get("work_item_id") or "") + or str(metadata.get("repair_candidate_work_item_id") or "") + ) + return href, work_item_id + + def _format_manual_handoff_package_lines( *, incident_id: str, @@ -1064,7 +1118,12 @@ def _format_manual_handoff_package_lines( f"├ PlayBook 草案欄位:{html.escape(field_text)}", ) insert_at += 1 - if repair_candidate_work_item_href: + work_item_deep_link = _repair_candidate_work_item_url( + incident_id=incident_ref, + repair_candidate_work_item_href=repair_candidate_work_item_href, + repair_candidate_work_item_id=repair_candidate_work_item_id, + ) + if work_item_deep_link: work_item_label = "AwoooP 修復候選草案" if repair_candidate_work_item_id: work_item_label = ( @@ -1075,7 +1134,7 @@ def _format_manual_handoff_package_lines( insert_at, ( "├ 工作項目:" - f"" + f"" f"{work_item_label}" ), ) @@ -1088,7 +1147,13 @@ def _format_manual_handoff_package_lines( ), ) if not compact: - lines.append("按鈕:處置包 看完整證據,重診 重新收集,Runs 追蹤狀態") + if work_item_deep_link: + lines.append( + "按鈕:Work Item 開啟 owner review," + "處置包 看完整證據,Runs 追蹤狀態" + ) + else: + lines.append("按鈕:處置包 看完整證據,重診 重新收集,Runs 追蹤狀態") return lines @@ -4854,6 +4919,8 @@ class TelegramGateway: notification_type: str = "", # 2026-04-27 Claude Sonnet 4.6: B3 — LLM 動態按鈕(ActionPlan,可選) action_plan: object = None, + repair_candidate_work_item_href: str = "", + repair_candidate_work_item_id: str = "", ) -> dict: """ 建立 Inline Keyboard @@ -4901,11 +4968,21 @@ class TelegramGateway: info_row: list[dict] = [] secondary_row: list[dict] = [] if incident_id: + work_item_deep_link = _repair_candidate_work_item_url( + incident_id=incident_id, + repair_candidate_work_item_href=repair_candidate_work_item_href, + repair_candidate_work_item_id=repair_candidate_work_item_id, + ) + if work_item_deep_link: + info_row.append({ + "text": "🧾 Work Item", + "url": work_item_deep_link, + }) info_row.extend([ {"text": "🧰 處置包", "callback_data": f"detail:{incident_id}"}, - {"text": "🔄 重診", "callback_data": f"reanalyze:{incident_id}"}, ]) secondary_row.extend([ + {"text": "🔄 重診", "callback_data": f"reanalyze:{incident_id}"}, {"text": "📊 歷史", "callback_data": f"history:{incident_id}"}, {"text": "🔕 靜默", "callback_data": silence_nonce}, ]) @@ -5369,6 +5446,8 @@ class TelegramGateway: suggested_action=suggested_action, alert_category=alert_category, notification_type=notification_type, + repair_candidate_work_item_href=repair_candidate_work_item_href, + repair_candidate_work_item_id=repair_candidate_work_item_id, ) # 發送訊息:2026-04-30 統帥指示,告警卡片完整切到 SRE 戰情室群組。 @@ -9964,6 +10043,8 @@ class TelegramGateway: username: str, execution_triggered: bool, approval_action: str | None = None, + repair_candidate_work_item_href: str = "", + repair_candidate_work_item_id: str = "", ) -> None: """ 2026-04-09 Claude Sonnet 4.6: 批准/拒絕後立即更新 Telegram 訊息狀態。 @@ -10005,7 +10086,16 @@ class TelegramGateway: ) if no_action_approval: status_emoji = "🟠" - suffix = "已轉人工處置包;請按處置包或重診補修復候選,這不是執行中" + work_item_deep_link = _repair_candidate_work_item_url( + incident_id=incident_id, + repair_candidate_work_item_href=repair_candidate_work_item_href, + repair_candidate_work_item_id=repair_candidate_work_item_id, + ) + suffix = ( + "已轉 owner review;請開 Work Item / 處置包補齊修復候選,這不是執行中" + if work_item_deep_link + else "已轉人工處置包;請按處置包或重診補修復候選,這不是執行中" + ) else: suffix = "⚡ 執行中..." if execution_triggered else "已簽核,等待更多簽核" else: @@ -10020,12 +10110,27 @@ class TelegramGateway: try: # 1. 移除批准/拒絕按鈕(只保留資訊按鈕列) if no_action_approval: - info_buttons = [[ - {"text": "🧰 處置包", "callback_data": f"detail:{incident_id}"}, - {"text": "🔄 重診", "callback_data": f"reanalyze:{incident_id}"}, - ], [ - {"text": "📊 歷史", "callback_data": f"history:{incident_id}"}, - ]] + no_action_first_row: list[dict[str, str]] = [] + work_item_deep_link = _repair_candidate_work_item_url( + incident_id=incident_id, + repair_candidate_work_item_href=repair_candidate_work_item_href, + repair_candidate_work_item_id=repair_candidate_work_item_id, + ) + if work_item_deep_link: + no_action_first_row.append({ + "text": "🧾 Work Item", + "url": work_item_deep_link, + }) + no_action_first_row.append( + {"text": "🧰 處置包", "callback_data": f"detail:{incident_id}"} + ) + info_buttons = [ + no_action_first_row, + [ + {"text": "🔄 重診", "callback_data": f"reanalyze:{incident_id}"}, + {"text": "📊 歷史", "callback_data": f"history:{incident_id}"}, + ], + ] else: info_buttons = [[ {"text": "📋 詳情", "callback_data": f"detail:{incident_id}"}, @@ -10132,6 +10237,9 @@ class TelegramGateway: # 2026-04-22 Claude Sonnet 4.6: 只有真正轉為 APPROVED 才發「執行中...」 # 非 PENDING 狀態下 sign_approval early-return → approval 是舊 record # 此時不應發「執行中...」,應告知用戶告警已處理過 + _work_item_href, _work_item_id = _repair_candidate_work_item_refs_from_metadata( + getattr(approval, "metadata", None) + ) if approval.status == ApprovalStatus.APPROVED and execution_triggered: _execution_allowed = not is_no_action_approval_action( getattr(approval, "action", None) @@ -10144,6 +10252,8 @@ class TelegramGateway: username=username, execution_triggered=execution_triggered and _execution_allowed, approval_action=getattr(approval, "action", None), + repair_candidate_work_item_href=_work_item_href, + repair_candidate_work_item_id=_work_item_id, ) elif ( approval.status == ApprovalStatus.APPROVED @@ -10156,6 +10266,8 @@ class TelegramGateway: username=username, execution_triggered=False, approval_action=getattr(approval, "action", None), + repair_candidate_work_item_href=_work_item_href, + repair_candidate_work_item_id=_work_item_id, ) else: # 告警已是 execution_failed / execution_success / rejected 等終態 diff --git a/apps/api/tests/test_telegram_ai_automation_block.py b/apps/api/tests/test_telegram_ai_automation_block.py index 9faca99d..a06aa545 100644 --- a/apps/api/tests/test_telegram_ai_automation_block.py +++ b/apps/api/tests/test_telegram_ai_automation_block.py @@ -84,7 +84,8 @@ def test_repair_candidate_missing_card_exposes_manual_handoff_package() -> None: assert "排程/監控:Observability" in body assert "Verifier:事件時間線" in body assert "不可視為自動化完成" in body - assert "按鈕:處置包" in body + assert "按鈕:Work Item 開啟 owner review" in body + assert "處置包 看完整證據" in body assert "修復候選狀態" in body assert "等待人工批准" not in body @@ -141,6 +142,7 @@ def test_repair_candidate_draft_ready_card_exposes_owner_review_handoff() -> Non assert "把診斷命令保留為 MCP evidence collector" in body assert "AwoooP 修復候選草案" in body assert "自動化資產總帳" in body + assert "按鈕:Work Item 開啟 owner review" in body assert "缺少可執行修復候選" not in body assert "等待人工批准" not in body diff --git a/apps/api/tests/test_telegram_message_templates.py b/apps/api/tests/test_telegram_message_templates.py index 3898b754..65211d42 100644 --- a/apps/api/tests/test_telegram_message_templates.py +++ b/apps/api/tests/test_telegram_message_templates.py @@ -1337,6 +1337,53 @@ async def test_build_inline_keyboard_hides_approval_for_no_action() -> None: } in buttons +@pytest.mark.asyncio +async def test_build_inline_keyboard_links_work_item_for_no_action_handoff() -> None: + """NO_ACTION 卡片要直接開 owner review Work Item,不能只丟給人工找頁面。""" + gateway = TelegramGateway() + + keyboard = await gateway._build_inline_keyboard( + approval_id="approval-no-repair-work-item", + include_auto_tuning=False, + incident_id="INC-20260625-977E5F", + suggested_action=( + "DRAFT_READY - REPAIR_CANDIDATE_OWNER_REVIEW_REQUIRED: " + "PlayBook 只有觀察或診斷步驟" + ), + repair_candidate_work_item_href=( + "/awooop/work-items?project_id=awoooi" + "&incident_id=INC-20260625-977E5F" + "&work_item_id=repair-candidate-draft%3Aawoooi%3AINC-20260625-977E5F" + ), + ) + buttons = [ + button + for row in keyboard["inline_keyboard"] + for button in row + ] + button_texts = {button["text"] for button in buttons} + + assert "✅ 批准" not in button_texts + assert "❌ 拒絕" not in button_texts + assert { + "text": "🧾 Work Item", + "url": ( + "https://awoooi.wooo.work/zh-TW/awooop/work-items" + "?project_id=awoooi&incident_id=INC-20260625-977E5F" + "&work_item_id=repair-candidate-draft%3Aawoooi%3AINC-20260625-977E5F" + ), + } in buttons + assert "🧰 處置包" in button_texts + assert "🔄 重診" in button_texts + assert { + "text": "🧭 Runs", + "url": ( + "https://awoooi.wooo.work/zh-TW/awooop/runs" + "?project_id=awoooi&incident_id=INC-20260625-977E5F" + ), + } in buttons + + @pytest.mark.asyncio async def test_send_request_strips_awooop_callback_metadata_before_telegram_api(monkeypatch): """AwoooP truth-chain metadata must be mirrored, not sent to Telegram Bot API."""