From fb9b0b3b7caa5c55c4db51a30bd4e83a4200e8eb Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 19 May 2026 00:13:40 +0800 Subject: [PATCH] feat(awooop): record recurrence handoff proposals --- apps/api/src/api/v1/platform/events.py | 40 ++++ .../services/channel_event_dossier_service.py | 204 ++++++++++++++++++ .../test_channel_event_dossier_service.py | 63 ++++++ apps/web/messages/en.json | 18 +- apps/web/messages/zh-TW.json | 18 +- .../app/[locale]/awooop/work-items/page.tsx | 100 +++++++-- 6 files changed, 424 insertions(+), 19 deletions(-) diff --git a/apps/api/src/api/v1/platform/events.py b/apps/api/src/api/v1/platform/events.py index be23f3ab..5533dfdd 100644 --- a/apps/api/src/api/v1/platform/events.py +++ b/apps/api/src/api/v1/platform/events.py @@ -14,12 +14,14 @@ from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel, Field from src.services.channel_event_dossier_service import ( + RecurrenceWorkItemHandoffKind, RecurrenceWorkItemMode, RecurrenceWorkItemNotFoundError, 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, ) from src.services.platform_operator_service import list_recent_channel_events @@ -184,6 +186,17 @@ class RecurrenceWorkItemDryRunRequest(BaseModel): limit: int = Field(default=300, ge=1, le=300) +class RecurrenceWorkItemHandoffRequest(BaseModel): + """AwoooP recurrence work item handoff request.""" + + project_id: str | None = Field(default=None, min_length=1) + work_item_id: str = Field(min_length=1) + mode: RecurrenceWorkItemMode = "auto" + handoff_kind: RecurrenceWorkItemHandoffKind = "ticket_proposal" + 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, @@ -313,6 +326,33 @@ async def dry_run_event_recurrence_work_item( ) from exc +@router.post( + "/events/dossier/recurrence/work-item/handoff", + summary="記錄重複告警工作項的交接提案", + description=( + "依 recurrence read model 與 dry-run 結果記錄 ticket proposal / 人工接手歷史," + "但不修改 incident、auto-repair 或外部 ticket 狀態。" + ), +) +async def handoff_event_recurrence_work_item( + request: RecurrenceWorkItemHandoffRequest, +) -> dict[str, Any]: + try: + return await fetch_recurrence_work_item_handoff( + project_id=request.project_id, + work_item_id=request.work_item_id, + mode=request.mode, + handoff_kind=request.handoff_kind, + 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 e84328f2..d05c0559 100644 --- a/apps/api/src/services/channel_event_dossier_service.py +++ b/apps/api/src/services/channel_event_dossier_service.py @@ -25,6 +25,7 @@ _MAX_RECURRENCE_EVENTS = 300 _MAX_REPAIR_INCIDENTS = 200 _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"] class RecurrenceWorkItemNotFoundError(LookupError): @@ -671,6 +672,64 @@ def build_recurrence_work_item_dry_run( return payload +def _recurrence_handoff_next_step( + handoff_kind: RecurrenceWorkItemHandoffKind, + allowed: bool, +) -> str: + if not allowed: + return "fix_preflight_checks" + if handoff_kind == "manual_review": + return "operator_manual_review" + return "operator_review_ticket_preview" + + +def build_recurrence_work_item_handoff( + recurrence: dict[str, Any], + *, + work_item_id: str, + mode: RecurrenceWorkItemMode = "auto", + handoff_kind: RecurrenceWorkItemHandoffKind = "ticket_proposal", +) -> dict[str, Any]: + """Build a record-only handoff proposal for a recurrence work item.""" + + payload = build_recurrence_work_item_dry_run( + recurrence, + work_item_id=work_item_id, + mode=mode, + ) + allowed = bool(payload.get("allowed")) + plan = _as_dict(payload.get("plan")) + read_model_route = _as_dict(payload.get("read_model_route")) + payload.update( + { + "schema_version": "awooop_recurrence_work_item_handoff_v1", + "handoff_kind": handoff_kind, + "handoff_status": "ready_to_record" if allowed else "blocked", + "handoff_owner": "operator", + "safety_level": "handoff_record_only", + "writes_incident_state": False, + "writes_auto_repair_result": False, + "writes_ticket": False, + "creates_external_ticket": False, + "plan": { + **plan, + "step": "record_recurrence_work_item_handoff", + "flywheel_node": "handoff", + "required_scope": "record_history", + "writes": ["timeline_events", "alert_operation_log"], + }, + "read_model_route": { + **read_model_route, + "required_scope": "record_history", + "is_shadow": False, + "flywheel_node": "handoff", + }, + "next_step": _recurrence_handoff_next_step(handoff_kind, allowed), + } + ) + return payload + + def _recurrence_history_context(payload: dict[str, Any]) -> dict[str, Any]: return { "schema_version": "awooop_recurrence_work_item_dry_run_history_v1", @@ -696,6 +755,35 @@ def _recurrence_history_context(payload: dict[str, Any]) -> dict[str, Any]: } +def _recurrence_handoff_context(payload: dict[str, Any]) -> dict[str, Any]: + return { + "schema_version": "awooop_recurrence_work_item_handoff_history_v1", + "source": payload.get("source"), + "project_id": payload.get("project_id"), + "work_item_id": payload.get("work_item_id"), + "incident_id": payload.get("incident_id"), + "auto_repair_id": payload.get("auto_repair_id"), + "mode": payload.get("mode"), + "requested_mode": payload.get("requested_mode"), + "handoff_kind": payload.get("handoff_kind"), + "handoff_status": payload.get("handoff_status"), + "handoff_owner": payload.get("handoff_owner"), + "allowed": payload.get("allowed"), + "executed": payload.get("executed"), + "safety_level": payload.get("safety_level"), + "writes_incident_state": payload.get("writes_incident_state"), + "writes_auto_repair_result": payload.get("writes_auto_repair_result"), + "writes_ticket": payload.get("writes_ticket"), + "creates_external_ticket": payload.get("creates_external_ticket"), + "verification_result_preview": payload.get("verification_result_preview"), + "current_state_summary": payload.get("current_state_summary"), + "ticket_preview": payload.get("ticket_preview"), + "read_model_route": payload.get("read_model_route"), + "checks": payload.get("checks"), + "next_step": payload.get("next_step"), + } + + async def _record_recurrence_work_item_dry_run_history( payload: dict[str, Any], ) -> dict[str, Any]: @@ -763,6 +851,75 @@ async def _record_recurrence_work_item_dry_run_history( return history +async def _record_recurrence_work_item_handoff_history( + payload: dict[str, Any], +) -> dict[str, Any]: + incident_id = str(payload.get("incident_id") or "") + if not incident_id: + return {"recorded": False, "reason": "missing_incident_id"} + + history: dict[str, Any] = { + "recorded": False, + "alert_operation_id": None, + "timeline_event_id": None, + } + context = _recurrence_handoff_context(payload) + allowed = bool(payload.get("allowed")) + + try: + from src.repositories.alert_operation_log_repository import ( + get_alert_operation_log_repository, + ) + + record = await get_alert_operation_log_repository().append( + "ESCALATED", + incident_id=incident_id, + auto_repair_id=str(payload.get("auto_repair_id") or "") or None, + actor="awooop_recurrence_work_item_service", + action_detail=( + f"recurrence_work_item_handoff:{payload.get('handoff_kind')}" + )[:200], + success=allowed, + context=context, + ) + if record is not None: + history["alert_operation_id"] = getattr(record, "id", None) + except Exception as exc: + logger.warning( + "awooop_recurrence_work_item_handoff_alert_operation_history_failed", + incident_id=incident_id, + error=str(exc), + ) + + try: + from src.services.approval_db import get_timeline_service + + event = await get_timeline_service().add_event( + event_type="human", + status="warning" if allowed else "error", + title="AwoooP recurrence work item handoff", + description=_recurrence_handoff_history_description(context), + actor="awooop_recurrence_work_item_service", + actor_role=str(payload.get("handoff_kind") or "handoff"), + incident_id=incident_id, + ) + if event: + history["timeline_event_id"] = event.get("id") + except Exception as exc: + logger.warning( + "awooop_recurrence_work_item_handoff_timeline_history_failed", + incident_id=incident_id, + error=str(exc), + ) + + 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 _recurrence_history_description(context: dict[str, Any]) -> str: state = context.get("current_state_summary") or {} route = context.get("read_model_route") or {} @@ -778,6 +935,23 @@ def _recurrence_history_description(context: dict[str, Any]) -> str: )[:500] +def _recurrence_handoff_history_description(context: dict[str, Any]) -> str: + state = context.get("current_state_summary") or {} + route = context.get("read_model_route") or {} + return ( + f"handoff={context.get('handoff_kind')} " + f"status={context.get('handoff_status')} " + f"mode={context.get('mode')} " + f"occurrences={state.get('occurrence_total')} " + f"repair_status={state.get('repair_status')} " + f"route={route.get('agent_id')}/{route.get('tool_name')} " + f"external_ticket={context.get('creates_external_ticket')} " + f"writes_incident={context.get('writes_incident_state')} " + f"writes_auto_repair={context.get('writes_auto_repair_result')} " + f"writes_ticket={context.get('writes_ticket')}" + )[:500] + + def build_dossier_coverage( rows: list[dict[str, Any]], *, @@ -1247,3 +1421,33 @@ async def fetch_recurrence_work_item_dry_run( ) payload["history"] = await _record_recurrence_work_item_dry_run_history(payload) return payload + + +async def fetch_recurrence_work_item_handoff( + *, + project_id: str | None, + work_item_id: str, + mode: RecurrenceWorkItemMode = "auto", + handoff_kind: RecurrenceWorkItemHandoffKind = "ticket_proposal", + provider: str | None = None, + limit: int = _MAX_RECURRENCE_EVENTS, +) -> dict[str, Any]: + """Fetch and record a safe handoff proposal for a recurrence work item.""" + + recurrence = await fetch_channel_event_dossier_recurrence( + project_id=project_id, + provider=provider, + limit=limit, + ) + payload = build_recurrence_work_item_handoff( + recurrence, + work_item_id=work_item_id, + mode=mode, + handoff_kind=handoff_kind, + ) + payload["history"] = await _record_recurrence_work_item_handoff_history(payload) + if payload["history"].get("recorded"): + payload["handoff_status"] = "recorded" + elif payload.get("allowed"): + payload["handoff_status"] = "record_failed" + 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 977cd314..4f0326bd 100644 --- a/apps/api/tests/test_channel_event_dossier_service.py +++ b/apps/api/tests/test_channel_event_dossier_service.py @@ -13,6 +13,7 @@ from src.services.channel_event_dossier_service import ( build_dossier_event, build_dossier_recurrence, build_recurrence_work_item_dry_run, + build_recurrence_work_item_handoff, build_recurrence_work_item_preview, fetch_channel_event_dossier, fetch_channel_event_dossier_coverage, @@ -461,6 +462,68 @@ def test_build_recurrence_work_item_dry_run_returns_ticket_preview_without_write assert dry_run["read_model_route"]["required_scope"] == "read" +def test_build_recurrence_work_item_handoff_records_ticket_proposal_contract() -> None: + recurrence = build_dossier_recurrence( + [ + { + "event_id": "event-1", + "project_id": "awoooi", + "channel_type": "internal", + "provider_event_id": "alertmanager:received:1", + "content_hash": "a" * 64, + "content_preview": "Docker container unhealthy", + "content_redacted": "Docker container unhealthy", + "redaction_version": "audit_sink_v1", + "source_envelope": { + "provider": "alertmanager", + "source_refs": { + "alert_ids": ["alert-1"], + "incident_ids": ["INC-20260517-F25B4A"], + "fingerprints": ["fp-container-unhealthy"], + }, + "log_correlation": { + "alertname": "DockerContainerUnhealthy", + "severity": "warning", + "namespace": "momo", + "target_resource": "bitan-pharmacy-bitan-1", + "fingerprint": "fp-container-unhealthy", + }, + }, + "is_duplicate": True, + "provider_ts": None, + "received_at": "2026-05-17T23:47:00", + "run_id": UUID("33333333-3333-4333-8333-333333333333"), + "run_state": "completed", + "run_agent_id": "openclaw", + } + ], + project_id="awoooi", + limit=20, + ) + + handoff = build_recurrence_work_item_handoff( + recurrence, + work_item_id="incident:INC-20260517-F25B4A", + ) + + assert handoff["schema_version"] == "awooop_recurrence_work_item_handoff_v1" + assert handoff["mode"] == "ticket" + assert handoff["handoff_kind"] == "ticket_proposal" + assert handoff["handoff_status"] == "ready_to_record" + assert handoff["handoff_owner"] == "operator" + assert handoff["safety_level"] == "handoff_record_only" + assert handoff["allowed"] is True + assert handoff["creates_external_ticket"] is False + assert handoff["writes_incident_state"] is False + assert handoff["writes_auto_repair_result"] is False + assert handoff["writes_ticket"] is False + assert handoff["ticket_preview"]["would_create"] is False + assert handoff["next_step"] == "operator_review_ticket_preview" + assert handoff["plan"]["step"] == "record_recurrence_work_item_handoff" + assert handoff["plan"]["writes"] == ["timeline_events", "alert_operation_log"] + assert handoff["read_model_route"]["required_scope"] == "record_history" + + def test_build_recurrence_work_item_preview_raises_for_missing_item() -> None: recurrence = build_dossier_recurrence( [], diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index f79e3b5f..9e9ba6a6 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1846,13 +1846,17 @@ "previewing": "Previewing", "dryRun": "Dry-run", "dryRunning": "Dry-running", - "failed": "The safe preview / dry-run API did not respond, so the next step cannot be claimed.", + "handoff": "Handoff", + "handoffing": "Handing off", + "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}", "history": "Dry-run stored: {recorded}", + "handoffStatus": "Handoff: {kind} / {status}", + "externalTicket": "External ticket created: {created}", "ticket": "Ticket preview: {title}", "modes": { "auto": "Auto select", @@ -1862,6 +1866,18 @@ "observe": "Observe", "unknown": "Unknown" }, + "handoffKinds": { + "ticket_proposal": "Ticket proposal", + "manual_review": "Manual review", + "unknown": "Unknown" + }, + "handoffStatuses": { + "ready_to_record": "Ready to record", + "recorded": "Recorded", + "record_failed": "Record failed", + "blocked": "Blocked", + "unknown": "Unknown" + }, "previews": { "ticket_preview_ready": "Ticket preview ready", "reverify_preview_ready": "Reverify preview ready", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index e29048f7..afe8f84e 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -1847,13 +1847,17 @@ "previewing": "預覽中", "dryRun": "乾跑", "dryRunning": "乾跑中", - "failed": "安全預覽 / 乾跑 API 未回應,不能判定下一步。", + "handoff": "交接", + "handoffing": "交接中", + "failed": "安全預覽 / 乾跑 / 交接 API 未回應,不能判定下一步。", "allowed": "安全閘門通過", "blocked": "安全閘門阻塞", "mode": "模式:{mode}", "previewResult": "結果:{result}", "writes": "寫入:incident={incident};autoRepair={autoRepair};ticket={ticket}", "history": "試跑入庫:{recorded}", + "handoffStatus": "交接:{kind} / {status}", + "externalTicket": "外部 Ticket 建立:{created}", "ticket": "Ticket 預覽:{title}", "modes": { "auto": "自動選擇", @@ -1863,6 +1867,18 @@ "observe": "觀察", "unknown": "未知" }, + "handoffKinds": { + "ticket_proposal": "Ticket 提案", + "manual_review": "人工覆核", + "unknown": "未知" + }, + "handoffStatuses": { + "ready_to_record": "待寫入歷史", + "recorded": "已寫入歷史", + "record_failed": "寫入失敗", + "blocked": "已阻塞", + "unknown": "未知" + }, "previews": { "ticket_preview_ready": "Ticket 預覽已就緒", "reverify_preview_ready": "重新驗證預覽已就緒", 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 8ce5b3ea..b5366636 100644 --- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx +++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx @@ -110,12 +110,16 @@ type RecurrenceWorkItemActionResult = { incident_id?: string | null; mode?: string | null; requested_mode?: string | null; + handoff_kind?: string | null; + handoff_status?: string | null; + handoff_owner?: string | null; allowed?: boolean | null; executed?: boolean | null; safety_level?: string | null; writes_incident_state?: boolean | null; writes_auto_repair_result?: boolean | null; writes_ticket?: boolean | null; + creates_external_ticket?: boolean | null; verification_result_preview?: string | null; next_step?: string | null; checks?: Array<{ name?: string | null; passed?: boolean | null; detail?: string | null }>; @@ -153,7 +157,7 @@ type RecurrenceWorkItemActionResult = { }; type RecurrenceWorkItemActionState = { - loading?: "preview" | "dryRun" | null; + loading?: "preview" | "dryRun" | "handoff" | null; result?: RecurrenceWorkItemActionResult | null; error?: string | null; }; @@ -331,6 +335,25 @@ function recurrencePreviewKey(preview?: string | null) { return "unknown"; } +function recurrenceHandoffStatusKey(status?: string | null) { + if ( + status === "ready_to_record" || + status === "recorded" || + status === "record_failed" || + status === "blocked" + ) { + return status; + } + return "unknown"; +} + +function recurrenceHandoffKindKey(kind?: string | null) { + if (kind === "ticket_proposal" || kind === "manual_review") { + return kind; + } + return "unknown"; +} + function buildWorkItems( telemetry: Telemetry, t: ReturnType @@ -653,7 +676,7 @@ function RecurrenceWorkQueuePanel({ const summary = recurrence?.summary; const runWorkItemAction = useCallback(async ( workItemId: string, - action: "preview" | "dryRun" + action: "preview" | "dryRun" | "handoff" ) => { setActionState((current) => ({ ...current, @@ -662,21 +685,36 @@ function RecurrenceWorkQueuePanel({ const encodedProjectId = encodeURIComponent(projectId); const encodedWorkItemId = encodeURIComponent(workItemId); - const result = action === "preview" - ? await fetchJson( - `${API_BASE}/api/v1/platform/events/dossier/recurrence/work-item/preview?project_id=${encodedProjectId}&work_item_id=${encodedWorkItemId}`, - 12000 - ) - : await postJson( - `${API_BASE}/api/v1/platform/events/dossier/recurrence/work-item/dry-run`, - { - project_id: projectId, - work_item_id: workItemId, - mode: "auto", - limit: 300, - }, - 15000 - ); + let result: RecurrenceWorkItemActionResult | null = null; + if (action === "preview") { + result = await fetchJson( + `${API_BASE}/api/v1/platform/events/dossier/recurrence/work-item/preview?project_id=${encodedProjectId}&work_item_id=${encodedWorkItemId}`, + 12000 + ); + } else if (action === "dryRun") { + result = await postJson( + `${API_BASE}/api/v1/platform/events/dossier/recurrence/work-item/dry-run`, + { + project_id: projectId, + work_item_id: workItemId, + mode: "auto", + limit: 300, + }, + 15000 + ); + } else { + result = await postJson( + `${API_BASE}/api/v1/platform/events/dossier/recurrence/work-item/handoff`, + { + project_id: projectId, + work_item_id: workItemId, + mode: "auto", + handoff_kind: "ticket_proposal", + limit: 300, + }, + 15000 + ); + } setActionState((current) => ({ ...current, @@ -736,6 +774,8 @@ function RecurrenceWorkQueuePanel({ const actionAllowed = actionResult?.allowed === true; const actionModeKey = recurrenceActionModeKey(actionResult?.mode); const previewKey = recurrencePreviewKey(actionResult?.verification_result_preview); + const handoffStatusKey = recurrenceHandoffStatusKey(actionResult?.handoff_status); + const handoffKindKey = recurrenceHandoffKindKey(actionResult?.handoff_kind); return (
+ ) : null} {runHref ? ( @@ -863,6 +914,21 @@ function RecurrenceWorkQueuePanel({ recorded: String(actionResult.history?.recorded ?? false), })}

+ {actionResult.handoff_status ? ( +

+ {t("actions.handoffStatus", { + status: t(`actions.handoffStatuses.${handoffStatusKey}` as never), + kind: t(`actions.handoffKinds.${handoffKindKey}` as never), + })} +

+ ) : null} + {actionResult.creates_external_ticket !== undefined ? ( +

+ {t("actions.externalTicket", { + created: String(actionResult.creates_external_ticket ?? false), + })} +

+ ) : null} {actionResult.ticket_preview?.title ? (

{t("actions.ticket", {