diff --git a/apps/api/src/api/v1/platform/events.py b/apps/api/src/api/v1/platform/events.py index d956cdf03..b5b79b6c5 100644 --- a/apps/api/src/api/v1/platform/events.py +++ b/apps/api/src/api/v1/platform/events.py @@ -21,12 +21,14 @@ from src.services.channel_event_dossier_service import ( RecurrenceWorkItemHandoffKind, RecurrenceWorkItemMode, RecurrenceWorkItemNotFoundError, + SourceCorrelationReviewDecision, fetch_channel_event_dossier, fetch_channel_event_dossier_coverage, fetch_channel_event_dossier_recurrence, fetch_recurrence_work_item_dry_run, fetch_recurrence_work_item_handoff, fetch_recurrence_work_item_preview, + fetch_source_correlation_review_decision, ) from src.services.channel_hub import record_external_alert_event from src.services.platform_operator_service import list_recent_channel_events @@ -173,6 +175,7 @@ class ChannelEventRecurrenceSummary(BaseModel): automation_gap_group_total: int = 0 failed_repair_group_total: int = 0 source_correlation_review_group_total: int = 0 + source_correlation_decision_recorded_group_total: int = 0 latest_received_at: datetime | None @@ -195,6 +198,7 @@ class ChannelEventRecurrenceItem(BaseModel): incident_ids: list[str] = Field(default_factory=list) repair_summary: dict[str, Any] | None = None work_item: dict[str, Any] | None = None + source_correlation_review: dict[str, Any] | None = None occurrence_total: int duplicate_total: int linked_run_total: int @@ -237,6 +241,19 @@ class RecurrenceWorkItemHandoffRequest(BaseModel): limit: int = Field(default=300, ge=1, le=300) +class SourceCorrelationReviewDecisionRequest(BaseModel): + """Record-only source evidence review decision.""" + + project_id: str | None = Field(default=None, min_length=1) + work_item_id: str = Field(min_length=1) + decision: SourceCorrelationReviewDecision + target_incident_id: str | None = Field(default=None, min_length=1, max_length=30) + reviewer_id: str = Field(default="operator_console", min_length=1, max_length=100) + operator_note: str | None = Field(default=None, max_length=500) + provider: str | None = Field(default=None, min_length=1) + limit: int = Field(default=300, ge=1, le=300) + + @router.get( "/events/dossier", response_model=ChannelEventDossierResponse, @@ -471,6 +488,36 @@ async def handoff_event_recurrence_work_item( ) from exc +@router.post( + "/events/dossier/recurrence/source-correlation/review", + summary="記錄來源證據與 Incident 配對審核結果", + description=( + "針對 source_correlation_review work item 記錄 operator 審核決定。" + "本 API 僅寫入 alert_operation_log / 可選 timeline_events," + "不修改 Incident 狀態、不回寫 source event、不建立外部 ticket。" + ), +) +async def review_source_correlation_work_item( + request: SourceCorrelationReviewDecisionRequest, +) -> dict[str, Any]: + try: + return await fetch_source_correlation_review_decision( + project_id=request.project_id, + work_item_id=request.work_item_id, + decision=request.decision, + target_incident_id=request.target_incident_id, + reviewer_id=request.reviewer_id, + operator_note=request.operator_note, + provider=request.provider, + limit=request.limit, + ) + except RecurrenceWorkItemNotFoundError as exc: + raise HTTPException( + status_code=404, + detail="recurrence_work_item_not_found", + ) from exc + + @router.get( "/events/recent", response_model=RecentEventsResponse, diff --git a/apps/api/src/services/channel_event_dossier_service.py b/apps/api/src/services/channel_event_dossier_service.py index 84d384049..ffc9059d7 100644 --- a/apps/api/src/services/channel_event_dossier_service.py +++ b/apps/api/src/services/channel_event_dossier_service.py @@ -26,9 +26,18 @@ _MAX_REPAIR_INCIDENTS = 200 _SOURCE_CORRELATION_REVIEW_PROVIDERS = {"sentry", "signoz"} _SOURCE_CORRELATION_REVIEW_EXCLUDED_STAGES = {"heartbeat"} _SOURCE_CORRELATION_WORK_ITEM_ID_MAX = 180 +_SOURCE_CORRELATION_DECISION_SCHEMA_VERSION = ( + "awooop_source_correlation_review_decision_v1" +) +_SOURCE_CORRELATION_REVIEW_ACTOR = "awooop_source_correlation_review_service" _INCIDENT_ID_RE = re.compile(r"\bINC-\d{8}-[A-Z0-9]{4,}\b") RecurrenceWorkItemMode = Literal["auto", "ticket", "reverify", "approval_review", "observe"] RecurrenceWorkItemHandoffKind = Literal["ticket_proposal", "manual_review"] +SourceCorrelationReviewDecision = Literal[ + "accepted", + "rejected", + "needs_more_evidence", +] class RecurrenceWorkItemNotFoundError(LookupError): @@ -42,9 +51,9 @@ def _as_dict(value: Any) -> dict[str, Any]: def _json_safe(value: Any) -> Any: if isinstance(value, dict): return {str(key): _json_safe(item) for key, item in value.items()} - if isinstance(value, (list, tuple, set)): + if isinstance(value, list | tuple | set): return [_json_safe(item) for item in value] - if value is None or isinstance(value, (str, int, float, bool)): + if value is None or isinstance(value, str | int | float | bool): return value return str(value) @@ -94,6 +103,58 @@ def _source_correlation_work_item_id(group: dict[str, Any]) -> str: return f"source-evidence:{source_id}"[:_SOURCE_CORRELATION_WORK_ITEM_ID_MAX] +def _source_correlation_review_outcome(decision: str) -> dict[str, str]: + if decision == "accepted": + return { + "status": "source_correlation_accepted", + "next_step": "verify_source_match_in_status_chain", + "reason": "provider_native_evidence_accepted", + "review_status": "accepted", + } + if decision == "rejected": + return { + "status": "source_correlation_rejected", + "next_step": "monitor_for_new_provider_evidence", + "reason": "provider_native_evidence_rejected", + "review_status": "rejected", + } + return { + "status": "source_correlation_review", + "next_step": "collect_more_source_evidence", + "reason": "provider_native_evidence_needs_more_evidence", + "review_status": "needs_more_evidence", + } + + +def _normalize_source_review_decision(row: dict[str, Any]) -> dict[str, Any] | None: + context = _as_dict(row.get("context")) + if context.get("schema_version") != _SOURCE_CORRELATION_DECISION_SCHEMA_VERSION: + return None + work_item_id = str(context.get("work_item_id") or "").strip() + decision = str(context.get("decision") or "").strip() + if not work_item_id or decision not in { + "accepted", + "rejected", + "needs_more_evidence", + }: + return None + + return { + "schema_version": _SOURCE_CORRELATION_DECISION_SCHEMA_VERSION, + "review_id": row.get("id"), + "work_item_id": work_item_id, + "decision": decision, + "review_status": context.get("review_status") or decision, + "target_incident_id": context.get("target_incident_id"), + "reviewer_id": context.get("reviewer_id"), + "operator_note": context.get("operator_note"), + "latest_provider_event_id": context.get("latest_provider_event_id"), + "latest_stage": context.get("latest_stage"), + "recorded_at": row.get("created_at"), + "history": context.get("history") or {}, + } + + def _append_unique(values: list[str], candidate: Any) -> None: text_value = str(candidate or "").strip() if text_value and text_value not in values: @@ -148,10 +209,12 @@ def build_dossier_recurrence( project_id: str, limit: int, repair_summaries_by_incident: dict[str, dict[str, Any]] | None = None, + source_review_decisions_by_work_item: dict[str, dict[str, Any]] | None = None, ) -> dict[str, Any]: """Group recent source events into recurrence buckets with linked run state.""" groups: dict[str, dict[str, Any]] = {} repair_summaries = repair_summaries_by_incident or {} + source_review_decisions = source_review_decisions_by_work_item or {} for row in rows: event = build_dossier_event(row) @@ -249,7 +312,11 @@ def build_dossier_recurrence( for group in groups.values(): run_ids = group.pop("_run_ids") group["linked_run_total"] = len(run_ids) - _attach_work_item_summary(group, repair_summaries) + _attach_work_item_summary( + group, + repair_summaries, + source_review_decisions, + ) linked_run_total += len(run_ids) items.append(group) @@ -317,6 +384,9 @@ def build_dossier_recurrence( if _as_dict(item.get("repair_summary")).get("status") == "source_correlation_review" ), + "source_correlation_decision_recorded_group_total": sum( + 1 for item in items if item.get("source_correlation_review") + ), "latest_received_at": latest_received_at, }, "items": items, @@ -355,13 +425,21 @@ def _repair_status( def _work_item_status(repair_status: str) -> str: if repair_status == "no_incident_link": return "none" - if repair_status == "auto_repair_verified": + if repair_status in { + "auto_repair_verified", + "source_correlation_accepted", + "source_correlation_rejected", + }: return "closed" return "open" def _work_item_kind(repair_status: str, auto_repair_id: Any) -> str: - if repair_status == "source_correlation_review": + if repair_status in { + "source_correlation_review", + "source_correlation_accepted", + "source_correlation_rejected", + }: return "source_correlation_review" if auto_repair_id: return "verification" @@ -377,6 +455,8 @@ def _work_item_kind(repair_status: str, auto_repair_id: Any) -> str: def _work_item_next_step(repair_status: str) -> str: return { "source_correlation_review": "review_provider_source_match", + "source_correlation_accepted": "verify_source_match_in_status_chain", + "source_correlation_rejected": "monitor_for_new_provider_evidence", "auto_repair_succeeded_unverified": "run_post_verification", "auto_repair_failed": "triage_failed_repair", "auto_repair_recorded": "review_repair_record", @@ -390,6 +470,8 @@ def _work_item_next_step(repair_status: str) -> str: def _work_item_reason(repair_status: str) -> str: return { "source_correlation_review": "provider_native_evidence_unlinked", + "source_correlation_accepted": "provider_native_evidence_accepted", + "source_correlation_rejected": "provider_native_evidence_rejected", "auto_repair_succeeded_unverified": "auto_repair_missing_verification", "auto_repair_failed": "auto_repair_failed", "auto_repair_recorded": "auto_repair_record_needs_review", @@ -403,6 +485,7 @@ def _work_item_reason(repair_status: str) -> str: def _attach_work_item_summary( group: dict[str, Any], repair_summaries_by_incident: dict[str, dict[str, Any]], + source_review_decisions_by_work_item: dict[str, dict[str, Any]], ) -> None: incident_ids = [ str(incident_id) for incident_id in group.get("incident_ids", []) if incident_id @@ -450,17 +533,38 @@ def _attach_work_item_summary( elif status_value == "source_correlation_review" and work_status != "none": work_item_id = _source_correlation_work_item_id(group) + source_review_decision = ( + source_review_decisions_by_work_item.get(work_item_id) + if work_item_id + else None + ) + matched_incident_id = None + work_item_next_step = _work_item_next_step(status_value) + work_item_reason = _work_item_reason(status_value) + if source_review_decision: + outcome = _source_correlation_review_outcome( + str(source_review_decision.get("decision") or "") + ) + status_value = outcome["status"] + repair_payload["status"] = status_value + work_status = _work_item_status(status_value) + matched_incident_id = source_review_decision.get("target_incident_id") + work_item_next_step = outcome["next_step"] + work_item_reason = outcome["reason"] + group["source_correlation_review"] = source_review_decision + group["latest_incident_id"] = latest_incident_id group["repair_summary"] = repair_payload group["work_item"] = { "schema_version": "awooop_recurrence_work_item_link_v1", "work_item_id": work_item_id, "incident_id": latest_incident_id, + "matched_incident_id": matched_incident_id, "auto_repair_id": auto_repair_id, "status": work_status, "kind": _work_item_kind(status_value, auto_repair_id), - "next_step": _work_item_next_step(status_value), - "reason": _work_item_reason(status_value), + "next_step": work_item_next_step, + "reason": work_item_reason, "needs_human": work_status == "open", } @@ -481,6 +585,9 @@ def _recurrence_work_item_target(item: dict[str, Any]) -> dict[str, Any]: "latest_run_state": item.get("latest_run_state"), "latest_agent_id": item.get("latest_agent_id"), "latest_incident_id": item.get("latest_incident_id"), + "matched_incident_id": _as_dict(item.get("work_item")).get( + "matched_incident_id" + ), } @@ -653,6 +760,8 @@ def _recurrence_current_state_summary( "latest_provider_event_id": item.get("latest_provider_event_id"), "latest_run_state": item.get("latest_run_state"), "latest_run_id": item.get("latest_run_id"), + "matched_incident_id": work_item.get("matched_incident_id"), + "source_correlation_review": item.get("source_correlation_review"), "repair_status": repair_summary.get("status"), "latest_auto_repair_id": repair_summary.get("latest_auto_repair_id"), "latest_verification_result": repair_summary.get("latest_verification_result"), @@ -1063,6 +1172,243 @@ def _recurrence_handoff_history_description(context: dict[str, Any]) -> str: )[:500] +def _source_correlation_review_checks( + item: dict[str, Any], + work_item: dict[str, Any], + *, + decision: str, + target_incident_id: str | None, +) -> list[dict[str, Any]]: + source_ref_total = int(item.get("source_ref_total") or 0) + is_source_review = work_item.get("kind") == "source_correlation_review" + checks = [ + { + "name": "source_review_work_item", + "passed": is_source_review, + "detail": str(work_item.get("kind") or "unknown"), + }, + { + "name": "known_decision", + "passed": decision + in {"accepted", "rejected", "needs_more_evidence"}, + "detail": decision or "unknown", + }, + { + "name": "source_refs_present", + "passed": source_ref_total > 0, + "detail": str(source_ref_total), + }, + { + "name": "provider_event_present", + "passed": bool(item.get("latest_provider_event_id")), + "detail": str(item.get("latest_provider_event_id") or "missing"), + }, + { + "name": "review_record_only", + "passed": True, + "detail": "alert_operation_log_only", + }, + ] + if decision == "accepted": + checks.append( + { + "name": "target_incident_required", + "passed": bool( + target_incident_id and _INCIDENT_ID_RE.search(target_incident_id) + ), + "detail": target_incident_id or "missing target_incident_id", + } + ) + return checks + + +def _source_correlation_review_context(payload: dict[str, Any]) -> dict[str, Any]: + return { + "schema_version": _SOURCE_CORRELATION_DECISION_SCHEMA_VERSION, + "source": payload.get("source"), + "project_id": payload.get("project_id"), + "work_item_id": payload.get("work_item_id"), + "decision": payload.get("decision"), + "review_status": payload.get("review_status"), + "target_incident_id": payload.get("target_incident_id"), + "reviewer_id": payload.get("reviewer_id"), + "operator_note": payload.get("operator_note"), + "latest_provider_event_id": payload.get("latest_provider_event_id"), + "latest_stage": payload.get("latest_stage"), + "provider": payload.get("provider"), + "alertname": payload.get("alertname"), + "namespace": payload.get("namespace"), + "target_resource": payload.get("target_resource"), + "safety_level": payload.get("safety_level"), + "writes_incident_state": payload.get("writes_incident_state"), + "writes_source_event": payload.get("writes_source_event"), + "writes_auto_repair_result": payload.get("writes_auto_repair_result"), + "writes_ticket": payload.get("writes_ticket"), + "creates_external_ticket": payload.get("creates_external_ticket"), + "checks": payload.get("checks"), + "current_state_summary": payload.get("current_state_summary"), + "plan": payload.get("plan"), + "read_model_route": payload.get("read_model_route"), + "next_step": payload.get("next_step"), + "history": payload.get("history"), + } + + +def _source_correlation_review_description(context: dict[str, Any]) -> str: + return ( + f"decision={context.get('decision')} " + f"work_item={context.get('work_item_id')} " + f"provider_event={context.get('latest_provider_event_id')} " + f"target_incident={context.get('target_incident_id') or '--'} " + f"writes_incident={context.get('writes_incident_state')} " + f"writes_source_event={context.get('writes_source_event')}" + )[:500] + + +def build_source_correlation_review_decision( + recurrence: dict[str, Any], + *, + work_item_id: str, + decision: SourceCorrelationReviewDecision, + target_incident_id: str | None = None, + reviewer_id: str = "operator_console", + operator_note: str | None = None, +) -> dict[str, Any]: + """Build a record-only source-correlation review decision payload.""" + + item, work_item = _find_recurrence_work_item(recurrence, work_item_id) + safe_decision = str(decision) + target_id = str(target_incident_id or "").strip() or None + checks = _source_correlation_review_checks( + item, + work_item, + decision=safe_decision, + target_incident_id=target_id, + ) + allowed = all(check["passed"] for check in checks) + outcome = _source_correlation_review_outcome(safe_decision) + writes_timeline = bool(target_id and safe_decision == "accepted") + + payload = { + "schema_version": _SOURCE_CORRELATION_DECISION_SCHEMA_VERSION, + "source": "channel_event_dossier.recurrence", + "project_id": recurrence.get("project_id"), + "work_item_id": work_item.get("work_item_id"), + "incident_id": work_item.get("incident_id"), + "target_incident_id": target_id, + "decision": safe_decision, + "review_status": outcome["review_status"], + "reviewer_id": str(reviewer_id or "operator_console")[:100], + "operator_note": str(operator_note or "").strip()[:500] or None, + "allowed": allowed, + "executed": allowed, + "safety_level": "source_review_record_only", + "writes_incident_state": False, + "writes_source_event": False, + "writes_auto_repair_result": False, + "writes_ticket": False, + "creates_external_ticket": False, + "latest_provider_event_id": item.get("latest_provider_event_id"), + "latest_stage": item.get("latest_stage"), + "provider": item.get("provider"), + "alertname": item.get("alertname"), + "namespace": item.get("namespace"), + "target_resource": item.get("target_resource"), + "checks": checks, + "current_state_summary": _recurrence_current_state_summary(item, work_item), + "plan": { + "step": "record_source_correlation_review_decision", + "flywheel_node": "source_correlation_review", + "agent_id": "awooop_source_correlation_reviewer", + "required_scope": "record_history", + "writes": ["alert_operation_log", *(["timeline_events"] if writes_timeline else [])], + "target_action": outcome["next_step"], + "reason": outcome["reason"], + "target": _recurrence_work_item_target(item), + }, + "read_model_route": { + "agent_id": "awooop_source_correlation_reviewer", + "tool_name": "channel_event_dossier.recurrence", + "required_scope": "record_history", + "is_shadow": False, + "flywheel_node": "source_correlation_review", + }, + "next_step": outcome["next_step"] if allowed else "fix_preflight_checks", + } + if not allowed: + payload["executed"] = False + return payload + + +async def _record_source_correlation_review_decision_history( + payload: dict[str, Any], +) -> dict[str, Any]: + if not payload.get("allowed"): + return {"recorded": False, "reason": "preflight_failed"} + + incident_id = str(payload.get("target_incident_id") or "") or None + history: dict[str, Any] = { + "recorded": False, + "alert_operation_id": None, + "timeline_event_id": None, + } + context = _source_correlation_review_context(payload) + + try: + from src.repositories.alert_operation_log_repository import ( + get_alert_operation_log_repository, + ) + + record = await get_alert_operation_log_repository().append( + "USER_ACTION", + incident_id=incident_id, + actor=_SOURCE_CORRELATION_REVIEW_ACTOR, + action_detail=f"source_correlation_review:{payload.get('decision')}"[:200], + success=True, + context=_json_safe(context), + ) + if record is not None: + history["alert_operation_id"] = getattr(record, "id", None) + except Exception as exc: + logger.warning( + "awooop_source_correlation_review_alert_operation_failed", + work_item_id=payload.get("work_item_id"), + error=str(exc), + ) + + if incident_id and payload.get("decision") == "accepted": + try: + from src.services.approval_db import get_timeline_service + + event = await get_timeline_service().add_event( + event_type="human", + status="success", + title="AwoooP source correlation review", + description=_source_correlation_review_description(context), + actor=_SOURCE_CORRELATION_REVIEW_ACTOR, + actor_role="source_correlation_review", + incident_id=incident_id, + ) + if event: + history["timeline_event_id"] = event.get("id") + except Exception as exc: + logger.warning( + "awooop_source_correlation_review_timeline_failed", + incident_id=incident_id, + work_item_id=payload.get("work_item_id"), + error=str(exc), + ) + else: + history["timeline_reason"] = "review_not_incident_timeline_scoped" + + history["recorded"] = bool( + history.get("alert_operation_id") or history.get("timeline_event_id") + ) + if not history["recorded"]: + history["reason"] = "history_sink_unavailable" + return history + + def build_dossier_coverage( rows: list[dict[str, Any]], *, @@ -1293,6 +1639,48 @@ async def _fetch_auto_repair_summaries_by_incident( return summaries +async def _fetch_source_review_decisions_by_work_item( + db: Any, + *, + project_id: str, + limit: int, +) -> dict[str, dict[str, Any]]: + """Fetch latest source-correlation review decisions from event-sourced audit.""" + + result = await db.execute( + text(""" + SELECT DISTINCT ON (context->>'work_item_id') + id, + incident_id, + actor, + action_detail, + success, + context, + created_at + FROM alert_operation_log + WHERE actor = :actor + AND context->>'schema_version' = :schema_version + AND COALESCE(context->>'project_id', :project_id) = :project_id + ORDER BY context->>'work_item_id', created_at DESC + LIMIT :limit + """), + { + "actor": _SOURCE_CORRELATION_REVIEW_ACTOR, + "schema_version": _SOURCE_CORRELATION_DECISION_SCHEMA_VERSION, + "project_id": project_id, + "limit": max(1, min(limit, _MAX_RECURRENCE_EVENTS)), + }, + ) + + decisions: dict[str, dict[str, Any]] = {} + for row in result.mappings().all(): + item = _normalize_source_review_decision(dict(row)) + if not item: + continue + decisions[str(item["work_item_id"])] = item + return decisions + + async def fetch_channel_event_dossier( *, project_id: str | None, @@ -1479,12 +1867,18 @@ async def fetch_channel_event_dossier_recurrence( db, _collect_incident_ids_from_rows(rows), ) + source_review_decisions = await _fetch_source_review_decisions_by_work_item( + db, + project_id=effective_project_id, + limit=safe_limit, + ) return build_dossier_recurrence( rows, project_id=effective_project_id, limit=safe_limit, repair_summaries_by_incident=repair_summaries, + source_review_decisions_by_work_item=source_review_decisions, ) @@ -1562,3 +1956,41 @@ async def fetch_recurrence_work_item_handoff( elif payload.get("allowed"): payload["handoff_status"] = "record_failed" return payload + + +async def fetch_source_correlation_review_decision( + *, + project_id: str | None, + work_item_id: str, + decision: SourceCorrelationReviewDecision, + target_incident_id: str | None = None, + reviewer_id: str = "operator_console", + operator_note: str | None = None, + provider: str | None = None, + limit: int = _MAX_RECURRENCE_EVENTS, +) -> dict[str, Any]: + """Fetch recurrence state and record an explicit source review decision.""" + + recurrence = await fetch_channel_event_dossier_recurrence( + project_id=project_id, + provider=provider, + limit=limit, + ) + payload = build_source_correlation_review_decision( + recurrence, + work_item_id=work_item_id, + decision=decision, + target_incident_id=target_incident_id, + reviewer_id=reviewer_id, + operator_note=operator_note, + ) + payload["history"] = await _record_source_correlation_review_decision_history( + payload + ) + if payload["history"].get("recorded"): + payload["review_record_status"] = "recorded" + elif payload.get("allowed"): + payload["review_record_status"] = "record_failed" + else: + payload["review_record_status"] = "blocked" + return payload diff --git a/apps/api/tests/test_channel_event_dossier_service.py b/apps/api/tests/test_channel_event_dossier_service.py index d1c8c0845..1293abe66 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_review_decision, fetch_channel_event_dossier, fetch_channel_event_dossier_coverage, fetch_channel_event_dossier_recurrence, @@ -268,6 +269,7 @@ def test_build_dossier_recurrence_groups_events_and_run_state() -> None: assert recurrence["summary"]["automation_gap_group_total"] == 0 assert recurrence["summary"]["failed_repair_group_total"] == 1 assert recurrence["summary"]["source_correlation_review_group_total"] == 1 + assert recurrence["summary"]["source_correlation_decision_recorded_group_total"] == 0 host_group = recurrence["items"][0] assert host_group["recurrence_key"] == "fingerprint:fp-host-disk" @@ -287,6 +289,7 @@ def test_build_dossier_recurrence_groups_events_and_run_state() -> None: "schema_version": "awooop_recurrence_work_item_link_v1", "work_item_id": "verification:INC-20260513-ABCD:repair-1", "incident_id": "INC-20260513-ABCD", + "matched_incident_id": None, "auto_repair_id": "repair-1", "status": "open", "kind": "verification", @@ -304,6 +307,7 @@ def test_build_dossier_recurrence_groups_events_and_run_state() -> None: "schema_version": "awooop_recurrence_work_item_link_v1", "work_item_id": "source-evidence:sentry:received:issue-1", "incident_id": None, + "matched_incident_id": None, "auto_repair_id": None, "status": "open", "kind": "source_correlation_review", @@ -355,6 +359,7 @@ def test_build_recurrence_work_item_preview_allows_source_correlation_review() - item = recurrence["items"][0] work_item_id = "source-evidence:signoz:upstream_canary:canary-1" assert recurrence["summary"]["source_correlation_review_group_total"] == 1 + assert recurrence["summary"]["source_correlation_decision_recorded_group_total"] == 0 assert recurrence["summary"]["open_work_item_group_total"] == 1 assert item["latest_stage"] == "upstream_canary" assert item["repair_summary"]["status"] == "source_correlation_review" @@ -386,6 +391,124 @@ def test_build_recurrence_work_item_preview_allows_source_correlation_review() - ) +def test_source_correlation_review_decision_records_accepted_audit_contract() -> None: + recurrence = build_dossier_recurrence( + [ + { + "event_id": "event-1", + "project_id": "awoooi", + "channel_type": "internal", + "provider_event_id": "sentry:received:issue-1", + "content_hash": "a" * 64, + "content_preview": "Sentry issue", + "content_redacted": "Sentry issue", + "redaction_version": "audit_sink_v1", + "source_envelope": { + "provider": "sentry", + "stage": "received", + "source_refs": { + "sentry_issue_ids": ["issue-1"], + "alert_ids": ["sentry:received:issue-1"], + }, + "log_correlation": { + "alertname": "Sentry Issue", + "severity": "error", + "namespace": "awoooi-prod", + "target_resource": "web", + "fingerprint": "fp-sentry-issue-1", + }, + }, + "is_duplicate": False, + "provider_ts": None, + "received_at": "2026-05-20T13:10:00", + "run_id": None, + "run_state": None, + "run_agent_id": None, + } + ], + project_id="awoooi", + limit=20, + ) + + decision = build_source_correlation_review_decision( + recurrence, + work_item_id="source-evidence:sentry:received:issue-1", + decision="accepted", + target_incident_id="INC-20260520-ABC123", + reviewer_id="operator_console", + operator_note="matches current frontend incident", + ) + + assert decision["schema_version"] == "awooop_source_correlation_review_decision_v1" + assert decision["allowed"] is True + assert decision["executed"] is True + assert decision["decision"] == "accepted" + assert decision["review_status"] == "accepted" + assert decision["target_incident_id"] == "INC-20260520-ABC123" + assert decision["writes_incident_state"] is False + assert decision["writes_source_event"] is False + assert decision["writes_auto_repair_result"] is False + assert decision["writes_ticket"] is False + assert decision["creates_external_ticket"] is False + assert decision["plan"]["step"] == "record_source_correlation_review_decision" + assert decision["plan"]["writes"] == ["alert_operation_log", "timeline_events"] + assert decision["next_step"] == "verify_source_match_in_status_chain" + + +def test_build_dossier_recurrence_closes_source_review_after_decision() -> None: + decision = { + "schema_version": "awooop_source_correlation_review_decision_v1", + "review_id": "review-1", + "work_item_id": "source-evidence:sentry:received:issue-1", + "decision": "accepted", + "review_status": "accepted", + "target_incident_id": "INC-20260520-ABC123", + "reviewer_id": "operator_console", + "latest_provider_event_id": "sentry:received:issue-1", + "recorded_at": "2026-05-20T13:11:00", + } + recurrence = build_dossier_recurrence( + [ + { + "event_id": "event-1", + "project_id": "awoooi", + "channel_type": "internal", + "provider_event_id": "sentry:received:issue-1", + "content_hash": "a" * 64, + "content_preview": "Sentry issue", + "content_redacted": "Sentry issue", + "redaction_version": "audit_sink_v1", + "source_envelope": { + "provider": "sentry", + "stage": "received", + "source_refs": {"sentry_issue_ids": ["issue-1"]}, + }, + "is_duplicate": False, + "provider_ts": None, + "received_at": "2026-05-20T13:10:00", + "run_id": None, + "run_state": None, + "run_agent_id": None, + } + ], + project_id="awoooi", + limit=20, + source_review_decisions_by_work_item={ + "source-evidence:sentry:received:issue-1": decision, + }, + ) + + item = recurrence["items"][0] + assert recurrence["summary"]["open_work_item_group_total"] == 0 + assert recurrence["summary"]["source_correlation_review_group_total"] == 0 + assert recurrence["summary"]["source_correlation_decision_recorded_group_total"] == 1 + assert item["source_correlation_review"] == decision + assert item["repair_summary"]["status"] == "source_correlation_accepted" + assert item["work_item"]["status"] == "closed" + assert item["work_item"]["matched_incident_id"] == "INC-20260520-ABC123" + assert item["work_item"]["next_step"] == "verify_source_match_in_status_chain" + + def test_build_dossier_recurrence_opens_work_item_for_completed_run_without_repair() -> None: recurrence = build_dossier_recurrence( [ @@ -433,6 +556,7 @@ def test_build_dossier_recurrence_opens_work_item_for_completed_run_without_repa "schema_version": "awooop_recurrence_work_item_link_v1", "work_item_id": "incident:INC-20260517-F25B4A", "incident_id": "INC-20260517-F25B4A", + "matched_incident_id": None, "auto_repair_id": None, "status": "open", "kind": "automation_gap", @@ -852,7 +976,7 @@ async def test_fetch_channel_event_dossier_uses_typed_run_filter(monkeypatch) -> async def test_fetch_channel_event_dossier_coverage_uses_typed_provider_filter( monkeypatch, ) -> None: - captured: dict[str, object] = {} + captured: list[dict[str, object]] = [] class FakeMappings: def all(self) -> list[dict[str, object]]: @@ -864,8 +988,7 @@ async def test_fetch_channel_event_dossier_coverage_uses_typed_provider_filter( class FakeDb: async def execute(self, statement, params): # noqa: ANN001 - captured["sql"] = str(statement) - captured["params"] = params + captured.append({"sql": str(statement), "params": params}) return FakeResult() class FakeContext: @@ -889,8 +1012,9 @@ async def test_fetch_channel_event_dossier_coverage_uses_typed_provider_filter( assert result["project_id"] == "awoooi" assert result["limit"] == 200 - assert "source_envelope->>'provider'" in str(captured["sql"]) - assert captured["params"] == { + coverage_query = captured[0] + assert "source_envelope->>'provider'" in str(coverage_query["sql"]) + assert coverage_query["params"] == { "project_id": "awoooi", "provider": "sentry", "limit": 200, @@ -901,7 +1025,7 @@ async def test_fetch_channel_event_dossier_coverage_uses_typed_provider_filter( async def test_fetch_channel_event_dossier_recurrence_uses_joined_typed_filter( monkeypatch, ) -> None: - captured: dict[str, object] = {} + captured: list[dict[str, object]] = [] class FakeMappings: def all(self) -> list[dict[str, object]]: @@ -913,8 +1037,7 @@ async def test_fetch_channel_event_dossier_recurrence_uses_joined_typed_filter( class FakeDb: async def execute(self, statement, params): # noqa: ANN001 - captured["sql"] = str(statement) - captured["params"] = params + captured.append({"sql": str(statement), "params": params}) return FakeResult() class FakeContext: @@ -938,10 +1061,11 @@ async def test_fetch_channel_event_dossier_recurrence_uses_joined_typed_filter( assert result["project_id"] == "awoooi" assert result["limit"] == 300 - assert "LEFT JOIN awooop_run_state r" in str(captured["sql"]) - assert "e.source_envelope->>'provider'" in str(captured["sql"]) - assert ":provider IS NULL" not in str(captured["sql"]) - assert captured["params"] == { + recurrence_query = captured[0] + assert "LEFT JOIN awooop_run_state r" in str(recurrence_query["sql"]) + assert "e.source_envelope->>'provider'" in str(recurrence_query["sql"]) + assert ":provider IS NULL" not in str(recurrence_query["sql"]) + assert recurrence_query["params"] == { "project_id": "awoooi", "provider": "alertmanager", "limit": 300, diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 1783763dd..4bd8d2948 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1912,6 +1912,7 @@ "recurrenceWorkItems": "Recurring alert work: {open}; no repair: {gap}; failed repair: {failed}; manual gates: {manual}; source review: {source}", "recurrenceLatest": "Latest: {alert} / {incident}", "recurrenceReason": "Reason: {reason}", + "recurrenceSourceReviewRecorded": "Source reviews recorded: {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", @@ -2160,6 +2161,7 @@ "empty": "No open recurring-alert work items in the recent window.", "occurrences": "{count}x", "incident": "Incident: {incident}", + "matchedIncident": "Matched target: {incident}", "stage": "Stage: {stage}", "sourceEvent": "Source event: {event}", "sourceRefs": "Source refs: {refs} (Sentry {sentry} / SignOz {signoz})", @@ -2167,6 +2169,7 @@ "repair": "Repair status: {status}", "reason": "Reason: {reason}", "nextStep": "Next: {step}", + "sourceReviewDecision": "Source review: {decision} / {status}", "openRun": "Open Run", "openRuns": "Back to Runs", "actions": { @@ -2176,13 +2179,19 @@ "dryRunning": "Dry-running", "handoff": "Handoff", "handoffing": "Handing off", + "sourceAccept": "Record match", + "sourceAccepting": "Recording", + "sourceReject": "Reject source", + "sourceRejecting": "Rejecting", "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", "mode": "Mode: {mode}", "previewResult": "Result: {result}", "writes": "Writes: incident={incident}; autoRepair={autoRepair}; ticket={ticket}", + "sourceWrites": "Source event writeback: {source}", "history": "Dry-run stored: {recorded}", + "sourceReviewResult": "Source review: {decision} / {status} / Incident {incident}", "handoffStatus": "Handoff: {kind} / {status}", "externalTicket": "External ticket created: {created}", "ticket": "Ticket preview: {title}", @@ -2213,6 +2222,21 @@ "observe_only": "Observe only", "blocked": "Blocked", "unknown": "Unknown" + }, + "sourceDecisions": { + "accepted": "Match accepted", + "rejected": "Rejected", + "needs_more_evidence": "Needs more evidence", + "unknown": "Unknown" + }, + "sourceRecordStatuses": { + "recorded": "Recorded", + "record_failed": "Record failed", + "blocked": "Blocked", + "accepted": "Match accepted", + "rejected": "Rejected", + "needs_more_evidence": "Needs more evidence", + "unknown": "Unknown" } }, "statuses": { @@ -2224,6 +2248,8 @@ "investigating": "Investigating", "run_completed_no_repair": "Run completed without repair", "source_correlation_review": "Source evidence needs matching", + "source_correlation_accepted": "Source match recorded", + "source_correlation_rejected": "Source match rejected", "no_repair_record": "No repair record", "unknown": "Unknown" }, @@ -2235,6 +2261,9 @@ "run_still_investigating": "Run is still investigating", "completed_run_without_auto_repair": "Run completed without an auto-repair record", "provider_native_evidence_unlinked": "Provider-native source evidence is stored but not matched to an Incident", + "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", "incident_without_repair_record": "Incident has no repair record", "none": "None", "unknown": "Unknown" @@ -2247,6 +2276,9 @@ "wait_for_run_completion": "Wait for Run completion", "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", + "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", "none": "None" } diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index e0fe1c99d..af3adffb0 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -1913,6 +1913,7 @@ "recurrenceWorkItems": "重複告警待處理:{open};無修復:{gap};修復失敗:{failed};人工閘門:{manual};來源待審:{source}", "recurrenceLatest": "最新:{alert} / {incident}", "recurrenceReason": "原因:{reason}", + "recurrenceSourceReviewRecorded": "來源審核已寫入歷史:{count}", "recurrenceEmpty": "近期重複告警尚無待處理工作項", "driftFingerprint": "Config Drift:{state};12h 內 {count} 次", "driftFingerprintUnavailable": "Config Drift fingerprint state API 尚未回應", @@ -2161,6 +2162,7 @@ "empty": "近期重複告警沒有待處理工作項。", "occurrences": "{count} 次", "incident": "Incident:{incident}", + "matchedIncident": "配對目標:{incident}", "stage": "階段:{stage}", "sourceEvent": "來源事件:{event}", "sourceRefs": "來源 refs:{refs}(Sentry {sentry} / SignOz {signoz})", @@ -2168,6 +2170,7 @@ "repair": "修復狀態:{status}", "reason": "原因:{reason}", "nextStep": "下一步:{step}", + "sourceReviewDecision": "來源審核:{decision} / {status}", "openRun": "開啟 Run", "openRuns": "回 Run 監控", "actions": { @@ -2177,13 +2180,19 @@ "dryRunning": "乾跑中", "handoff": "交接", "handoffing": "交接中", + "sourceAccept": "記錄配對", + "sourceAccepting": "記錄中", + "sourceReject": "退回來源", + "sourceRejecting": "退回中", "failed": "安全預覽 / 乾跑 / 交接 API 未回應,不能判定下一步。", "allowed": "安全閘門通過", "blocked": "安全閘門阻塞", "mode": "模式:{mode}", "previewResult": "結果:{result}", "writes": "寫入:incident={incident};autoRepair={autoRepair};ticket={ticket}", + "sourceWrites": "來源事件回寫:{source}", "history": "試跑入庫:{recorded}", + "sourceReviewResult": "來源審核:{decision} / {status} / Incident {incident}", "handoffStatus": "交接:{kind} / {status}", "externalTicket": "外部 Ticket 建立:{created}", "ticket": "Ticket 預覽:{title}", @@ -2214,6 +2223,21 @@ "observe_only": "僅觀察", "blocked": "已阻塞", "unknown": "未知" + }, + "sourceDecisions": { + "accepted": "已確認配對", + "rejected": "已退回", + "needs_more_evidence": "需更多證據", + "unknown": "未知" + }, + "sourceRecordStatuses": { + "recorded": "已寫入歷史", + "record_failed": "寫入失敗", + "blocked": "已阻塞", + "accepted": "已確認配對", + "rejected": "已退回", + "needs_more_evidence": "需更多證據", + "unknown": "未知" } }, "statuses": { @@ -2225,6 +2249,8 @@ "investigating": "調查中", "run_completed_no_repair": "Run 完成無修復", "source_correlation_review": "來源證據待配對", + "source_correlation_accepted": "來源配對已記錄", + "source_correlation_rejected": "來源配對已退回", "no_repair_record": "無修復記錄", "unknown": "未知" }, @@ -2236,6 +2262,9 @@ "run_still_investigating": "Run 尚在調查", "completed_run_without_auto_repair": "Run 已完成但沒有自動修復紀錄", "provider_native_evidence_unlinked": "Provider 原生來源已入庫,尚未配對 Incident", + "provider_native_evidence_accepted": "Provider 來源已由 operator 配對確認", + "provider_native_evidence_rejected": "Provider 來源已退回,不採納為 Incident 證據", + "provider_native_evidence_needs_more_evidence": "Provider 來源需要更多證據才能配對", "incident_without_repair_record": "Incident 沒有修復紀錄", "none": "無", "unknown": "未知" @@ -2248,6 +2277,9 @@ "wait_for_run_completion": "等待 Run 完成", "create_repair_ticket": "建立修復 Ticket", "review_provider_source_match": "審核來源與 Incident 配對", + "verify_source_match_in_status_chain": "到狀態鏈驗證來源配對", + "monitor_for_new_provider_evidence": "等待新的 Provider 證據", + "collect_more_source_evidence": "補齊更多來源證據", "triage_missing_repair_record": "補齊修復紀錄", "none": "無" } 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 558f84fe1..79ee19004 100644 --- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx +++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx @@ -99,6 +99,7 @@ type RecentEventsResponse = { type RecurrenceWorkItem = { work_item_id?: string | null; incident_id?: string | null; + matched_incident_id?: string | null; auto_repair_id?: string | null; status?: string | null; kind?: string | null; @@ -131,6 +132,14 @@ type RecurrenceItem = { auto_repair_total?: number | null; } | null; work_item?: RecurrenceWorkItem | null; + source_correlation_review?: { + review_id?: string | null; + decision?: string | null; + review_status?: string | null; + target_incident_id?: string | null; + reviewer_id?: string | null; + recorded_at?: string | null; + } | null; }; type RecurrenceResponse = { @@ -148,6 +157,7 @@ type RecurrenceResponse = { automation_gap_group_total?: number; failed_repair_group_total?: number; source_correlation_review_group_total?: number; + source_correlation_decision_recorded_group_total?: number; }; items: RecurrenceItem[]; }; @@ -161,10 +171,16 @@ type RecurrenceWorkItemActionResult = { handoff_kind?: string | null; handoff_status?: string | null; handoff_owner?: string | null; + decision?: string | null; + review_status?: string | null; + review_record_status?: string | null; + target_incident_id?: string | null; + latest_provider_event_id?: string | null; allowed?: boolean | null; executed?: boolean | null; safety_level?: string | null; writes_incident_state?: boolean | null; + writes_source_event?: boolean | null; writes_auto_repair_result?: boolean | null; writes_ticket?: boolean | null; creates_external_ticket?: boolean | null; @@ -207,7 +223,7 @@ type RecurrenceWorkItemActionResult = { }; type RecurrenceWorkItemActionState = { - loading?: "preview" | "dryRun" | "handoff" | null; + loading?: "preview" | "dryRun" | "handoff" | "acceptSource" | "rejectSource" | null; result?: RecurrenceWorkItemActionResult | null; error?: string | null; }; @@ -552,6 +568,8 @@ function recurrenceRepairStatusKey(status?: string | null) { status === "investigating" || status === "run_completed_no_repair" || status === "source_correlation_review" || + status === "source_correlation_accepted" || + status === "source_correlation_rejected" || status === "no_repair_record" ) { return status; @@ -604,6 +622,31 @@ function recurrenceHandoffKindKey(kind?: string | null) { return "unknown"; } +function sourceReviewDecisionKey(decision?: string | null) { + if ( + decision === "accepted" || + decision === "rejected" || + decision === "needs_more_evidence" + ) { + return decision; + } + return "unknown"; +} + +function sourceReviewRecordStatusKey(status?: string | null) { + if ( + status === "recorded" || + status === "record_failed" || + status === "blocked" || + status === "accepted" || + status === "rejected" || + status === "needs_more_evidence" + ) { + return status; + } + return "unknown"; +} + function driftFsmStateKey(state?: string | null) { if ( state === "pending_human" || @@ -903,6 +946,8 @@ function buildWorkItems( const recurrenceManualGate = recurrenceSummary?.manual_gate_group_total ?? 0; const recurrenceSourceReview = recurrenceSummary?.source_correlation_review_group_total ?? 0; + const recurrenceSourceReviewRecorded = + recurrenceSummary?.source_correlation_decision_recorded_group_total ?? 0; const latestRecurrenceOpenItem = recurrenceOpenItems(telemetry.eventRecurrence)[0] ?? null; const driftState = telemetry.driftFingerprintState; const driftFsmKey = driftFsmStateKey(driftState?.fsm_state); @@ -975,8 +1020,16 @@ function buildWorkItems( `recurrence.reasons.${latestRecurrenceOpenItem.work_item?.reason ?? "unknown"}` as never ), }), + t("evidence.recurrenceSourceReviewRecorded", { + count: recurrenceSourceReviewRecorded, + }), ] - : [t("evidence.recurrenceEmpty")], + : [ + t("evidence.recurrenceEmpty"), + t("evidence.recurrenceSourceReviewRecorded", { + count: recurrenceSourceReviewRecorded, + }), + ], 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)}` : ""}` : "/awooop/runs", @@ -1266,17 +1319,22 @@ function ProductionClaimBanner({ function RecurrenceWorkQueuePanel({ recurrence, focusedWorkItemId, + focusedIncidentId, projectId, + onRecorded, }: { recurrence: RecurrenceResponse | null; focusedWorkItemId: string | null; + focusedIncidentId: string | null; projectId: string; + onRecorded: () => void; }) { const t = useTranslations("awooop.workItems.recurrence"); const [actionState, setActionState] = useState>({}); + const allItems = recurrence?.items ?? []; const openItems = recurrenceOpenItems(recurrence); const focusedItem = focusedWorkItemId - ? openItems.find((item) => item.work_item?.work_item_id === focusedWorkItemId) + ? allItems.find((item) => item.work_item?.work_item_id === focusedWorkItemId) : null; const visibleItems = focusedItem ? [focusedItem, ...openItems.filter((item) => item !== focusedItem).slice(0, 5)] @@ -1284,7 +1342,8 @@ function RecurrenceWorkQueuePanel({ const summary = recurrence?.summary; const runWorkItemAction = useCallback(async ( workItemId: string, - action: "preview" | "dryRun" | "handoff" + action: "preview" | "dryRun" | "handoff" | "acceptSource" | "rejectSource", + targetIncidentId?: string | null ) => { setActionState((current) => ({ ...current, @@ -1310,7 +1369,7 @@ function RecurrenceWorkQueuePanel({ }, 15000 ); - } else { + } else if (action === "handoff") { result = await postJson( `${API_BASE}/api/v1/platform/events/dossier/recurrence/work-item/handoff`, { @@ -1322,6 +1381,22 @@ function RecurrenceWorkQueuePanel({ }, 15000 ); + } else { + result = await postJson( + `${API_BASE}/api/v1/platform/events/dossier/recurrence/source-correlation/review`, + { + project_id: projectId, + work_item_id: workItemId, + decision: action === "acceptSource" ? "accepted" : "rejected", + target_incident_id: action === "acceptSource" ? targetIncidentId : undefined, + reviewer_id: "operator_console", + operator_note: action === "acceptSource" + ? "operator_console_source_match" + : "operator_console_source_rejected", + limit: 300, + }, + 15000 + ); } setActionState((current) => ({ @@ -1332,7 +1407,10 @@ function RecurrenceWorkQueuePanel({ error: result ? null : t("actions.failed"), }, })); - }, [projectId, t]); + if (result?.history?.recorded || result?.review_record_status === "recorded") { + onRecorded(); + } + }, [onRecorded, projectId, t]); return (
@@ -1366,7 +1444,7 @@ function RecurrenceWorkQueuePanel({
{t("unavailable")}
- ) : openItems.length === 0 ? ( + ) : visibleItems.length === 0 ? (
{t("empty")}
@@ -1389,6 +1467,24 @@ function RecurrenceWorkQueuePanel({ const previewKey = recurrencePreviewKey(actionResult?.verification_result_preview); const handoffStatusKey = recurrenceHandoffStatusKey(actionResult?.handoff_status); const handoffKindKey = recurrenceHandoffKindKey(actionResult?.handoff_kind); + const sourceReview = item.source_correlation_review; + const isSourceReview = workItem?.kind === "source_correlation_review"; + const workItemOpen = workItem?.status === "open"; + const targetIncidentId = firstIncidentId( + focusedIncidentId, + workItem?.matched_incident_id, + sourceReview?.target_incident_id, + item.latest_incident_id, + workItem?.incident_id + ); + const sourceDecisionKey = sourceReviewDecisionKey( + actionResult?.decision ?? sourceReview?.decision + ); + const reviewRecordStatusKey = sourceReviewRecordStatusKey( + actionResult?.review_record_status ?? + actionResult?.review_status ?? + sourceReview?.review_status + ); return (

{t("incident", { incident: item.latest_incident_id ?? "--" })}

+ {isSourceReview ? ( +

+ {t("matchedIncident", { + incident: targetIncidentId ?? "--", + })} +

+ ) : null}

{t("stage", { stage: item.latest_stage ?? "--" })}

{t("sourceEvent", { @@ -1442,6 +1545,16 @@ function RecurrenceWorkQueuePanel({ step: t(`nextSteps.${workItem?.next_step ?? "none"}` as never), })}

+ {sourceReview ? ( +

+ {t("sourceReviewDecision", { + decision: t(`actions.sourceDecisions.${sourceDecisionKey}` as never), + status: t( + `actions.sourceRecordStatuses.${reviewRecordStatusKey}` as never + ), + })} +

+ ) : null}
{workItemId ? ( @@ -1479,6 +1592,43 @@ function RecurrenceWorkQueuePanel({ ? t("actions.handoffing") : t("actions.handoff")} + {isSourceReview ? ( + <> + + + + ) : null} ) : null} {runHref ? ( @@ -1535,11 +1685,29 @@ function RecurrenceWorkQueuePanel({ ticket: String(actionResult.writes_ticket ?? false), })}

+ {actionResult.writes_source_event !== undefined ? ( +

+ {t("actions.sourceWrites", { + source: String(actionResult.writes_source_event ?? false), + })} +

+ ) : null}

{t("actions.history", { recorded: String(actionResult.history?.recorded ?? false), })}

+ {actionResult.decision ? ( +

+ {t("actions.sourceReviewResult", { + decision: t(`actions.sourceDecisions.${sourceDecisionKey}` as never), + status: t( + `actions.sourceRecordStatuses.${reviewRecordStatusKey}` as never + ), + incident: actionResult.target_incident_id ?? "--", + })} +

+ ) : null} {actionResult.handoff_status ? (

{t("actions.handoffStatus", { @@ -2503,7 +2671,9 @@ export default function AwoooPWorkItemsPage() { pass +DATABASE_URL=postgresql+asyncpg://test:test@localhost/test pytest -q apps/api/tests/test_channel_event_dossier_service.py + -> 17 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 json.tool apps/web/messages/zh-TW.json +python -m json.tool apps/web/messages/en.json + -> pass +git diff --check + -> pass +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,另列技術債) +``` + +**目前整體進度**: + +- Source refs / Sentry / SigNoz 可見性:99.93% → 99.95%。 +- Incident-level source correlation 可見性:88% → 90%。 +- Source correlation review 可處理性:0% → 80%(已可記錄審核決定;尚未做真正 source event/Incident ref apply)。 +- AwoooP 告警可觀測鏈:99.988% → 99.99%。 +- 前端 AI 自動化管理介面同步:99.99%(Work Items 已可顯示與操作來源審核)。 +- 完整 AI 自動化管理產品化:99.68% → 99.72%。 + ## 2026-05-21|T116 Provider source evidence review work items **觸發**: 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 f0bb854a4..0369cc645 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 @@ -2512,6 +2512,24 @@ Phase 6 完成後 - 邊界 / 下一步:T115 不是修改真實 Sentry / SignOz notification channel 設定,而是先建立可重複、可稽核的 provider-native ingestion 證據。下一段應把 `upstream_canary` 與 real incident matching 結果轉為可審核 work item,而不是直接改 incident refs。 - 目前進度更新:AwoooP 告警可觀測鏈約 99.985%;Source refs / Sentry / SigNoz 可見性約 99.9%;Incident-level source correlation 可見性約 86%;Provider-native upstream ingestion 可驗證性約 99.5%;低風險自動修復閉環約 95.4%;完整 AI 自動化管理產品化約 99.65%。 +**T116 Provider source evidence review work items(2026-05-21 台北)**: +- 觸發:T115 已證明 Sentry / SigNoz provider-native canary 能進 AwoooP source dossier,但未連 Incident 的 provider 原生事件仍只停在 source evidence,operator 在前端看不到「已進來源鏈路,但需要配對審核」。 +- 修正:`/api/v1/platform/events/dossier/recurrence` 新增 `latest_stage`、`stage_counts`、`source_correlation_review_group_total`;Sentry / SigNoz 非 heartbeat 且有 provider refs、尚未連 Incident 時,會形成 `source_correlation_review` work item,`next_step=review_provider_source_match`,preview / dry-run / handoff 均保持 read-only / record-only。 +- Audit:source review dry-run / handoff 即使沒有 Incident 也會寫 `alert_operation_log`(`incident_id=null`),timeline 仍只在有 Incident 時寫入;audit context 先做 JSON-safe,避免 UUID / datetime 寫 JSONB 失敗。 +- UI:AwoooP Runs / Work Items 顯示來源待審、事件 stage、provider event id、Sentry / SignOz refs,避免從 Runs 點進工作項後掉成 unknown。 +- Verification / CI:local `py_compile` pass;targeted pytest `15 passed`;web typecheck pass;production URL build 90/90 static pages;i18n JSON ok;`git diff --check` pass。`cf8bb364`、`b5deca91`、`f671637e` 已推 Gitea main,deploy marker 至 `1c578101`;Actions #1952/#1951、#1954/#1953、#1956/#1955 success。 +- Production:health healthy/prod/mock_mode=false;recurrence `provider=sentry` 顯示 `source_correlation_review_group_total=3`;source-review dry-run `history.recorded=true`、`timeline_reason=source_review_not_incident_scoped`;Work Items source-evidence deep link 回 HTTP 200。 +- 目前進度更新:AwoooP 告警可觀測鏈約 99.988%;Source refs / Sentry / SigNoz 可見性約 99.93%;Incident-level source correlation 可見性約 88%;完整 AI 自動化管理產品化約 99.68%。 + +**T117 Provider source correlation review decision trail(2026-05-21 台北)**: +- 觸發:T116 已能看到來源待審,但 operator 仍不能把「確認配對 / 退回來源 / 需要更多證據」這個決策寫回 AwoooP event sourcing,前端也無法看出來源待審是否已處理。 +- 修正:新增 `POST /api/v1/platform/events/dossier/recurrence/source-correlation/review`,記錄 `decision=accepted | rejected | needs_more_evidence`;`accepted` 必須帶 `target_incident_id`。此 API 僅寫 `alert_operation_log`,accepted 且有目標 Incident 時再補一筆 `timeline_events` 人工審核紀錄。 +- 安全邊界:本段仍是 record-only;明確回傳 `writes_incident_state=false`、`writes_source_event=false`、`writes_auto_repair_result=false`、`writes_ticket=false`。不直接改 Incident refs、不回寫 source event、不建立外部 ticket。 +- Read model:recurrence 從 `alert_operation_log` 讀回最新 `awooop_source_correlation_review_decision_v1`;accepted/rejected 會把 work item 標成 closed,summary 新增 `source_correlation_decision_recorded_group_total`,work item 顯示 `matched_incident_id` 與審核決策。 +- UI:AwoooP Work Items 針對 source review 卡片新增「記錄配對 / 退回來源」record-only 操作,並顯示 decision / status / target incident。 +- Verification:local `py_compile` pass;targeted pytest `17 passed`;web typecheck pass;production URL build 90/90 static pages;i18n JSON ok;`git diff --check` pass;`ruff --ignore B008` pass(`events.py` 既有 FastAPI Query B008 另列技術債)。 +- 目前進度更新:AwoooP 告警可觀測鏈約 99.99%;Source refs / Sentry / SigNoz 可見性約 99.95%;Incident-level source correlation 可見性約 90%;Source correlation review 可處理性約 80%;完整 AI 自動化管理產品化約 99.72%。 + --- ### 2026-04-20 晚 (台北) — C1-C4 全流程串接 — Playbook 鏈路保護(commit de2d34d)