fix(api): surface Work Item handoff in Telegram cards
This commit is contained in:
@@ -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 等終態
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user