fix(api): surface Work Item handoff in Telegram cards
All checks were successful
Code Review / ai-code-review (push) Successful in 15s
CD Pipeline / tests (push) Successful in 1m37s
CD Pipeline / build-and-deploy (push) Successful in 4m49s
CD Pipeline / post-deploy-checks (push) Successful in 1m34s

This commit is contained in:
Your Name
2026-06-25 23:19:26 +08:00
parent 558762a307
commit 4e81439386
3 changed files with 173 additions and 12 deletions

View File

@@ -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 草案欄位:<code>{html.escape(field_text)}</code>",
)
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"<a href=\"{html.escape(str(repair_candidate_work_item_href), quote=True)}\">"
f"<a href=\"{html.escape(str(work_item_deep_link), quote=True)}\">"
f"{work_item_label}</a>"
),
)
@@ -1088,7 +1147,13 @@ def _format_manual_handoff_package_lines(
),
)
if not compact:
lines.append("按鈕:<b>處置包</b> 看完整證據,<b>重診</b> 重新收集,<b>Runs</b> 追蹤狀態")
if work_item_deep_link:
lines.append(
"按鈕:<b>Work Item</b> 開啟 owner review"
"<b>處置包</b> 看完整證據,<b>Runs</b> 追蹤狀態"
)
else:
lines.append("按鈕:<b>處置包</b> 看完整證據,<b>重診</b> 重新收集,<b>Runs</b> 追蹤狀態")
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 等終態

View File

@@ -84,7 +84,8 @@ def test_repair_candidate_missing_card_exposes_manual_handoff_package() -> None:
assert "排程/監控:<code>Observability</code>" in body
assert "Verifier<code>事件時間線</code>" in body
assert "不可視為自動化完成" in body
assert "按鈕:<b>處置包</b>" in body
assert "按鈕:<b>Work Item</b> 開啟 owner review" in body
assert "<b>處置包</b> 看完整證據" 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 "按鈕:<b>Work Item</b> 開啟 owner review" in body
assert "缺少可執行修復候選" not in body
assert "等待人工批准" not in body

View File

@@ -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."""