feat(awooop): link remediation evidence to run timeline
All checks were successful
Code Review / ai-code-review (push) Successful in 12s
CD Pipeline / tests (push) Successful in 1m12s
CD Pipeline / build-and-deploy (push) Successful in 4m14s
CD Pipeline / post-deploy-checks (push) Successful in 1m54s

This commit is contained in:
Your Name
2026-05-15 02:31:46 +08:00
parent 6ec424b15c
commit bc89940564
7 changed files with 610 additions and 7 deletions

View File

@@ -8,6 +8,7 @@ ADR-106AwoooP 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),
},
}