feat(awooop): record source correlation review decisions
This commit is contained in:
@@ -21,12 +21,14 @@ from src.services.channel_event_dossier_service import (
|
||||
RecurrenceWorkItemHandoffKind,
|
||||
RecurrenceWorkItemMode,
|
||||
RecurrenceWorkItemNotFoundError,
|
||||
SourceCorrelationReviewDecision,
|
||||
fetch_channel_event_dossier,
|
||||
fetch_channel_event_dossier_coverage,
|
||||
fetch_channel_event_dossier_recurrence,
|
||||
fetch_recurrence_work_item_dry_run,
|
||||
fetch_recurrence_work_item_handoff,
|
||||
fetch_recurrence_work_item_preview,
|
||||
fetch_source_correlation_review_decision,
|
||||
)
|
||||
from src.services.channel_hub import record_external_alert_event
|
||||
from src.services.platform_operator_service import list_recent_channel_events
|
||||
@@ -173,6 +175,7 @@ class ChannelEventRecurrenceSummary(BaseModel):
|
||||
automation_gap_group_total: int = 0
|
||||
failed_repair_group_total: int = 0
|
||||
source_correlation_review_group_total: int = 0
|
||||
source_correlation_decision_recorded_group_total: int = 0
|
||||
latest_received_at: datetime | None
|
||||
|
||||
|
||||
@@ -195,6 +198,7 @@ class ChannelEventRecurrenceItem(BaseModel):
|
||||
incident_ids: list[str] = Field(default_factory=list)
|
||||
repair_summary: dict[str, Any] | None = None
|
||||
work_item: dict[str, Any] | None = None
|
||||
source_correlation_review: dict[str, Any] | None = None
|
||||
occurrence_total: int
|
||||
duplicate_total: int
|
||||
linked_run_total: int
|
||||
@@ -237,6 +241,19 @@ class RecurrenceWorkItemHandoffRequest(BaseModel):
|
||||
limit: int = Field(default=300, ge=1, le=300)
|
||||
|
||||
|
||||
class SourceCorrelationReviewDecisionRequest(BaseModel):
|
||||
"""Record-only source evidence review decision."""
|
||||
|
||||
project_id: str | None = Field(default=None, min_length=1)
|
||||
work_item_id: str = Field(min_length=1)
|
||||
decision: SourceCorrelationReviewDecision
|
||||
target_incident_id: str | None = Field(default=None, min_length=1, max_length=30)
|
||||
reviewer_id: str = Field(default="operator_console", min_length=1, max_length=100)
|
||||
operator_note: str | None = Field(default=None, max_length=500)
|
||||
provider: str | None = Field(default=None, min_length=1)
|
||||
limit: int = Field(default=300, ge=1, le=300)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/events/dossier",
|
||||
response_model=ChannelEventDossierResponse,
|
||||
@@ -471,6 +488,36 @@ async def handoff_event_recurrence_work_item(
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/events/dossier/recurrence/source-correlation/review",
|
||||
summary="記錄來源證據與 Incident 配對審核結果",
|
||||
description=(
|
||||
"針對 source_correlation_review work item 記錄 operator 審核決定。"
|
||||
"本 API 僅寫入 alert_operation_log / 可選 timeline_events,"
|
||||
"不修改 Incident 狀態、不回寫 source event、不建立外部 ticket。"
|
||||
),
|
||||
)
|
||||
async def review_source_correlation_work_item(
|
||||
request: SourceCorrelationReviewDecisionRequest,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return await fetch_source_correlation_review_decision(
|
||||
project_id=request.project_id,
|
||||
work_item_id=request.work_item_id,
|
||||
decision=request.decision,
|
||||
target_incident_id=request.target_incident_id,
|
||||
reviewer_id=request.reviewer_id,
|
||||
operator_note=request.operator_note,
|
||||
provider=request.provider,
|
||||
limit=request.limit,
|
||||
)
|
||||
except RecurrenceWorkItemNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="recurrence_work_item_not_found",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/events/recent",
|
||||
response_model=RecentEventsResponse,
|
||||
|
||||
@@ -26,9 +26,18 @@ _MAX_REPAIR_INCIDENTS = 200
|
||||
_SOURCE_CORRELATION_REVIEW_PROVIDERS = {"sentry", "signoz"}
|
||||
_SOURCE_CORRELATION_REVIEW_EXCLUDED_STAGES = {"heartbeat"}
|
||||
_SOURCE_CORRELATION_WORK_ITEM_ID_MAX = 180
|
||||
_SOURCE_CORRELATION_DECISION_SCHEMA_VERSION = (
|
||||
"awooop_source_correlation_review_decision_v1"
|
||||
)
|
||||
_SOURCE_CORRELATION_REVIEW_ACTOR = "awooop_source_correlation_review_service"
|
||||
_INCIDENT_ID_RE = re.compile(r"\bINC-\d{8}-[A-Z0-9]{4,}\b")
|
||||
RecurrenceWorkItemMode = Literal["auto", "ticket", "reverify", "approval_review", "observe"]
|
||||
RecurrenceWorkItemHandoffKind = Literal["ticket_proposal", "manual_review"]
|
||||
SourceCorrelationReviewDecision = Literal[
|
||||
"accepted",
|
||||
"rejected",
|
||||
"needs_more_evidence",
|
||||
]
|
||||
|
||||
|
||||
class RecurrenceWorkItemNotFoundError(LookupError):
|
||||
@@ -42,9 +51,9 @@ def _as_dict(value: Any) -> dict[str, Any]:
|
||||
def _json_safe(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _json_safe(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
if isinstance(value, list | tuple | set):
|
||||
return [_json_safe(item) for item in value]
|
||||
if value is None or isinstance(value, (str, int, float, bool)):
|
||||
if value is None or isinstance(value, str | int | float | bool):
|
||||
return value
|
||||
return str(value)
|
||||
|
||||
@@ -94,6 +103,58 @@ def _source_correlation_work_item_id(group: dict[str, Any]) -> str:
|
||||
return f"source-evidence:{source_id}"[:_SOURCE_CORRELATION_WORK_ITEM_ID_MAX]
|
||||
|
||||
|
||||
def _source_correlation_review_outcome(decision: str) -> dict[str, str]:
|
||||
if decision == "accepted":
|
||||
return {
|
||||
"status": "source_correlation_accepted",
|
||||
"next_step": "verify_source_match_in_status_chain",
|
||||
"reason": "provider_native_evidence_accepted",
|
||||
"review_status": "accepted",
|
||||
}
|
||||
if decision == "rejected":
|
||||
return {
|
||||
"status": "source_correlation_rejected",
|
||||
"next_step": "monitor_for_new_provider_evidence",
|
||||
"reason": "provider_native_evidence_rejected",
|
||||
"review_status": "rejected",
|
||||
}
|
||||
return {
|
||||
"status": "source_correlation_review",
|
||||
"next_step": "collect_more_source_evidence",
|
||||
"reason": "provider_native_evidence_needs_more_evidence",
|
||||
"review_status": "needs_more_evidence",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_source_review_decision(row: dict[str, Any]) -> dict[str, Any] | None:
|
||||
context = _as_dict(row.get("context"))
|
||||
if context.get("schema_version") != _SOURCE_CORRELATION_DECISION_SCHEMA_VERSION:
|
||||
return None
|
||||
work_item_id = str(context.get("work_item_id") or "").strip()
|
||||
decision = str(context.get("decision") or "").strip()
|
||||
if not work_item_id or decision not in {
|
||||
"accepted",
|
||||
"rejected",
|
||||
"needs_more_evidence",
|
||||
}:
|
||||
return None
|
||||
|
||||
return {
|
||||
"schema_version": _SOURCE_CORRELATION_DECISION_SCHEMA_VERSION,
|
||||
"review_id": row.get("id"),
|
||||
"work_item_id": work_item_id,
|
||||
"decision": decision,
|
||||
"review_status": context.get("review_status") or decision,
|
||||
"target_incident_id": context.get("target_incident_id"),
|
||||
"reviewer_id": context.get("reviewer_id"),
|
||||
"operator_note": context.get("operator_note"),
|
||||
"latest_provider_event_id": context.get("latest_provider_event_id"),
|
||||
"latest_stage": context.get("latest_stage"),
|
||||
"recorded_at": row.get("created_at"),
|
||||
"history": context.get("history") or {},
|
||||
}
|
||||
|
||||
|
||||
def _append_unique(values: list[str], candidate: Any) -> None:
|
||||
text_value = str(candidate or "").strip()
|
||||
if text_value and text_value not in values:
|
||||
@@ -148,10 +209,12 @@ def build_dossier_recurrence(
|
||||
project_id: str,
|
||||
limit: int,
|
||||
repair_summaries_by_incident: dict[str, dict[str, Any]] | None = None,
|
||||
source_review_decisions_by_work_item: dict[str, dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Group recent source events into recurrence buckets with linked run state."""
|
||||
groups: dict[str, dict[str, Any]] = {}
|
||||
repair_summaries = repair_summaries_by_incident or {}
|
||||
source_review_decisions = source_review_decisions_by_work_item or {}
|
||||
|
||||
for row in rows:
|
||||
event = build_dossier_event(row)
|
||||
@@ -249,7 +312,11 @@ def build_dossier_recurrence(
|
||||
for group in groups.values():
|
||||
run_ids = group.pop("_run_ids")
|
||||
group["linked_run_total"] = len(run_ids)
|
||||
_attach_work_item_summary(group, repair_summaries)
|
||||
_attach_work_item_summary(
|
||||
group,
|
||||
repair_summaries,
|
||||
source_review_decisions,
|
||||
)
|
||||
linked_run_total += len(run_ids)
|
||||
items.append(group)
|
||||
|
||||
@@ -317,6 +384,9 @@ def build_dossier_recurrence(
|
||||
if _as_dict(item.get("repair_summary")).get("status")
|
||||
== "source_correlation_review"
|
||||
),
|
||||
"source_correlation_decision_recorded_group_total": sum(
|
||||
1 for item in items if item.get("source_correlation_review")
|
||||
),
|
||||
"latest_received_at": latest_received_at,
|
||||
},
|
||||
"items": items,
|
||||
@@ -355,13 +425,21 @@ def _repair_status(
|
||||
def _work_item_status(repair_status: str) -> str:
|
||||
if repair_status == "no_incident_link":
|
||||
return "none"
|
||||
if repair_status == "auto_repair_verified":
|
||||
if repair_status in {
|
||||
"auto_repair_verified",
|
||||
"source_correlation_accepted",
|
||||
"source_correlation_rejected",
|
||||
}:
|
||||
return "closed"
|
||||
return "open"
|
||||
|
||||
|
||||
def _work_item_kind(repair_status: str, auto_repair_id: Any) -> str:
|
||||
if repair_status == "source_correlation_review":
|
||||
if repair_status in {
|
||||
"source_correlation_review",
|
||||
"source_correlation_accepted",
|
||||
"source_correlation_rejected",
|
||||
}:
|
||||
return "source_correlation_review"
|
||||
if auto_repair_id:
|
||||
return "verification"
|
||||
@@ -377,6 +455,8 @@ def _work_item_kind(repair_status: str, auto_repair_id: Any) -> str:
|
||||
def _work_item_next_step(repair_status: str) -> str:
|
||||
return {
|
||||
"source_correlation_review": "review_provider_source_match",
|
||||
"source_correlation_accepted": "verify_source_match_in_status_chain",
|
||||
"source_correlation_rejected": "monitor_for_new_provider_evidence",
|
||||
"auto_repair_succeeded_unverified": "run_post_verification",
|
||||
"auto_repair_failed": "triage_failed_repair",
|
||||
"auto_repair_recorded": "review_repair_record",
|
||||
@@ -390,6 +470,8 @@ def _work_item_next_step(repair_status: str) -> str:
|
||||
def _work_item_reason(repair_status: str) -> str:
|
||||
return {
|
||||
"source_correlation_review": "provider_native_evidence_unlinked",
|
||||
"source_correlation_accepted": "provider_native_evidence_accepted",
|
||||
"source_correlation_rejected": "provider_native_evidence_rejected",
|
||||
"auto_repair_succeeded_unverified": "auto_repair_missing_verification",
|
||||
"auto_repair_failed": "auto_repair_failed",
|
||||
"auto_repair_recorded": "auto_repair_record_needs_review",
|
||||
@@ -403,6 +485,7 @@ def _work_item_reason(repair_status: str) -> str:
|
||||
def _attach_work_item_summary(
|
||||
group: dict[str, Any],
|
||||
repair_summaries_by_incident: dict[str, dict[str, Any]],
|
||||
source_review_decisions_by_work_item: dict[str, dict[str, Any]],
|
||||
) -> None:
|
||||
incident_ids = [
|
||||
str(incident_id) for incident_id in group.get("incident_ids", []) if incident_id
|
||||
@@ -450,17 +533,38 @@ def _attach_work_item_summary(
|
||||
elif status_value == "source_correlation_review" and work_status != "none":
|
||||
work_item_id = _source_correlation_work_item_id(group)
|
||||
|
||||
source_review_decision = (
|
||||
source_review_decisions_by_work_item.get(work_item_id)
|
||||
if work_item_id
|
||||
else None
|
||||
)
|
||||
matched_incident_id = None
|
||||
work_item_next_step = _work_item_next_step(status_value)
|
||||
work_item_reason = _work_item_reason(status_value)
|
||||
if source_review_decision:
|
||||
outcome = _source_correlation_review_outcome(
|
||||
str(source_review_decision.get("decision") or "")
|
||||
)
|
||||
status_value = outcome["status"]
|
||||
repair_payload["status"] = status_value
|
||||
work_status = _work_item_status(status_value)
|
||||
matched_incident_id = source_review_decision.get("target_incident_id")
|
||||
work_item_next_step = outcome["next_step"]
|
||||
work_item_reason = outcome["reason"]
|
||||
group["source_correlation_review"] = source_review_decision
|
||||
|
||||
group["latest_incident_id"] = latest_incident_id
|
||||
group["repair_summary"] = repair_payload
|
||||
group["work_item"] = {
|
||||
"schema_version": "awooop_recurrence_work_item_link_v1",
|
||||
"work_item_id": work_item_id,
|
||||
"incident_id": latest_incident_id,
|
||||
"matched_incident_id": matched_incident_id,
|
||||
"auto_repair_id": auto_repair_id,
|
||||
"status": work_status,
|
||||
"kind": _work_item_kind(status_value, auto_repair_id),
|
||||
"next_step": _work_item_next_step(status_value),
|
||||
"reason": _work_item_reason(status_value),
|
||||
"next_step": work_item_next_step,
|
||||
"reason": work_item_reason,
|
||||
"needs_human": work_status == "open",
|
||||
}
|
||||
|
||||
@@ -481,6 +585,9 @@ def _recurrence_work_item_target(item: dict[str, Any]) -> dict[str, Any]:
|
||||
"latest_run_state": item.get("latest_run_state"),
|
||||
"latest_agent_id": item.get("latest_agent_id"),
|
||||
"latest_incident_id": item.get("latest_incident_id"),
|
||||
"matched_incident_id": _as_dict(item.get("work_item")).get(
|
||||
"matched_incident_id"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -653,6 +760,8 @@ def _recurrence_current_state_summary(
|
||||
"latest_provider_event_id": item.get("latest_provider_event_id"),
|
||||
"latest_run_state": item.get("latest_run_state"),
|
||||
"latest_run_id": item.get("latest_run_id"),
|
||||
"matched_incident_id": work_item.get("matched_incident_id"),
|
||||
"source_correlation_review": item.get("source_correlation_review"),
|
||||
"repair_status": repair_summary.get("status"),
|
||||
"latest_auto_repair_id": repair_summary.get("latest_auto_repair_id"),
|
||||
"latest_verification_result": repair_summary.get("latest_verification_result"),
|
||||
@@ -1063,6 +1172,243 @@ def _recurrence_handoff_history_description(context: dict[str, Any]) -> str:
|
||||
)[:500]
|
||||
|
||||
|
||||
def _source_correlation_review_checks(
|
||||
item: dict[str, Any],
|
||||
work_item: dict[str, Any],
|
||||
*,
|
||||
decision: str,
|
||||
target_incident_id: str | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
source_ref_total = int(item.get("source_ref_total") or 0)
|
||||
is_source_review = work_item.get("kind") == "source_correlation_review"
|
||||
checks = [
|
||||
{
|
||||
"name": "source_review_work_item",
|
||||
"passed": is_source_review,
|
||||
"detail": str(work_item.get("kind") or "unknown"),
|
||||
},
|
||||
{
|
||||
"name": "known_decision",
|
||||
"passed": decision
|
||||
in {"accepted", "rejected", "needs_more_evidence"},
|
||||
"detail": decision or "unknown",
|
||||
},
|
||||
{
|
||||
"name": "source_refs_present",
|
||||
"passed": source_ref_total > 0,
|
||||
"detail": str(source_ref_total),
|
||||
},
|
||||
{
|
||||
"name": "provider_event_present",
|
||||
"passed": bool(item.get("latest_provider_event_id")),
|
||||
"detail": str(item.get("latest_provider_event_id") or "missing"),
|
||||
},
|
||||
{
|
||||
"name": "review_record_only",
|
||||
"passed": True,
|
||||
"detail": "alert_operation_log_only",
|
||||
},
|
||||
]
|
||||
if decision == "accepted":
|
||||
checks.append(
|
||||
{
|
||||
"name": "target_incident_required",
|
||||
"passed": bool(
|
||||
target_incident_id and _INCIDENT_ID_RE.search(target_incident_id)
|
||||
),
|
||||
"detail": target_incident_id or "missing target_incident_id",
|
||||
}
|
||||
)
|
||||
return checks
|
||||
|
||||
|
||||
def _source_correlation_review_context(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": _SOURCE_CORRELATION_DECISION_SCHEMA_VERSION,
|
||||
"source": payload.get("source"),
|
||||
"project_id": payload.get("project_id"),
|
||||
"work_item_id": payload.get("work_item_id"),
|
||||
"decision": payload.get("decision"),
|
||||
"review_status": payload.get("review_status"),
|
||||
"target_incident_id": payload.get("target_incident_id"),
|
||||
"reviewer_id": payload.get("reviewer_id"),
|
||||
"operator_note": payload.get("operator_note"),
|
||||
"latest_provider_event_id": payload.get("latest_provider_event_id"),
|
||||
"latest_stage": payload.get("latest_stage"),
|
||||
"provider": payload.get("provider"),
|
||||
"alertname": payload.get("alertname"),
|
||||
"namespace": payload.get("namespace"),
|
||||
"target_resource": payload.get("target_resource"),
|
||||
"safety_level": payload.get("safety_level"),
|
||||
"writes_incident_state": payload.get("writes_incident_state"),
|
||||
"writes_source_event": payload.get("writes_source_event"),
|
||||
"writes_auto_repair_result": payload.get("writes_auto_repair_result"),
|
||||
"writes_ticket": payload.get("writes_ticket"),
|
||||
"creates_external_ticket": payload.get("creates_external_ticket"),
|
||||
"checks": payload.get("checks"),
|
||||
"current_state_summary": payload.get("current_state_summary"),
|
||||
"plan": payload.get("plan"),
|
||||
"read_model_route": payload.get("read_model_route"),
|
||||
"next_step": payload.get("next_step"),
|
||||
"history": payload.get("history"),
|
||||
}
|
||||
|
||||
|
||||
def _source_correlation_review_description(context: dict[str, Any]) -> str:
|
||||
return (
|
||||
f"decision={context.get('decision')} "
|
||||
f"work_item={context.get('work_item_id')} "
|
||||
f"provider_event={context.get('latest_provider_event_id')} "
|
||||
f"target_incident={context.get('target_incident_id') or '--'} "
|
||||
f"writes_incident={context.get('writes_incident_state')} "
|
||||
f"writes_source_event={context.get('writes_source_event')}"
|
||||
)[:500]
|
||||
|
||||
|
||||
def build_source_correlation_review_decision(
|
||||
recurrence: dict[str, Any],
|
||||
*,
|
||||
work_item_id: str,
|
||||
decision: SourceCorrelationReviewDecision,
|
||||
target_incident_id: str | None = None,
|
||||
reviewer_id: str = "operator_console",
|
||||
operator_note: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a record-only source-correlation review decision payload."""
|
||||
|
||||
item, work_item = _find_recurrence_work_item(recurrence, work_item_id)
|
||||
safe_decision = str(decision)
|
||||
target_id = str(target_incident_id or "").strip() or None
|
||||
checks = _source_correlation_review_checks(
|
||||
item,
|
||||
work_item,
|
||||
decision=safe_decision,
|
||||
target_incident_id=target_id,
|
||||
)
|
||||
allowed = all(check["passed"] for check in checks)
|
||||
outcome = _source_correlation_review_outcome(safe_decision)
|
||||
writes_timeline = bool(target_id and safe_decision == "accepted")
|
||||
|
||||
payload = {
|
||||
"schema_version": _SOURCE_CORRELATION_DECISION_SCHEMA_VERSION,
|
||||
"source": "channel_event_dossier.recurrence",
|
||||
"project_id": recurrence.get("project_id"),
|
||||
"work_item_id": work_item.get("work_item_id"),
|
||||
"incident_id": work_item.get("incident_id"),
|
||||
"target_incident_id": target_id,
|
||||
"decision": safe_decision,
|
||||
"review_status": outcome["review_status"],
|
||||
"reviewer_id": str(reviewer_id or "operator_console")[:100],
|
||||
"operator_note": str(operator_note or "").strip()[:500] or None,
|
||||
"allowed": allowed,
|
||||
"executed": allowed,
|
||||
"safety_level": "source_review_record_only",
|
||||
"writes_incident_state": False,
|
||||
"writes_source_event": False,
|
||||
"writes_auto_repair_result": False,
|
||||
"writes_ticket": False,
|
||||
"creates_external_ticket": False,
|
||||
"latest_provider_event_id": item.get("latest_provider_event_id"),
|
||||
"latest_stage": item.get("latest_stage"),
|
||||
"provider": item.get("provider"),
|
||||
"alertname": item.get("alertname"),
|
||||
"namespace": item.get("namespace"),
|
||||
"target_resource": item.get("target_resource"),
|
||||
"checks": checks,
|
||||
"current_state_summary": _recurrence_current_state_summary(item, work_item),
|
||||
"plan": {
|
||||
"step": "record_source_correlation_review_decision",
|
||||
"flywheel_node": "source_correlation_review",
|
||||
"agent_id": "awooop_source_correlation_reviewer",
|
||||
"required_scope": "record_history",
|
||||
"writes": ["alert_operation_log", *(["timeline_events"] if writes_timeline else [])],
|
||||
"target_action": outcome["next_step"],
|
||||
"reason": outcome["reason"],
|
||||
"target": _recurrence_work_item_target(item),
|
||||
},
|
||||
"read_model_route": {
|
||||
"agent_id": "awooop_source_correlation_reviewer",
|
||||
"tool_name": "channel_event_dossier.recurrence",
|
||||
"required_scope": "record_history",
|
||||
"is_shadow": False,
|
||||
"flywheel_node": "source_correlation_review",
|
||||
},
|
||||
"next_step": outcome["next_step"] if allowed else "fix_preflight_checks",
|
||||
}
|
||||
if not allowed:
|
||||
payload["executed"] = False
|
||||
return payload
|
||||
|
||||
|
||||
async def _record_source_correlation_review_decision_history(
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if not payload.get("allowed"):
|
||||
return {"recorded": False, "reason": "preflight_failed"}
|
||||
|
||||
incident_id = str(payload.get("target_incident_id") or "") or None
|
||||
history: dict[str, Any] = {
|
||||
"recorded": False,
|
||||
"alert_operation_id": None,
|
||||
"timeline_event_id": None,
|
||||
}
|
||||
context = _source_correlation_review_context(payload)
|
||||
|
||||
try:
|
||||
from src.repositories.alert_operation_log_repository import (
|
||||
get_alert_operation_log_repository,
|
||||
)
|
||||
|
||||
record = await get_alert_operation_log_repository().append(
|
||||
"USER_ACTION",
|
||||
incident_id=incident_id,
|
||||
actor=_SOURCE_CORRELATION_REVIEW_ACTOR,
|
||||
action_detail=f"source_correlation_review:{payload.get('decision')}"[:200],
|
||||
success=True,
|
||||
context=_json_safe(context),
|
||||
)
|
||||
if record is not None:
|
||||
history["alert_operation_id"] = getattr(record, "id", None)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"awooop_source_correlation_review_alert_operation_failed",
|
||||
work_item_id=payload.get("work_item_id"),
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
if incident_id and payload.get("decision") == "accepted":
|
||||
try:
|
||||
from src.services.approval_db import get_timeline_service
|
||||
|
||||
event = await get_timeline_service().add_event(
|
||||
event_type="human",
|
||||
status="success",
|
||||
title="AwoooP source correlation review",
|
||||
description=_source_correlation_review_description(context),
|
||||
actor=_SOURCE_CORRELATION_REVIEW_ACTOR,
|
||||
actor_role="source_correlation_review",
|
||||
incident_id=incident_id,
|
||||
)
|
||||
if event:
|
||||
history["timeline_event_id"] = event.get("id")
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"awooop_source_correlation_review_timeline_failed",
|
||||
incident_id=incident_id,
|
||||
work_item_id=payload.get("work_item_id"),
|
||||
error=str(exc),
|
||||
)
|
||||
else:
|
||||
history["timeline_reason"] = "review_not_incident_timeline_scoped"
|
||||
|
||||
history["recorded"] = bool(
|
||||
history.get("alert_operation_id") or history.get("timeline_event_id")
|
||||
)
|
||||
if not history["recorded"]:
|
||||
history["reason"] = "history_sink_unavailable"
|
||||
return history
|
||||
|
||||
|
||||
def build_dossier_coverage(
|
||||
rows: list[dict[str, Any]],
|
||||
*,
|
||||
@@ -1293,6 +1639,48 @@ async def _fetch_auto_repair_summaries_by_incident(
|
||||
return summaries
|
||||
|
||||
|
||||
async def _fetch_source_review_decisions_by_work_item(
|
||||
db: Any,
|
||||
*,
|
||||
project_id: str,
|
||||
limit: int,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Fetch latest source-correlation review decisions from event-sourced audit."""
|
||||
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT DISTINCT ON (context->>'work_item_id')
|
||||
id,
|
||||
incident_id,
|
||||
actor,
|
||||
action_detail,
|
||||
success,
|
||||
context,
|
||||
created_at
|
||||
FROM alert_operation_log
|
||||
WHERE actor = :actor
|
||||
AND context->>'schema_version' = :schema_version
|
||||
AND COALESCE(context->>'project_id', :project_id) = :project_id
|
||||
ORDER BY context->>'work_item_id', created_at DESC
|
||||
LIMIT :limit
|
||||
"""),
|
||||
{
|
||||
"actor": _SOURCE_CORRELATION_REVIEW_ACTOR,
|
||||
"schema_version": _SOURCE_CORRELATION_DECISION_SCHEMA_VERSION,
|
||||
"project_id": project_id,
|
||||
"limit": max(1, min(limit, _MAX_RECURRENCE_EVENTS)),
|
||||
},
|
||||
)
|
||||
|
||||
decisions: dict[str, dict[str, Any]] = {}
|
||||
for row in result.mappings().all():
|
||||
item = _normalize_source_review_decision(dict(row))
|
||||
if not item:
|
||||
continue
|
||||
decisions[str(item["work_item_id"])] = item
|
||||
return decisions
|
||||
|
||||
|
||||
async def fetch_channel_event_dossier(
|
||||
*,
|
||||
project_id: str | None,
|
||||
@@ -1479,12 +1867,18 @@ async def fetch_channel_event_dossier_recurrence(
|
||||
db,
|
||||
_collect_incident_ids_from_rows(rows),
|
||||
)
|
||||
source_review_decisions = await _fetch_source_review_decisions_by_work_item(
|
||||
db,
|
||||
project_id=effective_project_id,
|
||||
limit=safe_limit,
|
||||
)
|
||||
|
||||
return build_dossier_recurrence(
|
||||
rows,
|
||||
project_id=effective_project_id,
|
||||
limit=safe_limit,
|
||||
repair_summaries_by_incident=repair_summaries,
|
||||
source_review_decisions_by_work_item=source_review_decisions,
|
||||
)
|
||||
|
||||
|
||||
@@ -1562,3 +1956,41 @@ async def fetch_recurrence_work_item_handoff(
|
||||
elif payload.get("allowed"):
|
||||
payload["handoff_status"] = "record_failed"
|
||||
return payload
|
||||
|
||||
|
||||
async def fetch_source_correlation_review_decision(
|
||||
*,
|
||||
project_id: str | None,
|
||||
work_item_id: str,
|
||||
decision: SourceCorrelationReviewDecision,
|
||||
target_incident_id: str | None = None,
|
||||
reviewer_id: str = "operator_console",
|
||||
operator_note: str | None = None,
|
||||
provider: str | None = None,
|
||||
limit: int = _MAX_RECURRENCE_EVENTS,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch recurrence state and record an explicit source review decision."""
|
||||
|
||||
recurrence = await fetch_channel_event_dossier_recurrence(
|
||||
project_id=project_id,
|
||||
provider=provider,
|
||||
limit=limit,
|
||||
)
|
||||
payload = build_source_correlation_review_decision(
|
||||
recurrence,
|
||||
work_item_id=work_item_id,
|
||||
decision=decision,
|
||||
target_incident_id=target_incident_id,
|
||||
reviewer_id=reviewer_id,
|
||||
operator_note=operator_note,
|
||||
)
|
||||
payload["history"] = await _record_source_correlation_review_decision_history(
|
||||
payload
|
||||
)
|
||||
if payload["history"].get("recorded"):
|
||||
payload["review_record_status"] = "recorded"
|
||||
elif payload.get("allowed"):
|
||||
payload["review_record_status"] = "record_failed"
|
||||
else:
|
||||
payload["review_record_status"] = "blocked"
|
||||
return payload
|
||||
|
||||
@@ -15,6 +15,7 @@ from src.services.channel_event_dossier_service import (
|
||||
build_recurrence_work_item_dry_run,
|
||||
build_recurrence_work_item_handoff,
|
||||
build_recurrence_work_item_preview,
|
||||
build_source_correlation_review_decision,
|
||||
fetch_channel_event_dossier,
|
||||
fetch_channel_event_dossier_coverage,
|
||||
fetch_channel_event_dossier_recurrence,
|
||||
@@ -268,6 +269,7 @@ def test_build_dossier_recurrence_groups_events_and_run_state() -> None:
|
||||
assert recurrence["summary"]["automation_gap_group_total"] == 0
|
||||
assert recurrence["summary"]["failed_repair_group_total"] == 1
|
||||
assert recurrence["summary"]["source_correlation_review_group_total"] == 1
|
||||
assert recurrence["summary"]["source_correlation_decision_recorded_group_total"] == 0
|
||||
|
||||
host_group = recurrence["items"][0]
|
||||
assert host_group["recurrence_key"] == "fingerprint:fp-host-disk"
|
||||
@@ -287,6 +289,7 @@ def test_build_dossier_recurrence_groups_events_and_run_state() -> None:
|
||||
"schema_version": "awooop_recurrence_work_item_link_v1",
|
||||
"work_item_id": "verification:INC-20260513-ABCD:repair-1",
|
||||
"incident_id": "INC-20260513-ABCD",
|
||||
"matched_incident_id": None,
|
||||
"auto_repair_id": "repair-1",
|
||||
"status": "open",
|
||||
"kind": "verification",
|
||||
@@ -304,6 +307,7 @@ def test_build_dossier_recurrence_groups_events_and_run_state() -> None:
|
||||
"schema_version": "awooop_recurrence_work_item_link_v1",
|
||||
"work_item_id": "source-evidence:sentry:received:issue-1",
|
||||
"incident_id": None,
|
||||
"matched_incident_id": None,
|
||||
"auto_repair_id": None,
|
||||
"status": "open",
|
||||
"kind": "source_correlation_review",
|
||||
@@ -355,6 +359,7 @@ def test_build_recurrence_work_item_preview_allows_source_correlation_review() -
|
||||
item = recurrence["items"][0]
|
||||
work_item_id = "source-evidence:signoz:upstream_canary:canary-1"
|
||||
assert recurrence["summary"]["source_correlation_review_group_total"] == 1
|
||||
assert recurrence["summary"]["source_correlation_decision_recorded_group_total"] == 0
|
||||
assert recurrence["summary"]["open_work_item_group_total"] == 1
|
||||
assert item["latest_stage"] == "upstream_canary"
|
||||
assert item["repair_summary"]["status"] == "source_correlation_review"
|
||||
@@ -386,6 +391,124 @@ def test_build_recurrence_work_item_preview_allows_source_correlation_review() -
|
||||
)
|
||||
|
||||
|
||||
def test_source_correlation_review_decision_records_accepted_audit_contract() -> None:
|
||||
recurrence = build_dossier_recurrence(
|
||||
[
|
||||
{
|
||||
"event_id": "event-1",
|
||||
"project_id": "awoooi",
|
||||
"channel_type": "internal",
|
||||
"provider_event_id": "sentry:received:issue-1",
|
||||
"content_hash": "a" * 64,
|
||||
"content_preview": "Sentry issue",
|
||||
"content_redacted": "Sentry issue",
|
||||
"redaction_version": "audit_sink_v1",
|
||||
"source_envelope": {
|
||||
"provider": "sentry",
|
||||
"stage": "received",
|
||||
"source_refs": {
|
||||
"sentry_issue_ids": ["issue-1"],
|
||||
"alert_ids": ["sentry:received:issue-1"],
|
||||
},
|
||||
"log_correlation": {
|
||||
"alertname": "Sentry Issue",
|
||||
"severity": "error",
|
||||
"namespace": "awoooi-prod",
|
||||
"target_resource": "web",
|
||||
"fingerprint": "fp-sentry-issue-1",
|
||||
},
|
||||
},
|
||||
"is_duplicate": False,
|
||||
"provider_ts": None,
|
||||
"received_at": "2026-05-20T13:10:00",
|
||||
"run_id": None,
|
||||
"run_state": None,
|
||||
"run_agent_id": None,
|
||||
}
|
||||
],
|
||||
project_id="awoooi",
|
||||
limit=20,
|
||||
)
|
||||
|
||||
decision = build_source_correlation_review_decision(
|
||||
recurrence,
|
||||
work_item_id="source-evidence:sentry:received:issue-1",
|
||||
decision="accepted",
|
||||
target_incident_id="INC-20260520-ABC123",
|
||||
reviewer_id="operator_console",
|
||||
operator_note="matches current frontend incident",
|
||||
)
|
||||
|
||||
assert decision["schema_version"] == "awooop_source_correlation_review_decision_v1"
|
||||
assert decision["allowed"] is True
|
||||
assert decision["executed"] is True
|
||||
assert decision["decision"] == "accepted"
|
||||
assert decision["review_status"] == "accepted"
|
||||
assert decision["target_incident_id"] == "INC-20260520-ABC123"
|
||||
assert decision["writes_incident_state"] is False
|
||||
assert decision["writes_source_event"] is False
|
||||
assert decision["writes_auto_repair_result"] is False
|
||||
assert decision["writes_ticket"] is False
|
||||
assert decision["creates_external_ticket"] is False
|
||||
assert decision["plan"]["step"] == "record_source_correlation_review_decision"
|
||||
assert decision["plan"]["writes"] == ["alert_operation_log", "timeline_events"]
|
||||
assert decision["next_step"] == "verify_source_match_in_status_chain"
|
||||
|
||||
|
||||
def test_build_dossier_recurrence_closes_source_review_after_decision() -> None:
|
||||
decision = {
|
||||
"schema_version": "awooop_source_correlation_review_decision_v1",
|
||||
"review_id": "review-1",
|
||||
"work_item_id": "source-evidence:sentry:received:issue-1",
|
||||
"decision": "accepted",
|
||||
"review_status": "accepted",
|
||||
"target_incident_id": "INC-20260520-ABC123",
|
||||
"reviewer_id": "operator_console",
|
||||
"latest_provider_event_id": "sentry:received:issue-1",
|
||||
"recorded_at": "2026-05-20T13:11:00",
|
||||
}
|
||||
recurrence = build_dossier_recurrence(
|
||||
[
|
||||
{
|
||||
"event_id": "event-1",
|
||||
"project_id": "awoooi",
|
||||
"channel_type": "internal",
|
||||
"provider_event_id": "sentry:received:issue-1",
|
||||
"content_hash": "a" * 64,
|
||||
"content_preview": "Sentry issue",
|
||||
"content_redacted": "Sentry issue",
|
||||
"redaction_version": "audit_sink_v1",
|
||||
"source_envelope": {
|
||||
"provider": "sentry",
|
||||
"stage": "received",
|
||||
"source_refs": {"sentry_issue_ids": ["issue-1"]},
|
||||
},
|
||||
"is_duplicate": False,
|
||||
"provider_ts": None,
|
||||
"received_at": "2026-05-20T13:10:00",
|
||||
"run_id": None,
|
||||
"run_state": None,
|
||||
"run_agent_id": None,
|
||||
}
|
||||
],
|
||||
project_id="awoooi",
|
||||
limit=20,
|
||||
source_review_decisions_by_work_item={
|
||||
"source-evidence:sentry:received:issue-1": decision,
|
||||
},
|
||||
)
|
||||
|
||||
item = recurrence["items"][0]
|
||||
assert recurrence["summary"]["open_work_item_group_total"] == 0
|
||||
assert recurrence["summary"]["source_correlation_review_group_total"] == 0
|
||||
assert recurrence["summary"]["source_correlation_decision_recorded_group_total"] == 1
|
||||
assert item["source_correlation_review"] == decision
|
||||
assert item["repair_summary"]["status"] == "source_correlation_accepted"
|
||||
assert item["work_item"]["status"] == "closed"
|
||||
assert item["work_item"]["matched_incident_id"] == "INC-20260520-ABC123"
|
||||
assert item["work_item"]["next_step"] == "verify_source_match_in_status_chain"
|
||||
|
||||
|
||||
def test_build_dossier_recurrence_opens_work_item_for_completed_run_without_repair() -> None:
|
||||
recurrence = build_dossier_recurrence(
|
||||
[
|
||||
@@ -433,6 +556,7 @@ def test_build_dossier_recurrence_opens_work_item_for_completed_run_without_repa
|
||||
"schema_version": "awooop_recurrence_work_item_link_v1",
|
||||
"work_item_id": "incident:INC-20260517-F25B4A",
|
||||
"incident_id": "INC-20260517-F25B4A",
|
||||
"matched_incident_id": None,
|
||||
"auto_repair_id": None,
|
||||
"status": "open",
|
||||
"kind": "automation_gap",
|
||||
@@ -852,7 +976,7 @@ async def test_fetch_channel_event_dossier_uses_typed_run_filter(monkeypatch) ->
|
||||
async def test_fetch_channel_event_dossier_coverage_uses_typed_provider_filter(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
captured: list[dict[str, object]] = []
|
||||
|
||||
class FakeMappings:
|
||||
def all(self) -> list[dict[str, object]]:
|
||||
@@ -864,8 +988,7 @@ async def test_fetch_channel_event_dossier_coverage_uses_typed_provider_filter(
|
||||
|
||||
class FakeDb:
|
||||
async def execute(self, statement, params): # noqa: ANN001
|
||||
captured["sql"] = str(statement)
|
||||
captured["params"] = params
|
||||
captured.append({"sql": str(statement), "params": params})
|
||||
return FakeResult()
|
||||
|
||||
class FakeContext:
|
||||
@@ -889,8 +1012,9 @@ async def test_fetch_channel_event_dossier_coverage_uses_typed_provider_filter(
|
||||
|
||||
assert result["project_id"] == "awoooi"
|
||||
assert result["limit"] == 200
|
||||
assert "source_envelope->>'provider'" in str(captured["sql"])
|
||||
assert captured["params"] == {
|
||||
coverage_query = captured[0]
|
||||
assert "source_envelope->>'provider'" in str(coverage_query["sql"])
|
||||
assert coverage_query["params"] == {
|
||||
"project_id": "awoooi",
|
||||
"provider": "sentry",
|
||||
"limit": 200,
|
||||
@@ -901,7 +1025,7 @@ async def test_fetch_channel_event_dossier_coverage_uses_typed_provider_filter(
|
||||
async def test_fetch_channel_event_dossier_recurrence_uses_joined_typed_filter(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
captured: list[dict[str, object]] = []
|
||||
|
||||
class FakeMappings:
|
||||
def all(self) -> list[dict[str, object]]:
|
||||
@@ -913,8 +1037,7 @@ async def test_fetch_channel_event_dossier_recurrence_uses_joined_typed_filter(
|
||||
|
||||
class FakeDb:
|
||||
async def execute(self, statement, params): # noqa: ANN001
|
||||
captured["sql"] = str(statement)
|
||||
captured["params"] = params
|
||||
captured.append({"sql": str(statement), "params": params})
|
||||
return FakeResult()
|
||||
|
||||
class FakeContext:
|
||||
@@ -938,10 +1061,11 @@ async def test_fetch_channel_event_dossier_recurrence_uses_joined_typed_filter(
|
||||
|
||||
assert result["project_id"] == "awoooi"
|
||||
assert result["limit"] == 300
|
||||
assert "LEFT JOIN awooop_run_state r" in str(captured["sql"])
|
||||
assert "e.source_envelope->>'provider'" in str(captured["sql"])
|
||||
assert ":provider IS NULL" not in str(captured["sql"])
|
||||
assert captured["params"] == {
|
||||
recurrence_query = captured[0]
|
||||
assert "LEFT JOIN awooop_run_state r" in str(recurrence_query["sql"])
|
||||
assert "e.source_envelope->>'provider'" in str(recurrence_query["sql"])
|
||||
assert ":provider IS NULL" not in str(recurrence_query["sql"])
|
||||
assert recurrence_query["params"] == {
|
||||
"project_id": "awoooi",
|
||||
"provider": "alertmanager",
|
||||
"limit": 300,
|
||||
|
||||
Reference in New Issue
Block a user