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