diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml
index 5e9b6df3..3a008873 100644
--- a/.gitea/workflows/cd.yaml
+++ b/.gitea/workflows/cd.yaml
@@ -225,9 +225,26 @@ jobs:
fi
DEPLOY
+ # [首席架構師] 新增 Playwright E2E Smoke Test 步驟 v1.0.0 2026-04-01 (台北時間)
+ # continue-on-error: true — smoke 失敗不阻塞部署,但結果會反映在 TG 通知
+ - name: E2E Smoke Test
+ id: smoke
+ continue-on-error: true
+ run: |
+ cd apps/web
+ # 安裝 Playwright Chromium(CI 環境,含系統依賴)
+ npx playwright install chromium --with-deps
+ # 跑 smoke test,line reporter 方便 CI 日誌閱讀
+ npx playwright test tests/e2e/smoke.spec.ts --reporter=line
+ echo "smoke_status=pass" >> $GITHUB_OUTPUT
+ env:
+ # Playwright 在 CI 環境使用已建置的 pnpm node_modules
+ CI: "true"
+
- name: Notify Health Check Success
env:
- TG_MSG: "✅ AWOOOI 部署完成\n├ 📝 ${{ steps.commit.outputs.message }}\n├ 🔖 ${{ steps.commit.outputs.short_sha }}\n├ ⏱️ 耗時: ${MINUTES}m ${SECONDS}s\n├ 📦 API: ✅ Web: ✅\n└ 🩺 Health: ✅"
+ SMOKE_RESULT: ${{ steps.smoke.outcome == 'success' && '✅' || '⚠️' }}
+ TG_MSG: "✅ AWOOOI 部署完成\n├ 📝 ${{ steps.commit.outputs.message }}\n├ 🔖 ${{ steps.commit.outputs.short_sha }}\n├ ⏱️ 耗時: ${MINUTES}m ${SECONDS}s\n├ 📦 API: ✅ Web: ✅\n├ 🩺 Health: ✅\n└ 🎭 Smoke: ${SMOKE_RESULT}"
run: |
END_TIME=$(date +%s)
DURATION=$((END_TIME - ${{ steps.commit.outputs.start_time }}))
diff --git a/apps/api/src/api/v1/webhooks.py b/apps/api/src/api/v1/webhooks.py
index dd370afd..2fab1688 100644
--- a/apps/api/src/api/v1/webhooks.py
+++ b/apps/api/src/api/v1/webhooks.py
@@ -45,8 +45,9 @@ from src.models.approval import (
from src.models.incident import Incident, IncidentStatus, Severity, Signal
# R4 #129 (2026-04-01 ogt): AlertPayload/AlertResponse 移至 models 層,AlertAnalyzer 移至 services 層
# ogt 更新 v1.1 2026-04-01 台北時間: generate_alert_fingerprint 移至 alert_analyzer_service (ADR-024)
+# [首席架構師] 移除 generate_alert_fingerprint 直接 import,改用 AlertAnalyzer.generate_fingerprint v1.2 2026-04-01 Asia/Taipei
from src.models.webhook import AlertPayload, AlertResponse
-from src.services.alert_analyzer_service import AlertAnalyzer, generate_alert_fingerprint
+from src.services.alert_analyzer_service import AlertAnalyzer
from src.services.approval_db import get_approval_service
# Phase 17 P0: Service 層 (消除 Router 直接存取 Redis)
@@ -338,7 +339,7 @@ async def verify_webhook_signature(
return True
-# generate_alert_fingerprint 已移至 src/services/alert_analyzer_service.py (ogt v1.1 2026-04-01 台北時間)
+# generate_alert_fingerprint 已封裝為 AlertAnalyzer.generate_fingerprint (首席架構師 v1.2 2026-04-01 Asia/Taipei)
# 戰略 B: 滑動時間窗 (5 分鐘)
DEBOUNCE_WINDOW_MINUTES = 5
@@ -550,7 +551,7 @@ async def receive_alert(
# ==========================================================================
# 戰略 B Step 1: 生成告警指紋
# ==========================================================================
- fingerprint = generate_alert_fingerprint(alert)
+ fingerprint = AlertAnalyzer.generate_fingerprint(alert)
logger.info(
"webhook_alert_received",
@@ -987,7 +988,7 @@ async def alertmanager_webhook(
# ==========================================================================
# 告警指紋 + 收斂
# ==========================================================================
- fingerprint = generate_alert_fingerprint(normalized_alert)
+ fingerprint = AlertAnalyzer.generate_fingerprint(normalized_alert)
logger.info(
"alertmanager_normalized",
diff --git a/apps/api/src/core/config.py b/apps/api/src/core/config.py
index f487ca6e..ce85c12b 100644
--- a/apps/api/src/core/config.py
+++ b/apps/api/src/core/config.py
@@ -425,6 +425,15 @@ class Settings(BaseSettings):
HttpUrl(v)
return v
+ # ==========================================================================
+ # Phase 23 (ADR-048): Sentry Webhook → OpenClaw AI Triage
+ # Sentry Issue Alert Webhook 簽章驗證 (sentry-hook-signature header)
+ # ==========================================================================
+ SENTRY_WEBHOOK_SECRET: str = Field(
+ default="",
+ description="Sentry Webhook secret for HMAC-SHA256 signature verification",
+ )
+
# ==========================================================================
# Phase 13.1: GitHub Webhook → OpenClaw 整合
# GitHub PR/Push 事件自動觸發 AI 代碼審查
diff --git a/apps/api/src/models/approval.py b/apps/api/src/models/approval.py
index e2328b60..ea63e87c 100644
--- a/apps/api/src/models/approval.py
+++ b/apps/api/src/models/approval.py
@@ -14,7 +14,7 @@ from datetime import UTC, datetime
from enum import Enum
from uuid import UUID, uuid4
-from pydantic import BaseModel, ConfigDict, Field
+from pydantic import BaseModel, Field
# =============================================================================
# Enums
@@ -123,13 +123,7 @@ class Signature(BaseModel):
description="Telegram 訊息 ID",
)
- # Claude 遷移 Pydantic v1→v2 2026-04-01 Asia/Taipei
- model_config = ConfigDict(
- json_encoders={
- datetime: lambda v: v.isoformat(),
- UUID: lambda v: str(v),
- }
- )
+ # [首席架構師] 移除 json_encoders (Pydantic v2 已 deprecated),原生序列化輸出格式與 .isoformat() 一致 v1.1 2026-04-01 Asia/Taipei
# =============================================================================
@@ -187,13 +181,7 @@ class ApprovalRequest(ApprovalRequestBase):
"""檢查某人是否已簽核"""
return any(s.signer_id == signer_id for s in self.signatures)
- # Claude 遷移 Pydantic v1→v2 2026-04-01 Asia/Taipei
- model_config = ConfigDict(
- json_encoders={
- datetime: lambda v: v.isoformat(),
- UUID: lambda v: str(v),
- }
- )
+ # [首席架構師] 移除 json_encoders (Pydantic v2 已 deprecated),原生序列化輸出格式與 .isoformat() 一致 v1.1 2026-04-01 Asia/Taipei
# =============================================================================
diff --git a/apps/api/src/models/incident.py b/apps/api/src/models/incident.py
index 2aab015f..8acfe69a 100644
--- a/apps/api/src/models/incident.py
+++ b/apps/api/src/models/incident.py
@@ -25,7 +25,7 @@ from enum import Enum
from typing import Literal
from uuid import UUID, uuid4
-from pydantic import BaseModel, ConfigDict, Field
+from pydantic import BaseModel, Field
# 復用現有模型 (避免重複定義)
from src.models.approval import BlastRadius
@@ -107,10 +107,7 @@ class Signal(BaseModel):
description="告警指紋 Hash,用於去重與聚合",
)
- # Claude 遷移 Pydantic v1→v2 2026-04-01 Asia/Taipei
- model_config = ConfigDict(
- json_encoders={datetime: lambda v: v.isoformat()}
- )
+ # [首席架構師] 移除 json_encoders (Pydantic v2 已 deprecated),原生序列化輸出格式與 .isoformat() 一致 v1.1 2026-04-01 Asia/Taipei
# =============================================================================
@@ -181,10 +178,7 @@ class AIDecisionChain(BaseModel):
inference_completed_at: datetime = Field(..., description="推論完成時間")
latency_ms: int = Field(..., description="推論延遲 (毫秒)")
- # Claude 遷移 Pydantic v1→v2 2026-04-01 Asia/Taipei
- model_config = ConfigDict(
- json_encoders={datetime: lambda v: v.isoformat()}
- )
+ # [首席架構師] 移除 json_encoders (Pydantic v2 已 deprecated),原生序列化輸出格式與 .isoformat() 一致 v1.1 2026-04-01 Asia/Taipei
# =============================================================================
@@ -423,13 +417,7 @@ class Incident(BaseModel):
description="是否已向量化到 Vector DB (Semantic Memory)",
)
- # Claude 遷移 Pydantic v1→v2 2026-04-01 Asia/Taipei
- model_config = ConfigDict(
- json_encoders={
- datetime: lambda v: v.isoformat(),
- UUID: lambda v: str(v),
- }
- )
+ # [首席架構師] 移除 json_encoders (Pydantic v2 已 deprecated),原生序列化輸出格式與 .isoformat() 一致 v1.1 2026-04-01 Asia/Taipei
# =============================================================================
@@ -489,7 +477,4 @@ class IncidentResponse(BaseModel):
closed_at=incident.closed_at,
)
- # Claude 遷移 Pydantic v1→v2 2026-04-01 Asia/Taipei
- model_config = ConfigDict(
- json_encoders={datetime: lambda v: v.isoformat()}
- )
+ # [首席架構師] 移除 json_encoders (Pydantic v2 已 deprecated),原生序列化輸出格式與 .isoformat() 一致 v1.1 2026-04-01 Asia/Taipei
diff --git a/apps/api/src/services/alert_analyzer_service.py b/apps/api/src/services/alert_analyzer_service.py
index afca0e4b..db319fea 100644
--- a/apps/api/src/services/alert_analyzer_service.py
+++ b/apps/api/src/services/alert_analyzer_service.py
@@ -32,34 +32,6 @@ from src.models.webhook import AlertPayload
from src.utils.k8s_naming import normalize_resource_name
-# =============================================================================
-# 戰略 B: 告警指紋生成
-# ogt 移至 Service 層 v1.1 2026-04-01 台北時間 (ADR-024 R4 #129)
-# =============================================================================
-
-def generate_alert_fingerprint(alert: AlertPayload) -> str:
- """
- 生成告警唯一指紋 (SHA256 Hash)
-
- 指紋組成: namespace:deployment:alert_type:target_resource
-
- 同一個告警模式(相同位置、相同類型)會產生相同指紋,
- 用於識別重複告警並進行聚合。
- """
- # 從 labels 取得 deployment,如果沒有則用 target_resource
- deployment = ""
- if alert.labels:
- deployment = alert.labels.get("deployment", alert.labels.get("app", ""))
- if not deployment:
- deployment = alert.target_resource
-
- # 組合指紋來源
- fingerprint_source = f"{alert.namespace}:{deployment}:{alert.alert_type}:{alert.target_resource}"
-
- # SHA256 Hash
- return hashlib.sha256(fingerprint_source.encode()).hexdigest()[:32]
-
-
class AlertAnalyzer:
"""
告警分析器 - AWOOOI 核心大腦
@@ -203,3 +175,27 @@ class AlertAnalyzer:
dry_run_checks=dry_run_checks,
requested_by="OpenClaw",
)
+
+ # [首席架構師] 封裝 generate_alert_fingerprint 為 staticmethod v1.2 2026-04-01 Asia/Taipei
+ @staticmethod
+ def generate_fingerprint(alert: AlertPayload) -> str:
+ """
+ 生成告警唯一指紋 (SHA256 Hash)
+
+ 指紋組成: namespace:deployment:alert_type:target_resource
+
+ 同一個告警模式(相同位置、相同類型)會產生相同指紋,
+ 用於識別重複告警並進行聚合。
+ """
+ # 從 labels 取得 deployment,如果沒有則用 target_resource
+ deployment = ""
+ if alert.labels:
+ deployment = alert.labels.get("deployment", alert.labels.get("app", ""))
+ if not deployment:
+ deployment = alert.target_resource
+
+ # 組合指紋來源
+ fingerprint_source = f"{alert.namespace}:{deployment}:{alert.alert_type}:{alert.target_resource}"
+
+ # SHA256 Hash
+ return hashlib.sha256(fingerprint_source.encode()).hexdigest()[:32]
diff --git a/apps/api/src/services/sentry_webhook_service.py b/apps/api/src/services/sentry_webhook_service.py
new file mode 100644
index 00000000..3079ed55
--- /dev/null
+++ b/apps/api/src/services/sentry_webhook_service.py
@@ -0,0 +1,452 @@
+"""
+Sentry Webhook Service
+======================
+Phase 23 (ADR-048): Sentry → OpenClaw AI Triage 業務邏輯層
+
+遵循 leWOOOgo 積木化原則:
+- Service 層負責: 解析、分析、建立 Incident、組裝訊息
+- Router 層只做: 接收、驗證、呼叫 Service、回傳
+- 禁止 Router 層直接存取 Redis/DB
+
+版本: v1.0
+建立: 2026-04-01 (台北時區)
+建立者: Claude Code (Phase 23 ADR-048)
+"""
+
+import hashlib
+import hmac
+
+import structlog
+
+from src.core.config import settings
+from src.models.approval import (
+ ApprovalRequestCreate,
+ BlastRadius,
+ DataImpact,
+ RiskLevel,
+)
+from src.services.approval_db import get_approval_service
+from src.services.openclaw_http_service import get_openclaw_http_service
+
+logger = structlog.get_logger(__name__)
+
+# Sentry Level → Risk Level 映射 (ADR-048)
+SENTRY_LEVEL_TO_RISK: dict[str, RiskLevel] = {
+ "fatal": RiskLevel.CRITICAL,
+ "error": RiskLevel.HIGH,
+ "warning": RiskLevel.MEDIUM,
+ "info": RiskLevel.LOW,
+}
+
+
+# =============================================================================
+# Data Models (純資料容器,不含業務邏輯)
+# =============================================================================
+
+class SentryIssueContext:
+ """
+ 解析後的 Sentry Issue 上下文
+
+ 從 Sentry webhook payload 提取關鍵欄位,
+ 供後續 OpenClaw 分析與 Incident 建立使用。
+ """
+
+ def __init__(
+ self,
+ issue_id: str,
+ title: str,
+ culprit: str,
+ level: str,
+ project: str,
+ first_seen: str | None,
+ count: int,
+ message: str | None,
+ platform: str | None,
+ tags: list,
+ stacktrace: list[dict],
+ ) -> None:
+ self.issue_id = issue_id
+ self.title = title
+ self.culprit = culprit
+ self.level = level
+ self.project = project
+ self.first_seen = first_seen
+ self.count = count
+ self.message = message
+ self.platform = platform
+ self.tags = tags
+ self.stacktrace = stacktrace
+
+ def to_dict(self) -> dict:
+ return {
+ "issue_id": self.issue_id,
+ "title": self.title,
+ "culprit": self.culprit,
+ "level": self.level,
+ "project": self.project,
+ "first_seen": self.first_seen,
+ "count": self.count,
+ "message": self.message,
+ "platform": self.platform,
+ "tags": self.tags,
+ "stacktrace": self.stacktrace,
+ }
+
+
+class AIDecision:
+ """
+ OpenClaw AI 分析結果
+
+ 封裝 OpenClaw 對 Sentry Issue 的分析輸出,
+ 包含根因、影響範圍、修復建議、預防措施。
+ """
+
+ def __init__(
+ self,
+ root_cause: str,
+ impact: str,
+ fix_suggestion: str,
+ prevention: str,
+ confidence: float,
+ analyzed_by: str,
+ ) -> None:
+ self.root_cause = root_cause
+ self.impact = impact
+ self.fix_suggestion = fix_suggestion
+ self.prevention = prevention
+ self.confidence = confidence
+ self.analyzed_by = analyzed_by
+
+
+# =============================================================================
+# SentryWebhookService
+# =============================================================================
+
+class SentryWebhookService:
+ """
+ Sentry Webhook 業務邏輯 Service
+
+ 職責:
+ 1. parse_sentry_issue() - 解析 webhook payload → SentryIssueContext
+ 2. analyze_with_openclaw() - 呼叫 OpenClaw 分析 → AIDecision | None
+ 3. create_incident() - 建立 Approval (Incident) 記錄
+ 4. build_telegram_message() - 組裝 Telegram 告警訊息
+
+ leWOOOgo 積木化原則:
+ - 禁止直接存取 Redis/DB,透過對應 Service 呼叫
+ - 每個方法單一職責
+ """
+
+ def parse_sentry_issue(self, payload: dict) -> SentryIssueContext | None:
+ """
+ 解析 Sentry Issue Alert Payload → SentryIssueContext
+
+ Args:
+ payload: Sentry webhook raw JSON dict
+
+ Returns:
+ SentryIssueContext 若解析成功,否則 None
+
+ Sentry Payload 結構:
+ {
+ "action": "triggered",
+ "data": {
+ "issue": { id, title, culprit, level, firstSeen, count, project },
+ "event": { message, platform, tags, exception }
+ }
+ }
+ """
+ try:
+ issue_data = payload.get("data", {}).get("issue", {})
+ event_data = payload.get("data", {}).get("event", {})
+
+ issue_id = issue_data.get("id")
+ if not issue_id:
+ logger.warning("sentry_parse_missing_issue_id")
+ return None
+
+ return SentryIssueContext(
+ issue_id=str(issue_id),
+ title=issue_data.get("title", "Unknown Error"),
+ culprit=issue_data.get("culprit", "unknown"),
+ level=issue_data.get("level", "error"),
+ project=issue_data.get("project", {}).get("slug", "unknown"),
+ first_seen=issue_data.get("firstSeen"),
+ count=int(issue_data.get("count", 1)),
+ message=event_data.get("message"),
+ platform=event_data.get("platform"),
+ tags=event_data.get("tags", []),
+ stacktrace=self._extract_stacktrace(event_data),
+ )
+
+ except Exception as e:
+ logger.exception("sentry_parse_failed", error=str(e))
+ return None
+
+ def _extract_stacktrace(self, event_data: dict) -> list[dict]:
+ """提取 Stack Trace 最後 5 個 frame"""
+ try:
+ values = event_data.get("exception", {}).get("values", [])
+ if not values:
+ return []
+ frames = values[0].get("stacktrace", {}).get("frames", [])
+ return [
+ {
+ "filename": f.get("filename"),
+ "function": f.get("function"),
+ "lineno": f.get("lineno"),
+ "context_line": f.get("context_line"),
+ }
+ for f in frames[-5:]
+ ]
+ except Exception:
+ return []
+
+ async def analyze_with_openclaw(
+ self,
+ issue: SentryIssueContext,
+ ) -> AIDecision | None:
+ """
+ 透過 OpenClawHttpService 分析 Sentry Issue
+
+ Args:
+ issue: 已解析的 SentryIssueContext
+
+ Returns:
+ AIDecision 若分析成功,否則 None (降級:無 AI 分析仍繼續流程)
+ """
+ try:
+ service = get_openclaw_http_service()
+ data = await service.analyze_error(
+ error_context=issue.to_dict(),
+ prefer_local=True,
+ timeout=60.0,
+ )
+ if not data:
+ return None
+
+ return AIDecision(
+ root_cause=data.get("root_cause", "無法判斷根本原因"),
+ impact=data.get("impact", "影響範圍未知"),
+ fix_suggestion=data.get("fix_suggestion", "請人工排查"),
+ prevention=data.get("prevention", "待補充"),
+ confidence=float(data.get("confidence", 0.0)),
+ analyzed_by=data.get("analyzed_by", "unknown"),
+ )
+
+ except Exception as e:
+ logger.exception("sentry_openclaw_analyze_failed", error=str(e))
+ return None
+
+ async def create_incident(
+ self,
+ issue: SentryIssueContext,
+ decision: AIDecision | None,
+ anomaly_frequency: dict | None = None,
+ ) -> str:
+ """
+ 建立 Approval (Incident) 記錄
+
+ Args:
+ issue: Sentry Issue 上下文
+ decision: AI 分析結果 (可為 None,降級處理)
+ anomaly_frequency: 頻率統計 dict (可為 None)
+
+ Returns:
+ str: Approval ID
+ """
+ import uuid
+
+ try:
+ approval_service = get_approval_service()
+
+ # 基礎風險等級
+ risk_level = SENTRY_LEVEL_TO_RISK.get(issue.level, RiskLevel.MEDIUM)
+
+ # 根據頻率升級 (ADR-037)
+ if anomaly_frequency:
+ escalation = anomaly_frequency.get("escalation_level")
+ if escalation == "PERMANENT_FIX":
+ risk_level = RiskLevel.CRITICAL
+ elif escalation == "ESCALATE" and risk_level != RiskLevel.CRITICAL:
+ risk_level = RiskLevel.HIGH
+
+ metadata: dict = {
+ "source": "sentry",
+ "alert_type": f"sentry_{issue.level}",
+ "sentry_issue_id": issue.issue_id,
+ "sentry_project": issue.project,
+ "culprit": issue.culprit,
+ "error_count": issue.count,
+ "first_seen": issue.first_seen,
+ "stacktrace": issue.stacktrace,
+ "llm_provider": decision.analyzed_by if decision else "pending",
+ "llm_confidence": decision.confidence if decision else 0.0,
+ }
+ if anomaly_frequency:
+ metadata["anomaly_frequency"] = anomaly_frequency
+
+ approval_request = ApprovalRequestCreate(
+ action=f"Sentry {issue.level.upper()} Alert: {issue.culprit}",
+ description=(
+ f"{issue.title}\n\n"
+ f"Root Cause: {decision.root_cause if decision else '待分析'}\n"
+ f"Suggestion: {decision.fix_suggestion if decision else '待 AI 分析'}"
+ ),
+ risk_level=risk_level,
+ blast_radius=BlastRadius(
+ affected_pods=1,
+ estimated_downtime="0",
+ related_services=[issue.project],
+ data_impact=DataImpact.READ_ONLY,
+ ),
+ dry_run_checks=[],
+ requested_by="sentry-webhook",
+ metadata=metadata,
+ )
+
+ approval = await approval_service.create_approval(request=approval_request)
+ approval_id = str(approval.id)
+
+ logger.info(
+ "sentry_incident_created",
+ approval_id=approval_id,
+ issue_id=issue.issue_id,
+ risk_level=risk_level.value,
+ )
+ return approval_id
+
+ except Exception as e:
+ logger.exception("sentry_incident_creation_failed", error=str(e))
+ return f"temp-{uuid.uuid4().hex[:8]}"
+
+ def build_telegram_message(
+ self,
+ issue: SentryIssueContext,
+ decision: AIDecision | None,
+ approval_id: str,
+ anomaly_frequency: dict | None = None,
+ ) -> str:
+ """
+ 組裝 Telegram 告警訊息 (純文字摘要)
+
+ 格式符合 feedback_telegram_alert_format.md 規範。
+ 實際傳送使用 TelegramGateway.send_approval_card(),
+ 此方法提供純文字版本供日誌記錄與測試。
+
+ Args:
+ issue: Sentry Issue 上下文
+ decision: AI 分析結果
+ approval_id: Approval ID
+ anomaly_frequency: 頻率統計
+
+ Returns:
+ str: 格式化訊息文字
+ """
+ level_emoji = {"fatal": "💀", "error": "❌", "warning": "⚠️"}.get(issue.level, "🐛")
+ freq_text = ""
+ if anomaly_frequency and anomaly_frequency.get("count_24h", 0) > 1:
+ freq_text = (
+ f"\n📊 頻率: "
+ f"1h:{anomaly_frequency.get('count_1h', 0)} / "
+ f"24h:{anomaly_frequency.get('count_24h', 0)} / "
+ f"7d:{anomaly_frequency.get('count_7d', 0)}"
+ )
+
+ analysis_text = ""
+ if decision:
+ analysis_text = (
+ f"\n───────────────────────────\n"
+ f"🧠 OpenClaw 分析 (信心: {decision.confidence:.0%}):\n"
+ f"「{decision.root_cause[:120]}」\n"
+ f"💡 建議: {decision.fix_suggestion[:100]}"
+ )
+
+ return (
+ f"═══════════════════════════\n"
+ f"{level_emoji} SENTRY {issue.level.upper()} 告警\n"
+ f"═══════════════════════════\n"
+ f"📦 專案: {issue.project}\n"
+ f"📍 {issue.culprit}\n"
+ f"🔖 {issue.title[:100]}"
+ f"{freq_text}"
+ f"{analysis_text}\n"
+ f"───────────────────────────\n"
+ f"🆔 Approval: {approval_id}"
+ )
+
+
+# =============================================================================
+# Webhook Secret 驗證 (ADR-048)
+# =============================================================================
+
+class SentrySignatureError(Exception):
+ """Sentry Webhook 簽章驗證失敗"""
+
+
+def verify_sentry_signature(body: bytes, signature_header: str | None) -> bool:
+ """
+ 驗證 Sentry Webhook 請求的 HMAC-SHA256 簽章
+
+ Sentry 使用 Header: sentry-hook-signature (hmac-sha256 hex digest)
+
+ Fail-Closed 安全策略 (對齊 GitHub Webhook ADR):
+ - 生產環境: Secret 未設定 → 拒絕
+ - 開發環境: Secret 未設定 → 允許 (僅供本地測試)
+
+ Args:
+ body: Request body bytes
+ signature_header: sentry-hook-signature header 值
+
+ Returns:
+ bool: 驗證通過
+
+ Raises:
+ SentrySignatureError: 驗證失敗
+ """
+ secret = settings.SENTRY_WEBHOOK_SECRET
+
+ if not secret:
+ if settings.ENVIRONMENT == "prod":
+ logger.critical(
+ "sentry_webhook_secret_missing_in_production",
+ message="CRITICAL: SENTRY_WEBHOOK_SECRET missing in production!",
+ )
+ raise SentrySignatureError(
+ "SENTRY_WEBHOOK_SECRET missing in production environment"
+ )
+ # 開發環境允許跳過
+ logger.warning(
+ "sentry_signature_skipped_dev_only",
+ reason="SENTRY_WEBHOOK_SECRET not configured (dev mode only)",
+ )
+ return True
+
+ if not signature_header:
+ raise SentrySignatureError("Missing sentry-hook-signature header")
+
+ expected = hmac.new(
+ secret.encode(),
+ body,
+ hashlib.sha256,
+ ).hexdigest()
+
+ if not hmac.compare_digest(signature_header, expected):
+ raise SentrySignatureError("Invalid sentry-hook-signature")
+
+ return True
+
+
+# =============================================================================
+# Singleton
+# =============================================================================
+
+_sentry_webhook_service: SentryWebhookService | None = None
+
+
+def get_sentry_webhook_service() -> SentryWebhookService:
+ """取得 SentryWebhookService 實例 (Singleton)"""
+ global _sentry_webhook_service
+ if _sentry_webhook_service is None:
+ _sentry_webhook_service = SentryWebhookService()
+ return _sentry_webhook_service
diff --git a/apps/web/tests/e2e/smoke.spec.ts b/apps/web/tests/e2e/smoke.spec.ts
new file mode 100644
index 00000000..6dcf7dc3
--- /dev/null
+++ b/apps/web/tests/e2e/smoke.spec.ts
@@ -0,0 +1,163 @@
+/**
+ * AWOOOI Smoke Tests
+ * ==================
+ * 5 個關鍵頁面的 Smoke Test — 部署後快速驗證基本功能
+ *
+ * 測試範圍:
+ * 1. 首頁 (/) — 200 OK,標題包含 AWOOOI
+ * 2. Dashboard (/dashboard) — 無 JS console 錯誤
+ * 3. Incidents (/incidents) — 列表容器存在
+ * 4. Approvals (/approvals) — 頁面可存取
+ * 5. Omni-Terminal (/terminal) — WebSocket 連線不報錯
+ *
+ * 注意: 需登入才能查看的頁面內容以 test.skip 跳過,僅驗證頁面可存取性。
+ * 截圖存至 test-results/screenshots/ 供人工比對。
+ *
+ * @author 首席架構師 (Claude Code)
+ * @version 1.0.0
+ * @date 2026-04-01 (台北時間)
+ */
+
+import { test, expect } from '@playwright/test'
+import * as fs from 'fs'
+import * as path from 'path'
+
+const BASE_URL = 'https://awoooi.wooo.work'
+const SCREENSHOT_DIR = 'test-results/screenshots'
+
+// 確保截圖目錄存在
+test.beforeAll(() => {
+ if (!fs.existsSync(SCREENSHOT_DIR)) {
+ fs.mkdirSync(SCREENSHOT_DIR, { recursive: true })
+ }
+})
+
+test.setTimeout(30000)
+
+// =============================================================================
+// 1. 首頁 — 200 OK + 標題包含 AWOOOI
+// =============================================================================
+
+test('首頁載入 — HTTP 200 + 標題含 AWOOOI', async ({ page }) => {
+ const response = await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
+
+ // HTTP 狀態碼 200
+ expect(response?.status()).toBe(200)
+
+ // 頁面標題含 AWOOOI (大小寫不限)
+ const title = await page.title()
+ expect(title.toLowerCase()).toContain('awoooi')
+
+ // 截圖
+ await page.screenshot({
+ path: path.join(SCREENSHOT_DIR, '01-home.png'),
+ fullPage: false,
+ })
+})
+
+// =============================================================================
+// 2. Dashboard — 無 JS console 錯誤
+// =============================================================================
+
+test('Dashboard 載入 — 無 JS console 錯誤', async ({ page }) => {
+ const jsErrors: string[] = []
+
+ // 收集 JS 錯誤 (排除 Next.js HMR/chunk 載入警告)
+ page.on('pageerror', (err) => {
+ jsErrors.push(err.message)
+ })
+
+ const response = await page.goto(`${BASE_URL}/dashboard`, { waitUntil: 'domcontentloaded' })
+
+ // 頁面可存取 (200 或 重導向後的頁面)
+ expect(response?.status()).toBeLessThan(500)
+
+ // 截圖
+ await page.screenshot({
+ path: path.join(SCREENSHOT_DIR, '02-dashboard.png'),
+ fullPage: false,
+ })
+
+ // 無嚴重 JS 錯誤
+ const criticalErrors = jsErrors.filter(
+ (e) =>
+ !e.includes('ChunkLoadError') && // Next.js 開發時的 chunk 錯誤可忽略
+ !e.includes('Loading chunk') &&
+ !e.includes('Loading CSS chunk'),
+ )
+ expect(criticalErrors).toHaveLength(0)
+})
+
+// =============================================================================
+// 3. Incidents — 列表容器存在
+// =============================================================================
+
+test('Incidents 頁面 — 列表容器存在', async ({ page }) => {
+ const response = await page.goto(`${BASE_URL}/incidents`, { waitUntil: 'domcontentloaded' })
+
+ expect(response?.status()).toBeLessThan(500)
+
+ // 截圖 (登入前)
+ await page.screenshot({
+ path: path.join(SCREENSHOT_DIR, '03-incidents.png'),
+ fullPage: false,
+ })
+
+ // 頁面應存在某個容器 (未登入時會是登入頁或列表頁)
+ // 驗證頁面 body 有內容 (非空白頁)
+ const bodyText = await page.evaluate(() => document.body.innerText.trim())
+ expect(bodyText.length).toBeGreaterThan(0)
+
+ // Note: 列表容器 (data-testid="incidents-list" 或同類) 需登入才可見,
+ // 此處僅驗證頁面可存取且非空白。
+ // 完整列表驗證見 authenticated E2E tests。
+})
+
+// =============================================================================
+// 4. Approvals — 頁面可存取
+// =============================================================================
+
+test('Approvals 頁面 — 頁面可存取', async ({ page }) => {
+ const response = await page.goto(`${BASE_URL}/approvals`, { waitUntil: 'domcontentloaded' })
+
+ expect(response?.status()).toBeLessThan(500)
+
+ await page.screenshot({
+ path: path.join(SCREENSHOT_DIR, '04-approvals.png'),
+ fullPage: false,
+ })
+
+ // 頁面有內容
+ const bodyText = await page.evaluate(() => document.body.innerText.trim())
+ expect(bodyText.length).toBeGreaterThan(0)
+})
+
+// =============================================================================
+// 5. Omni-Terminal — WebSocket 連線不報錯
+// =============================================================================
+
+test('Omni-Terminal — 頁面載入且無 WebSocket 連線錯誤', async ({ page }) => {
+ const wsErrors: string[] = []
+
+ // 監聽 WebSocket 錯誤
+ page.on('pageerror', (err) => {
+ if (err.message.toLowerCase().includes('websocket')) {
+ wsErrors.push(err.message)
+ }
+ })
+
+ const response = await page.goto(`${BASE_URL}/terminal`, { waitUntil: 'domcontentloaded' })
+
+ expect(response?.status()).toBeLessThan(500)
+
+ // 等待短暫時間讓 WS 初始化
+ await page.waitForTimeout(2000)
+
+ await page.screenshot({
+ path: path.join(SCREENSHOT_DIR, '05-terminal.png'),
+ fullPage: false,
+ })
+
+ // 無 WebSocket 錯誤
+ expect(wsErrors).toHaveLength(0)
+})
diff --git a/docs/adr/ADR-048-sentry-openclaw-ai-triage.md b/docs/adr/ADR-048-sentry-openclaw-ai-triage.md
new file mode 100644
index 00000000..1e180a66
--- /dev/null
+++ b/docs/adr/ADR-048-sentry-openclaw-ai-triage.md
@@ -0,0 +1,154 @@
+# ADR-048: Sentry → OpenClaw AI Triage 自動化
+
+**狀態**: 已實作 (Phase 23)
+**日期**: 2026-04-01 (台北時區)
+**作者**: Claude Code
+**關聯**: Phase 10 (Sentry 基礎整合), Phase 21 (ADR-037 異常頻率統計), Phase 22 (ADR-044 Nemotron 協作)
+
+---
+
+## 問題陳述
+
+Sentry 持續捕獲後端 Python 與前端 Next.js 錯誤,但缺乏自動化 triage 流程:
+
+- 工程師需手動查看 Sentry Issue 並判斷嚴重程度
+- 錯誤發生後沒有即時通知渠道(Telegram)
+- 無法自動關聯歷史頻率判斷是否為常態錯誤或異常升級
+- 沒有 AI 輔助根因分析,所有診斷依賴人工
+
+**目標**:Sentry 新錯誤觸發時,自動完成 triage → AI 分析 → Incident 建立 → Telegram 通知,無需人工介入。
+
+---
+
+## 決策
+
+透過 **Sentry Issue Alert Webhook** 整合 OpenClaw AI Triage 流程。
+
+### 選擇 Webhook 而非輪詢的原因
+
+| 方案 | 延遲 | 基礎設施成本 | 複雜度 |
+|------|------|-------------|--------|
+| Webhook (本方案) | 近即時 (<2s) | 無額外組件 | 低 |
+| 輪詢 (每分鐘) | 最高 60s | 需 scheduler | 中 |
+| Sentry Alerts API | 近即時 | 需 OAuth | 高 |
+
+---
+
+## 架構
+
+```
+自架 Sentry (192.168.0.110:9000)
+ │ Issue Alert Webhook (action=triggered, level=error/fatal)
+ ▼
+AWOOOI API POST /api/v1/webhooks/sentry/error
+ │
+ ├─ [同步] 去重檢查 (Redis, 10min TTL)
+ ├─ [同步] Level 過濾 (只處理 error/fatal)
+ │
+ └─ [背景 BackgroundTask]
+ │
+ ├─ 1. AnomalyCounter.record_anomaly() → 頻率統計
+ │ (ADR-037: 1h/24h/7d/30d + 升級判斷)
+ │
+ ├─ 2. OpenClawHttpService.analyze_error()
+ │ Circuit Breaker (ADR-038) + Semaphore
+ │ 優先 Ollama (本地) → Fallback Claude
+ │ → ErrorAnalysisResult (root_cause, fix, prevention, confidence)
+ │
+ ├─ 3. ApprovalService.create_approval()
+ │ 風險等級 = Sentry Level + 頻率升級
+ │ → Approval ID
+ │
+ ├─ 4. TelegramGateway.send_approval_card()
+ │ 格式: 🐛 SENTRY 錯誤告警 + OpenClaw 分析 + [Y/n]
+ │
+ └─ 5. SentryService.post_issue_comment()
+ AI 分析結果回寫至 Sentry Issue
+```
+
+### Sentry Cloud (wooo-92.sentry.io) 前端整合
+
+前端 Next.js 錯誤透過 Sentry Cloud 捕獲,使用相同 Webhook 格式。
+在 Sentry Cloud 配置 Alert Rule 指向同一端點:
+`POST https://awoooi.wooo.work/api/v1/webhooks/sentry/error`
+
+---
+
+## 實作細節
+
+### 端點
+
+```
+POST /api/v1/webhooks/sentry/error
+```
+
+### 觸發條件 (Sentry Alert Rule 配置)
+
+- action: `triggered`
+- level: `error` 或 `fatal`
+
+### 去重策略
+
+- Redis key: `sentry_dedup:{issue_id}`
+- TTL: 600 秒 (10 分鐘)
+- 同一 issue 10 分鐘內不重複處理
+
+### 風險等級映射
+
+| Sentry Level | 基礎風險 | 頻率升級 (ESCALATE) | 頻率升級 (PERMANENT_FIX) |
+|-------------|---------|-------------------|------------------------|
+| fatal | CRITICAL | CRITICAL | CRITICAL |
+| error | HIGH | HIGH | CRITICAL |
+| warning | MEDIUM | HIGH | CRITICAL |
+
+### Webhook Secret 驗證
+
+- Config: `SENTRY_WEBHOOK_SECRET`
+- Header: `sentry-hook-signature` (HMAC-SHA256)
+- 生產環境未設定 → Fail-Closed 拒絕
+- 開發環境未設定 → 允許跳過 (僅供測試)
+
+---
+
+## 影響評估
+
+### 正向影響
+
+- **MTTR 縮短**: AI 自動根因分析,工程師無需從 log 推斷
+- **告警品質提升**: 頻率統計避免重複告警噪音
+- **可審計性**: 所有 Sentry Issue 自動建立 Approval 記錄
+- **回饋閉環**: AI 分析結果回寫 Sentry Comment,歷史可查
+
+### 風險與緩解
+
+| 風險 | 緩解措施 |
+|------|---------|
+| OpenClaw 不可用 | Circuit Breaker (ADR-038) + 降級:無 AI 分析仍建立 Approval |
+| Telegram 傳送失敗 | Exception catch + log,不中斷主流程 |
+| Redis 不可用 | check_dedup 失敗 → 預設 True(允許處理,可能重複) |
+| Sentry API Token 未設 | Comment 回寫跳過,其他流程不受影響 |
+
+### 不影響項目
+
+- 現有 Alertmanager → AWOOOI → Telegram 流程不變
+- 現有 GitHub Webhook 整合不變
+- Sentry SDK 錯誤捕獲邏輯不變
+
+---
+
+## 相關檔案
+
+- **Router**: `apps/api/src/api/v1/sentry_webhook.py`
+- **Service (Sentry API)**: `apps/api/src/services/sentry_service.py`
+- **Service (Webhook 業務邏輯)**: `apps/api/src/services/sentry_webhook_service.py`
+- **Config**: `apps/api/src/core/config.py` (`SENTRY_WEBHOOK_SECRET`)
+- **Circuit Breaker**: `apps/api/src/core/circuit_breaker.py` (ADR-038)
+
+---
+
+## 後續工作
+
+- [ ] 在自架 Sentry 配置 Issue Alert Rule 指向 `/webhooks/sentry/error`
+- [ ] 在 Sentry Cloud 配置相同 Alert Rule
+- [ ] K8s Secret 注入 `SENTRY_WEBHOOK_SECRET`
+- [ ] E2E 測試:手動觸發 Sentry Issue → 驗證 Telegram 收到告警
diff --git a/docs/adr/ADR-049-figma-code-connect-design-system.md b/docs/adr/ADR-049-figma-code-connect-design-system.md
new file mode 100644
index 00000000..242fb696
--- /dev/null
+++ b/docs/adr/ADR-049-figma-code-connect-design-system.md
@@ -0,0 +1,163 @@
+# ADR-049: Figma Code Connect 設計系統同步
+
+| 項目 | 內容 |
+|------|------|
+| **狀態** | 📋 已批准,待實作 |
+| **日期** | 2026-04-01 |
+| **決策者** | 首席架構師 + 統帥 |
+| **觸發** | MCP 整合長期計畫 |
+
+## 背景
+
+前端 UI 元件與 Figma 設計稿可能產生漂移(drift):設計師在 Figma 更新了元件樣式或佈局,但 `apps/web/src/components/` 下的 React 元件未同步更新,導致 QA 發現視覺差異時已累積大量技術債。
+
+目前缺乏任何機制能夠:
+1. 讓設計師在 Figma 中直接看到對應的程式碼元件
+2. 在 Code Review 時確認 UI 實作與設計稿的一致性
+3. 系統化追蹤哪些元件存在 design-code drift
+
+## 問題
+
+| 面向 | 現況 | 目標 |
+|------|------|------|
+| 設計-代碼映射 | 無正式對應關係 | Figma 元件 ↔ React 元件 1:1 映射 |
+| 漂移偵測 | 手動 QA 發現 | 每週自動掃描確認映射狀態 |
+| 設計師工作流 | 不知道對應哪支 component | Figma Dev Mode 直接顯示程式碼 |
+
+## 決策
+
+使用 **Figma Code Connect** 建立 `.figma.js` 映射文件,將每個 Figma 元件對應到 React 元件的 import 與 props 結構。
+
+定期(每週)使用 Figma MCP 掃描設計稿,確認映射是否需要更新。
+
+## 架構
+
+### 文件結構
+
+```
+apps/web/src/components/
+├── ui/
+│ ├── button/
+│ │ ├── Button.tsx
+│ │ └── Button.figma.js ← Code Connect 映射
+│ ├── card/
+│ │ ├── Card.tsx
+│ │ └── Card.figma.js
+│ └── badge/
+│ ├── Badge.tsx
+│ └── Badge.figma.js
+├── incident/
+│ ├── IncidentCard.tsx
+│ └── IncidentCard.figma.js
+└── approval/
+ ├── ApprovalCard.tsx
+ └── ApprovalCard.figma.js
+```
+
+### Code Connect 映射格式
+
+```js
+// Button.figma.js
+import figma from '@figma/code-connect'
+import { Button } from './Button'
+
+figma.connect(Button, 'https://www.figma.com/design/FILEID?node-id=NODE_ID', {
+ props: {
+ variant: figma.enum('Variant', {
+ primary: 'primary',
+ secondary: 'secondary',
+ destructive: 'destructive',
+ }),
+ size: figma.enum('Size', {
+ sm: 'sm',
+ md: 'md',
+ lg: 'lg',
+ }),
+ label: figma.string('Label'),
+ disabled: figma.boolean('Disabled'),
+ },
+ example: ({ variant, size, label, disabled }) => (
+
+ ),
+})
+```
+
+### 每週掃描流程
+
+```
+Claude Code + Figma MCP
+ → get_design_context (核心元件節點)
+ → get_code_connect_suggestions
+ → 比對現有 .figma.js 映射
+ → 輸出「drift report」
+ → 若有差異 → 建立 GitHub Issue
+```
+
+## 實作計畫
+
+### P1:核心 UI 元件(第 1 週)
+
+| 元件 | Figma 節點 | React 路徑 |
+|------|------------|------------|
+| Button | TBD | `components/ui/button/Button.tsx` |
+| Card | TBD | `components/ui/card/Card.tsx` |
+| Badge | TBD | `components/ui/badge/Badge.tsx` |
+| Input | TBD | `components/ui/input/Input.tsx` |
+| Modal | TBD | `components/ui/modal/Modal.tsx` |
+
+**前置條件**: 確認 Figma 文件 URL 與節點 ID(需統帥提供)
+
+### P2:業務元件(第 2-3 週)
+
+| 元件 | Figma 節點 | React 路徑 |
+|------|------------|------------|
+| IncidentCard | TBD | `components/incident/IncidentCard.tsx` |
+| ApprovalCard | TBD | `components/approval/ApprovalCard.tsx` |
+| AlertBanner | TBD | `components/alert/AlertBanner.tsx` |
+| StatusBadge | TBD | `components/status/StatusBadge.tsx` |
+
+### P3:Storybook 整合(第 4 週,視現況決定)
+
+- 確認是否已有 Storybook 設置
+- 若已有:在 Story 中引用 Code Connect 映射
+- 若未有:評估導入成本,記入 INSPIRATION_LAB.md 待批准
+
+## 工具需求
+
+```json
+// package.json (apps/web)
+{
+ "devDependencies": {
+ "@figma/code-connect": "^1.x"
+ }
+}
+```
+
+發布指令(需 Figma Personal Access Token):
+```bash
+npx figma connect publish --token $FIGMA_ACCESS_TOKEN
+```
+
+## 注意事項
+
+1. **Figma URL 需要統帥提供**:在執行 P1 前,需要確認 Figma 設計稿的文件 ID 和各核心元件的節點 ID
+2. **Token 管理**:`FIGMA_ACCESS_TOKEN` 需存入 K8s Secrets,不得 hardcode
+3. **每週掃描**:由 Claude Code + Figma MCP 執行,不需要新增 CI 步驟
+4. **`.figma.js` 檔案不影響 build**:只在開發環境和 Code Connect 發布時使用
+
+## 影響
+
+| 面向 | 影響 |
+|------|------|
+| **設計師** | Figma Dev Mode 可直接看到對應元件的 import 和 props |
+| **前端開發** | PR 時有明確參考,減少「我以為設計是這樣」問題 |
+| **QA** | 視覺差異有跡可查,drift report 提前預警 |
+| **Build 時間** | 無影響,`.figma.js` 不參與 webpack/next build |
+
+## 相關文件
+
+- [feedback_design_system_quickref.md](~/.claude/projects/-Users-ogt-awoooi/memory/feedback_design_system_quickref.md)
+- [feedback_ui_collaboration_protocol.md](~/.claude/projects/-Users-ogt-awoooi/memory/feedback_ui_collaboration_protocol.md)
+- Figma Code Connect 官方文件:https://www.figma.com/developers/code-connect
diff --git a/docs/adr/ADR-050-telegram-interactive-incident-v2.md b/docs/adr/ADR-050-telegram-interactive-incident-v2.md
new file mode 100644
index 00000000..5d1b4bd9
--- /dev/null
+++ b/docs/adr/ADR-050-telegram-interactive-incident-v2.md
@@ -0,0 +1,176 @@
+# ADR-050: Telegram 互動式 Incident 管理 2.0
+
+| 項目 | 內容 |
+|------|------|
+| **狀態** | 📋 已批准,待實作 |
+| **日期** | 2026-04-01 |
+| **決策者** | 首席架構師 + 統帥 |
+| **觸發** | MCP 整合長期計畫 |
+
+## 背景
+
+目前 AWOOOI Telegram 告警訊息提供三個 Inline Keyboard 按鈕:
+
+```
+[✅ 批准 (Y)] [❌ 拒絕 (n)] [🔕 靜默]
+```
+
+統帥在 Telegram 收到告警時,除了批准/拒絕外,還需要切換到 Web UI 或 CLI 才能:
+- 查看 Incident 的完整詳情(metrics、stack trace、診斷結果)
+- 重新觸發 OpenClaw 分析(當第一次分析結果可疑時)
+- 查詢歷史相似 Incident 的處置結果
+
+這增加了認知負擔,也拖慢了 MTTR(Mean Time To Respond)。
+
+## 問題
+
+| 操作 | 現況 | 痛點 |
+|------|------|------|
+| 查看詳情 | 須切換至 Web UI | 手機操作不便,流程中斷 |
+| 重新診斷 | 須 CLI 觸發 | 無法在 Telegram 中完成閉環 |
+| 歷史對比 | 須查詢資料庫 | 完全手動,耗時 |
+
+## 決策
+
+擴充 Telegram Inline Keyboard,加入第二行按鈕:
+
+```
+[✅ 批准] [❌ 拒絕] [🔕 靜默]
+[📋 詳情] [🔄 重診] [📊 歷史]
+```
+
+在後端新增 `TelegramCallbackService`,處理新按鈕的回調邏輯,並使用 `edit_message` 在原訊息中就地更新(不發送新訊息)。
+
+## 架構
+
+### 回調處理流程
+
+```
+Telegram Inline Button Click
+ → Bot API callback_query
+ → AWOOOI API POST /telegram/callback
+ → TelegramCallbackRouter (apps/api/src/routers/telegram_callback.py)
+ → TelegramCallbackService (apps/api/src/services/telegram_callback_service.py)
+ → 根據 action 分派:
+ "detail" → IncidentRepository.get_by_id()
+ → 格式化 Markdown 回傳
+ "reanalyze" → IncidentService.trigger_reanalysis()
+ → OpenClaw 重分析排程
+ "history" → IncidentRepository.find_similar()
+ → 格式化歷史對比表格
+ → Telegram Bot API edit_message_text()
+ → 原訊息就地更新(保留按鈕,附加展開內容)
+```
+
+### Callback Data 格式
+
+```
+{action}:{incident_id}
+
+範例:
+ detail:INC-2026-0401-001
+ reanalyze:INC-2026-0401-001
+ history:INC-2026-0401-001
+```
+
+### 新 Inline Keyboard 結構
+
+```python
+# src/services/telegram_notification_service.py
+InlineKeyboardMarkup(inline_keyboard=[
+ [
+ InlineKeyboardButton(text="✅ 批准", callback_data=f"approve:{incident_id}"),
+ InlineKeyboardButton(text="❌ 拒絕", callback_data=f"reject:{incident_id}"),
+ InlineKeyboardButton(text="🔕 靜默", callback_data=f"silence:{incident_id}"),
+ ],
+ [
+ InlineKeyboardButton(text="📋 詳情", callback_data=f"detail:{incident_id}"),
+ InlineKeyboardButton(text="🔄 重診", callback_data=f"reanalyze:{incident_id}"),
+ InlineKeyboardButton(text="📊 歷史", callback_data=f"history:{incident_id}"),
+ ],
+])
+```
+
+### 詳情回覆格式
+
+```
+📋 Incident 詳情:INC-2026-0401-001
+━━━━━━━━━━━━━━━━━━
+🏷 類型:CPU_SPIKE
+🖥 主機:k3s-node-1 (192.168.0.125)
+⏱ 時間:2026-04-01 14:32 +08
+📊 觸發值:CPU 94.3% (閾值 90%)
+🤖 OpenClaw 信心:0.87
+
+診斷摘要:
+過去 30 分鐘 CPU 呈上升趨勢,
+與 incident INC-2026-0318-007 模式相符。
+建議:重啟 signal_worker pod。
+```
+
+### 歷史對比格式
+
+```
+📊 相似 Incident 歷史(近 30 天)
+━━━━━━━━━━━━━━━━━━
+| 日期 | 類型 | 處置 | 結果 |
+|-------|-----------|-------|------|
+| 03-18 | CPU_SPIKE | 批准 | ✅ |
+| 03-10 | CPU_SPIKE | 批准 | ✅ |
+| 02-28 | CPU_SPIKE | 靜默 | ⚠️ |
+
+相似度最高:INC-2026-0318-007 (0.94)
+```
+
+## 實作計畫
+
+### P1:骨架建立(Sprint 1)
+
+- [ ] 新增 `/telegram/callback` endpoint(`src/routers/telegram_callback.py`)
+- [ ] 建立 `TelegramCallbackService` 骨架(含 action dispatch)
+- [ ] 更新 `telegram_notification_service.py` 的 Inline Keyboard 結構
+- [ ] Webhook 設定確認(Bot API setWebhook 指向新 endpoint)
+
+**交付條件**:點擊新按鈕不報錯,回傳「功能開發中」佔位訊息
+
+### P2:詳情查詢 + 重診觸發(Sprint 2)
+
+- [ ] `detail` action:呼叫 `IncidentRepository.get_by_id()`,格式化後 `edit_message`
+- [ ] `reanalyze` action:呼叫 `IncidentService.trigger_reanalysis()`,回傳「重診已排程」
+- [ ] 防止重複觸發:重診進行中時,按鈕顯示「⏳ 診斷中」並 disable
+
+### P3:歷史對比分析(Sprint 3)
+
+- [ ] `history` action:`IncidentRepository.find_similar(incident_id, top_k=5)`
+- [ ] 相似度演算法:基於 type + host + time_window 的向量搜尋(若已啟用 pgvector)
+- [ ] 格式化歷史表格,含處置結果與信心分數
+
+## 技術約束
+
+1. **leWOOOgo 積木化**:`TelegramCallbackService` 必須透過 `IncidentRepository` interface 存取資料,禁止 Router 層直接查 DB 或 Redis
+2. **edit_message 限制**:Telegram Bot API 限制 edit_message 只能在 48 小時內有效,超時需降級為發送新訊息
+3. **Callback Query 答覆**:所有 callback_query 必須在 5 秒內呼叫 `answerCallbackQuery()`,否則 Telegram 顯示 loading 轉圈
+4. **去重保護**:同一 Incident 的 reanalyze 在 10 分鐘 TTL 內只觸發一次(參考 feedback_telegram_dedup.md)
+
+## 安全考量
+
+- Callback data 中的 `incident_id` 需驗證存在且屬於合法狀態
+- Callback query 的 `from.id` 需與 allowlist 核對(使用現有 Telegram access 控制)
+- 禁止在 callback 中執行不可逆操作(如刪除 Incident)
+
+## 影響
+
+| 面向 | 影響 |
+|------|------|
+| **MTTR** | 預計減少 40%(統帥不需切換視窗) |
+| **API** | 新增 1 個 endpoint,無現有 endpoint 變更 |
+| **DB** | 新增 `find_similar` query,需確認索引 |
+| **Telegram Bot** | 需重新設定 Webhook(一次性操作) |
+
+## 相關文件
+
+- [ADR-035-telegram-alert-chain-enforcement.md](ADR-035-telegram-alert-chain-enforcement.md)
+- [ADR-045-telegram-gateway-consolidation.md](ADR-045-telegram-gateway-consolidation.md)
+- [feedback_telegram_dedup.md](~/.claude/projects/-Users-ogt-awoooi/memory/feedback_telegram_dedup.md)
+- [feedback_telegram_alert_format.md](~/.claude/projects/-Users-ogt-awoooi/memory/feedback_telegram_alert_format.md)
+- [feedback_lewooogo_modular_enforcement.md](~/.claude/projects/-Users-ogt-awoooi/memory/feedback_lewooogo_modular_enforcement.md)
diff --git a/docs/adr/ADR-051-context7-dependency-advisor.md b/docs/adr/ADR-051-context7-dependency-advisor.md
new file mode 100644
index 00000000..01497a68
--- /dev/null
+++ b/docs/adr/ADR-051-context7-dependency-advisor.md
@@ -0,0 +1,170 @@
+# ADR-051: Context7 依賴升級顧問整合
+
+| 項目 | 內容 |
+|------|------|
+| **狀態** | 📋 已批准,待實作 |
+| **日期** | 2026-04-01 |
+| **決策者** | 首席架構師 + 統帥 |
+| **觸發** | MCP 整合長期計畫 |
+
+## 背景
+
+AWOOOI monorepo 依賴多個快速迭代的框架,手動追蹤升級耗時且容易遺漏重要的安全修補或 Breaking Changes:
+
+| 套件 | 當前版本 | 最新版本 | 差距 |
+|------|----------|----------|------|
+| FastAPI | `>=0.115.0` | `0.128.0` | 13 個小版本 |
+| Next.js | `14.1.0` | `15.1.11` | 1 個主版本 |
+| Pydantic | `>=2.5.0` | v2 最新 | 需確認 |
+| @sentry/nextjs | `^10.45.0` | 需確認 | 需確認 |
+| lewooogo-brain | 內部套件 | - | 依發布追蹤 |
+
+**核心問題**:
+
+1. **安全漏洞**:CVE 修補若不及時,可能已有已知漏洞在生產環境
+2. **技術債累積**:次版本差距越大,升級時 Breaking Changes 越難處理
+3. **文件落後**:框架升級後,Claude Code 若依賴訓練資料,可能給出過時的 API 建議
+
+## 決策
+
+建立**每月依賴審查流程**,在每月 1 日,由 Claude Code 使用 **Context7 MCP** 查詢各主要依賴的最新版本、Breaking Changes 與官方遷移指南,生成升級評估報告,並建立追蹤 Issue 排入技術債計畫。
+
+## 審查流程
+
+### 觸發時機
+
+- **定期**:每月 1 日(由統帥手動觸發或 Schedule Agent)
+- **臨時**:收到 CVE 通知、框架發布重大版本(如 Next.js 16)
+- **被動**:CI 出現 deprecation warning 時
+
+### 執行步驟
+
+```
+Step 1: 使用 Context7 查詢各依賴最新文件
+ → resolve-library-id: "fastapi", "nextjs", "pydantic", "sentry"
+ → query-docs: "latest version", "changelog", "migration guide"
+
+Step 2: 比對當前版本(讀 apps/api/requirements.txt + apps/web/package.json)
+
+Step 3: 評估 Breaking Changes 風險
+ → 主版本升級 → 🔴 高風險,需完整測試
+ → 次版本升級 → 🟡 中風險,需讀 changelog
+ → Patch 升級 → 🟢 低風險,通常可直接升
+
+Step 4: 生成升級評估報告(輸出至 docs/adr/upgrade-report-YYYY-MM.md)
+
+Step 5: 建立 GitHub Issue(含升級計畫 + 預估工時)
+ 排入 Phase S+N 技術債看板
+```
+
+## 升級優先順序
+
+### 🔴 P1:安全修補(立即執行)
+
+**觸發條件**:任何 CVE CVSS ≥ 7.0,或套件官方發布安全公告
+
+**處理流程**:
+1. 當日確認修補版本
+2. 本地測試通過後,當日發 PR
+3. 直接合入 main,不等月度審查
+4. 同步更新 LOGBOOK.md
+
+**已知高風險套件**:
+- `cryptography` (Python) — 頻繁 CVE
+- `next` (Node) — SSR 相關安全漏洞
+- `pydantic` — 資料驗證繞過風險
+
+### 🟡 P2:主要功能改善(月度計畫)
+
+**範例**:
+
+| 升級 | 主要改善 | 預估工時 |
+|------|----------|----------|
+| Next.js 14 → 15 | App Router 效能優化、Turbopack 穩定 | 2-4 天 |
+| FastAPI 0.115 → 0.128 | dependency injection 改善、OpenAPI 更新 | 0.5 天 |
+
+**Next.js 15 升級注意事項**(預查):
+- `async` Server Components 預設行為變更
+- `cookies()`、`headers()` 改為 async API
+- Turbopack 成為預設(需測試 build 輸出)
+- 需執行 `npx @next/codemod@latest upgrade`
+
+### 🟢 P3:小版本 / Patch(批次更新)
+
+每季度批次更新,使用 `npm update` + `pip install -U` 後執行完整測試套件。
+
+## 報告格式
+
+```markdown
+# 依賴升級報告 2026-04
+
+生成時間:2026-04-01 00:00 +08
+工具:Context7 MCP + Claude Code
+
+## 摘要
+
+| 優先級 | 套件數 | 行動 |
+|--------|--------|------|
+| 🔴 P1 (安全) | 0 | 無 CVE |
+| 🟡 P2 (功能) | 2 | 建立 Issue |
+| 🟢 P3 (patch) | 5 | 排入季度批次 |
+
+## P2 升級建議
+
+### Next.js 15.1.11
+- 當前版本:14.1.0
+- Breaking Changes:[列出]
+- 遷移指南:[Context7 查詢結果]
+- 預估工時:2-4 天
+- 建議 Phase:Phase S+3
+
+...
+```
+
+## 技術約束
+
+1. **Context7 MCP 使用限制**:每次查詢消耗 token,月度審查批次執行,不頻繁觸發
+2. **升級前必讀**:`feedback_read_comments_first.md` — 涉及 `requirements.txt` 和 `package.json` 的修改
+3. **禁止直接 push**:所有版本升級必須經過 PR + CI 測試,遵守 `feedback_build_from_git_only.md`
+4. **Next.js 升級特別注意**:`apps/web/` 的升級需確認 `NEXT_PUBLIC_*` build-time 變數仍正常(`feedback_docker_nextjs_api_url.md`)
+
+## 當前待處理升級清單
+
+| 套件 | 當前 | 目標 | 優先級 | 預計執行 |
+|------|------|------|--------|----------|
+| FastAPI | `>=0.115.0` | `0.128.0` | 🟡 P2 | Phase S+1 |
+| Next.js | `14.1.0` | `15.1.11` | 🟡 P2 | Phase S+2 |
+| @sentry/nextjs | `^10.45.0` | 需 Context7 確認 | 🟢 P3 | 季度批次 |
+| Pydantic | `>=2.5.0` | 需 Context7 確認 | 🟢 P3 | 季度批次 |
+
+## 首次執行指令(由 Claude Code 執行)
+
+```bash
+# 1. 確認當前版本
+cat /Users/ogt/awoooi/apps/api/requirements.txt | grep -E "fastapi|pydantic|sentry"
+cat /Users/ogt/awoooi/apps/web/package.json | grep -E "next|sentry"
+
+# 2. 使用 Context7 查詢(Claude Code 手動執行)
+# → resolve-library-id: fastapi
+# → query-docs: "0.128 changelog breaking changes"
+# → 依此類推
+
+# 3. 生成報告並建立 Issue
+```
+
+## 影響
+
+| 面向 | 影響 |
+|------|------|
+| **安全** | P1 CVE 修補時間從「被動發現」縮短為「月內主動處理」 |
+| **技術債** | 避免版本差距累積超過 1 個主版本 |
+| **CI 穩定性** | 及時升級避免 deprecation warning 轉成 error |
+| **開發體驗** | Claude Code 可查最新 API 文件,減少過時建議 |
+
+## 相關文件
+
+- [feedback_stable_mainstream_practices.md](~/.claude/projects/-Users-ogt-awoooi/memory/feedback_stable_mainstream_practices.md)
+- [feedback_build_from_git_only.md](~/.claude/projects/-Users-ogt-awoooi/memory/feedback_build_from_git_only.md)
+- [feedback_docker_nextjs_api_url.md](~/.claude/projects/-Users-ogt-awoooi/memory/feedback_docker_nextjs_api_url.md)
+- [project_phase_r_refactoring.md](~/.claude/projects/-Users-ogt-awoooi/memory/project_phase_r_refactoring.md)
+- Context7 官方文件:https://context7.com/docs