{html.escape(incident_id)}\n"
f"🎯 資源:{safe_resource}\n"
f"{category_line}"
+ f"🧭 處置狀態:{safe_automation_summary}\n"
f"\n"
f"{automation_block}"
f"\n"
@@ -4462,8 +4489,6 @@ class TelegramGateway:
2026-04-09 Claude Sonnet 4.6 Asia/Taipei (統帥要求: 狀態變更在原訊息延續)
"""
- from src.core.redis_client import get_redis
-
redis = get_redis()
redis_key = f"tg_msg:{incident_id}"
stored = await redis.get(redis_key)
@@ -4481,6 +4506,31 @@ class TelegramGateway:
logger.warning("append_incident_update_invalid_message_id", stored=stored)
return False
+ # Telegram 只適合放決策摘要;同一 incident 的相同狀態 5 分鐘內不重複回覆,
+ # 詳細執行紀錄應進 timeline / AwoooP Run Monitor,避免群組被 auto-failure 洗版。
+ status_hash = hashlib.sha1(status_line.encode("utf-8")).hexdigest()[:16]
+ dedup_key = f"{INCIDENT_UPDATE_DEDUP_PREFIX}{incident_id}:{status_hash}"
+ try:
+ was_set = await redis.set(
+ dedup_key,
+ "1",
+ ex=INCIDENT_UPDATE_DEDUP_TTL_SECONDS,
+ nx=True,
+ )
+ if not was_set:
+ logger.info(
+ "append_incident_update_dedup_suppressed",
+ incident_id=incident_id,
+ dedup_key=dedup_key,
+ )
+ return True
+ except Exception as exc:
+ logger.warning(
+ "append_incident_update_dedup_failed",
+ incident_id=incident_id,
+ error=str(exc),
+ )
+
# Step 1: 取得原始訊息文字(Telegram Bot API 不提供讀取原文,只能在 editMessageText 裡重建)
# 策略: 只追加 status_line,不讀取原文(Telegram edit 要傳完整新文字)
# 所以先用 editMessageReplyMarkup 換按鈕,再 sendMessage 同 chat 以 reply 方式追加狀態
diff --git a/apps/api/tests/test_telegram_message_templates.py b/apps/api/tests/test_telegram_message_templates.py
index 0c10404c..26cabf6f 100644
--- a/apps/api/tests/test_telegram_message_templates.py
+++ b/apps/api/tests/test_telegram_message_templates.py
@@ -7,6 +7,7 @@ test_telegram_message_templates.py - Telegram 訊息模板測試
import pytest
+import src.services.telegram_gateway as telegram_gateway_module
from src.services.telegram_gateway import (
DailySummaryMessage,
DeploySuccessMessage,
@@ -15,6 +16,7 @@ from src.services.telegram_gateway import (
ResourceWarnMessage,
SentryErrorMessage,
TelegramMessage,
+ TelegramGateway,
)
@@ -38,12 +40,50 @@ class TestTelegramMessageFormat:
assert "🚨" in result
assert "嚴重" in result
assert "test-pod-123" in result
+ assert "處置狀態" in result
+ assert "規則建議待審批" in result
assert "AI 自動化鏈路" in result
assert "OpenClaw" in result
assert "NemoTron" in result
assert "ElephantAlpha" in result
assert len(result) <= 4096 # Telegram HTML message limit
+ def test_telegram_message_ai_proposal_marks_approval_wait(self):
+ """有 AI 信心分數的修復建議必須標示為 AI 待審批。"""
+ msg = TelegramMessage(
+ status_emoji="⚠️",
+ risk_level="MEDIUM",
+ resource_name="awoooi-api",
+ root_cause="CPU sustained high",
+ suggested_action="kubectl rollout restart deployment/awoooi-api",
+ estimated_downtime="~30s",
+ approval_id="INC-20260506-0000",
+ confidence=0.82,
+ ai_provider="ollama_gcp_a",
+ )
+
+ result = msg.format()
+
+ assert "處置狀態" in result
+ assert "AI 已提出修復建議,等待人工批准" in result
+
+ def test_telegram_message_no_action_marks_manual_judgement(self):
+ """NO_ACTION 卡片必須一眼看得出需要人工判斷。"""
+ msg = TelegramMessage(
+ status_emoji="ℹ️",
+ risk_level="LOW",
+ resource_name="node-exporter-110",
+ root_cause="規則命中但沒有安全可執行動作",
+ suggested_action="NO_ACTION",
+ estimated_downtime="unknown",
+ approval_id="INC-20260506-0001",
+ )
+
+ result = msg.format()
+
+ assert "處置狀態" in result
+ assert "AI 無可安全執行動作,需人工判斷" in result
+
def test_telegram_message_with_token_cost(self):
"""測試含 Token/Cost 的訊息"""
msg = TelegramMessage(
@@ -63,6 +103,46 @@ class TestTelegramMessageFormat:
assert "💰 Tokens: 1,500 / $0.0015" in result
+@pytest.mark.asyncio
+async def test_append_incident_update_deduplicates_same_status(monkeypatch):
+ """同一 Incident 的相同狀態更新 5 分鐘內不可重複洗版。"""
+
+ class FakeRedis:
+ def __init__(self):
+ self.set_calls = 0
+
+ async def get(self, key):
+ assert key == "tg_msg:INC-DEDUP"
+ return "12345"
+
+ async def set(self, *args, **kwargs):
+ self.set_calls += 1
+ assert kwargs["nx"] is True
+ assert kwargs["ex"] > 0
+ return self.set_calls == 1
+
+ fake_redis = FakeRedis()
+ sent_requests = []
+ gateway = TelegramGateway()
+
+ async def fake_send_request(method, payload):
+ sent_requests.append((method, payload))
+ return {"ok": True}
+
+ monkeypatch.setattr(telegram_gateway_module, "get_redis", lambda: fake_redis)
+ monkeypatch.setattr(gateway, "_send_request", fake_send_request)
+
+ status_line = "🤖❌ [AUTO] AI 自動修復失敗,已升級人工介入"
+
+ assert await gateway.append_incident_update("INC-DEDUP", status_line) is True
+ assert await gateway.append_incident_update("INC-DEDUP", status_line) is True
+
+ assert [method for method, _ in sent_requests] == [
+ "editMessageReplyMarkup",
+ "sendMessage",
+ ]
+
+
class TestSentryErrorMessage:
"""測試 Sentry 錯誤訊息"""
diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
index efc8fa39..5f27ab30 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -1480,5 +1480,50 @@
"error": "Failed to load queue",
"retry": "Retry"
}
+ },
+ "awooop": {
+ "home": {
+ "eyebrow": "AI Automation Control Plane",
+ "title": "AwoooP Governance Overview",
+ "subtitle": "Unifies tenants, contracts, runs, approvals, and channel state into one operator surface so the AI flywheel and governance plane do not drift apart.",
+ "refresh": "Refresh",
+ "snapshotStatus": "Snapshot Status",
+ "lastUpdated": "Last Updated",
+ "migrationMode": "Migration Mode",
+ "migrationValue": "mirror / shadow",
+ "ready": "In Sync",
+ "loading": "Loading",
+ "degraded": "Degraded",
+ "metrics": {
+ "tenants": "Tenants",
+ "tenantsDetail": "{active} active, {shadow} in shadow",
+ "runs": "Operator Runs",
+ "runsDetail": "Run state is the single view into async work",
+ "approvals": "Pending Approvals",
+ "approvalsDetail": "Every high-risk action must stop at the human gate",
+ "contracts": "Contracts",
+ "contractsDetail": "Project / Agent / Policy contract publish state"
+ },
+ "lanes": {
+ "title": "Flywheel Lanes",
+ "live": "Live",
+ "mirror": "Mirror",
+ "providerName": "Provider Order",
+ "providerDetail": "GCP-A Ollama -> GCP-B Ollama -> 111 Ollama -> OpenClaw/Nemo -> Gemini",
+ "mcpName": "MCP Gateway",
+ "mcpDetail": "MCP Gateway stays in mirror / wrap mode before audit and redaction are proven as the only execution gate",
+ "channelName": "Channel Hub",
+ "channelDetail": "Telegram / LINE / Slack enter Channel Event first, then message ownership moves gradually",
+ "approvalName": "Approval Plane",
+ "approvalDetail": "Run state and Approval plane share one approval meaning"
+ },
+ "next": {
+ "title": "Next Actions",
+ "item1": "Review run monitor and provider fallback",
+ "item2": "Handle pending high-risk approvals",
+ "item3": "Review contract lifecycle",
+ "item4": "Open the AwoooP work map"
+ }
+ }
}
}
diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json
index 4e27c776..aad2a159 100644
--- a/apps/web/messages/zh-TW.json
+++ b/apps/web/messages/zh-TW.json
@@ -1481,5 +1481,50 @@
"error": "無法載入待辦佇列",
"retry": "重試"
}
+ },
+ "awooop": {
+ "home": {
+ "eyebrow": "AI 自動化飛輪控制面",
+ "title": "AwoooP 治理總覽",
+ "subtitle": "把租戶、合約、Run、審批與通道狀態收斂到同一個操作面,避免 AI 自動化飛輪和治理面各自長出一套邏輯。",
+ "refresh": "重新整理",
+ "snapshotStatus": "快照狀態",
+ "lastUpdated": "最後更新",
+ "migrationMode": "遷移模式",
+ "migrationValue": "mirror / shadow",
+ "ready": "同步中",
+ "loading": "讀取中",
+ "degraded": "降級",
+ "metrics": {
+ "tenants": "租戶",
+ "tenantsDetail": "{active} 個啟用,{shadow} 個 shadow",
+ "runs": "Operator Runs",
+ "runsDetail": "Run state 是非同步任務的唯一觀測入口",
+ "approvals": "待審批",
+ "approvalsDetail": "所有高風險動作都必須停在人工閘門",
+ "contracts": "合約",
+ "contractsDetail": "Project / Agent / Policy contract 發布狀態"
+ },
+ "lanes": {
+ "title": "飛輪鏈路",
+ "live": "已接線",
+ "mirror": "Mirror",
+ "providerName": "Provider 順序",
+ "providerDetail": "GCP-A Ollama -> GCP-B Ollama -> 111 Ollama -> OpenClaw/Nemo -> Gemini",
+ "mcpName": "MCP Gateway",
+ "mcpDetail": "MCP Gateway 先 mirror / wrap,確認 audit 與 redaction 後才切成唯一閘門",
+ "channelName": "Channel Hub",
+ "channelDetail": "Telegram / LINE / Slack 先進 Channel Event,再逐步切換發送責任",
+ "approvalName": "Approval Plane",
+ "approvalDetail": "Run state 與 Approval plane 共享同一條審批語義"
+ },
+ "next": {
+ "title": "下一步操作",
+ "item1": "查看 Run 監控與 provider fallback",
+ "item2": "處理等待審批的高風險操作",
+ "item3": "審查 Contract lifecycle",
+ "item4": "查看 AwoooP 工作鏈路地圖"
+ }
+ }
}
}
diff --git a/apps/web/src/app/[locale]/awooop/page.tsx b/apps/web/src/app/[locale]/awooop/page.tsx
index 31321056..51994740 100644
--- a/apps/web/src/app/[locale]/awooop/page.tsx
+++ b/apps/web/src/app/[locale]/awooop/page.tsx
@@ -1,9 +1,360 @@
// =============================================================================
-// WOOO AIOps - AwoooP Console 入口頁
+// WOOO AIOps - AwoooP Operator Console 首頁
// =============================================================================
+// 將 AwoooP 定位為 AI 自動化飛輪的治理面、稽核面與人工操作面。
-import AwoooPWorkItemsPage from "./work-items/page";
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { useLocale, useTranslations } from "next-intl";
+import {
+ Activity,
+ ArrowRight,
+ BrainCircuit,
+ CheckCircle2,
+ FileText,
+ GitBranch,
+ RefreshCw,
+ ShieldCheck,
+ Waypoints,
+} from "lucide-react";
+import { Link } from "@/i18n/routing";
+import { cn } from "@/lib/utils";
+
+type Tenant = {
+ project_id: string;
+ display_name?: string;
+ migration_mode?: string;
+ is_active?: boolean;
+};
+
+type PlatformResponse = {
+ tenants?: Tenant[];
+ total?: number;
+ runs?: unknown[];
+ contracts?: unknown[];
+ items?: unknown[];
+};
+
+type Snapshot = {
+ tenants: number;
+ activeTenants: number;
+ shadowTenants: number;
+ runs: number;
+ approvals: number;
+ contracts: number;
+};
+
+type SnapshotStatus = "loading" | "ready" | "degraded";
+
+const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
+
+const emptySnapshot: Snapshot = {
+ tenants: 0,
+ activeTenants: 0,
+ shadowTenants: 0,
+ runs: 0,
+ approvals: 0,
+ contracts: 0,
+};
+
+function numberValue(value: unknown): number {
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
+}
+
+function countRows(data: PlatformResponse, keys: Array{label}
++ {value} +
+{detail}
++ {t("subtitle")} +
+