([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"\1INC-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。