feat(telegram): persist callback owner review snapshots
All checks were successful
CD Pipeline / tests (push) Successful in 1m10s
Code Review / ai-code-review (push) Successful in 10s
CD Pipeline / build-and-deploy (push) Successful in 4m23s
CD Pipeline / post-deploy-checks (push) Successful in 1m28s

This commit is contained in:
Your Name
2026-05-25 09:23:35 +08:00
parent 862f35fee7
commit 263d752367
8 changed files with 229 additions and 3 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 (
<div className="mt-3 border-t border-[#e0ddd4] pt-3">
<div className="flex items-center gap-2">
<ShieldCheck className="h-3.5 w-3.5 text-[#1f5b9b]" aria-hidden="true" />
<p className="text-xs font-semibold text-[#141413]">
{t("snapshotTitle")}
</p>
</div>
<div className="mt-2 grid gap-1 text-xs leading-5 text-[#5f5b52]">
<p>
{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,
})}
</p>
{triage ? (
<>
<p>
{t("snapshotFlow", {
stage: triage.flow_stage ?? "--",
strategy: triage.matching_strategy ?? "--",
})}
</p>
<p>
{t("snapshotAutomation", {
lead: triage.ai_lead_agent ?? "--",
state: triage.automation_state ?? "--",
safe: String(triage.safe_to_auto_repair ?? false),
blocker: triage.blocking_reason ?? "--",
})}
</p>
</>
) : null}
</div>
</div>
);
}
function CallbackReplyEvidencePanel({
events,
total,
@@ -1680,6 +1735,9 @@ function CallbackReplyEvidencePanel({
<CallbackKmCompletionSummary
summary={event.km_stale_completion_summary}
/>
<CallbackKmCompletionSnapshot
snapshot={event.persisted_km_stale_completion_summary}
/>
<Link
href={runHref as never}
className="mt-3 inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-[#faf9f3] px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#1f6feb] hover:bg-[#edf4ff] hover:text-[#0f4fa8]"