fix(api): 連結修復候選草案工作項
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)}"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 啟用,使用雙軌格式
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user