From 263d75236771d69a96306930c3780cd6a34f4189 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 25 May 2026 09:23:35 +0800 Subject: [PATCH] feat(telegram): persist callback owner review snapshots --- apps/api/src/api/v1/platform/operator_runs.py | 1 + .../src/services/platform_operator_service.py | 5 ++ apps/api/src/services/telegram_gateway.py | 69 ++++++++++++++++++- .../test_awooop_operator_timeline_labels.py | 39 +++++++++++ .../tests/test_telegram_message_templates.py | 52 +++++++++++++- apps/web/messages/en.json | 4 ++ apps/web/messages/zh-TW.json | 4 ++ .../web/src/app/[locale]/awooop/runs/page.tsx | 58 ++++++++++++++++ 8 files changed, 229 insertions(+), 3 deletions(-) diff --git a/apps/api/src/api/v1/platform/operator_runs.py b/apps/api/src/api/v1/platform/operator_runs.py index 6bc03af8..8a975c49 100644 --- a/apps/api/src/api/v1/platform/operator_runs.py +++ b/apps/api/src/api/v1/platform/operator_runs.py @@ -96,6 +96,7 @@ class CallbackReplyItem(BaseModel): callback_reply: dict[str, Any] awooop_status_chain: dict[str, Any] | None = None km_stale_completion_summary: dict[str, Any] | None = None + persisted_km_stale_completion_summary: dict[str, Any] | None = None run_detail_href: str | None = None diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index ef951ab6..43c78642 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -374,6 +374,8 @@ async def list_callback_replies( m.sent_at, m.triggered_by_state, m.source_envelope -> 'callback_reply' AS callback_reply, + m.source_envelope -> 'km_stale_completion_summary' + AS persisted_km_stale_completion_summary, r.agent_id, r.state AS run_state, r.created_at AS run_created_at @@ -1053,6 +1055,9 @@ def _callback_reply_event_item(row: Mapping[str, Any]) -> dict[str, Any]: "agent_id": row.get("agent_id"), "run_created_at": row.get("run_created_at"), "callback_reply": callback_reply, + "persisted_km_stale_completion_summary": _as_dict( + row.get("persisted_km_stale_completion_summary"), + ) or None, "run_detail_href": ( f"/awooop/runs/{run_id}?project_id={project_id}" if run_id and project_id diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py index ae8673f1..a2880535 100644 --- a/apps/api/src/services/telegram_gateway.py +++ b/apps/api/src/services/telegram_gateway.py @@ -824,6 +824,7 @@ def _callback_reply_source_envelope_extra( callback_action: str | None = None, parse_mode: str | None = None, error: str | None = None, + km_stale_completion_summary: dict[str, object] | None = None, ) -> dict[str, object] | None: """Build AwoooP metadata for Telegram detail/history callback replies.""" if not incident_id: @@ -844,12 +845,68 @@ def _callback_reply_source_envelope_extra( if error: callback_reply["error"] = _sanitize_telegram_error(error)[:300] - return { + extra: dict[str, object] = { "callback_reply": callback_reply, "source_refs": { "incident_ids": [incident_id], }, } + km_snapshot = _callback_reply_km_stale_completion_snapshot( + km_stale_completion_summary, + ) + if km_snapshot: + extra["km_stale_completion_summary"] = km_snapshot + + return extra + + +def _callback_reply_km_stale_completion_snapshot( + summary: dict[str, object] | None, +) -> dict[str, object] | None: + """Persist a compact KM owner-review state snapshot with callback evidence.""" + if not isinstance(summary, dict): + return None + + snapshot: dict[str, object] = { + "schema_version": "km_stale_owner_review_callback_reply_snapshot_v1", + "source_schema_version": str(summary.get("schema_version") or ""), + "status": str(summary.get("status") or "observed"), + "incident_id": str(summary.get("incident_id") or ""), + "project_id": str(summary.get("project_id") or ""), + "missing_reason": str(summary.get("missing_reason") or ""), + "ready_count": _safe_int(summary.get("ready_count")), + "blocked_count": _safe_int(summary.get("blocked_count")), + "completed_count": _safe_int(summary.get("completed_count")), + "failed_count": _safe_int(summary.get("failed_count")), + "related_total": _safe_int(summary.get("related_total")), + "writes_on_read": bool(summary.get("writes_on_read")), + "batch_writes_allowed": bool(summary.get("batch_writes_allowed")), + "manual_review_required": bool(summary.get("manual_review_required", True)), + } + triage = _km_stale_completion_triage(summary) + if triage: + raw_supporting_agents = triage.get("supporting_agents") + supporting_agents = ( + raw_supporting_agents + if isinstance(raw_supporting_agents, list) + else [] + ) + snapshot["triage"] = { + "schema_version": str(triage.get("schema_version") or ""), + "flow_stage": str(triage.get("flow_stage") or ""), + "ai_lead_agent": str(triage.get("ai_lead_agent") or ""), + "supporting_agents": [ + str(agent) + for agent in supporting_agents + if str(agent).strip() + ][:5], + "automation_state": str(triage.get("automation_state") or ""), + "safe_to_auto_repair": bool(triage.get("safe_to_auto_repair")), + "blocking_reason": str(triage.get("blocking_reason") or ""), + "matching_strategy": str(triage.get("matching_strategy") or ""), + } + + return snapshot def _merge_outbound_source_envelope_extra( @@ -864,6 +921,10 @@ def _merge_outbound_source_envelope_extra( if isinstance(callback_reply, dict): envelope["callback_reply"] = callback_reply + km_stale_completion_summary = extra.get("km_stale_completion_summary") + if isinstance(km_stale_completion_summary, dict): + envelope["km_stale_completion_summary"] = km_stale_completion_summary + extra_refs = extra.get("source_refs") if isinstance(extra_refs, dict): source_refs = envelope.setdefault("source_refs", {}) @@ -6101,6 +6162,7 @@ class TelegramGateway: reply_markup=_awooop_runs_reply_markup(incident_id), incident_id=incident_id, callback_action="detail", + km_stale_completion_summary=km_completion_summary, ) except Exception as e: @@ -6253,6 +6315,7 @@ class TelegramGateway: reply_markup=_awooop_runs_reply_markup(incident_id), incident_id=incident_id, callback_action="history", + km_stale_completion_summary=km_completion_summary, ) except Exception as e: @@ -6491,6 +6554,7 @@ class TelegramGateway: reply_markup: dict | None = None, incident_id: str | None = None, callback_action: str | None = None, + km_stale_completion_summary: dict[str, object] | None = None, ) -> None: """Send a multi-line HTML message without cutting Telegram tags in half.""" chunks = _telegram_html_chunks(lines) @@ -6510,6 +6574,7 @@ class TelegramGateway: chunk_count=len(chunks), callback_action=callback_action, parse_mode="HTML", + km_stale_completion_summary=km_stale_completion_summary, ) if source_extra: payload[_AWOOOP_SOURCE_ENVELOPE_EXTRA_KEY] = source_extra @@ -6540,6 +6605,7 @@ class TelegramGateway: callback_action=callback_action, parse_mode="plain_text", error=str(exc), + km_stale_completion_summary=km_stale_completion_summary, ) if fallback_source_extra: fallback_payload[_AWOOOP_SOURCE_ENVELOPE_EXTRA_KEY] = fallback_source_extra @@ -6572,6 +6638,7 @@ class TelegramGateway: callback_action=callback_action, parse_mode="plain_text", error=str(fallback_exc), + km_stale_completion_summary=km_stale_completion_summary, ) if rescue_source_extra: rescue_payload[_AWOOOP_SOURCE_ENVELOPE_EXTRA_KEY] = rescue_source_extra diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py index 5c89d055..9bad23a7 100644 --- a/apps/api/tests/test_awooop_operator_timeline_labels.py +++ b/apps/api/tests/test_awooop_operator_timeline_labels.py @@ -375,6 +375,15 @@ def test_callback_reply_event_item_surfaces_run_link_and_human_flag() -> None: "incident_id": "INC-20260513-79ED5E", "error": "HTTP error: 400", }, + "persisted_km_stale_completion_summary": { + "schema_version": "km_stale_owner_review_callback_reply_snapshot_v1", + "status": "no_related_owner_review", + "ready_count": 4, + "triage": { + "flow_stage": "callback_observed_owner_review_link_missing", + "ai_lead_agent": "Hermes", + }, + }, }) assert item["status"] == "failed" @@ -385,6 +394,10 @@ def test_callback_reply_event_item_surfaces_run_link_and_human_flag() -> None: assert item["run_detail_href"] == ( "/awooop/runs/5c0306e0-591a-5445-9a33-80f499426b38?project_id=awoooi" ) + assert item["persisted_km_stale_completion_summary"]["ready_count"] == 4 + assert item["persisted_km_stale_completion_summary"]["triage"]["ai_lead_agent"] == ( + "Hermes" + ) def test_list_callback_replies_response_preserves_callback_evidence() -> None: @@ -444,6 +457,29 @@ def test_list_callback_replies_response_preserves_callback_evidence() -> None: } ], }, + "persisted_km_stale_completion_summary": { + "schema_version": ( + "km_stale_owner_review_callback_reply_snapshot_v1" + ), + "source_schema_version": ( + "km_stale_owner_review_completion_callback_summary_v1" + ), + "project_id": "awoooi", + "incident_id": "INC-20260513-79ED5E", + "status": "matched_owner_review", + "ready_count": 3, + "blocked_count": 1, + "completed_count": 2, + "failed_count": 0, + "batch_writes_allowed": False, + "manual_review_required": True, + "related_total": 1, + "triage": { + "flow_stage": "callback_observed_owner_review_link_missing", + "ai_lead_agent": "Hermes", + "automation_state": "manual_owner_review_required", + }, + }, "run_detail_href": ( "/awooop/runs/5c0306e0-591a-5445-9a33-80f499426b38" "?project_id=awoooi" @@ -463,6 +499,9 @@ def test_list_callback_replies_response_preserves_callback_evidence() -> None: ) assert dumped["items"][0]["km_stale_completion_summary"]["ready_count"] == 3 assert dumped["items"][0]["km_stale_completion_summary"]["related_total"] == 1 + assert dumped["items"][0]["persisted_km_stale_completion_summary"]["triage"][ + "ai_lead_agent" + ] == "Hermes" assert dumped["items"][0]["run_detail_href"].endswith("project_id=awoooi") diff --git a/apps/api/tests/test_telegram_message_templates.py b/apps/api/tests/test_telegram_message_templates.py index 72ec78ae..16cbde6b 100644 --- a/apps/api/tests/test_telegram_message_templates.py +++ b/apps/api/tests/test_telegram_message_templates.py @@ -329,6 +329,16 @@ async def test_send_request_strips_awooop_callback_metadata_before_telegram_api( "status": "callback_reply_sent", "incident_id": "INC-20260513-79ED5E", }, + "km_stale_completion_summary": { + "schema_version": ( + "km_stale_owner_review_callback_reply_snapshot_v1" + ), + "status": "no_related_owner_review", + "ready_count": 2, + "triage": { + "flow_stage": "callback_observed_owner_review_link_missing", + }, + }, }, }, ) @@ -339,6 +349,9 @@ async def test_send_request_strips_awooop_callback_metadata_before_telegram_api( assert captured["mirror"]["source_envelope_extra"]["callback_reply"]["status"] == ( "callback_reply_sent" ) + assert captured["mirror"]["source_envelope_extra"][ + "km_stale_completion_summary" + ]["ready_count"] == 2 @pytest.mark.asyncio @@ -394,17 +407,52 @@ async def test_send_html_line_message_marks_callback_reply_evidence(monkeypatch) reply_markup=reply_markup, incident_id="INC-20260514-F85F21", callback_action="history", + km_stale_completion_summary={ + "schema_version": "km_stale_owner_review_completion_callback_summary_v1", + "project_id": "awoooi", + "incident_id": "INC-20260514-F85F21", + "status": "no_related_owner_review", + "ready_count": 3, + "blocked_count": 1, + "completed_count": 2, + "failed_count": 0, + "writes_on_read": False, + "batch_writes_allowed": False, + "manual_review_required": True, + "related_total": 0, + "work_item": { + "triage": { + "schema_version": "km_stale_callback_owner_review_triage_v1", + "flow_stage": "callback_observed_owner_review_link_missing", + "ai_lead_agent": "Hermes", + "supporting_agents": ["OpenClaw", "ElephantAlpha"], + "automation_state": "manual_owner_review_required", + "safe_to_auto_repair": False, + "blocking_reason": "no_matching_completion_item", + "matching_strategy": "related_incident_id_exact_match", + }, + }, + }, ) - first_extra = sent_requests[0][1]["_awooop_source_envelope_extra"]["callback_reply"] - fallback_extra = sent_requests[1][1]["_awooop_source_envelope_extra"]["callback_reply"] + first_source_extra = sent_requests[0][1]["_awooop_source_envelope_extra"] + fallback_source_extra = sent_requests[1][1]["_awooop_source_envelope_extra"] + first_extra = first_source_extra["callback_reply"] + fallback_extra = fallback_source_extra["callback_reply"] assert first_extra["status"] == "callback_reply_sent" assert first_extra["action"] == "history" assert first_extra["parse_mode"] == "HTML" + assert first_source_extra["km_stale_completion_summary"]["ready_count"] == 3 + assert first_source_extra["km_stale_completion_summary"]["triage"]["flow_stage"] == ( + "callback_observed_owner_review_link_missing" + ) assert fallback_extra["status"] == "callback_reply_fallback_sent" assert fallback_extra["incident_id"] == "INC-20260514-F85F21" assert fallback_extra["parse_mode"] == "plain_text" + assert fallback_source_extra["km_stale_completion_summary"]["triage"][ + "ai_lead_agent" + ] == "Hermes" @pytest.mark.asyncio diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 1f7b94d5..86e3b496 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -2805,6 +2805,10 @@ "noRelated": "This incident has no matching owner-review completion item yet.", "fetchFailed": "KM owner-review summary failed to load: {reason}", "openWorkItem": "Open work item", + "snapshotTitle": "Callback-time Evidence Snapshot", + "snapshotStatus": "Snapshot status: {status}; ready {ready} / blocked {blocked} / completed {completed} / failed {failed}", + "snapshotFlow": "Snapshot flow: {stage}; match: {strategy}", + "snapshotAutomation": "Snapshot automation: lead {lead}; state {state}; safe auto-repair={safe}; blocker {blocker}", "triageFlow": "Flow: {stage}; match: {strategy}", "triageAgents": "Lead: {lead}; support: {support}", "triageAutomation": "Automation: {state}; safe auto-repair={safe}", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 4e57d7ff..351685d4 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -2806,6 +2806,10 @@ "noRelated": "本 Incident 尚未對到 owner-review completion item。", "fetchFailed": "KM owner-review 摘要讀取失敗:{reason}", "openWorkItem": "開啟工作項", + "snapshotTitle": "Callback 當下 Evidence Snapshot", + "snapshotStatus": "當下狀態:{status};ready {ready} / blocked {blocked} / completed {completed} / failed {failed}", + "snapshotFlow": "當下流程:{stage};匹配:{strategy}", + "snapshotAutomation": "當下自動化:主責 {lead};狀態 {state};可安全自動修復={safe};卡點 {blocker}", "triageFlow": "流程:{stage};匹配:{strategy}", "triageAgents": "主責:{lead};協作:{support}", "triageAutomation": "自動化:{state};可安全自動修復={safe}", diff --git a/apps/web/src/app/[locale]/awooop/runs/page.tsx b/apps/web/src/app/[locale]/awooop/runs/page.tsx index 8c021b44..e380482c 100644 --- a/apps/web/src/app/[locale]/awooop/runs/page.tsx +++ b/apps/web/src/app/[locale]/awooop/runs/page.tsx @@ -328,6 +328,7 @@ interface KmStaleCallbackOwnerReviewTriage { interface KmStaleCompletionSummary { schema_version?: string; + source_schema_version?: string | null; project_id?: string | null; incident_id?: string | null; status?: string | null; @@ -346,6 +347,7 @@ interface KmStaleCompletionSummary { related_total?: number; related_items?: KmStaleCompletionSummaryItem[]; work_item?: KmStaleCallbackOwnerReviewWorkItem | null; + triage?: KmStaleCallbackOwnerReviewTriage | null; } interface CallbackReplyEvent { @@ -369,6 +371,7 @@ interface CallbackReplyEvent { run_detail_href?: string | null; awooop_status_chain?: AwoooPStatusChain | null; km_stale_completion_summary?: KmStaleCompletionSummary | null; + persisted_km_stale_completion_summary?: KmStaleCompletionSummary | null; } interface CallbackRepliesResponse { @@ -1587,6 +1590,58 @@ function CallbackKmCompletionSummary({ ); } +function CallbackKmCompletionSnapshot({ + snapshot, +}: { + snapshot?: KmStaleCompletionSummary | null; +}) { + const t = useTranslations("awooop.callbackReply.events.kmCompletion"); + if (!snapshot) return null; + + const statusKey = normalizeKmCompletionStatus(snapshot.status); + const triage = snapshot.triage ?? null; + + return ( +
+
+
+
+

+ {t("snapshotStatus", { + status: t(`statuses.${statusKey}` as never), + ready: snapshot.ready_count ?? 0, + blocked: snapshot.blocked_count ?? 0, + completed: snapshot.completed_count ?? 0, + failed: snapshot.failed_count ?? 0, + })} +

+ {triage ? ( + <> +

+ {t("snapshotFlow", { + stage: triage.flow_stage ?? "--", + strategy: triage.matching_strategy ?? "--", + })} +

+

+ {t("snapshotAutomation", { + lead: triage.ai_lead_agent ?? "--", + state: triage.automation_state ?? "--", + safe: String(triage.safe_to_auto_repair ?? false), + blocker: triage.blocking_reason ?? "--", + })} +

+ + ) : null} +
+
+ ); +} + function CallbackReplyEvidencePanel({ events, total, @@ -1680,6 +1735,9 @@ function CallbackReplyEvidencePanel({ +