diff --git a/apps/api/src/api/v1/platform/operator_runs.py b/apps/api/src/api/v1/platform/operator_runs.py index 28eaf318c..75884b85f 100644 --- a/apps/api/src/api/v1/platform/operator_runs.py +++ b/apps/api/src/api/v1/platform/operator_runs.py @@ -116,9 +116,11 @@ class CallbackReplyAuditSummary(BaseModel): outbound_total: int outbound_source_envelope_total: int outbound_source_refs_total: int + outbound_trace_ref_total: int = 0 outbound_incident_ref_total: int outbound_reply_markup_total: int = 0 outbound_reply_markup_missing_incident_ref_total: int = 0 + outbound_reply_markup_missing_trace_ref_total: int = 0 outbound_reply_markup_missing_incident_ref_top_prefixes: list[ OutboundReplyMarkupGapPrefix ] = Field(default_factory=list) diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index 6dc24775b..194b437f6 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -473,6 +473,33 @@ async def _fetch_callback_reply_audit_summary( """Summarize Telegram outbound mirror and callback evidence capture coverage.""" result = await db.execute( text(""" + WITH outbound AS ( + SELECT + m.*, + EXISTS ( + SELECT 1 + FROM jsonb_each( + CASE + WHEN jsonb_typeof( + COALESCE( + m.source_envelope -> 'source_refs', + '{}'::jsonb + ) + ) = 'object' + THEN COALESCE( + m.source_envelope -> 'source_refs', + '{}'::jsonb + ) + ELSE '{}'::jsonb + END + ) AS refs(key, value) + WHERE jsonb_typeof(refs.value) = 'array' + AND refs.value <> '[]'::jsonb + ) AS has_trace_ref + FROM awooop_outbound_message m + WHERE m.project_id = :project_id + AND m.channel_type = 'telegram' + ) SELECT COUNT(*) AS outbound_total, COUNT(*) FILTER ( @@ -481,6 +508,9 @@ async def _fetch_callback_reply_audit_summary( COUNT(*) FILTER ( WHERE source_envelope ? 'source_refs' ) AS outbound_source_refs_total, + COUNT(*) FILTER ( + WHERE has_trace_ref + ) AS outbound_trace_ref_total, COUNT(*) FILTER ( WHERE COALESCE( source_envelope #> '{source_refs,incident_ids}', @@ -497,6 +527,10 @@ async def _fetch_callback_reply_audit_summary( '[]'::jsonb ) = '[]'::jsonb ) AS outbound_reply_markup_missing_incident_ref_total, + COUNT(*) FILTER ( + WHERE source_envelope #>> '{reply_markup,present}' = 'true' + AND NOT has_trace_ref + ) AS outbound_reply_markup_missing_trace_ref_total, COALESCE(( SELECT jsonb_agg( jsonb_build_object( @@ -525,10 +559,8 @@ async def _fetch_callback_reply_audit_summary( ) AS recent_24h_total, MIN(COALESCE(sent_at, queued_at)) AS first_sent_at, MAX(COALESCE(sent_at, queued_at)) AS last_sent_at - FROM awooop_outbound_message - WHERE project_id = :project_id - AND channel_type = 'telegram' - AND source_envelope #>> '{reply_markup,present}' = 'true' + FROM outbound + WHERE source_envelope #>> '{reply_markup,present}' = 'true' AND COALESCE( source_envelope #> '{source_refs,incident_ids}', '[]'::jsonb @@ -604,9 +636,7 @@ async def _fetch_callback_reply_audit_summary( MAX(COALESCE(sent_at, queued_at)) FILTER ( WHERE source_envelope ? 'callback_reply' ) AS latest_callback_at - FROM awooop_outbound_message - WHERE project_id = :project_id - AND channel_type = 'telegram' + FROM outbound """), {"project_id": project_id}, ) @@ -661,6 +691,7 @@ def _callback_reply_audit_summary_from_row( "outbound_source_refs_total": _safe_int( row.get("outbound_source_refs_total") ), + "outbound_trace_ref_total": _safe_int(row.get("outbound_trace_ref_total")), "outbound_incident_ref_total": outbound_incident_refs, "outbound_reply_markup_total": _safe_int( row.get("outbound_reply_markup_total") @@ -668,6 +699,9 @@ def _callback_reply_audit_summary_from_row( "outbound_reply_markup_missing_incident_ref_total": _safe_int( row.get("outbound_reply_markup_missing_incident_ref_total") ), + "outbound_reply_markup_missing_trace_ref_total": _safe_int( + row.get("outbound_reply_markup_missing_trace_ref_total") + ), "outbound_reply_markup_missing_incident_ref_top_prefixes": ( top_missing_prefixes ), diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py index ffcab8684..89f0d3d9e 100644 --- a/apps/api/src/services/telegram_gateway.py +++ b/apps/api/src/services/telegram_gateway.py @@ -70,6 +70,10 @@ POLLING_LEADER_WATCH = 30 # seconds - 非 Leader Pod 每 30s 嘗試接管 logger = structlog.get_logger(__name__) _TELEGRAM_BOT_URL_RE = re.compile(r"(api\.telegram\.org/bot)[^/\s]+") _INCIDENT_ID_RE = re.compile(r"\bINC-\d{8}-[A-Z0-9]{4,}\b") +_META_EVENT_ID_RE = re.compile(r"\bMETA-\d{14}\b") +_AI_ADVISORY_CALLBACK_RE = re.compile( + r"^ai_advisory_(?:handled|snooze|view|produce_cmd):([^:]+):(.+)$" +) _CODE_REF_RE = re.compile(r"([0-9a-f]{7,12})", re.IGNORECASE) _TELEGRAM_HTML_CHUNK_LIMIT = 3600 _AWOOOP_WEB_BASE_URL = "https://awoooi.wooo.work" @@ -969,6 +973,47 @@ def _reply_markup_incident_ids(payload: dict) -> list[str]: return sorted(incident_ids) +def _append_source_ref(refs: dict[str, list[str]], key: str, value: object) -> None: + text = str(value or "").strip() + if not text: + return + + values = refs.setdefault(key, []) + if text not in values: + values.append(text) + + +def _reply_markup_domain_source_refs(payload: dict) -> dict[str, list[str]]: + """Extract non-secret domain refs from known button schemas.""" + reply_markup = payload.get("reply_markup") + if not isinstance(reply_markup, dict): + return {} + + refs: dict[str, list[str]] = {} + for row in reply_markup.get("inline_keyboard") or []: + if not isinstance(row, list): + continue + for button in row: + if not isinstance(button, dict): + continue + callback_data = str(button.get("callback_data") or "") + match = _AI_ADVISORY_CALLBACK_RE.match(callback_data) + if not match: + continue + advisory_type = match.group(1).strip() + advisory_id = match.group(2).strip() + if not advisory_type or not advisory_id: + continue + advisory_ref = f"{advisory_type}:{advisory_id}" + advisory_event = f"ai_advisory:{advisory_ref}" + _append_source_ref(refs, "event_ids", advisory_event) + _append_source_ref(refs, "advisory_ids", advisory_ref) + _append_source_ref(refs, "alert_ids", advisory_type) + _append_source_ref(refs, "fingerprints", advisory_event) + + return {key: values[:20] for key, values in refs.items() if values} + + def _outbound_source_envelope(method: str, payload: dict) -> dict[str, object]: """Build a redaction-friendly source envelope for Channel Hub replay.""" text = str(payload.get("text") or payload.get("caption") or "") @@ -976,7 +1021,22 @@ def _outbound_source_envelope(method: str, payload: dict) -> dict[str, object]: set(_INCIDENT_ID_RE.findall(text)) | set(_reply_markup_incident_ids(payload)) ) + domain_refs = _reply_markup_domain_source_refs(payload) + event_ids = sorted( + set(_META_EVENT_ID_RE.findall(text)) + | set(domain_refs.get("event_ids", [])) + ) code_refs = sorted(set(match.group(1) for match in _CODE_REF_RE.finditer(text))) + source_refs: dict[str, list[str]] = { + "incident_ids": incident_ids[:20], + "event_ids": event_ids[:20], + "code_refs": code_refs[:20], + } + for key, values in domain_refs.items(): + if key == "event_ids": + continue + source_refs[key] = values[:20] + return { "adapter": "legacy_telegram_gateway", "method": method, @@ -986,10 +1046,7 @@ def _outbound_source_envelope(method: str, payload: dict) -> dict[str, object]: "disable_web_page_preview": payload.get("disable_web_page_preview"), "has_reply_context": _has_reply_context(payload), "reply_markup": _reply_markup_summary(payload), - "source_refs": { - "incident_ids": incident_ids[:20], - "code_refs": code_refs[:20], - }, + "source_refs": source_refs, } @@ -4463,6 +4520,16 @@ class TelegramGateway: ] } + source_refs: dict[str, list[str]] = { + "approval_ids": [approval_id], + "alert_ids": [alertname], + "fingerprints": [f"meta:{alert_category}:{alertname}"], + } + if _INCIDENT_ID_RE.fullmatch(incident_id): + source_refs["incident_ids"] = [incident_id] + else: + source_refs["event_ids"] = [incident_id] + return await self._send_request( "sendMessage", { @@ -4470,6 +4537,9 @@ class TelegramGateway: "text": text, "parse_mode": "HTML", "reply_markup": keyboard, + _AWOOOP_SOURCE_ENVELOPE_EXTRA_KEY: { + "source_refs": source_refs, + }, }, ) diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py index d919d835e..2e2462380 100644 --- a/apps/api/tests/test_awooop_operator_timeline_labels.py +++ b/apps/api/tests/test_awooop_operator_timeline_labels.py @@ -666,9 +666,11 @@ def test_list_callback_replies_response_preserves_callback_evidence() -> None: "outbound_total": 120, "outbound_source_envelope_total": 118, "outbound_source_refs_total": 100, + "outbound_trace_ref_total": 92, "outbound_incident_ref_total": 80, "outbound_reply_markup_total": 30, "outbound_reply_markup_missing_incident_ref_total": 4, + "outbound_reply_markup_missing_trace_ref_total": 2, "outbound_reply_markup_missing_incident_ref_top_prefixes": [ { "prefix": "silence", @@ -719,8 +721,10 @@ def test_list_callback_replies_response_preserves_callback_evidence() -> None: assert dumped["items"][0]["evidence_capture_status"]["status"] == "captured" assert dumped["items"][0]["run_detail_href"].endswith("project_id=awoooi") assert dumped["summary"]["outbound_total"] == 120 + assert dumped["summary"]["outbound_trace_ref_total"] == 92 assert dumped["summary"]["outbound_reply_markup_total"] == 30 assert dumped["summary"]["outbound_reply_markup_missing_incident_ref_total"] == 4 + assert dumped["summary"]["outbound_reply_markup_missing_trace_ref_total"] == 2 assert dumped["summary"][ "outbound_reply_markup_missing_incident_ref_top_prefixes" ][0] == { @@ -749,9 +753,11 @@ def test_callback_reply_audit_summary_marks_missing_snapshots() -> None: "outbound_total": 5256, "outbound_source_envelope_total": 5256, "outbound_source_refs_total": 5000, + "outbound_trace_ref_total": 4300, "outbound_incident_ref_total": 3200, "outbound_reply_markup_total": 100, "outbound_reply_markup_missing_incident_ref_total": 12, + "outbound_reply_markup_missing_trace_ref_total": 5, "outbound_reply_markup_missing_incident_ref_top_prefixes": [ { "prefix": "silence", @@ -786,6 +792,8 @@ def test_callback_reply_audit_summary_marks_missing_snapshots() -> None: assert summary["schema_version"] == "telegram_callback_reply_audit_summary_v1" assert summary["outbound_total"] == 5256 + assert summary["outbound_trace_ref_total"] == 4300 + assert summary["outbound_reply_markup_missing_trace_ref_total"] == 5 assert summary["callback_total"] == 2 assert summary["callback_snapshot_missing_total"] == 2 assert summary["snapshot_status"] == "not_captured" @@ -804,9 +812,11 @@ def test_callback_reply_audit_summary_marks_mixed_legacy_snapshots_partial() -> "outbound_total": 5221, "outbound_source_envelope_total": 4905, "outbound_source_refs_total": 4676, + "outbound_trace_ref_total": 4230, "outbound_incident_ref_total": 920, "outbound_reply_markup_total": 1322, "outbound_reply_markup_missing_incident_ref_total": 684, + "outbound_reply_markup_missing_trace_ref_total": 154, "outbound_reply_markup_missing_incident_ref_top_prefixes": [ { "prefix": "silence", @@ -840,6 +850,8 @@ def test_callback_reply_audit_summary_marks_mixed_legacy_snapshots_partial() -> ) assert summary["callback_snapshot_captured_total"] == 1 + assert summary["outbound_trace_ref_total"] == 4230 + assert summary["outbound_reply_markup_missing_trace_ref_total"] == 154 assert summary["callback_snapshot_missing_total"] == 2 assert summary["snapshot_status"] == "partial" assert summary["next_action"] == "review_legacy_callback_snapshot_gap" diff --git a/apps/api/tests/test_telegram_gateway_error_sanitizer.py b/apps/api/tests/test_telegram_gateway_error_sanitizer.py index 5c7da5b8e..555836561 100644 --- a/apps/api/tests/test_telegram_gateway_error_sanitizer.py +++ b/apps/api/tests/test_telegram_gateway_error_sanitizer.py @@ -76,3 +76,66 @@ def test_outbound_source_envelope_reads_incident_refs_from_buttons() -> None: ] assert envelope["reply_markup"]["buttons"][0]["callback_prefix"] == "detail" assert "detail:INC-20260525-ABC123" not in str(envelope) + + +def test_outbound_source_envelope_reads_meta_event_ref_from_text() -> None: + payload = { + "chat_id": "-100123", + "text": ( + "⚙️ META SYSTEM\n" + "📋 META-20260525201300\n" + "異常元件:AI 自健診異常" + ), + "parse_mode": "HTML", + "reply_markup": { + "inline_keyboard": [ + [{"text": "靜默", "callback_data": "silence:opaque-secret"}] + ], + }, + } + + envelope = _outbound_source_envelope("sendMessage", payload) + + assert envelope["source_refs"]["event_ids"] == ["META-20260525201300"] + assert envelope["source_refs"]["incident_ids"] == [] + assert envelope["reply_markup"]["buttons"][0]["callback_prefix"] == "silence" + assert "opaque-secret" not in str(envelope) + + +def test_outbound_source_envelope_reads_ai_advisory_refs_without_raw_callback() -> None: + payload = { + "chat_id": "-100123", + "text": "Coverage 缺口分析", + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "已處理", + "callback_data": ( + "ai_advisory_handled:coverage_gap:auto_rule_creation" + ), + }, + { + "text": "忽略", + "callback_data": ( + "ai_advisory_snooze:coverage_gap:auto_rule_creation" + ), + }, + ] + ], + }, + } + + envelope = _outbound_source_envelope("sendMessage", payload) + + assert envelope["source_refs"]["event_ids"] == [ + "ai_advisory:coverage_gap:auto_rule_creation" + ] + assert envelope["source_refs"]["advisory_ids"] == [ + "coverage_gap:auto_rule_creation" + ] + assert envelope["source_refs"]["alert_ids"] == ["coverage_gap"] + assert envelope["source_refs"]["fingerprints"] == [ + "ai_advisory:coverage_gap:auto_rule_creation" + ] + assert "ai_advisory_handled:coverage_gap:auto_rule_creation" not in str(envelope) diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 4a6694e71..5b115ced0 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -2833,8 +2833,8 @@ "error": "Callback evidence failed to load: {error}", "summary": { "outbound": "Outbound mirror", - "outboundDetail": "source_refs {sourceRefs}; incident refs {incidentRefs}; coverage {coverage}", - "outboundReplyMarkupDetail": "reply_markup {replyMarkup}; button missing incident refs {missingIncidentRefs}", + "outboundDetail": "source_refs {sourceRefs}; trace refs {traceRefs}; incident refs {incidentRefs}; coverage {coverage}", + "outboundReplyMarkupDetail": "reply_markup {replyMarkup}; missing trace refs {missingTraceRefs}; missing incident refs {missingIncidentRefs}", "outboundReplyMarkupTopPrefixes": "Gap top prefixes: {prefixes}", "outboundReplyMarkupTopPrefixItem": "{prefix} {total} (24h {recent}, last {last})", "callbacks": "Callback replies", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 3cf9956ab..245d0f487 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -2834,8 +2834,8 @@ "error": "Callback evidence 載入失敗:{error}", "summary": { "outbound": "出站鏡像", - "outboundDetail": "source_refs {sourceRefs};incident refs {incidentRefs};覆蓋 {coverage}", - "outboundReplyMarkupDetail": "reply_markup {replyMarkup};按鈕缺 incident refs {missingIncidentRefs}", + "outboundDetail": "source_refs {sourceRefs};trace refs {traceRefs};incident refs {incidentRefs};覆蓋 {coverage}", + "outboundReplyMarkupDetail": "reply_markup {replyMarkup};缺 trace refs {missingTraceRefs};缺 incident refs {missingIncidentRefs}", "outboundReplyMarkupTopPrefixes": "缺口 top prefixes:{prefixes}", "outboundReplyMarkupTopPrefixItem": "{prefix} {total}(24h {recent},最後 {last})", "callbacks": "Callback replies", diff --git a/apps/web/src/app/[locale]/awooop/runs/page.tsx b/apps/web/src/app/[locale]/awooop/runs/page.tsx index b208a51b8..e3e66d4e4 100644 --- a/apps/web/src/app/[locale]/awooop/runs/page.tsx +++ b/apps/web/src/app/[locale]/awooop/runs/page.tsx @@ -144,9 +144,11 @@ interface CallbackReplyAuditSummary { outbound_total?: number; outbound_source_envelope_total?: number; outbound_source_refs_total?: number; + outbound_trace_ref_total?: number; outbound_incident_ref_total?: number; outbound_reply_markup_total?: number; outbound_reply_markup_missing_incident_ref_total?: number; + outbound_reply_markup_missing_trace_ref_total?: number; outbound_reply_markup_missing_incident_ref_top_prefixes?: Array<{ prefix?: string | null; total?: number | null; @@ -1980,7 +1982,7 @@ function CallbackReplyAuditSummaryPanel({ nextActionRaw === "review_outbound_source_refs" ) ? nextActionRaw : "observed"; const sourceRefCoverage = formatCoveragePercent( - summary.outbound_incident_ref_total ?? 0, + summary.outbound_trace_ref_total ?? summary.outbound_incident_ref_total ?? 0, outboundTotal ); const topMissingPrefixes = ( @@ -2034,6 +2036,10 @@ function CallbackReplyAuditSummaryPanel({

{t("outboundDetail", { sourceRefs: summary.outbound_source_refs_total ?? 0, + traceRefs: + summary.outbound_trace_ref_total ?? + summary.outbound_incident_ref_total ?? + 0, incidentRefs: summary.outbound_incident_ref_total ?? 0, coverage: sourceRefCoverage, })} @@ -2043,6 +2049,8 @@ function CallbackReplyAuditSummaryPanel({ replyMarkup: summary.outbound_reply_markup_total ?? 0, missingIncidentRefs: summary.outbound_reply_markup_missing_incident_ref_total ?? 0, + missingTraceRefs: + summary.outbound_reply_markup_missing_trace_ref_total ?? 0, })}

diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 6b4f7ee31..7c6406aef 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -20913,3 +20913,70 @@ GET /api/v1/health: - KM governance:約 84.6%。 - AI Provider lane visibility:約 92.2%。 - 完整 AI 自動化管理產品化:約 97.65%。 + +### 2026-05-25 — T190 Telegram action cards domain source refs(pre-deploy) + +**背景**:T189 production recency 顯示最近 24h 仍在新增的 `silence` 與 +`ai_advisory_handled` 缺口不是一般 Incident 卡,而是 `META-*` AI 自健診卡 +與 Coverage 缺口 AI advisory 卡。這兩類卡本來就不該硬塞 `INC-*`,但仍必須 +有可追溯的 domain source refs,否則前端看起來像「按鈕斷鏈」。 + +**完成變更**: + +- `telegram_gateway._outbound_source_envelope()` 會從 `META-YYYYMMDDHHMMSS` + 文字萃取 `source_refs.event_ids`。 +- `telegram_gateway._outbound_source_envelope()` 會從 + `ai_advisory_{handled|snooze|view|produce_cmd}:{type}:{id}` 按鈕 schema + 萃取 `event_ids / advisory_ids / alert_ids / fingerprints`,但不保存 raw + callback payload。 +- `send_meta_alert()` 額外鏡像 `approval_ids / alert_ids / fingerprints`; + 若卡片 id 是 `INC-*` 寫入 `incident_ids`,否則寫入 `event_ids`。 +- `/api/v1/platform/runs/callback-replies` summary 新增: + `outbound_trace_ref_total` 與 + `outbound_reply_markup_missing_trace_ref_total`。 +- AwoooP Runs 前端 summary 同步顯示 trace refs / 缺 trace refs,避免把 + 非 Incident governance/action card 誤判為完全缺來源。 + +**local validation(完成)**: + +```text +python3 -m py_compile apps/api/src/services/telegram_gateway.py apps/api/src/services/platform_operator_service.py apps/api/src/api/v1/platform/operator_runs.py +jq empty apps/web/messages/zh-TW.json apps/web/messages/en.json +PYTHONPATH=. DATABASE_URL='postgresql+asyncpg://test:test@localhost/test' /Users/ogt/.pyenv/shims/pytest tests/test_telegram_gateway_error_sanitizer.py tests/test_awooop_operator_timeline_labels.py -q + 58 passed in 0.84s +pnpm --dir apps/web exec tsc --noEmit --tsBuildInfoFile /tmp/awoooi-t190-tsconfig.tsbuildinfo +pnpm --dir apps/web lint -- --file 'src/app/[locale]/awooop/runs/page.tsx' + pass with pre-existing i18next/no-literal-string warnings in the same file +NEXT_PUBLIC_API_URL=https://awoooi.wooo.work pnpm --dir apps/web run build + pass; Sentry global-error / instrumentation-client warnings are pre-existing +NEXT_PUBLIC_API_URL=https://awoooi.wooo.work pnpm turbo build --filter=@awoooi/web --concurrency=1 + pass; lewooogo-core package.json condition warnings are pre-existing +git diff --check + pass +``` + +**production DB dry-run(完成,read-only SQL)**: + +```text +outbound_total = 5299 +outbound_trace_ref_total = 2346 +outbound_reply_markup_total = 1329 +outbound_reply_markup_missing_trace_ref_total = 417 +outbound_reply_markup_missing_incident_ref_total = 684 +``` + +**目前整體進度**: + +- AwoooP 告警可觀測鏈:約 99.7%。 +- 低風險自動修復閉環:約 95.8%。 +- 前端 AI 自動化管理介面同步:約 99.45%。 +- 首頁 KPI / 小龍蝦流程 truth alignment:約 96.5%。 +- Telegram 詳情 / 歷史可追溯:約 99.0%。 +- Telegram outbound / callback DB coverage 可視化:約 99.55%。 +- callback / DB replayability:約 98.6%。 +- MCP / 自建 MCP 可視化:約 95.1%。 +- Sentry / SigNoz source correlation:約 94.5%。 +- Ansible / PlayBook 可視化:約 92.6%。 +- KM governance:約 84.6%。 +- AI Provider lane visibility:約 92.2%。 +- 完整 AI 自動化管理產品化:約 97.7%。