feat(awooop): apply source correlation links
All checks were successful
Code Review / ai-code-review (push) Successful in 12s
CD Pipeline / tests (push) Successful in 4m1s
CD Pipeline / build-and-deploy (push) Successful in 4m8s
CD Pipeline / post-deploy-checks (push) Successful in 2m2s

This commit is contained in:
Your Name
2026-05-21 10:23:29 +08:00
parent d25237a31f
commit fe3bf5dc18
8 changed files with 1011 additions and 7 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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(
[

View File

@@ -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",

View File

@@ -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": "補齊修復紀錄",

View File

@@ -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", {

View File

@@ -1,3 +1,55 @@
## 2026-05-21T118 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-21T117 Provider source correlation review decision trail
**觸發**

View File

@@ -2531,6 +2531,15 @@ Phase 6 完成後
- Production / CI`88e7477a feat(awooop): record source correlation review decisions` 已推 Gitea maindeploy marker `242b2f41 chore(cd): deploy 88e7477 [skip ci]`。Actions#1958 Code Review success、#1957 CD success。Production health healthy/prod/mock_mode=falsesource-correlation review smoke 對 canary work item 記錄 `decision=needs_more_evidence``review_record_status=recorded``alert_operation_id=37de0c0c-ba30-47e1-9d47-115ec61100b0`write flags 全為 falserecurrence `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 link2026-05-21 台北)**
- 觸發T117 已能記錄來源配對審核,但 accepted review 尚未形成可由 recurrence / status-chain 讀回的 append-only source linkoperator 仍無法確認「已配對」是否真的套用到來源鏈。
- 修正:新增 `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 modelrecurrence summary 新增 `source_correlation_applied_group_total`item 顯示 `source_correlation_apply``source_event_provider_event_id`;已套用項目下一步為 `verify_source_link_in_status_chain`
- UIAwoooP Work Items 在 accepted source review 後顯示「套用配對」,卡片同步顯示來源套用狀態與 append-only source event idsummary 顯示已套用數量。
- Local verification`py_compile` passtargeted pytest `21 passed`web typecheck passproduction URL build 90/90 static pagesi18n JSON oktargeted 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