feat(awooop): link remediation evidence to run timeline
This commit is contained in:
@@ -8,6 +8,7 @@ ADR-106(AwoooP Agent Platform)
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
@@ -40,6 +41,8 @@ _MAX_PER_PAGE = 200
|
||||
_MAX_EVENTS = 100
|
||||
_MAX_TIMELINE_ITEMS = 100
|
||||
_MAX_STEP_SUMMARY_CHARS = 128
|
||||
_REMEDIATION_HISTORY_LIMIT = 20
|
||||
_INCIDENT_ID_RE = re.compile(r"\bINC-\d{8}-[A-Z0-9]{4,}\b")
|
||||
|
||||
# =============================================================================
|
||||
# Tenants
|
||||
@@ -252,6 +255,165 @@ def _mcp_gateway_summary_row(row: AwoooPMcpGatewayAudit) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _as_dict(value: Any) -> dict[str, Any]:
|
||||
"""Return dict payloads defensively; DB JSON fields may be null or stale."""
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _append_unique(values: list[str], candidate: Any) -> None:
|
||||
"""Append non-empty string once while preserving discovery order."""
|
||||
text_value = str(candidate or "").strip()
|
||||
if text_value and text_value not in values:
|
||||
values.append(text_value)
|
||||
|
||||
|
||||
def _append_incident_ids_from_text(values: list[str], text_value: Any) -> None:
|
||||
"""Extract incident ids from legacy text payloads."""
|
||||
if not text_value:
|
||||
return
|
||||
for incident_id in _INCIDENT_ID_RE.findall(str(text_value)):
|
||||
_append_unique(values, incident_id)
|
||||
|
||||
|
||||
def _append_incident_ids_from_source_envelope(values: list[str], envelope: Any) -> None:
|
||||
"""Extract incident ids from AwoooP channel event source_refs."""
|
||||
source_refs = _as_dict(_as_dict(envelope).get("source_refs"))
|
||||
incident_ids = source_refs.get("incident_ids")
|
||||
if isinstance(incident_ids, list):
|
||||
for incident_id in incident_ids:
|
||||
_append_unique(values, incident_id)
|
||||
else:
|
||||
_append_unique(values, incident_ids)
|
||||
|
||||
|
||||
def _collect_run_incident_ids(
|
||||
*,
|
||||
run: AwoooPRunState,
|
||||
inbound_events: list[AwoooPConversationEvent],
|
||||
outbound_messages: list[AwoooPOutboundMessage],
|
||||
) -> list[str]:
|
||||
"""Collect incident ids that tie a Run back to legacy incident evidence."""
|
||||
incident_ids: list[str] = []
|
||||
_append_incident_ids_from_text(incident_ids, run.trigger_ref)
|
||||
_append_incident_ids_from_text(incident_ids, run.error_detail)
|
||||
|
||||
for event in inbound_events:
|
||||
_append_incident_ids_from_source_envelope(incident_ids, event.source_envelope)
|
||||
_append_incident_ids_from_text(incident_ids, event.content_preview)
|
||||
_append_incident_ids_from_text(incident_ids, event.content_redacted)
|
||||
|
||||
for message in outbound_messages:
|
||||
_append_incident_ids_from_text(incident_ids, message.content_preview)
|
||||
_append_incident_ids_from_text(incident_ids, message.send_error)
|
||||
|
||||
return incident_ids
|
||||
|
||||
|
||||
def _route_label_from_remediation(item: dict[str, Any]) -> str:
|
||||
"""Render remediation MCP route consistently with Telegram / Work Items."""
|
||||
return "/".join(
|
||||
str(part)
|
||||
for part in (
|
||||
item.get("agent_id"),
|
||||
item.get("tool_name"),
|
||||
item.get("required_scope"),
|
||||
)
|
||||
if part
|
||||
) or "--"
|
||||
|
||||
|
||||
def _remediation_timeline_status(item: dict[str, Any]) -> str:
|
||||
if item.get("success") is False or item.get("allowed") is False:
|
||||
return "failed"
|
||||
if item.get("verification_result_preview") == "success":
|
||||
return "success"
|
||||
return "warning"
|
||||
|
||||
|
||||
def _remediation_timeline_summary(item: dict[str, Any]) -> str:
|
||||
return (
|
||||
f"incident={item.get('incident_id') or '--'} "
|
||||
f"mode={item.get('mode') or '--'} "
|
||||
f"preview={item.get('verification_result_preview') or '--'} "
|
||||
f"route={_route_label_from_remediation(item)} "
|
||||
f"writes_incident={item.get('writes_incident_state')} "
|
||||
f"writes_auto_repair={item.get('writes_auto_repair_result')}"
|
||||
)[:500]
|
||||
|
||||
|
||||
def _summarize_run_remediation_by_work_item(
|
||||
items: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
summary: dict[str, dict[str, Any]] = {}
|
||||
for item in items:
|
||||
key = str(item.get("work_item_id") or item.get("incident_id") or item.get("id"))
|
||||
if key not in summary:
|
||||
summary[key] = {
|
||||
"work_item_id": item.get("work_item_id"),
|
||||
"incident_id": item.get("incident_id"),
|
||||
"count": 0,
|
||||
"latest_at": item.get("created_at"),
|
||||
"latest_preview": item.get("verification_result_preview"),
|
||||
"latest_mode": item.get("mode"),
|
||||
"latest_route": _route_label_from_remediation(item),
|
||||
}
|
||||
summary[key]["count"] += 1
|
||||
return list(summary.values())
|
||||
|
||||
|
||||
async def _fetch_run_remediation_history(
|
||||
incident_ids: list[str],
|
||||
*,
|
||||
limit: int = _REMEDIATION_HISTORY_LIMIT,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch durable ADR-100 remediation dry-run evidence linked to run incidents."""
|
||||
if not incident_ids:
|
||||
return {
|
||||
"schema_version": "awooop_run_remediation_evidence_v1",
|
||||
"source": "alert_operation_log",
|
||||
"incident_ids": [],
|
||||
"total": 0,
|
||||
"limit": limit,
|
||||
"items": [],
|
||||
"by_work_item": [],
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
from src.services.adr100_remediation_service import Adr100RemediationService
|
||||
|
||||
service = Adr100RemediationService(record_history=False)
|
||||
items: list[dict[str, Any]] = []
|
||||
errors: list[dict[str, str]] = []
|
||||
for incident_id in incident_ids:
|
||||
try:
|
||||
history = await service.history(limit=limit, incident_id=incident_id)
|
||||
items.extend(
|
||||
item
|
||||
for item in history.get("items", [])
|
||||
if isinstance(item, dict)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"run_remediation_history_fetch_failed",
|
||||
incident_id=incident_id,
|
||||
error=str(exc),
|
||||
)
|
||||
errors.append({"incident_id": incident_id, "error": str(exc)})
|
||||
|
||||
items.sort(key=lambda item: str(item.get("created_at") or ""), reverse=True)
|
||||
visible_items = items[:limit]
|
||||
return {
|
||||
"schema_version": "awooop_run_remediation_evidence_v1",
|
||||
"source": "alert_operation_log",
|
||||
"incident_ids": incident_ids,
|
||||
"total": len(items),
|
||||
"limit": limit,
|
||||
"items": visible_items,
|
||||
"by_work_item": _summarize_run_remediation_by_work_item(visible_items),
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
async def get_run_detail(
|
||||
run_id: str,
|
||||
project_id: str | None = None,
|
||||
@@ -406,6 +568,12 @@ async def get_run_detail(
|
||||
mcp_gateway_summary = _summarize_gateway_mcp([
|
||||
_mcp_gateway_summary_row(row) for row in mcp_calls
|
||||
])
|
||||
incident_ids = _collect_run_incident_ids(
|
||||
run=run,
|
||||
inbound_events=inbound_events,
|
||||
outbound_messages=outbound_messages,
|
||||
)
|
||||
remediation_history = await _fetch_run_remediation_history(incident_ids)
|
||||
|
||||
timeline: list[dict[str, Any]] = [
|
||||
_timeline_item(
|
||||
@@ -480,6 +648,26 @@ async def get_run_detail(
|
||||
},
|
||||
)
|
||||
)
|
||||
for item in remediation_history.get("items", []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
timeline.append(
|
||||
_timeline_item(
|
||||
ts=item.get("created_at"),
|
||||
kind="remediation",
|
||||
title="ADR-100 補救試跑",
|
||||
status=_remediation_timeline_status(item),
|
||||
summary=_remediation_timeline_summary(item),
|
||||
metadata={
|
||||
"incident_id": item.get("incident_id"),
|
||||
"work_item_id": item.get("work_item_id"),
|
||||
"mcp_route": _route_label_from_remediation(item),
|
||||
"writes_incident_state": item.get("writes_incident_state"),
|
||||
"writes_auto_repair_result": item.get("writes_auto_repair_result"),
|
||||
"history_source": "alert_operation_log",
|
||||
},
|
||||
)
|
||||
)
|
||||
for row in outbound_messages:
|
||||
timeline.append(
|
||||
_timeline_item(
|
||||
@@ -522,12 +710,14 @@ async def get_run_detail(
|
||||
"outbound_messages": outbound_items,
|
||||
"mcp_calls": mcp_items,
|
||||
"mcp_gateway": mcp_gateway_summary,
|
||||
"remediation_history": remediation_history,
|
||||
"timeline": timeline,
|
||||
"counts": {
|
||||
"steps": len(step_items),
|
||||
"inbound_events": len(inbound_items),
|
||||
"outbound_messages": len(outbound_items),
|
||||
"mcp_calls": len(mcp_items),
|
||||
"remediation_history": remediation_history.get("total", 0),
|
||||
"timeline": len(timeline),
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user