fix(api): 連結修復候選草案工作項
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled
CD Pipeline / post-deploy-checks (push) Has been cancelled
CD Pipeline / tests (push) Has been cancelled
Code Review / ai-code-review (push) Has been cancelled

This commit is contained in:
Your Name
2026-06-11 18:40:06 +08:00
parent f121a6e281
commit e8d5eafb9f
6 changed files with 187 additions and 15 deletions

View File

@@ -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:

View File

@@ -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)}"

View File

@@ -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,

View File

@@ -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 草案欄位:<code>{html.escape(field_text)}</code>",
)
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"<a href=\"{html.escape(str(repair_candidate_work_item_href), quote=True)}\">"
f"{work_item_label}</a>"
),
)
elif repair_candidate_work_item_id:
lines.insert(
insert_at,
(
"├ 工作項目:"
f"<code>{html.escape(str(repair_candidate_work_item_id)[:120])}</code>"
),
)
if not compact:
lines.append("按鈕:<b>處置包</b> 看完整證據,<b>重診</b> 重新收集,<b>Runs</b> 追蹤狀態")
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 啟用,使用雙軌格式

View File

@@ -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:

View File

@@ -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 "按鈕:<b>處置包</b>" in body