diff --git a/apps/api/src/api/v1/platform/events.py b/apps/api/src/api/v1/platform/events.py index b5b79b6c..c8f3089f 100644 --- a/apps/api/src/api/v1/platform/events.py +++ b/apps/api/src/api/v1/platform/events.py @@ -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, diff --git a/apps/api/src/services/channel_event_dossier_service.py b/apps/api/src/services/channel_event_dossier_service.py index ffc9059d..df7d9f65 100644 --- a/apps/api/src/services/channel_event_dossier_service.py +++ b/apps/api/src/services/channel_event_dossier_service.py @@ -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 diff --git a/apps/api/tests/test_channel_event_dossier_service.py b/apps/api/tests/test_channel_event_dossier_service.py index 1293abe6..e5da7d2b 100644 --- a/apps/api/tests/test_channel_event_dossier_service.py +++ b/apps/api/tests/test_channel_event_dossier_service.py @@ -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( [ diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 4bd8d294..48b9712c 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -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", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index af3adffb..87e375cd 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -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": "補齊修復紀錄", diff --git a/apps/web/src/app/[locale]/awooop/work-items/page.tsx b/apps/web/src/app/[locale]/awooop/work-items/page.tsx index 79ee1900..88b60c1e 100644 --- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx +++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx @@ -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( + `${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( `${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, })} + + {t("sourceApplied", { + count: summary?.source_correlation_applied_group_total ?? 0, + })} + @@ -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 (
) : null} + {sourceApply || actionResult?.apply_status ? ( +

+ {t("sourceApplyStatus", { + status: t( + `actions.sourceApplyStatuses.${sourceApplyStatusKeyValue}` as never + ), + event: + actionResult?.source_event_provider_event_id ?? + sourceApply?.source_event_provider_event_id ?? + "--", + })} +

+ ) : null}
{workItemId ? ( @@ -1627,6 +1716,20 @@ function RecurrenceWorkQueuePanel({ ? t("actions.sourceRejecting") : t("actions.sourceReject")} + ) : null} @@ -1708,6 +1811,16 @@ function RecurrenceWorkQueuePanel({ })}

) : null} + {actionResult.apply_status ? ( +

+ {t("actions.sourceApplyResult", { + status: t( + `actions.sourceApplyStatuses.${sourceApplyStatusKeyValue}` as never + ), + event: actionResult.source_event_provider_event_id ?? "--", + })} +

+ ) : null} {actionResult.handoff_status ? (

{t("actions.handoffStatus", { diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 8075bbca..73a1dd23 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -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 **觸發**: diff --git a/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md b/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md index 3714fcb5..aa5648b5 100644 --- a/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md +++ b/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md @@ -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)