feat(telegram): persist callback status chain snapshots
All checks were successful
CD Pipeline / tests (push) Successful in 1m8s
Code Review / ai-code-review (push) Successful in 11s
CD Pipeline / build-and-deploy (push) Successful in 4m23s
CD Pipeline / post-deploy-checks (push) Successful in 1m31s

This commit is contained in:
Your Name
2026-05-25 09:58:42 +08:00
parent 4818ba45c0
commit daf9d4b00b
8 changed files with 304 additions and 0 deletions

View File

@@ -95,6 +95,7 @@ class CallbackReplyItem(BaseModel):
run_created_at: datetime | None = None
callback_reply: dict[str, Any]
awooop_status_chain: dict[str, Any] | None = None
persisted_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 -> 'awooop_status_chain'
AS persisted_awooop_status_chain,
m.source_envelope -> 'km_stale_completion_summary'
AS persisted_km_stale_completion_summary,
r.agent_id,
@@ -1055,6 +1057,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_awooop_status_chain": _as_dict(
row.get("persisted_awooop_status_chain"),
) or None,
"persisted_km_stale_completion_summary": _as_dict(
row.get("persisted_km_stale_completion_summary"),
) or None,

View File

@@ -825,6 +825,7 @@ def _callback_reply_source_envelope_extra(
parse_mode: str | None = None,
error: str | None = None,
km_stale_completion_summary: dict[str, object] | None = None,
awooop_status_chain: dict[str, object] | None = None,
) -> dict[str, object] | None:
"""Build AwoooP metadata for Telegram detail/history callback replies."""
if not incident_id:
@@ -856,6 +857,8 @@ def _callback_reply_source_envelope_extra(
)
if km_snapshot:
extra["km_stale_completion_summary"] = km_snapshot
if isinstance(awooop_status_chain, dict):
extra["awooop_status_chain"] = awooop_status_chain
return extra
@@ -909,6 +912,131 @@ def _callback_reply_km_stale_completion_snapshot(
return snapshot
def _callback_reply_awooop_status_chain_snapshot(
*,
incident_id: str | None,
truth_chain: dict[str, object] | None = None,
remediation_history: dict[str, object] | None = None,
) -> dict[str, object] | None:
"""Persist a compact AwoooP status-chain snapshot with callback evidence."""
if not incident_id or (not truth_chain and not remediation_history):
return None
truth_status = (
truth_chain.get("truth_status")
if isinstance(truth_chain, dict) and isinstance(truth_chain.get("truth_status"), dict)
else {}
)
quality = (
truth_chain.get("automation_quality")
if isinstance(truth_chain, dict) and isinstance(truth_chain.get("automation_quality"), dict)
else {}
)
facts = quality.get("facts") if isinstance(quality.get("facts"), dict) else {}
latest = _latest_remediation_history_item(remediation_history)
remediation_state = _remediation_evidence_state(remediation_history) or "missing"
remediation_total = (
_safe_int(remediation_history.get("total"))
if isinstance(remediation_history, dict)
else 0
)
latest_route = "none"
if latest:
latest_route = (
f"{latest.get('agent_id') or 'unknown_agent'}/"
f"{latest.get('tool_name') or 'current_state'}/"
f"{latest.get('required_scope') or 'unknown'}"
)
current_stage = str(truth_status.get("current_stage") or "unknown")
stage_status = str(truth_status.get("stage_status") or "unknown")
verdict = str(quality.get("verdict") or "unknown")
verification = (
facts.get("verification_result")
or latest.get("verification_result_preview")
or "missing"
)
auto_repair_records = _safe_int(facts.get("auto_repair_execution_records"))
operation_records = _safe_int(facts.get("automation_operation_records"))
gateway_total = _safe_int(facts.get("mcp_gateway_total"))
km_entries = _safe_int(facts.get("knowledge_entries"))
needs_human = bool(truth_status.get("needs_human"))
if verdict == "auto_repaired_verified":
repair_state = "auto_repaired_verified"
next_step = "monitor_for_regression"
elif auto_repair_records > 0 or operation_records > 0:
repair_state = "executed_pending_verification" if verification == "missing" else "executed"
next_step = "verify_execution_result"
elif remediation_state == "read_only":
repair_state = "read_only_dry_run"
next_step = "approve_or_escalate_from_awooop"
elif remediation_state == "write_observed":
repair_state = "write_observed_manual_review"
next_step = "review_write_evidence"
elif remediation_state == "blocked":
repair_state = "blocked_manual_required"
next_step = "manual_investigation"
elif needs_human:
repair_state = "manual_required"
next_step = "manual_investigation"
else:
repair_state = "no_execution_evidence"
next_step = "collect_evidence_or_wait"
if remediation_state in {"blocked", "fetch_failed"}:
needs_human = True
if (
remediation_state == "write_observed"
and repair_state != "auto_repaired_verified"
):
needs_human = True
truth_blockers = (
truth_status.get("blockers") if isinstance(truth_status.get("blockers"), list) else []
)
quality_blockers = (
quality.get("blockers") if isinstance(quality.get("blockers"), list) else []
)
blockers = [
str(item)
for item in [*truth_blockers, *quality_blockers]
if item
]
return {
"schema_version": "awooop_status_chain_callback_reply_snapshot_v1",
"source_schema_version": "awooop_status_chain_v1",
"source": "telegram_callback_reply_snapshot",
"source_id": incident_id,
"incident_ids": [incident_id],
"current_stage": current_stage,
"stage_status": stage_status,
"verdict": verdict,
"repair_state": repair_state,
"verification": str(verification),
"needs_human": needs_human,
"next_step": next_step,
"blockers": blockers[:8],
"evidence": {
"auto_repair_records": auto_repair_records,
"operation_records": operation_records,
"mcp_gateway_total": gateway_total,
"knowledge_entries": km_entries,
"remediation_total": remediation_total,
"remediation_state": remediation_state,
"latest_route": latest_route,
"latest_mode": latest.get("mode"),
"latest_at": latest.get("created_at"),
"latest_preview": latest.get("verification_result_preview"),
},
"writes": {
"incident": latest.get("writes_incident_state"),
"auto_repair": latest.get("writes_auto_repair_result"),
},
}
def _merge_outbound_source_envelope_extra(
envelope: dict[str, object],
extra: dict[str, object] | None,
@@ -925,6 +1053,10 @@ def _merge_outbound_source_envelope_extra(
if isinstance(km_stale_completion_summary, dict):
envelope["km_stale_completion_summary"] = km_stale_completion_summary
awooop_status_chain = extra.get("awooop_status_chain")
if isinstance(awooop_status_chain, dict):
envelope["awooop_status_chain"] = awooop_status_chain
extra_refs = extra.get("source_refs")
if isinstance(extra_refs, dict):
source_refs = envelope.setdefault("source_refs", {})
@@ -6123,6 +6255,7 @@ class TelegramGateway:
incident_id=incident_id,
project_id=project_id,
)
truth_chain: dict[str, object] | None = None
try:
from src.services.awooop_truth_chain_service import fetch_truth_chain
@@ -6156,6 +6289,11 @@ class TelegramGateway:
lines += _format_km_stale_completion_lines(km_completion_summary)
lines += _format_remediation_history_lines(remediation_history)
awooop_status_chain_snapshot = _callback_reply_awooop_status_chain_snapshot(
incident_id=incident_id,
truth_chain=truth_chain,
remediation_history=remediation_history,
)
await self._send_html_line_message(
lines,
failure_context="incident_detail",
@@ -6163,6 +6301,7 @@ class TelegramGateway:
incident_id=incident_id,
callback_action="detail",
km_stale_completion_summary=km_completion_summary,
awooop_status_chain=awooop_status_chain_snapshot,
)
except Exception as e:
@@ -6281,6 +6420,7 @@ class TelegramGateway:
incident_id=incident_id,
project_id=project_id,
)
truth_chain: dict[str, object] | None = None
try:
from src.services.awooop_truth_chain_service import fetch_truth_chain
@@ -6309,6 +6449,11 @@ class TelegramGateway:
lines += _format_km_stale_completion_lines(km_completion_summary)
lines += _format_remediation_history_lines(remediation_history)
awooop_status_chain_snapshot = _callback_reply_awooop_status_chain_snapshot(
incident_id=incident_id,
truth_chain=truth_chain,
remediation_history=remediation_history,
)
await self._send_html_line_message(
lines,
failure_context="incident_history",
@@ -6316,6 +6461,7 @@ class TelegramGateway:
incident_id=incident_id,
callback_action="history",
km_stale_completion_summary=km_completion_summary,
awooop_status_chain=awooop_status_chain_snapshot,
)
except Exception as e:
@@ -6555,6 +6701,7 @@ class TelegramGateway:
incident_id: str | None = None,
callback_action: str | None = None,
km_stale_completion_summary: dict[str, object] | None = None,
awooop_status_chain: dict[str, object] | None = None,
) -> None:
"""Send a multi-line HTML message without cutting Telegram tags in half."""
chunks = _telegram_html_chunks(lines)
@@ -6575,6 +6722,7 @@ class TelegramGateway:
callback_action=callback_action,
parse_mode="HTML",
km_stale_completion_summary=km_stale_completion_summary,
awooop_status_chain=awooop_status_chain,
)
if source_extra:
payload[_AWOOOP_SOURCE_ENVELOPE_EXTRA_KEY] = source_extra
@@ -6606,6 +6754,7 @@ class TelegramGateway:
parse_mode="plain_text",
error=str(exc),
km_stale_completion_summary=km_stale_completion_summary,
awooop_status_chain=awooop_status_chain,
)
if fallback_source_extra:
fallback_payload[_AWOOOP_SOURCE_ENVELOPE_EXTRA_KEY] = fallback_source_extra
@@ -6639,6 +6788,7 @@ class TelegramGateway:
parse_mode="plain_text",
error=str(fallback_exc),
km_stale_completion_summary=km_stale_completion_summary,
awooop_status_chain=awooop_status_chain,
)
if rescue_source_extra:
rescue_payload[_AWOOOP_SOURCE_ENVELOPE_EXTRA_KEY] = rescue_source_extra

