diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py index 54d36658f..3ddc5476a 100644 --- a/apps/api/src/services/telegram_gateway.py +++ b/apps/api/src/services/telegram_gateway.py @@ -70,6 +70,7 @@ 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") _CODE_REF_RE = re.compile(r"([0-9a-f]{7,12})", re.IGNORECASE) +_TELEGRAM_HTML_CHUNK_LIMIT = 3600 def _top_gateway_bucket( @@ -195,6 +196,34 @@ def _format_remediation_history_lines(history: dict[str, object] | None) -> list ] +def _telegram_html_chunks(lines: list[str], limit: int = _TELEGRAM_HTML_CHUNK_LIMIT) -> list[str]: + """Split HTML messages by complete lines so Telegram does not receive broken tags.""" + chunks: list[str] = [] + current: list[str] = [] + current_len = 0 + for raw_line in lines: + line = str(raw_line) + line_len = len(line) + 1 + if current and current_len + line_len > limit: + chunks.append("\n".join(current)) + current = [] + current_len = 0 + if line_len > limit: + chunks.append(line[:limit]) + continue + current.append(line) + current_len += line_len + if current: + chunks.append("\n".join(current)) + return chunks + + +def _plain_text_from_html(text: str, limit: int = 3900) -> str: + """Fallback renderer for Telegram HTML parse failures.""" + plain = re.sub(r"]+>", "", text) + return html.unescape(plain)[:limit] + + def _sanitize_telegram_error(text: str) -> str: """遮蔽 Telegram Bot URL 中的 token,避免例外字串污染 log / trace。""" return _TELEGRAM_BOT_URL_RE.sub(r"\1", text) @@ -5366,7 +5395,10 @@ class TelegramGateway: error=str(remediation_exc), ) - await self.send_notification("\n".join(lines)) + await self._send_html_line_message( + lines, + failure_context="incident_detail", + ) except Exception as e: logger.warning("send_incident_detail_failed", incident_id=incident_id, error=str(e)) @@ -5501,7 +5533,10 @@ class TelegramGateway: error=str(truth_exc), ) - await self.send_notification("\n".join(lines)) + await self._send_html_line_message( + lines, + failure_context="incident_history", + ) except Exception as e: logger.warning("send_incident_history_failed", incident_id=incident_id, error=str(e)) @@ -5714,6 +5749,41 @@ class TelegramGateway: return await self._send_request("sendMessage", payload) + async def _send_html_line_message( + self, + lines: list[str], + *, + chat_id: str | int | None = None, + failure_context: str, + ) -> None: + """Send a multi-line HTML message without cutting Telegram tags in half.""" + chunks = _telegram_html_chunks(lines) + for index, chunk in enumerate(chunks): + try: + await self._send_request( + "sendMessage", + { + "chat_id": chat_id or self.alert_chat_id, + "text": chunk, + "parse_mode": "HTML", + }, + ) + except Exception as exc: + logger.warning( + "telegram_html_line_message_failed", + failure_context=failure_context, + chunk_index=index, + chunk_count=len(chunks), + error=str(exc), + ) + await self._send_request( + "sendMessage", + { + "chat_id": chat_id or self.alert_chat_id, + "text": _plain_text_from_html(chunk), + }, + ) + async def send_alert_notification( self, text: str, diff --git a/apps/api/tests/test_telegram_message_templates.py b/apps/api/tests/test_telegram_message_templates.py index 710daa3d7..cba9f9551 100644 --- a/apps/api/tests/test_telegram_message_templates.py +++ b/apps/api/tests/test_telegram_message_templates.py @@ -53,6 +53,51 @@ def test_auto_repair_status_line_distinguishes_auto_resolved() -> None: assert "CPU 92%->30%" in result +def test_telegram_html_chunks_preserve_complete_lines() -> None: + """歷史/詳情長訊息不得用 text[:500] 切壞 HTML tag。""" + lines = [ + "📊 事件歷史統計", + "", + "INC-20260513-79ED5E", + "🧭 DB Truth-chain", + "階段: blocked / manual_required", + ] * 90 + + chunks = telegram_gateway_module._telegram_html_chunks(lines, limit=900) + + assert len(chunks) > 1 + assert all(len(chunk) <= 900 for chunk in chunks) + assert all(chunk.count("") == chunk.count("") for chunk in chunks) + assert all(chunk.count("") == chunk.count("") for chunk in chunks) + + +@pytest.mark.asyncio +async def test_send_html_line_message_falls_back_to_plain_text_on_parse_error(monkeypatch): + """Telegram HTML parse 400 時要送純文字 fallback,不可回報成歷史查詢失敗。""" + sent_requests = [] + gateway = TelegramGateway() + + async def fake_send_request(method, payload): + sent_requests.append((method, payload)) + if payload.get("parse_mode") == "HTML": + raise telegram_gateway_module.TelegramGatewayError("HTTP error: 400") + return {"ok": True} + + monkeypatch.setattr(gateway, "_send_request", fake_send_request) + + await gateway._send_html_line_message( + ["📊 事件歷史統計", "階段: blocked"], + chat_id="chat", + failure_context="test_history", + ) + + assert len(sent_requests) == 2 + assert sent_requests[0][1]["parse_mode"] == "HTML" + assert "parse_mode" not in sent_requests[1][1] + assert "" not in sent_requests[1][1]["text"] + assert "blocked" in sent_requests[1][1]["text"] + + class TestTelegramMessageFormat: """測試現有 TelegramMessage 格式化""" diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 171b5aff9..3a3c70016 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1788,6 +1788,10 @@ "frontendConsole": "This page now reads production APIs instead of a static list", "mcpReady": "MCP Gateway gate is not currently a top gap", "mcpMissing": "Quality summary still reports an MCP Gateway observation gap", + "remediationHistory": "Dry-run history: {count}x; latest {preview}", + "remediationHistoryEmpty": "No remediation dry-run history yet", + "remediationRoute": "MCP: {route}", + "remediationWrites": "Writes: incident={incident}; autoRepair={autoRepair}", "timelineReady": "Timeline gate is not currently a top gap", "timelineMissing": "Quality summary still reports a Timeline / audit gap" } diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index e4a5a3d1b..7c3770e62 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -1789,6 +1789,10 @@ "frontendConsole": "本頁已改讀 production API,而非靜態清單", "mcpReady": "MCP Gateway gate 目前未列為主要缺口", "mcpMissing": "品質總覽仍指出 MCP Gateway 觀測缺口", + "remediationHistory": "試跑歷史:{count} 次;上次 {preview}", + "remediationHistoryEmpty": "尚無補救試跑歷史", + "remediationRoute": "MCP:{route}", + "remediationWrites": "寫入:incident={incident};autoRepair={autoRepair}", "timelineReady": "Timeline gate 目前未列為主要缺口", "timelineMissing": "品質總覽仍指出 Timeline / 稽核記錄缺口" } 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 43013ff83..0a59c6518 100644 --- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx +++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx @@ -58,12 +58,31 @@ type SloResponse = { }; }; +type RemediationHistoryItem = { + work_item_id?: string | null; + incident_id?: string | null; + verification_result_preview?: string | null; + agent_id?: string | null; + tool_name?: string | null; + required_scope?: string | null; + writes_incident_state?: boolean | null; + writes_auto_repair_result?: boolean | null; + created_at?: string | null; +}; + +type RemediationHistoryResponse = { + schema_version?: string; + total?: number; + items?: RemediationHistoryItem[]; +}; + type Telemetry = { quality: AutomationQualitySummary | null; governanceEvents: GovernanceEventsResponse | null; governanceQueue: GovernanceQueueResponse | null; channelEvents: RecentEventsResponse | null; slo: SloResponse | null; + remediationHistory: RemediationHistoryResponse | null; }; type WorkItem = { @@ -74,6 +93,7 @@ type WorkItem = { source: string; gateKey: string; evidence: string; + evidenceDetails?: string[]; href: string; }; @@ -119,6 +139,15 @@ function hasGateFailure(summary: AutomationQualitySummary | null, gate: string) return Boolean(summary?.gate_failures?.some((row) => row.gate === gate && row.total > 0)); } +function routeLabel(item?: RemediationHistoryItem | null) { + if (!item) return "--"; + const route = [item.agent_id, item.tool_name, item.required_scope] + .filter(Boolean) + .map(String) + .join("/"); + return route || "--"; +} + function buildWorkItems( telemetry: Telemetry, t: ReturnType @@ -132,6 +161,8 @@ function buildWorkItems( const remediationTotal = remediationQueue?.total ?? 0; const remediationReadyForAi = remediationQueue?.ready_for_ai ?? 0; const remediationNeedsHuman = remediationQueue?.needs_human ?? 0; + const remediationHistoryTotal = telemetry.remediationHistory?.total ?? 0; + const latestRemediationHistory = telemetry.remediationHistory?.items?.[0] ?? null; const governanceEventsUnavailable = telemetry.governanceEvents === null; const governanceQueueMissing = telemetry.governanceQueue?.table_pending === true; const governanceDispatchBlocked = @@ -174,13 +205,28 @@ function buildWorkItems( ? "in_progress" : "blocked", surfaceKey: "governance", - source: "/api/v1/ai/slo remediation_queue", + source: "/api/v1/ai/slo remediation_queue + remediation_history", gateKey: "remediationQueue", evidence: t("evidence.remediationQueue", { total: remediationTotal, ready: remediationReadyForAi, human: remediationNeedsHuman, }), + evidenceDetails: latestRemediationHistory + ? [ + t("evidence.remediationHistory", { + count: remediationHistoryTotal, + preview: latestRemediationHistory.verification_result_preview ?? "--", + }), + t("evidence.remediationRoute", { + route: routeLabel(latestRemediationHistory), + }), + t("evidence.remediationWrites", { + incident: String(latestRemediationHistory.writes_incident_state ?? "--"), + autoRepair: String(latestRemediationHistory.writes_auto_repair_result ?? "--"), + }), + ] + : [t("evidence.remediationHistoryEmpty")], href: "/governance", }, { @@ -295,6 +341,7 @@ export default function AwoooPWorkItemsPage() { governanceQueue: null, channelEvents: null, slo: null, + remediationHistory: null, }); const [loading, setLoading] = useState(true); const [lastUpdated, setLastUpdated] = useState(null); @@ -306,16 +353,18 @@ export default function AwoooPWorkItemsPage() { const governanceQueueUrl = `${API_BASE}/api/v1/ai/governance/queue?dispatch_status=pending&size=10`; const channelEventsUrl = `${API_BASE}/api/v1/platform/events/recent?project_id=awoooi&provider_prefix=alertmanager&limit=20`; const sloUrl = `${API_BASE}/api/v1/ai/slo`; + const remediationHistoryUrl = `${API_BASE}/api/v1/ai/slo/remediation/history?limit=80`; - const [quality, governanceEvents, governanceQueue, channelEvents, slo] = await Promise.all([ + const [quality, governanceEvents, governanceQueue, channelEvents, slo, remediationHistory] = await Promise.all([ fetchJson(qualityUrl), fetchJson(governanceEventsUrl), fetchJson(governanceQueueUrl), fetchJson(channelEventsUrl), fetchJson(sloUrl), + fetchJson(remediationHistoryUrl), ]); - setTelemetry({ quality, governanceEvents, governanceQueue, channelEvents, slo }); + setTelemetry({ quality, governanceEvents, governanceQueue, channelEvents, slo, remediationHistory }); setLastUpdated(new Date()); setLoading(false); }, []); @@ -437,6 +486,18 @@ export default function AwoooPWorkItemsPage() { {item.evidence} + {item.evidenceDetails && item.evidenceDetails.length > 0 && ( +
+ {item.evidenceDetails.map((detail) => ( + + {detail} + + ))} +
+ )} diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 4c6d9bfed..220cff037 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,32 @@ +## 2026-05-14 | T29 Telegram 詳情/歷史避免 HTML 截斷 400,Work Items 顯示補救試跑證據 + +**背景**:Telegram 截圖中 `AwoooPAutoRepairCanaryT16` 的「詳情」已能顯示 truth-chain 與處理歷程,但「歷史」回 `HTTP error: 400`。production API 直接查 `/api/v1/incidents/INC-20260513-79ED5E/timeline`、`/api/v1/ai/slo/remediation/history?incident_id=...`、`/api/v1/alert-operation-logs?incident_id=...` 均可取得資料,根因不是 DB 缺資料,而是 Telegram `send_notification()` 以 `text[:500]` 截斷 HTML,可能把 `` / `` tag 切壞,Telegram parse mode 回 400,最後被誤報成「無法取得歷史統計」。 + +**修正**: +- Telegram incident `detail` / `history` 改用 line-based HTML chunk sender,不再走 `send_notification()` 的 500 字截斷。 +- HTML chunk 送出若仍遇到 Telegram parse 400,會記錄 `telegram_html_line_message_failed` 並以純文字 fallback 重送,不再把訊息送出失敗偽裝成資料查詢失敗。 +- AwoooP Work Items 讀取 `/api/v1/ai/slo/remediation/history?limit=80`,在補救工作列顯示 dry-run history 次數、latest preview、MCP route、`writes_incident_state / writes_auto_repair_result`,讓前端能看到補救試跑是否真的只讀、是否有走 MCP。 +- `zh-TW` / `en` i18n 補齊 Work Items remediation history 文案。 + +**本地驗證**: +- `python -m py_compile apps/api/src/services/telegram_gateway.py apps/api/tests/test_telegram_message_templates.py`:pass。 +- `ruff check --select F,E9 src/services/telegram_gateway.py tests/test_telegram_message_templates.py`:pass。 +- `DATABASE_URL=postgresql+asyncpg://test:test@localhost:5432/test python -m pytest tests/test_telegram_message_templates.py tests/test_telegram_adr050.py -q`:62 passed。 +- i18n JSON parse:pass。 +- `pnpm --filter @awoooi/web typecheck`:pass。 +- `pnpm --dir apps/web exec next lint --file 'src/app/[locale]/awooop/work-items/page.tsx'`:pass。 +- `NEXT_PUBLIC_API_URL=https://awoooi.wooo.work pnpm --filter @awoooi/web build`:pass,僅有既有 Sentry global error handler / instrumentation-client 建議警告。 + +**推版與 production 驗證**: +- Pending:commit / Gitea main / CI/CD / production smoke。 + +**目前整體進度**: +- Alertmanager 低風險自動修復主線:約 98%。 +- 完整 AI 自動化管理產品化:約 94%。 +- 告警詳情/歷史可追溯:約 93%。 +- 前端 AI 自動化管理介面同步:約 83%。 +- T29 修掉 Telegram「有資料但歷史按鈕顯示 400」的關鍵體驗缺口,並把 remediation evidence 帶進 Work Items。下一段應把相同 evidence 與 Run Timeline / Approval Flow 做更一致的單一狀態機展示。 + ## 2026-05-14 | T28 IncidentCard 展開 timeline events,告警詳情不再只有壓縮 ascii **背景**:T27 已讓 remediation dry-run history 能從 governance 與 Telegram 摘要看到,但前端 IncidentCard 展開「處理歷程」時仍主要顯示 stage 摘要與 ascii timeline。Operator 仍無法在告警卡內直接看到每筆事件來自 `timeline_events` 還是 `alert_operation_log`,以及該事件的 MCP route / write flags。