feat(awooop): apply source correlation links
This commit is contained in:
@@ -28,6 +28,7 @@ from src.services.channel_event_dossier_service import (
|
||||
fetch_recurrence_work_item_dry_run,
|
||||
fetch_recurrence_work_item_handoff,
|
||||
fetch_recurrence_work_item_preview,
|
||||
fetch_source_correlation_apply,
|
||||
fetch_source_correlation_review_decision,
|
||||
)
|
||||
from src.services.channel_hub import record_external_alert_event
|
||||
@@ -176,6 +177,7 @@ class ChannelEventRecurrenceSummary(BaseModel):
|
||||
failed_repair_group_total: int = 0
|
||||
source_correlation_review_group_total: int = 0
|
||||
source_correlation_decision_recorded_group_total: int = 0
|
||||
source_correlation_applied_group_total: int = 0
|
||||
latest_received_at: datetime | None
|
||||
|
||||
|
||||
@@ -199,6 +201,7 @@ class ChannelEventRecurrenceItem(BaseModel):
|
||||
repair_summary: dict[str, Any] | None = None
|
||||
work_item: dict[str, Any] | None = None
|
||||
source_correlation_review: dict[str, Any] | None = None
|
||||
source_correlation_apply: dict[str, Any] | None = None
|
||||
occurrence_total: int
|
||||
duplicate_total: int
|
||||
linked_run_total: int
|
||||
@@ -254,6 +257,17 @@ class SourceCorrelationReviewDecisionRequest(BaseModel):
|
||||
limit: int = Field(default=300, ge=1, le=300)
|
||||
|
||||
|
||||
class SourceCorrelationApplyRequest(BaseModel):
|
||||
"""Append-only source evidence link apply request."""
|
||||
|
||||
project_id: str | None = Field(default=None, min_length=1)
|
||||
work_item_id: str = Field(min_length=1)
|
||||
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,
|
||||
@@ -518,6 +532,35 @@ async def review_source_correlation_work_item(
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/events/dossier/recurrence/source-correlation/apply",
|
||||
summary="套用已確認的來源證據與 Incident 配對",
|
||||
description=(
|
||||
"只接受已寫入 accepted review 的 source_correlation_review work item。"
|
||||
"成功時以 append-only 方式新增 source_correlation_linked 來源事件,"
|
||||
"並寫入 alert_operation_log / timeline_events。"
|
||||
"不修改 Incident 狀態、不修改 auto-repair 結果、不建立外部 ticket。"
|
||||
),
|
||||
)
|
||||
async def apply_source_correlation_work_item(
|
||||
request: SourceCorrelationApplyRequest,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return await fetch_source_correlation_apply(
|
||||
project_id=request.project_id,
|
||||
work_item_id=request.work_item_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,
|
||||
|
||||
@@ -29,7 +29,10 @@ _SOURCE_CORRELATION_WORK_ITEM_ID_MAX = 180
|
||||
_SOURCE_CORRELATION_DECISION_SCHEMA_VERSION = (
|
||||
"awooop_source_correlation_review_decision_v1"
|
||||
)
|
||||
_SOURCE_CORRELATION_APPLY_SCHEMA_VERSION = "awooop_source_correlation_apply_v1"
|
||||
_SOURCE_CORRELATION_APPLY_STAGE = "source_correlation_linked"
|
||||
_SOURCE_CORRELATION_REVIEW_ACTOR = "awooop_source_correlation_review_service"
|
||||
_SOURCE_CORRELATION_APPLY_ACTOR = "awooop_source_correlation_apply_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"]
|
||||
@@ -155,6 +158,43 @@ def _normalize_source_review_decision(row: dict[str, Any]) -> dict[str, Any] | N
|
||||
}
|
||||
|
||||
|
||||
def _normalize_source_apply(row: dict[str, Any]) -> dict[str, Any] | None:
|
||||
context = _as_dict(row.get("context"))
|
||||
if context.get("schema_version") != _SOURCE_CORRELATION_APPLY_SCHEMA_VERSION:
|
||||
return None
|
||||
work_item_id = str(context.get("work_item_id") or "").strip()
|
||||
target_incident_id = str(context.get("target_incident_id") or "").strip()
|
||||
if not work_item_id or not target_incident_id:
|
||||
return None
|
||||
|
||||
history = _as_dict(context.get("history"))
|
||||
return {
|
||||
"schema_version": _SOURCE_CORRELATION_APPLY_SCHEMA_VERSION,
|
||||
"apply_id": row.get("id"),
|
||||
"work_item_id": work_item_id,
|
||||
"apply_status": context.get("apply_status") or "applied",
|
||||
"target_incident_id": target_incident_id,
|
||||
"review_id": context.get("review_id"),
|
||||
"reviewer_id": context.get("reviewer_id"),
|
||||
"operator_note": context.get("operator_note"),
|
||||
"latest_provider_event_id": context.get("latest_provider_event_id"),
|
||||
"source_event_provider_event_id": context.get(
|
||||
"source_event_provider_event_id"
|
||||
),
|
||||
"source_event_id": history.get("source_event_id"),
|
||||
"timeline_event_id": history.get("timeline_event_id"),
|
||||
"recorded_at": row.get("created_at"),
|
||||
"history": history,
|
||||
}
|
||||
|
||||
|
||||
def _provider_raw_event_id(provider_event_id: Any) -> str:
|
||||
parts = str(provider_event_id or "").split(":", 2)
|
||||
if len(parts) == 3 and parts[2].strip():
|
||||
return parts[2].strip()
|
||||
return str(provider_event_id or "unknown").strip() or "unknown"
|
||||
|
||||
|
||||
def _append_unique(values: list[str], candidate: Any) -> None:
|
||||
text_value = str(candidate or "").strip()
|
||||
if text_value and text_value not in values:
|
||||
@@ -210,11 +250,13 @@ def build_dossier_recurrence(
|
||||
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,
|
||||
source_applies_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 {}
|
||||
source_applies = source_applies_by_work_item or {}
|
||||
|
||||
for row in rows:
|
||||
event = build_dossier_event(row)
|
||||
@@ -258,6 +300,7 @@ def build_dossier_recurrence(
|
||||
"first_received_at": received_at,
|
||||
"latest_received_at": received_at,
|
||||
"_run_ids": set(),
|
||||
"_source_correlation_work_item_ids": [],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -271,6 +314,20 @@ def build_dossier_recurrence(
|
||||
stage = str(event.get("stage") or "received")
|
||||
stage_counts = group["stage_counts"]
|
||||
stage_counts[stage] = int(stage_counts.get(stage, 0)) + 1
|
||||
if (
|
||||
str(event.get("provider") or "").lower()
|
||||
in _SOURCE_CORRELATION_REVIEW_PROVIDERS
|
||||
and stage.lower() not in _SOURCE_CORRELATION_REVIEW_EXCLUDED_STAGES
|
||||
and source_ref_count > 0
|
||||
and not incident_ids
|
||||
):
|
||||
_append_unique(
|
||||
group["_source_correlation_work_item_ids"],
|
||||
_source_correlation_work_item_id({
|
||||
"latest_provider_event_id": event.get("provider_event_id"),
|
||||
"recurrence_key": key,
|
||||
}),
|
||||
)
|
||||
|
||||
for incident_id in incident_ids:
|
||||
_append_unique(group["incident_ids"], incident_id)
|
||||
@@ -316,6 +373,7 @@ def build_dossier_recurrence(
|
||||
group,
|
||||
repair_summaries,
|
||||
source_review_decisions,
|
||||
source_applies,
|
||||
)
|
||||
linked_run_total += len(run_ids)
|
||||
items.append(group)
|
||||
@@ -387,6 +445,9 @@ def build_dossier_recurrence(
|
||||
"source_correlation_decision_recorded_group_total": sum(
|
||||
1 for item in items if item.get("source_correlation_review")
|
||||
),
|
||||
"source_correlation_applied_group_total": sum(
|
||||
1 for item in items if item.get("source_correlation_apply")
|
||||
),
|
||||
"latest_received_at": latest_received_at,
|
||||
},
|
||||
"items": items,
|
||||
@@ -457,6 +518,7 @@ def _work_item_next_step(repair_status: str) -> str:
|
||||
"source_correlation_review": "review_provider_source_match",
|
||||
"source_correlation_accepted": "verify_source_match_in_status_chain",
|
||||
"source_correlation_rejected": "monitor_for_new_provider_evidence",
|
||||
"source_correlation_applied": "verify_source_link_in_status_chain",
|
||||
"auto_repair_succeeded_unverified": "run_post_verification",
|
||||
"auto_repair_failed": "triage_failed_repair",
|
||||
"auto_repair_recorded": "review_repair_record",
|
||||
@@ -472,6 +534,7 @@ def _work_item_reason(repair_status: str) -> str:
|
||||
"source_correlation_review": "provider_native_evidence_unlinked",
|
||||
"source_correlation_accepted": "provider_native_evidence_accepted",
|
||||
"source_correlation_rejected": "provider_native_evidence_rejected",
|
||||
"source_correlation_applied": "provider_native_evidence_link_applied",
|
||||
"auto_repair_succeeded_unverified": "auto_repair_missing_verification",
|
||||
"auto_repair_failed": "auto_repair_failed",
|
||||
"auto_repair_recorded": "auto_repair_record_needs_review",
|
||||
@@ -486,6 +549,7 @@ 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]],
|
||||
source_applies_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
|
||||
@@ -533,10 +597,31 @@ 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
|
||||
source_work_item_ids = [
|
||||
str(candidate)
|
||||
for candidate in group.pop("_source_correlation_work_item_ids", [])
|
||||
if candidate
|
||||
]
|
||||
candidate_work_item_ids = [
|
||||
candidate
|
||||
for candidate in [work_item_id, *source_work_item_ids]
|
||||
if candidate
|
||||
]
|
||||
source_review_decision = next(
|
||||
(
|
||||
source_review_decisions_by_work_item[candidate]
|
||||
for candidate in candidate_work_item_ids
|
||||
if candidate in source_review_decisions_by_work_item
|
||||
),
|
||||
None,
|
||||
)
|
||||
source_apply = next(
|
||||
(
|
||||
source_applies_by_work_item[candidate]
|
||||
for candidate in candidate_work_item_ids
|
||||
if candidate in source_applies_by_work_item
|
||||
),
|
||||
None,
|
||||
)
|
||||
matched_incident_id = None
|
||||
work_item_next_step = _work_item_next_step(status_value)
|
||||
@@ -552,6 +637,15 @@ def _attach_work_item_summary(
|
||||
work_item_next_step = outcome["next_step"]
|
||||
work_item_reason = outcome["reason"]
|
||||
group["source_correlation_review"] = source_review_decision
|
||||
if source_apply:
|
||||
matched_incident_id = source_apply.get("target_incident_id")
|
||||
group["source_correlation_apply"] = source_apply
|
||||
if status_value in {
|
||||
"source_correlation_review",
|
||||
"source_correlation_accepted",
|
||||
}:
|
||||
work_item_next_step = "verify_source_link_in_status_chain"
|
||||
work_item_reason = "provider_native_evidence_link_applied"
|
||||
|
||||
group["latest_incident_id"] = latest_incident_id
|
||||
group["repair_summary"] = repair_payload
|
||||
@@ -762,6 +856,7 @@ def _recurrence_current_state_summary(
|
||||
"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"),
|
||||
"source_correlation_apply": item.get("source_correlation_apply"),
|
||||
"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"),
|
||||
@@ -1409,6 +1504,314 @@ async def _record_source_correlation_review_decision_history(
|
||||
return history
|
||||
|
||||
|
||||
def _source_correlation_apply_checks(
|
||||
item: dict[str, Any],
|
||||
work_item: dict[str, Any],
|
||||
source_review: dict[str, Any],
|
||||
*,
|
||||
target_incident_id: str | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
provider = str(item.get("provider") or "").lower()
|
||||
source_ref_total = int(item.get("source_ref_total") or 0)
|
||||
return [
|
||||
{
|
||||
"name": "source_review_work_item",
|
||||
"passed": work_item.get("kind") == "source_correlation_review",
|
||||
"detail": str(work_item.get("kind") or "unknown"),
|
||||
},
|
||||
{
|
||||
"name": "accepted_review_recorded",
|
||||
"passed": source_review.get("decision") == "accepted",
|
||||
"detail": str(source_review.get("decision") or "missing"),
|
||||
},
|
||||
{
|
||||
"name": "target_incident_present",
|
||||
"passed": bool(target_incident_id),
|
||||
"detail": target_incident_id or "missing target_incident_id",
|
||||
},
|
||||
{
|
||||
"name": "provider_supported",
|
||||
"passed": provider in _SOURCE_CORRELATION_REVIEW_PROVIDERS,
|
||||
"detail": provider 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": "append_only_source_link",
|
||||
"passed": True,
|
||||
"detail": "awooop_conversation_event_append_only",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _source_correlation_apply_context(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": _SOURCE_CORRELATION_APPLY_SCHEMA_VERSION,
|
||||
"source": payload.get("source"),
|
||||
"project_id": payload.get("project_id"),
|
||||
"work_item_id": payload.get("work_item_id"),
|
||||
"review_id": payload.get("review_id"),
|
||||
"apply_status": payload.get("apply_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"),
|
||||
"source_event_provider_event_id": payload.get(
|
||||
"source_event_provider_event_id"
|
||||
),
|
||||
"source_event_stage": payload.get("source_event_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_apply_description(context: dict[str, Any]) -> str:
|
||||
return (
|
||||
f"work_item={context.get('work_item_id')} "
|
||||
f"provider_event={context.get('latest_provider_event_id')} "
|
||||
f"source_event={context.get('source_event_provider_event_id')} "
|
||||
f"target_incident={context.get('target_incident_id')} "
|
||||
f"writes_source_event={context.get('writes_source_event')} "
|
||||
f"writes_incident={context.get('writes_incident_state')}"
|
||||
)[:500]
|
||||
|
||||
|
||||
def build_source_correlation_apply(
|
||||
recurrence: dict[str, Any],
|
||||
*,
|
||||
work_item_id: str,
|
||||
reviewer_id: str = "operator_console",
|
||||
operator_note: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build an append-only source link apply payload for an accepted review."""
|
||||
|
||||
item, work_item = _find_recurrence_work_item(recurrence, work_item_id)
|
||||
source_review = _as_dict(item.get("source_correlation_review"))
|
||||
target_id = (
|
||||
str(
|
||||
source_review.get("target_incident_id")
|
||||
or work_item.get("matched_incident_id")
|
||||
or ""
|
||||
).strip()
|
||||
or None
|
||||
)
|
||||
checks = _source_correlation_apply_checks(
|
||||
item,
|
||||
work_item,
|
||||
source_review,
|
||||
target_incident_id=target_id,
|
||||
)
|
||||
allowed = all(check["passed"] for check in checks)
|
||||
raw_event_id = _provider_raw_event_id(item.get("latest_provider_event_id"))
|
||||
provider = str(item.get("provider") or "external").strip().lower() or "external"
|
||||
source_event_provider_event_id = (
|
||||
f"{provider}:{_SOURCE_CORRELATION_APPLY_STAGE}:{raw_event_id[:96]}"
|
||||
)
|
||||
|
||||
payload = {
|
||||
"schema_version": _SOURCE_CORRELATION_APPLY_SCHEMA_VERSION,
|
||||
"source": "channel_event_dossier.recurrence",
|
||||
"project_id": recurrence.get("project_id"),
|
||||
"work_item_id": work_item.get("work_item_id"),
|
||||
"review_id": source_review.get("review_id"),
|
||||
"target_incident_id": target_id,
|
||||
"reviewer_id": str(reviewer_id or "operator_console")[:100],
|
||||
"operator_note": str(operator_note or "").strip()[:500] or None,
|
||||
"allowed": allowed,
|
||||
"executed": allowed,
|
||||
"apply_status": "ready_to_apply" if allowed else "blocked",
|
||||
"safety_level": "source_review_append_only_apply",
|
||||
"writes_incident_state": False,
|
||||
"writes_source_event": allowed,
|
||||
"writes_auto_repair_result": False,
|
||||
"writes_ticket": False,
|
||||
"creates_external_ticket": False,
|
||||
"latest_provider_event_id": item.get("latest_provider_event_id"),
|
||||
"source_event_provider_event_id": source_event_provider_event_id,
|
||||
"source_event_stage": _SOURCE_CORRELATION_APPLY_STAGE,
|
||||
"raw_event_id": raw_event_id,
|
||||
"provider": provider,
|
||||
"alertname": item.get("alertname"),
|
||||
"severity": item.get("severity"),
|
||||
"namespace": item.get("namespace"),
|
||||
"target_resource": item.get("target_resource"),
|
||||
"fingerprint": item.get("fingerprint"),
|
||||
"checks": checks,
|
||||
"current_state_summary": _recurrence_current_state_summary(item, work_item),
|
||||
"plan": {
|
||||
"step": "append_source_correlation_link_event",
|
||||
"flywheel_node": "source_correlation_apply",
|
||||
"agent_id": "awooop_source_correlation_reviewer",
|
||||
"required_scope": "append_source_link",
|
||||
"writes": (
|
||||
["awooop_conversation_event", "timeline_events", "alert_operation_log"]
|
||||
if allowed
|
||||
else []
|
||||
),
|
||||
"target_action": "verify_source_link_in_status_chain",
|
||||
"reason": (
|
||||
"provider_native_evidence_link_applied"
|
||||
if allowed
|
||||
else "source_correlation_apply_preflight_failed"
|
||||
),
|
||||
"target": _recurrence_work_item_target(item),
|
||||
},
|
||||
"read_model_route": {
|
||||
"agent_id": "awooop_source_correlation_reviewer",
|
||||
"tool_name": "channel_event_dossier.recurrence",
|
||||
"required_scope": "append_source_link",
|
||||
"is_shadow": False,
|
||||
"flywheel_node": "source_correlation_apply",
|
||||
},
|
||||
"next_step": (
|
||||
"verify_source_link_in_status_chain"
|
||||
if allowed
|
||||
else "fix_preflight_checks"
|
||||
),
|
||||
}
|
||||
if not allowed:
|
||||
payload["executed"] = False
|
||||
return payload
|
||||
|
||||
|
||||
async def _record_source_correlation_apply_history(
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if not payload.get("allowed"):
|
||||
return {"recorded": False, "reason": "preflight_failed"}
|
||||
|
||||
history: dict[str, Any] = {
|
||||
"recorded": False,
|
||||
"source_event_id": None,
|
||||
"alert_operation_id": None,
|
||||
"timeline_event_id": None,
|
||||
}
|
||||
incident_id = str(payload.get("target_incident_id") or "")
|
||||
|
||||
try:
|
||||
from src.services.channel_hub import record_external_alert_event
|
||||
|
||||
event_id = await record_external_alert_event(
|
||||
project_id=str(payload.get("project_id") or "awoooi"),
|
||||
provider=str(payload.get("provider") or "external"),
|
||||
event_id=str(payload.get("raw_event_id") or payload.get("work_item_id")),
|
||||
stage=_SOURCE_CORRELATION_APPLY_STAGE,
|
||||
title=str(payload.get("alertname") or "Source correlation link"),
|
||||
severity=str(payload.get("severity") or "info"),
|
||||
namespace=payload.get("namespace"),
|
||||
target_resource=payload.get("target_resource"),
|
||||
fingerprint=payload.get("fingerprint"),
|
||||
incident_id=incident_id,
|
||||
source_url=None,
|
||||
labels={
|
||||
"awooop_source_correlation": "applied",
|
||||
"work_item_id": payload.get("work_item_id"),
|
||||
},
|
||||
annotations={
|
||||
"review_id": payload.get("review_id") or "",
|
||||
"original_provider_event_id": payload.get("latest_provider_event_id")
|
||||
or "",
|
||||
},
|
||||
payload={
|
||||
"schema_version": _SOURCE_CORRELATION_APPLY_SCHEMA_VERSION,
|
||||
"work_item_id": payload.get("work_item_id"),
|
||||
"review_id": payload.get("review_id"),
|
||||
"target_incident_id": incident_id,
|
||||
},
|
||||
is_duplicate=False,
|
||||
)
|
||||
if event_id:
|
||||
history["source_event_id"] = str(event_id)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"awooop_source_correlation_apply_source_event_failed",
|
||||
work_item_id=payload.get("work_item_id"),
|
||||
incident_id=incident_id,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
context = _source_correlation_apply_context({**payload, "history": history})
|
||||
try:
|
||||
from src.services.approval_db import get_timeline_service
|
||||
|
||||
event = await get_timeline_service().add_event(
|
||||
event_type="human",
|
||||
status="success" if history.get("source_event_id") else "warning",
|
||||
title="AwoooP source correlation apply",
|
||||
description=_source_correlation_apply_description(context),
|
||||
actor=_SOURCE_CORRELATION_APPLY_ACTOR,
|
||||
actor_role="source_correlation_apply",
|
||||
incident_id=incident_id,
|
||||
)
|
||||
if event:
|
||||
history["timeline_event_id"] = event.get("id")
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"awooop_source_correlation_apply_timeline_failed",
|
||||
incident_id=incident_id,
|
||||
work_item_id=payload.get("work_item_id"),
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
try:
|
||||
from src.repositories.alert_operation_log_repository import (
|
||||
get_alert_operation_log_repository,
|
||||
)
|
||||
|
||||
final_context = _source_correlation_apply_context({
|
||||
**payload,
|
||||
"apply_status": "applied" if history.get("source_event_id") else "partial",
|
||||
"history": history,
|
||||
})
|
||||
record = await get_alert_operation_log_repository().append(
|
||||
"USER_ACTION",
|
||||
incident_id=incident_id,
|
||||
actor=_SOURCE_CORRELATION_APPLY_ACTOR,
|
||||
action_detail="source_correlation_apply:append_source_link",
|
||||
success=bool(history.get("source_event_id")),
|
||||
context=_json_safe(final_context),
|
||||
)
|
||||
if record is not None:
|
||||
history["alert_operation_id"] = getattr(record, "id", None)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"awooop_source_correlation_apply_alert_operation_failed",
|
||||
work_item_id=payload.get("work_item_id"),
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
history["recorded"] = bool(
|
||||
history.get("source_event_id")
|
||||
or 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]],
|
||||
*,
|
||||
@@ -1681,6 +2084,48 @@ async def _fetch_source_review_decisions_by_work_item(
|
||||
return decisions
|
||||
|
||||
|
||||
async def _fetch_source_applies_by_work_item(
|
||||
db: Any,
|
||||
*,
|
||||
project_id: str,
|
||||
limit: int,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Fetch latest source-correlation apply records 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_APPLY_ACTOR,
|
||||
"schema_version": _SOURCE_CORRELATION_APPLY_SCHEMA_VERSION,
|
||||
"project_id": project_id,
|
||||
"limit": max(1, min(limit, _MAX_RECURRENCE_EVENTS)),
|
||||
},
|
||||
)
|
||||
|
||||
applies: dict[str, dict[str, Any]] = {}
|
||||
for row in result.mappings().all():
|
||||
item = _normalize_source_apply(dict(row))
|
||||
if not item:
|
||||
continue
|
||||
applies[str(item["work_item_id"])] = item
|
||||
return applies
|
||||
|
||||
|
||||
async def fetch_channel_event_dossier(
|
||||
*,
|
||||
project_id: str | None,
|
||||
@@ -1872,6 +2317,11 @@ async def fetch_channel_event_dossier_recurrence(
|
||||
project_id=effective_project_id,
|
||||
limit=safe_limit,
|
||||
)
|
||||
source_applies = await _fetch_source_applies_by_work_item(
|
||||
db,
|
||||
project_id=effective_project_id,
|
||||
limit=safe_limit,
|
||||
)
|
||||
|
||||
return build_dossier_recurrence(
|
||||
rows,
|
||||
@@ -1879,6 +2329,7 @@ async def fetch_channel_event_dossier_recurrence(
|
||||
limit=safe_limit,
|
||||
repair_summaries_by_incident=repair_summaries,
|
||||
source_review_decisions_by_work_item=source_review_decisions,
|
||||
source_applies_by_work_item=source_applies,
|
||||
)
|
||||
|
||||
|
||||
@@ -1994,3 +2445,37 @@ async def fetch_source_correlation_review_decision(
|
||||
else:
|
||||
payload["review_record_status"] = "blocked"
|
||||
return payload
|
||||
|
||||
|
||||
async def fetch_source_correlation_apply(
|
||||
*,
|
||||
project_id: str | None,
|
||||
work_item_id: str,
|
||||
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 append an accepted source review link event."""
|
||||
|
||||
recurrence = await fetch_channel_event_dossier_recurrence(
|
||||
project_id=project_id,
|
||||
provider=provider,
|
||||
limit=limit,
|
||||
)
|
||||
payload = build_source_correlation_apply(
|
||||
recurrence,
|
||||
work_item_id=work_item_id,
|
||||
reviewer_id=reviewer_id,
|
||||
operator_note=operator_note,
|
||||
)
|
||||
payload["history"] = await _record_source_correlation_apply_history(payload)
|
||||
if payload["history"].get("source_event_id"):
|
||||
payload["apply_status"] = "applied"
|
||||
elif payload["history"].get("recorded") and payload.get("allowed"):
|
||||
payload["apply_status"] = "partial"
|
||||
elif payload.get("allowed"):
|
||||
payload["apply_status"] = "record_failed"
|
||||
else:
|
||||
payload["apply_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_apply,
|
||||
build_source_correlation_review_decision,
|
||||
fetch_channel_event_dossier,
|
||||
fetch_channel_event_dossier_coverage,
|
||||
@@ -509,6 +510,178 @@ def test_build_dossier_recurrence_closes_source_review_after_decision() -> None:
|
||||
assert item["work_item"]["next_step"] == "verify_source_match_in_status_chain"
|
||||
|
||||
|
||||
def test_source_correlation_apply_requires_accepted_review_and_plans_source_link() -> None:
|
||||
accepted_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"],
|
||||
"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,
|
||||
source_review_decisions_by_work_item={
|
||||
"source-evidence:sentry:received:issue-1": accepted_decision,
|
||||
},
|
||||
)
|
||||
|
||||
apply_payload = build_source_correlation_apply(
|
||||
recurrence,
|
||||
work_item_id="source-evidence:sentry:received:issue-1",
|
||||
)
|
||||
|
||||
assert apply_payload["schema_version"] == "awooop_source_correlation_apply_v1"
|
||||
assert apply_payload["allowed"] is True
|
||||
assert apply_payload["apply_status"] == "ready_to_apply"
|
||||
assert apply_payload["target_incident_id"] == "INC-20260520-ABC123"
|
||||
assert apply_payload["writes_source_event"] is True
|
||||
assert apply_payload["writes_incident_state"] is False
|
||||
assert apply_payload["writes_auto_repair_result"] is False
|
||||
assert apply_payload["writes_ticket"] is False
|
||||
assert apply_payload["source_event_stage"] == "source_correlation_linked"
|
||||
assert apply_payload["source_event_provider_event_id"] == (
|
||||
"sentry:source_correlation_linked:issue-1"
|
||||
)
|
||||
assert apply_payload["plan"]["writes"] == [
|
||||
"awooop_conversation_event",
|
||||
"timeline_events",
|
||||
"alert_operation_log",
|
||||
]
|
||||
assert apply_payload["next_step"] == "verify_source_link_in_status_chain"
|
||||
|
||||
|
||||
def test_source_correlation_apply_blocks_without_accepted_review() -> 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"]},
|
||||
},
|
||||
"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,
|
||||
)
|
||||
|
||||
apply_payload = build_source_correlation_apply(
|
||||
recurrence,
|
||||
work_item_id="source-evidence:sentry:received:issue-1",
|
||||
)
|
||||
|
||||
assert apply_payload["allowed"] is False
|
||||
assert apply_payload["executed"] is False
|
||||
assert apply_payload["writes_source_event"] is False
|
||||
assert apply_payload["apply_status"] == "blocked"
|
||||
assert apply_payload["plan"]["writes"] == []
|
||||
assert apply_payload["next_step"] == "fix_preflight_checks"
|
||||
|
||||
|
||||
def test_build_dossier_recurrence_surfaces_source_apply_history() -> None:
|
||||
apply_record = {
|
||||
"schema_version": "awooop_source_correlation_apply_v1",
|
||||
"apply_id": "apply-1",
|
||||
"work_item_id": "source-evidence:sentry:received:issue-1",
|
||||
"apply_status": "applied",
|
||||
"target_incident_id": "INC-20260520-ABC123",
|
||||
"review_id": "review-1",
|
||||
"source_event_id": "source-event-1",
|
||||
"source_event_provider_event_id": (
|
||||
"sentry:source_correlation_linked:issue-1"
|
||||
),
|
||||
"recorded_at": "2026-05-20T13:12: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_applies_by_work_item={
|
||||
"source-evidence:sentry:received:issue-1": apply_record,
|
||||
},
|
||||
)
|
||||
|
||||
item = recurrence["items"][0]
|
||||
assert recurrence["summary"]["source_correlation_applied_group_total"] == 1
|
||||
assert item["source_correlation_apply"] == apply_record
|
||||
assert item["work_item"]["matched_incident_id"] == "INC-20260520-ABC123"
|
||||
assert item["work_item"]["next_step"] == "verify_source_link_in_status_chain"
|
||||
|
||||
|
||||
def test_build_dossier_recurrence_opens_work_item_for_completed_run_without_repair() -> None:
|
||||
recurrence = build_dossier_recurrence(
|
||||
[
|
||||
@@ -760,6 +933,101 @@ async def test_source_correlation_dry_run_history_records_without_incident(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_correlation_apply_history_appends_source_event_and_audit(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
source_calls: list[dict[str, object]] = []
|
||||
timeline_calls: list[dict[str, object]] = []
|
||||
alert_calls: list[dict[str, object]] = []
|
||||
|
||||
async def fake_record_external_alert_event(**kwargs): # noqa: ANN003
|
||||
source_calls.append(kwargs)
|
||||
return UUID("99999999-9999-4999-8999-999999999999")
|
||||
|
||||
class FakeTimeline:
|
||||
async def add_event(self, **kwargs): # noqa: ANN001
|
||||
timeline_calls.append(kwargs)
|
||||
return {"id": "timeline-1"}
|
||||
|
||||
class FakeRecord:
|
||||
id = "alert-op-1"
|
||||
|
||||
class FakeRepo:
|
||||
async def append(self, event_type: str, **kwargs): # noqa: ANN001
|
||||
alert_calls.append({"event_type": event_type, **kwargs})
|
||||
return FakeRecord()
|
||||
|
||||
from src.repositories import alert_operation_log_repository
|
||||
from src.services import approval_db, channel_hub
|
||||
|
||||
monkeypatch.setattr(
|
||||
channel_hub,
|
||||
"record_external_alert_event",
|
||||
fake_record_external_alert_event,
|
||||
)
|
||||
monkeypatch.setattr(approval_db, "get_timeline_service", lambda: FakeTimeline())
|
||||
monkeypatch.setattr(
|
||||
alert_operation_log_repository,
|
||||
"get_alert_operation_log_repository",
|
||||
lambda: FakeRepo(),
|
||||
)
|
||||
|
||||
history = await channel_event_dossier_service._record_source_correlation_apply_history(
|
||||
{
|
||||
"schema_version": "awooop_source_correlation_apply_v1",
|
||||
"source": "channel_event_dossier.recurrence",
|
||||
"project_id": "awoooi",
|
||||
"work_item_id": "source-evidence:sentry:received:issue-1",
|
||||
"review_id": "review-1",
|
||||
"target_incident_id": "INC-20260520-ABC123",
|
||||
"reviewer_id": "operator_console",
|
||||
"operator_note": "matches incident",
|
||||
"allowed": True,
|
||||
"apply_status": "ready_to_apply",
|
||||
"safety_level": "source_review_append_only_apply",
|
||||
"writes_incident_state": False,
|
||||
"writes_source_event": True,
|
||||
"writes_auto_repair_result": False,
|
||||
"writes_ticket": False,
|
||||
"creates_external_ticket": False,
|
||||
"latest_provider_event_id": "sentry:received:issue-1",
|
||||
"source_event_provider_event_id": (
|
||||
"sentry:source_correlation_linked:issue-1"
|
||||
),
|
||||
"source_event_stage": "source_correlation_linked",
|
||||
"raw_event_id": "issue-1",
|
||||
"provider": "sentry",
|
||||
"alertname": "Sentry Issue",
|
||||
"severity": "error",
|
||||
"namespace": "awoooi-prod",
|
||||
"target_resource": "web",
|
||||
"fingerprint": "fp-sentry-issue-1",
|
||||
"checks": [],
|
||||
"current_state_summary": {},
|
||||
"plan": {"writes": ["awooop_conversation_event"]},
|
||||
"read_model_route": {},
|
||||
"next_step": "verify_source_link_in_status_chain",
|
||||
}
|
||||
)
|
||||
|
||||
assert history == {
|
||||
"recorded": True,
|
||||
"source_event_id": "99999999-9999-4999-8999-999999999999",
|
||||
"alert_operation_id": "alert-op-1",
|
||||
"timeline_event_id": "timeline-1",
|
||||
}
|
||||
assert source_calls[0]["stage"] == "source_correlation_linked"
|
||||
assert source_calls[0]["incident_id"] == "INC-20260520-ABC123"
|
||||
assert source_calls[0]["event_id"] == "issue-1"
|
||||
assert timeline_calls[0]["incident_id"] == "INC-20260520-ABC123"
|
||||
assert alert_calls[0]["actor"] == "awooop_source_correlation_apply_service"
|
||||
assert alert_calls[0]["context"]["schema_version"] == (
|
||||
"awooop_source_correlation_apply_v1"
|
||||
)
|
||||
assert alert_calls[0]["context"]["apply_status"] == "applied"
|
||||
|
||||
|
||||
def test_build_recurrence_work_item_handoff_records_ticket_proposal_contract() -> None:
|
||||
recurrence = build_dossier_recurrence(
|
||||
[
|
||||
|
||||
@@ -1913,6 +1913,7 @@
|
||||
"recurrenceLatest": "Latest: {alert} / {incident}",
|
||||
"recurrenceReason": "Reason: {reason}",
|
||||
"recurrenceSourceReviewRecorded": "Source reviews recorded: {count}",
|
||||
"recurrenceSourceApplied": "Source matches applied: {count}",
|
||||
"recurrenceEmpty": "No open recurring-alert work item in the recent window",
|
||||
"driftFingerprint": "Config Drift: {state}; {count}x in 12h",
|
||||
"driftFingerprintUnavailable": "Config Drift fingerprint state API has not responded",
|
||||
@@ -2157,6 +2158,7 @@
|
||||
"automationGap": "No repair {count}",
|
||||
"failed": "Failed {count}",
|
||||
"sourceReview": "Source review {count}",
|
||||
"sourceApplied": "Applied {count}",
|
||||
"unavailable": "The recurrence API has not responded, so work item state cannot be claimed.",
|
||||
"empty": "No open recurring-alert work items in the recent window.",
|
||||
"occurrences": "{count}x",
|
||||
@@ -2170,6 +2172,7 @@
|
||||
"reason": "Reason: {reason}",
|
||||
"nextStep": "Next: {step}",
|
||||
"sourceReviewDecision": "Source review: {decision} / {status}",
|
||||
"sourceApplyStatus": "Source apply: {status} / {event}",
|
||||
"openRun": "Open Run",
|
||||
"openRuns": "Back to Runs",
|
||||
"actions": {
|
||||
@@ -2183,6 +2186,8 @@
|
||||
"sourceAccepting": "Recording",
|
||||
"sourceReject": "Reject source",
|
||||
"sourceRejecting": "Rejecting",
|
||||
"sourceApply": "Apply match",
|
||||
"sourceApplying": "Applying",
|
||||
"failed": "The safe preview / dry-run / handoff API did not respond, so the next step cannot be claimed.",
|
||||
"allowed": "Safety gate passed",
|
||||
"blocked": "Safety gate blocked",
|
||||
@@ -2192,6 +2197,7 @@
|
||||
"sourceWrites": "Source event writeback: {source}",
|
||||
"history": "Dry-run stored: {recorded}",
|
||||
"sourceReviewResult": "Source review: {decision} / {status} / Incident {incident}",
|
||||
"sourceApplyResult": "Source match apply: {status} / {event}",
|
||||
"handoffStatus": "Handoff: {kind} / {status}",
|
||||
"externalTicket": "External ticket created: {created}",
|
||||
"ticket": "Ticket preview: {title}",
|
||||
@@ -2237,6 +2243,14 @@
|
||||
"rejected": "Rejected",
|
||||
"needs_more_evidence": "Needs more evidence",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"sourceApplyStatuses": {
|
||||
"ready_to_apply": "Ready to apply",
|
||||
"applied": "Applied",
|
||||
"partial": "Partially recorded",
|
||||
"record_failed": "Record failed",
|
||||
"blocked": "Blocked",
|
||||
"unknown": "Unknown"
|
||||
}
|
||||
},
|
||||
"statuses": {
|
||||
@@ -2250,6 +2264,7 @@
|
||||
"source_correlation_review": "Source evidence needs matching",
|
||||
"source_correlation_accepted": "Source match recorded",
|
||||
"source_correlation_rejected": "Source match rejected",
|
||||
"source_correlation_applied": "Source match applied",
|
||||
"no_repair_record": "No repair record",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
@@ -2264,6 +2279,7 @@
|
||||
"provider_native_evidence_accepted": "Provider source was matched by an operator",
|
||||
"provider_native_evidence_rejected": "Provider source was rejected and not adopted as Incident evidence",
|
||||
"provider_native_evidence_needs_more_evidence": "Provider source needs more evidence before matching",
|
||||
"provider_native_evidence_link_applied": "Provider source link event was appended",
|
||||
"incident_without_repair_record": "Incident has no repair record",
|
||||
"none": "None",
|
||||
"unknown": "Unknown"
|
||||
@@ -2277,6 +2293,7 @@
|
||||
"create_repair_ticket": "Create repair ticket",
|
||||
"review_provider_source_match": "Review source-to-Incident match",
|
||||
"verify_source_match_in_status_chain": "Verify source match in the status chain",
|
||||
"verify_source_link_in_status_chain": "Verify source link event in the status chain",
|
||||
"monitor_for_new_provider_evidence": "Wait for new provider evidence",
|
||||
"collect_more_source_evidence": "Collect more source evidence",
|
||||
"triage_missing_repair_record": "Fill missing repair record",
|
||||
|
||||
@@ -1914,6 +1914,7 @@
|
||||
"recurrenceLatest": "最新:{alert} / {incident}",
|
||||
"recurrenceReason": "原因:{reason}",
|
||||
"recurrenceSourceReviewRecorded": "來源審核已寫入歷史:{count}",
|
||||
"recurrenceSourceApplied": "來源配對已套用:{count}",
|
||||
"recurrenceEmpty": "近期重複告警尚無待處理工作項",
|
||||
"driftFingerprint": "Config Drift:{state};12h 內 {count} 次",
|
||||
"driftFingerprintUnavailable": "Config Drift fingerprint state API 尚未回應",
|
||||
@@ -2158,6 +2159,7 @@
|
||||
"automationGap": "無修復 {count}",
|
||||
"failed": "修復失敗 {count}",
|
||||
"sourceReview": "來源待審 {count}",
|
||||
"sourceApplied": "已套用 {count}",
|
||||
"unavailable": "recurrence API 尚未回應,不能判定工作項狀態。",
|
||||
"empty": "近期重複告警沒有待處理工作項。",
|
||||
"occurrences": "{count} 次",
|
||||
@@ -2171,6 +2173,7 @@
|
||||
"reason": "原因:{reason}",
|
||||
"nextStep": "下一步:{step}",
|
||||
"sourceReviewDecision": "來源審核:{decision} / {status}",
|
||||
"sourceApplyStatus": "來源套用:{status} / {event}",
|
||||
"openRun": "開啟 Run",
|
||||
"openRuns": "回 Run 監控",
|
||||
"actions": {
|
||||
@@ -2184,6 +2187,8 @@
|
||||
"sourceAccepting": "記錄中",
|
||||
"sourceReject": "退回來源",
|
||||
"sourceRejecting": "退回中",
|
||||
"sourceApply": "套用配對",
|
||||
"sourceApplying": "套用中",
|
||||
"failed": "安全預覽 / 乾跑 / 交接 API 未回應,不能判定下一步。",
|
||||
"allowed": "安全閘門通過",
|
||||
"blocked": "安全閘門阻塞",
|
||||
@@ -2193,6 +2198,7 @@
|
||||
"sourceWrites": "來源事件回寫:{source}",
|
||||
"history": "試跑入庫:{recorded}",
|
||||
"sourceReviewResult": "來源審核:{decision} / {status} / Incident {incident}",
|
||||
"sourceApplyResult": "來源配對套用:{status} / {event}",
|
||||
"handoffStatus": "交接:{kind} / {status}",
|
||||
"externalTicket": "外部 Ticket 建立:{created}",
|
||||
"ticket": "Ticket 預覽:{title}",
|
||||
@@ -2238,6 +2244,14 @@
|
||||
"rejected": "已退回",
|
||||
"needs_more_evidence": "需更多證據",
|
||||
"unknown": "未知"
|
||||
},
|
||||
"sourceApplyStatuses": {
|
||||
"ready_to_apply": "待套用",
|
||||
"applied": "已套用",
|
||||
"partial": "部分寫入",
|
||||
"record_failed": "寫入失敗",
|
||||
"blocked": "已阻塞",
|
||||
"unknown": "未知"
|
||||
}
|
||||
},
|
||||
"statuses": {
|
||||
@@ -2251,6 +2265,7 @@
|
||||
"source_correlation_review": "來源證據待配對",
|
||||
"source_correlation_accepted": "來源配對已記錄",
|
||||
"source_correlation_rejected": "來源配對已退回",
|
||||
"source_correlation_applied": "來源配對已套用",
|
||||
"no_repair_record": "無修復記錄",
|
||||
"unknown": "未知"
|
||||
},
|
||||
@@ -2265,6 +2280,7 @@
|
||||
"provider_native_evidence_accepted": "Provider 來源已由 operator 配對確認",
|
||||
"provider_native_evidence_rejected": "Provider 來源已退回,不採納為 Incident 證據",
|
||||
"provider_native_evidence_needs_more_evidence": "Provider 來源需要更多證據才能配對",
|
||||
"provider_native_evidence_link_applied": "Provider 來源已附加 Incident 連結事件",
|
||||
"incident_without_repair_record": "Incident 沒有修復紀錄",
|
||||
"none": "無",
|
||||
"unknown": "未知"
|
||||
@@ -2278,6 +2294,7 @@
|
||||
"create_repair_ticket": "建立修復 Ticket",
|
||||
"review_provider_source_match": "審核來源與 Incident 配對",
|
||||
"verify_source_match_in_status_chain": "到狀態鏈驗證來源配對",
|
||||
"verify_source_link_in_status_chain": "到狀態鏈驗證來源連結事件",
|
||||
"monitor_for_new_provider_evidence": "等待新的 Provider 證據",
|
||||
"collect_more_source_evidence": "補齊更多來源證據",
|
||||
"triage_missing_repair_record": "補齊修復紀錄",
|
||||
|
||||
@@ -140,6 +140,14 @@ type RecurrenceItem = {
|
||||
reviewer_id?: string | null;
|
||||
recorded_at?: string | null;
|
||||
} | null;
|
||||
source_correlation_apply?: {
|
||||
apply_id?: string | null;
|
||||
apply_status?: string | null;
|
||||
target_incident_id?: string | null;
|
||||
source_event_id?: string | null;
|
||||
source_event_provider_event_id?: string | null;
|
||||
recorded_at?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type RecurrenceResponse = {
|
||||
@@ -158,6 +166,7 @@ type RecurrenceResponse = {
|
||||
failed_repair_group_total?: number;
|
||||
source_correlation_review_group_total?: number;
|
||||
source_correlation_decision_recorded_group_total?: number;
|
||||
source_correlation_applied_group_total?: number;
|
||||
};
|
||||
items: RecurrenceItem[];
|
||||
};
|
||||
@@ -174,8 +183,11 @@ type RecurrenceWorkItemActionResult = {
|
||||
decision?: string | null;
|
||||
review_status?: string | null;
|
||||
review_record_status?: string | null;
|
||||
apply_status?: string | null;
|
||||
target_incident_id?: string | null;
|
||||
latest_provider_event_id?: string | null;
|
||||
source_event_id?: string | null;
|
||||
source_event_provider_event_id?: string | null;
|
||||
allowed?: boolean | null;
|
||||
executed?: boolean | null;
|
||||
safety_level?: string | null;
|
||||
@@ -223,7 +235,14 @@ type RecurrenceWorkItemActionResult = {
|
||||
};
|
||||
|
||||
type RecurrenceWorkItemActionState = {
|
||||
loading?: "preview" | "dryRun" | "handoff" | "acceptSource" | "rejectSource" | null;
|
||||
loading?:
|
||||
| "preview"
|
||||
| "dryRun"
|
||||
| "handoff"
|
||||
| "acceptSource"
|
||||
| "rejectSource"
|
||||
| "applySource"
|
||||
| null;
|
||||
result?: RecurrenceWorkItemActionResult | null;
|
||||
error?: string | null;
|
||||
};
|
||||
@@ -904,6 +923,19 @@ function staleRatioRecheckStatusKey(status?: string | null) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function sourceApplyStatusKey(status?: string | null) {
|
||||
if (
|
||||
status === "ready_to_apply" ||
|
||||
status === "applied" ||
|
||||
status === "partial" ||
|
||||
status === "record_failed" ||
|
||||
status === "blocked"
|
||||
) {
|
||||
return status;
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function formatStaleRatio(value: number) {
|
||||
return `${(value * 100).toFixed(1)}%`;
|
||||
}
|
||||
@@ -948,6 +980,8 @@ function buildWorkItems(
|
||||
recurrenceSummary?.source_correlation_review_group_total ?? 0;
|
||||
const recurrenceSourceReviewRecorded =
|
||||
recurrenceSummary?.source_correlation_decision_recorded_group_total ?? 0;
|
||||
const recurrenceSourceApplied =
|
||||
recurrenceSummary?.source_correlation_applied_group_total ?? 0;
|
||||
const latestRecurrenceOpenItem = recurrenceOpenItems(telemetry.eventRecurrence)[0] ?? null;
|
||||
const driftState = telemetry.driftFingerprintState;
|
||||
const driftFsmKey = driftFsmStateKey(driftState?.fsm_state);
|
||||
@@ -1023,12 +1057,18 @@ function buildWorkItems(
|
||||
t("evidence.recurrenceSourceReviewRecorded", {
|
||||
count: recurrenceSourceReviewRecorded,
|
||||
}),
|
||||
t("evidence.recurrenceSourceApplied", {
|
||||
count: recurrenceSourceApplied,
|
||||
}),
|
||||
]
|
||||
: [
|
||||
t("evidence.recurrenceEmpty"),
|
||||
t("evidence.recurrenceSourceReviewRecorded", {
|
||||
count: recurrenceSourceReviewRecorded,
|
||||
}),
|
||||
t("evidence.recurrenceSourceApplied", {
|
||||
count: recurrenceSourceApplied,
|
||||
}),
|
||||
],
|
||||
href: latestRecurrenceOpenItem?.work_item?.work_item_id
|
||||
? `/awooop/work-items?project_id=${encodeURIComponent(telemetry.eventRecurrence?.project_id ?? "awoooi")}&work_item_id=${encodeURIComponent(latestRecurrenceOpenItem.work_item.work_item_id)}${latestRecurrenceOpenItem.work_item.incident_id ? `&incident_id=${encodeURIComponent(latestRecurrenceOpenItem.work_item.incident_id)}` : ""}`
|
||||
@@ -1342,7 +1382,13 @@ function RecurrenceWorkQueuePanel({
|
||||
const summary = recurrence?.summary;
|
||||
const runWorkItemAction = useCallback(async (
|
||||
workItemId: string,
|
||||
action: "preview" | "dryRun" | "handoff" | "acceptSource" | "rejectSource",
|
||||
action:
|
||||
| "preview"
|
||||
| "dryRun"
|
||||
| "handoff"
|
||||
| "acceptSource"
|
||||
| "rejectSource"
|
||||
| "applySource",
|
||||
targetIncidentId?: string | null
|
||||
) => {
|
||||
setActionState((current) => ({
|
||||
@@ -1381,6 +1427,18 @@ function RecurrenceWorkQueuePanel({
|
||||
},
|
||||
15000
|
||||
);
|
||||
} else if (action === "applySource") {
|
||||
result = await postJson<RecurrenceWorkItemActionResult>(
|
||||
`${API_BASE}/api/v1/platform/events/dossier/recurrence/source-correlation/apply`,
|
||||
{
|
||||
project_id: projectId,
|
||||
work_item_id: workItemId,
|
||||
reviewer_id: "operator_console",
|
||||
operator_note: "operator_console_apply_source_match",
|
||||
limit: 300,
|
||||
},
|
||||
15000
|
||||
);
|
||||
} else {
|
||||
result = await postJson<RecurrenceWorkItemActionResult>(
|
||||
`${API_BASE}/api/v1/platform/events/dossier/recurrence/source-correlation/review`,
|
||||
@@ -1407,7 +1465,12 @@ function RecurrenceWorkQueuePanel({
|
||||
error: result ? null : t("actions.failed"),
|
||||
},
|
||||
}));
|
||||
if (result?.history?.recorded || result?.review_record_status === "recorded") {
|
||||
if (
|
||||
result?.history?.recorded ||
|
||||
result?.review_record_status === "recorded" ||
|
||||
result?.apply_status === "applied" ||
|
||||
result?.apply_status === "partial"
|
||||
) {
|
||||
onRecorded();
|
||||
}
|
||||
}, [onRecorded, projectId, t]);
|
||||
@@ -1437,6 +1500,11 @@ function RecurrenceWorkQueuePanel({
|
||||
count: summary?.source_correlation_review_group_total ?? 0,
|
||||
})}
|
||||
</span>
|
||||
<span className="border border-[#9bc7a4] bg-[#f0faf2] px-2 py-0.5 text-[#17602a]">
|
||||
{t("sourceApplied", {
|
||||
count: summary?.source_correlation_applied_group_total ?? 0,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1468,6 +1536,7 @@ function RecurrenceWorkQueuePanel({
|
||||
const handoffStatusKey = recurrenceHandoffStatusKey(actionResult?.handoff_status);
|
||||
const handoffKindKey = recurrenceHandoffKindKey(actionResult?.handoff_kind);
|
||||
const sourceReview = item.source_correlation_review;
|
||||
const sourceApply = item.source_correlation_apply;
|
||||
const isSourceReview = workItem?.kind === "source_correlation_review";
|
||||
const workItemOpen = workItem?.status === "open";
|
||||
const targetIncidentId = firstIncidentId(
|
||||
@@ -1485,6 +1554,13 @@ function RecurrenceWorkQueuePanel({
|
||||
actionResult?.review_status ??
|
||||
sourceReview?.review_status
|
||||
);
|
||||
const sourceApplyStatus = actionResult?.apply_status ?? sourceApply?.apply_status;
|
||||
const sourceApplyStatusKeyValue = sourceApplyStatusKey(sourceApplyStatus);
|
||||
const canApplySource =
|
||||
isSourceReview &&
|
||||
sourceReview?.decision === "accepted" &&
|
||||
Boolean(targetIncidentId) &&
|
||||
sourceApplyStatusKeyValue !== "applied";
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -1555,6 +1631,19 @@ function RecurrenceWorkQueuePanel({
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{sourceApply || actionResult?.apply_status ? (
|
||||
<p>
|
||||
{t("sourceApplyStatus", {
|
||||
status: t(
|
||||
`actions.sourceApplyStatuses.${sourceApplyStatusKeyValue}` as never
|
||||
),
|
||||
event:
|
||||
actionResult?.source_event_provider_event_id ??
|
||||
sourceApply?.source_event_provider_event_id ??
|
||||
"--",
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{workItemId ? (
|
||||
@@ -1627,6 +1716,20 @@ function RecurrenceWorkQueuePanel({
|
||||
? t("actions.sourceRejecting")
|
||||
: t("actions.sourceReject")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runWorkItemAction(workItemId, "applySource")}
|
||||
disabled={
|
||||
currentAction?.loading === "applySource" ||
|
||||
!canApplySource
|
||||
}
|
||||
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#1f6feb] hover:bg-[#edf4ff] hover:text-[#0f4fa8] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<GitBranch className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{currentAction?.loading === "applySource"
|
||||
? t("actions.sourceApplying")
|
||||
: t("actions.sourceApply")}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
@@ -1708,6 +1811,16 @@ function RecurrenceWorkQueuePanel({
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{actionResult.apply_status ? (
|
||||
<p>
|
||||
{t("actions.sourceApplyResult", {
|
||||
status: t(
|
||||
`actions.sourceApplyStatuses.${sourceApplyStatusKeyValue}` as never
|
||||
),
|
||||
event: actionResult.source_event_provider_event_id ?? "--",
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{actionResult.handoff_status ? (
|
||||
<p>
|
||||
{t("actions.handoffStatus", {
|
||||
|
||||
@@ -1,3 +1,55 @@
|
||||
## 2026-05-21|T118 Source correlation apply append-only link
|
||||
|
||||
**觸發**:
|
||||
|
||||
- T117 已能把 Sentry / SignOz 來源待審的「確認配對 / 退回 / 需要更多證據」寫入 AwoooP 稽核資料。
|
||||
- 但 accepted review 仍只是審核紀錄,尚未形成可由 recurrence / status-chain 讀回的 append-only 來源連結事件,因此 operator 還看不出「已確認配對」是否真正套用到來源鏈。
|
||||
|
||||
**修正**:
|
||||
|
||||
- 新增 `POST /api/v1/platform/events/dossier/recurrence/source-correlation/apply`:
|
||||
- 只接受已有 accepted review 的 `source_correlation_review` work item。
|
||||
- 成功時 append 一筆 `source_correlation_linked` source event 到 `awooop_conversation_event`。
|
||||
- 同步寫入 `timeline_events` 與 `alert_operation_log`,schema 為 `awooop_source_correlation_apply_v1`。
|
||||
- 明確維持 `writes_incident_state=false`、`writes_auto_repair_result=false`、`writes_ticket=false`,只允許 `writes_source_event=true`。
|
||||
- Recurrence read model 新增:
|
||||
- `source_correlation_applied_group_total`。
|
||||
- work item 顯示 `source_correlation_apply`、`source_event_provider_event_id` 與 apply 狀態。
|
||||
- 已 apply 的來源待審會把下一步推到 `verify_source_link_in_status_chain`。
|
||||
- AwoooP Work Items 前端新增:
|
||||
- 來源審核 accepted 後顯示「套用配對」。
|
||||
- 卡片顯示來源套用狀態與 append-only source event id。
|
||||
- summary 與 work item evidence 顯示來源配對已套用數量。
|
||||
|
||||
**Verification**:
|
||||
|
||||
```text
|
||||
python -m py_compile apps/api/src/services/channel_event_dossier_service.py apps/api/src/api/v1/platform/events.py
|
||||
-> pass
|
||||
DATABASE_URL=postgresql+asyncpg://test:test@localhost/test pytest -q apps/api/tests/test_channel_event_dossier_service.py
|
||||
-> 21 passed
|
||||
pnpm --dir apps/web exec tsc --noEmit
|
||||
-> pass
|
||||
NEXT_PUBLIC_API_URL=https://awoooi.wooo.work pnpm --dir apps/web run build
|
||||
-> compiled successfully, 90/90 static pages
|
||||
python -m ruff check --ignore B008 apps/api/src/services/channel_event_dossier_service.py apps/api/src/api/v1/platform/events.py apps/api/tests/test_channel_event_dossier_service.py
|
||||
-> pass(`events.py` 仍有既有 FastAPI Query B008,另列技術債)
|
||||
python -m json.tool apps/web/messages/zh-TW.json
|
||||
python -m json.tool apps/web/messages/en.json
|
||||
-> pass
|
||||
git diff --check
|
||||
-> pass
|
||||
```
|
||||
|
||||
**目前整體進度**:
|
||||
|
||||
- Source correlation review 可處理性:80% → 90%(accepted 後已有 append-only apply path)。
|
||||
- Incident-level source correlation 可見性:90% → 93%。
|
||||
- Source refs / Sentry / SigNoz 可見性:99.95% → 99.96%。
|
||||
- AwoooP 告警可觀測鏈:99.99% → 99.991%。
|
||||
- 前端 AI 自動化管理介面同步:99.99%(Work Items 已能記錄、套用、顯示來源配對狀態)。
|
||||
- 完整 AI 自動化管理產品化:99.72% → 99.76%。
|
||||
|
||||
## 2026-05-21|T117 Provider source correlation review decision trail
|
||||
|
||||
**觸發**:
|
||||
|
||||
@@ -2531,6 +2531,15 @@ Phase 6 完成後
|
||||
- Production / CI:`88e7477a feat(awooop): record source correlation review decisions` 已推 Gitea main;deploy marker `242b2f41 chore(cd): deploy 88e7477 [skip ci]`。Actions:#1958 Code Review success、#1957 CD success。Production health healthy/prod/mock_mode=false;source-correlation review smoke 對 canary work item 記錄 `decision=needs_more_evidence`,`review_record_status=recorded`,`alert_operation_id=37de0c0c-ba30-47e1-9d47-115ec61100b0`,write flags 全為 false;recurrence `provider=sentry` 回 `source_correlation_decision_recorded_group_total=1`,canary work item `next_step=collect_more_source_evidence`;Work Items source-evidence deep link HTTP 200。
|
||||
- 目前進度更新:AwoooP 告警可觀測鏈約 99.99%;Source refs / Sentry / SigNoz 可見性約 99.95%;Incident-level source correlation 可見性約 90%;Source correlation review 可處理性約 80%;完整 AI 自動化管理產品化約 99.72%。
|
||||
|
||||
**T118 Source correlation apply append-only link(2026-05-21 台北)**:
|
||||
- 觸發:T117 已能記錄來源配對審核,但 accepted review 尚未形成可由 recurrence / status-chain 讀回的 append-only source link,operator 仍無法確認「已配對」是否真的套用到來源鏈。
|
||||
- 修正:新增 `POST /api/v1/platform/events/dossier/recurrence/source-correlation/apply`;只接受已 accepted 的 `source_correlation_review` work item,成功時 append `source_correlation_linked` 來源事件到 `awooop_conversation_event`,並寫入 `timeline_events` 與 `alert_operation_log`(schema `awooop_source_correlation_apply_v1`)。
|
||||
- 安全邊界:`writes_incident_state=false`、`writes_auto_repair_result=false`、`writes_ticket=false`,不改 Incident 狀態、不改 auto-repair、不建外部 ticket;本階段只允許 append-only `writes_source_event=true`。
|
||||
- Read model:recurrence summary 新增 `source_correlation_applied_group_total`,item 顯示 `source_correlation_apply` 與 `source_event_provider_event_id`;已套用項目下一步為 `verify_source_link_in_status_chain`。
|
||||
- UI:AwoooP Work Items 在 accepted source review 後顯示「套用配對」,卡片同步顯示來源套用狀態與 append-only source event id,summary 顯示已套用數量。
|
||||
- Local verification:`py_compile` pass;targeted pytest `21 passed`;web typecheck pass;production URL build 90/90 static pages;i18n JSON ok;targeted ruff pass with existing B008 ignored;`git diff --check` pass。
|
||||
- 目前進度更新:AwoooP 告警可觀測鏈約 99.991%;Source refs / Sentry / SigNoz 可見性約 99.96%;Incident-level source correlation 可見性約 93%;Source correlation review 可處理性約 90%;完整 AI 自動化管理產品化約 99.76%。
|
||||
|
||||
---
|
||||
|
||||
### 2026-04-20 晚 (台北) — C1-C4 全流程串接 — Playbook 鏈路保護(commit de2d34d)
|
||||
|
||||
Reference in New Issue
Block a user