View File

@@ -375,6 +375,12 @@ def test_callback_reply_event_item_surfaces_run_link_and_human_flag() -> None:
"incident_id": "INC-20260513-79ED5E",
"error": "HTTP error: 400",
},
"persisted_awooop_status_chain": {
"schema_version": "awooop_status_chain_callback_reply_snapshot_v1",
"repair_state": "blocked_manual_required",
"needs_human": True,
"next_step": "manual_investigation",
},
"persisted_km_stale_completion_summary": {
"schema_version": "km_stale_owner_review_callback_reply_snapshot_v1",
"status": "no_related_owner_review",
@@ -398,6 +404,9 @@ def test_callback_reply_event_item_surfaces_run_link_and_human_flag() -> None:
assert item["persisted_km_stale_completion_summary"]["triage"]["ai_lead_agent"] == (
"Hermes"
)
assert item["persisted_awooop_status_chain"]["repair_state"] == (
"blocked_manual_required"
)
def test_list_callback_replies_response_preserves_callback_evidence() -> None:
@@ -434,6 +443,30 @@ def test_list_callback_replies_response_preserves_callback_evidence() -> None:
"repair_state": "read_only_dry_run",
"needs_human": True,
},
"persisted_awooop_status_chain": {
"schema_version": "awooop_status_chain_callback_reply_snapshot_v1",
"source_schema_version": "awooop_status_chain_v1",
"source": "telegram_callback_reply_snapshot",
"source_id": "INC-20260513-79ED5E",
"incident_ids": ["INC-20260513-79ED5E"],
"current_stage": "approval_required",
"stage_status": "waiting",
"verdict": "approval_required",
"repair_state": "read_only_dry_run",
"verification": "missing",
"needs_human": True,
"next_step": "approve_or_escalate_from_awooop",
"evidence": {
"auto_repair_records": 0,
"operation_records": 0,
"mcp_gateway_total": 1,
"knowledge_entries": 0,
},
"writes": {
"incident": False,
"auto_repair": False,
},
},
"km_stale_completion_summary": {
"schema_version": (
"km_stale_owner_review_completion_callback_summary_v1"
@@ -497,6 +530,9 @@ def test_list_callback_replies_response_preserves_callback_evidence() -> None:
assert dumped["items"][0]["awooop_status_chain"]["repair_state"] == (
"read_only_dry_run"
)
assert dumped["items"][0]["persisted_awooop_status_chain"]["next_step"] == (
"approve_or_escalate_from_awooop"
)
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"][

View File

@@ -177,6 +177,58 @@ def test_awooop_status_chain_lines_show_read_only_manual_gate() -> None:
assert "pending_human_approval" in joined
def test_callback_reply_awooop_status_chain_snapshot_marks_manual_gate() -> None:
"""Callback evidence 要保存當下 AwoooP 狀態鏈,不只保存 live query 結果。"""
snapshot = telegram_gateway_module._callback_reply_awooop_status_chain_snapshot(
incident_id="INC-20260514-F85F21",
truth_chain={
"truth_status": {
"current_stage": "approval_required",
"stage_status": "waiting",
"needs_human": True,
"blockers": ["pending_human_approval"],
},
"automation_quality": {
"verdict": "approval_required",
"facts": {
"auto_repair_execution_records": 0,
"automation_operation_records": 0,
"verification_result": "missing",
"mcp_gateway_total": 1,
"knowledge_entries": 0,
},
"blockers": [],
},
},
remediation_history={
"total": 1,
"items": [
{
"agent_id": "investigator",
"tool_name": "ssh_diagnose",
"required_scope": "read",
"safety_level": "read_only",
"verification_result_preview": "degraded",
"writes_incident_state": False,
"writes_auto_repair_result": False,
}
],
},
)
assert snapshot is not None
assert snapshot["schema_version"] == (
"awooop_status_chain_callback_reply_snapshot_v1"
)
assert snapshot["repair_state"] == "read_only_dry_run"
assert snapshot["needs_human"] is True
assert snapshot["next_step"] == "approve_or_escalate_from_awooop"
assert snapshot["evidence"]["mcp_gateway_total"] == 1
assert snapshot["evidence"]["latest_route"] == "investigator/ssh_diagnose/read"
assert snapshot["writes"]["incident"] is False
assert snapshot["blockers"] == ["pending_human_approval"]
def test_km_stale_completion_lines_show_owner_review_queue_state() -> None:
"""詳情/歷史要顯示 KM owner-review completion queue 是否卡住或可處理。"""
lines = telegram_gateway_module._format_km_stale_completion_lines({
@@ -329,6 +381,15 @@ async def test_send_request_strips_awooop_callback_metadata_before_telegram_api(
"status": "callback_reply_sent",
"incident_id": "INC-20260513-79ED5E",
},
"awooop_status_chain": {
"schema_version": "awooop_status_chain_callback_reply_snapshot_v1",
"repair_state": "read_only_dry_run",
"needs_human": True,
"evidence": {
"auto_repair_records": 0,
"operation_records": 0,
},
},
"km_stale_completion_summary": {
"schema_version": (
"km_stale_owner_review_callback_reply_snapshot_v1"
@@ -349,6 +410,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"]["awooop_status_chain"][
"repair_state"
] == "read_only_dry_run"
assert captured["mirror"]["source_envelope_extra"][
"km_stale_completion_summary"
]["ready_count"] == 2
@@ -407,6 +471,29 @@ async def test_send_html_line_message_marks_callback_reply_evidence(monkeypatch)
reply_markup=reply_markup,
incident_id="INC-20260514-F85F21",
callback_action="history",
awooop_status_chain={
"schema_version": "awooop_status_chain_callback_reply_snapshot_v1",
"source": "telegram_callback_reply_snapshot",
"source_id": "INC-20260514-F85F21",
"incident_ids": ["INC-20260514-F85F21"],
"current_stage": "approval_required",
"stage_status": "waiting",
"verdict": "approval_required",
"repair_state": "read_only_dry_run",
"verification": "missing",
"needs_human": True,
"next_step": "approve_or_escalate_from_awooop",
"evidence": {
"auto_repair_records": 0,
"operation_records": 0,
"mcp_gateway_total": 1,
"knowledge_entries": 0,
},
"writes": {
"incident": False,
"auto_repair": False,
},
},
km_stale_completion_summary={
"schema_version": "km_stale_owner_review_completion_callback_summary_v1",
"project_id": "awoooi",
@@ -443,6 +530,10 @@ async def test_send_html_line_message_marks_callback_reply_evidence(monkeypatch)
assert first_extra["status"] == "callback_reply_sent"
assert first_extra["action"] == "history"
assert first_extra["parse_mode"] == "HTML"
assert first_source_extra["awooop_status_chain"]["repair_state"] == (
"read_only_dry_run"
)
assert first_source_extra["awooop_status_chain"]["needs_human"] is True
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"
@@ -450,6 +541,9 @@ async def test_send_html_line_message_marks_callback_reply_evidence(monkeypatch)
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["awooop_status_chain"]["next_step"] == (
"approve_or_escalate_from_awooop"
)
assert fallback_source_extra["km_stale_completion_summary"]["triage"][
"ai_lead_agent"
] == "Hermes"

View File

@@ -2796,6 +2796,7 @@
"providerMessage": "Message: {messageId}",
"previewEmpty": "No preview",
"openRun": "Open Run",
"awooopSnapshotTitle": "Callback-time AwoooP Status Chain",
"kmCompletion": {
"title": "KM Owner Review",
"status": "Status: {status}",

View File

@@ -2797,6 +2797,7 @@
"providerMessage": "Message{messageId}",
"previewEmpty": "無摘要",
"openRun": "開啟 Run",
"awooopSnapshotTitle": "Callback 當下 AwoooP 狀態鏈",
"kmCompletion": {
"title": "KM Owner Review",
"status": "狀態:{status}",

View File

@@ -370,6 +370,7 @@ interface CallbackReplyEvent {
agent_id?: string | null;
run_detail_href?: string | null;
awooop_status_chain?: AwoooPStatusChain | null;
persisted_awooop_status_chain?: AwoooPStatusChain | null;
km_stale_completion_summary?: KmStaleCompletionSummary | null;
persisted_km_stale_completion_summary?: KmStaleCompletionSummary | null;
}
@@ -1732,6 +1733,21 @@ function CallbackReplyEvidencePanel({
compact
className="mt-3"
/>
{event.persisted_awooop_status_chain ? (
<div className="mt-3 border-t border-[#e0ddd4] pt-3">
<div className="flex items-center gap-2">
<Activity className="h-3.5 w-3.5 text-[#1f5b9b]" aria-hidden="true" />
<p className="text-xs font-semibold text-[#141413]">
{t("awooopSnapshotTitle")}
</p>
</div>
<AwoooPStatusChainPanel
chain={event.persisted_awooop_status_chain}
compact
className="mt-2"
/>
</div>
) : null}
<CallbackKmCompletionSummary
summary={event.km_stale_completion_summary}
/>