fix(api): 補修復候選 coverage gap 契約
Some checks failed
CD Pipeline / tests (push) Successful in 1m28s
Code Review / ai-code-review (push) Successful in 23s
CD Pipeline / post-deploy-checks (push) Has been cancelled
CD Pipeline / build-and-deploy (push) Has been cancelled

This commit is contained in:
Your Name
2026-06-11 19:47:48 +08:00
parent 8f3ec9f416
commit 1e08440cd0
4 changed files with 173 additions and 6 deletions

View File

@@ -478,6 +478,15 @@ class RepairCandidateService:
"approve_no_action_as_repair",
"generic_fallback_repair",
]
coverage_gap = self._build_coverage_gap(
blockers=blockers,
lane=lane,
alertname=alertname,
target_resource=target_resource,
namespace=namespace,
evidence=evidence,
playbook=playbook,
)
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
@@ -507,6 +516,7 @@ class RepairCandidateService:
"reason": ",".join(blockers),
"next_step": next_step,
"required_fields": required_fields,
"coverage_gap": coverage_gap,
"blocked_operations": blocked_operations,
"target_href": f"/awooop/runs?{run_query}",
"work_item_href": f"/awooop/work-items?{work_item_query}",
@@ -531,12 +541,110 @@ class RepairCandidateService:
"matched_playbook_name": playbook.name if playbook else None,
"evidence_snapshot_id": evidence_ref,
"required_fields": required_fields,
"coverage_gap": coverage_gap,
"blocked_operations": blocked_operations,
}
if work_item:
package["awooop_work_item"] = work_item
return package
def _build_coverage_gap(
self,
*,
blockers: list[str],
lane: str,
alertname: str,
target_resource: str,
namespace: str,
evidence: EvidenceSnapshot | None,
playbook: Playbook | None,
) -> dict[str, Any]:
"""Describe why this alert is not yet covered by an executable PlayBook."""
blocker_set = set(blockers)
coverage_key = self._coverage_key(alertname=alertname, target_resource=target_resource)
target_kind = self._infer_target_kind(target_resource=target_resource, namespace=namespace)
if "mcp_evidence_missing" in blocker_set:
blocking_stage = "mcp_evidence"
elif {
"playbook_not_matched",
"playbook_not_found",
"playbook_generic_fallback_not_repair",
"playbook_observe_only",
"playbook_has_no_repair_steps",
"playbook_has_no_executable_step",
} & blocker_set:
blocking_stage = "service_playbook_coverage"
elif {"playbook_not_approved", "playbook_trust_below_gate"} & blocker_set:
blocking_stage = "playbook_trust_gate"
elif "playbook_command_not_safely_routable" in blocker_set:
blocking_stage = "safe_execution_route"
else:
blocking_stage = "owner_review"
evidence_refs = [
"alertmanager_event",
"incident_timeline",
"mcp_health_snapshot",
"k8s_or_host_status",
"metrics_or_logs_window",
"recurrence_fingerprint",
]
if target_kind == "k8s_workload":
evidence_refs.extend(["k8s_events", "rollout_status"])
elif target_kind == "host_service":
evidence_refs.extend(["systemd_or_container_status", "host_resource_window"])
return {
"schema_version": "repair_candidate_coverage_gap_v1",
"coverage_key": coverage_key,
"target_kind": target_kind,
"blocking_stage": blocking_stage,
"next_owner_lane": lane,
"alertname": alertname or None,
"namespace": namespace or None,
"target_resource": target_resource or None,
"matched_playbook_id": playbook.playbook_id if playbook else None,
"evidence_snapshot_id": evidence.snapshot_id if evidence else None,
"mcp_evidence_ready": bool(evidence and evidence.sensors_succeeded > 0),
"required_mcp_evidence_refs": list(dict.fromkeys(evidence_refs)),
"playbook_template_required": True,
"playbook_template_fields": [
"symptom_pattern.alert_names",
"symptom_pattern.affected_services",
"mcp_evidence_refs",
"repair_steps.command_or_ansible_ref",
"repair_steps.rollback_command",
"verifier_plan",
"owner_review_record",
"trust_score_update_policy",
],
"blocked_operations": [
"auto_execute",
"mark_repaired_without_execution",
"approve_no_action_as_repair",
],
"runtime_execution_authorized": False,
"writes_runtime_state": False,
}
def _coverage_key(self, *, alertname: str, target_resource: str) -> str:
raw_alert = (alertname or "unknown-alert").strip().lower()
raw_target = (target_resource or "unknown-target").strip().lower()
safe_alert = "-".join(raw_alert.replace("_", "-").split())
safe_target = "-".join(raw_target.replace("_", "-").split())
return f"{safe_alert}:{safe_target}"
def _infer_target_kind(self, *, target_resource: str, namespace: str) -> str:
target = (target_resource or "").lower()
if any(marker in target for marker in ("node-exporter", "host", "188", "111", "168")):
return "host_service"
if namespace or any(marker in target for marker in ("deployment/", "pod/", "svc/", "api")):
return "k8s_workload"
if target:
return "service"
return "unknown"
def _build_description(
self,
*,