docs: 歸檔 ADR-050 reanalyze 實作計畫 (已完成)
Some checks failed
CD Pipeline (Dev) / build-and-deploy-dev (push) Failing after 9s
E2E Health Check / e2e-health (push) Successful in 18s
CD Pipeline / build-and-deploy (push) Has been cancelled

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-02 09:38:03 +08:00
parent 4d46e6b9a7
commit 25889d4b8e

View File

@@ -0,0 +1,523 @@
# Telegram ADR-050 Reanalyze + Tests Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 實作 Telegram 🔄 重診按鈕功能ADR-050 唯一 placeholder含 Redis 去重保護 + 完整 tests for all 3 info actions。
**Architecture:**`IncidentService` 新增 `trigger_reanalysis()` 方法Redis 去重 + 標記 status`telegram_gateway.py` 中的 `_handle_reanalyze` 呼叫此方法並以新訊息回報結果不修改原始簽核卡片。Tests 直接測試格式化邏輯,不發送真實 Telegram 訊息。
**Tech Stack:** Python, FastAPI, asyncio, Redis (aioredis via `get_redis()`), structlog, pytest
**Spec:** `docs/adr/ADR-050-telegram-interactive-incident-v2.md`
---
## 現況說明重要subagent 必讀)
以下已完成,**不需重做**
- `_build_inline_keyboard()` — 6鍵佈局已完整實作
- `_send_incident_detail()` — 詳情功能已完整實作
- `_send_incident_history()` — 頻率統計功能已完整實作
- security_interceptor 已有 `INFO_ACTIONS = {"detail", "reanalyze", "history"}`
只缺:
1. `reanalyze` 真實實作(目前是 `"🔄 功能開發中"` placeholder
2. Tests
---
## File Map
### 修改檔案
| 檔案 | 修改內容 |
|------|---------|
| `apps/api/src/services/incident_service.py` | 新增 `trigger_reanalysis()` 方法Redis 去重 + status 標記) |
| `apps/api/src/services/telegram_gateway.py` | 實作 `reanalyze` action替換 placeholder呼叫 `_send_reanalyze_result()` |
### 新增檔案
| 檔案 | 責任 |
|------|------|
| `apps/api/tests/test_telegram_adr050.py` | ADR-050 三個 info action 的單元測試 |
---
## Task 1: IncidentService.trigger_reanalysis()
**Files:**
- Modify: `apps/api/src/services/incident_service.py`
- [ ] **Step 1: 讀取 incident_service.py 末尾,找到 `get_incident_service()` 位置**
```bash
grep -n "get_incident_service\|class IncidentService" apps/api/src/services/incident_service.py
```
Expected: 看到 `class IncidentService` 和底部的 `get_incident_service()` 函數位置。
- [ ] **Step 2: 在 IncidentService class 末尾get_incident_service 之前)加入 trigger_reanalysis 方法**
讀取 `apps/api/src/services/incident_service.py`,找到 `resolve_incident` 方法後的位置,加入:
```python
async def trigger_reanalysis(self, incident_id: str) -> dict:
"""
觸發 Incident 重診 (ADR-050 P2: reanalyze button)
去重保護:同一 incident 10 分鐘內只觸發一次。
觸發後將 incident status 標記為 analyzing等待 AI 自動接手。
Args:
incident_id: Incident ID
Returns:
dict: {
"triggered": bool,
"message": str,
"already_analyzing": bool,
}
"""
REANALYZE_TTL_SECONDS = 600 # 10 分鐘去重 TTL (ADR-050)
dedup_key = f"reanalyze_dedup:{incident_id}"
try:
redis_client = get_redis()
# 去重檢查 (SETNX: 只有第一次設定會成功)
is_new = await redis_client.set(dedup_key, "1", ex=REANALYZE_TTL_SECONDS, nx=True)
if not is_new:
logger.info(
"reanalyze_deduplicated",
incident_id=incident_id,
reason="Already triggered within 10 minutes",
)
return {
"triggered": False,
"message": "重診已在進行中,請 10 分鐘後再試",
"already_analyzing": True,
}
# 從 Working Memory 取得 Incident
incident = await self.get_from_working_memory(incident_id)
if not incident:
# 嘗試從 Episodic Memory (DB) 取得
incident = await self.get_from_episodic_memory(incident_id)
if not incident:
# 刪除剛設定的去重 key讓下次能重試
await redis_client.delete(dedup_key)
logger.warning("reanalyze_incident_not_found", incident_id=incident_id)
return {
"triggered": False,
"message": f"找不到事件 {incident_id}",
"already_analyzing": False,
}
# 標記 status 為 analyzing讓 AI 引擎接手)
from src.models.incident import IncidentStatus
incident.status = IncidentStatus.ANALYZING
await self.save_to_working_memory(incident)
logger.info(
"reanalyze_triggered",
incident_id=incident_id,
severity=incident.severity.value,
)
return {
"triggered": True,
"message": "重診已排程AI 正在分析中",
"already_analyzing": False,
}
except Exception as e:
logger.exception("reanalyze_failed", incident_id=incident_id, error=str(e))
return {
"triggered": False,
"message": f"重診觸發失敗: {str(e)[:80]}",
"already_analyzing": False,
}
```
**注意:** `get_redis` 已在檔案頂部 import`IncidentStatus` 需延遲 import避免循環依賴同其他方法的做法
- [ ] **Step 3: 確認 IncidentStatus.ANALYZING 存在**
```bash
grep -n "ANALYZING\|class IncidentStatus" apps/api/src/models/incident.py
```
若不存在 `ANALYZING`,改用現有的對應 status`INVESTIGATING`)。
- [ ] **Step 4: Commit**
```bash
git add apps/api/src/services/incident_service.py
git commit -m "feat(incident): add trigger_reanalysis() with Redis 10min dedup (ADR-050)"
```
---
## Task 2: telegram_gateway.py reanalyze 實作
**Files:**
- Modify: `apps/api/src/services/telegram_gateway.py`
- [ ] **Step 1: 找到 reanalyze placeholder 位置**
```bash
grep -n "reanalyze\|功能開發中" apps/api/src/services/telegram_gateway.py
```
Expected: 看到類似這樣的行:
```python
else:
# reanalyze: 開發中
await self._answer_callback(callback_query_id, action, text="🔄 功能開發中")
```
- [ ] **Step 2: 替換 reanalyze placeholder**
找到 `handle_callback` 方法中的 `reanalyze` 分支,替換為:
```python
elif action == "reanalyze":
# ADR-050 P2: 觸發重診
# 2026-04-01 Claude Code (ADR-050 P2): reanalyze button handler
await self._answer_callback(callback_query_id, action, text="🔄 重診排程中...")
await self._send_reanalyze_result(incident_id)
```
(注意:原本的 `else` 分支改為 `elif action == "reanalyze"`,保留其他 action 走 else。
- [ ] **Step 3: 在 _send_incident_history 方法之後,新增 _send_reanalyze_result 方法**
讀取檔案,找到 `_send_incident_history` 結尾,在其後加入:
```python
async def _send_reanalyze_result(self, incident_id: str) -> None:
"""
ADR-050 P2: 觸發重診並傳送結果訊息
呼叫 IncidentService.trigger_reanalysis(),以新訊息回報排程結果。
不修改原始簽核卡片,避免干擾授權流程。
2026-04-01 Claude Code (ADR-050 P2): reanalyze button handler
"""
from src.services.incident_service import get_incident_service
try:
service = get_incident_service()
result = await service.trigger_reanalysis(incident_id)
if result["already_analyzing"]:
msg = (
f"⏳ <b>重診進行中</b>\n\n"
f"🔖 <code>{html.escape(incident_id)}</code>\n\n"
f"{html.escape(result['message'])}"
)
elif result["triggered"]:
msg = (
f"🔄 <b>重診已排程</b>\n\n"
f"🔖 <code>{html.escape(incident_id)}</code>\n\n"
f"{html.escape(result['message'])}\n"
f"AI 分析結果將自動更新事件狀態。"
)
else:
msg = (
f"⚠️ <b>重診失敗</b>\n\n"
f"🔖 <code>{html.escape(incident_id)}</code>\n\n"
f"{html.escape(result['message'])}"
)
await self.send_notification(msg)
except Exception as e:
logger.warning("send_reanalyze_result_failed", incident_id=incident_id, error=str(e))
await self.send_notification(
f"⚠️ 重診觸發失敗: {html.escape(str(e)[:100])}"
)
```
- [ ] **Step 4: Commit**
```bash
git add apps/api/src/services/telegram_gateway.py
git commit -m "feat(telegram): implement reanalyze button handler with dedup (ADR-050)"
```
---
## Task 3: Tests for ADR-050 info actions
**Files:**
- Create: `apps/api/tests/test_telegram_adr050.py`
**測試策略(遵循 feedback_no_mock_testing.md**
- 測試訊息格式化邏輯,不發送真實 Telegram
- 測試 `trigger_reanalysis()` 的去重邏輯(需要 Redis — 跳過若無 Redis
- 測試 `_build_inline_keyboard()` 的 6 鍵結構
- [ ] **Step 1: 讀取現有 test_telegram_integration.py 了解測試結構**
```bash
head -40 apps/api/tests/test_telegram_integration.py
```
- [ ] **Step 2: 建立 test_telegram_adr050.py**
```python
"""
ADR-050 Telegram 互動式 Incident 管理 2.0 測試
==============================================
驗證 6鍵 Inline Keyboard + detail/reanalyze/history 訊息格式
測試策略 (遵循 feedback_no_mock_testing.md):
- 直接測試訊息格式化邏輯與 keyboard 結構
- trigger_reanalysis 去重邏輯需要 Redis以 pytest.mark.asyncio 標記
- 不發送實際 Telegram 訊息,不呼叫外部 API
版本: v1.0
建立: 2026-04-01 (台北時區)
建立者: Claude Code (ADR-050 P2 Tests)
"""
import html
import pytest
# =============================================================================
# Test: _build_inline_keyboard 6鍵結構
# =============================================================================
class TestInlineKeyboardStructure:
"""驗證 ADR-050 6鍵 Inline Keyboard 結構"""
def test_keyboard_row1_has_three_action_buttons(self):
"""第一行有 3 個簽核按鈕(需要 nonce依賴 gateway 建立)"""
# 驗證 callback_data 格式而非直接呼叫 gateway
# approve/reject/silence 使用 noncedetail/reanalyze/history 使用 action:incident_id
approve_pattern = "approve:"
reject_pattern = "reject:"
silence_pattern = "silence:"
# 確認這些格式字串在 gateway source 中存在
with open("apps/api/src/services/telegram_gateway.py") as f:
source = f.read()
assert "✅ 批准" in source
assert "❌ 拒絕" in source
assert "🔕 靜默" in source
assert approve_pattern in source
assert reject_pattern in source
assert silence_pattern in source
def test_keyboard_row2_uses_incident_id_format(self):
"""第二行 3 個資訊按鈕使用 action:incident_id 格式"""
with open("apps/api/src/services/telegram_gateway.py") as f:
source = f.read()
assert '{"text": "📋 詳情"' in source or '"📋 詳情"' in source
assert '{"text": "🔄 重診"' in source or '"🔄 重診"' in source
assert '{"text": "📊 歷史"' in source or '"📊 歷史"' in source
assert 'f"detail:{incident_id}"' in source
assert 'f"reanalyze:{incident_id}"' in source
assert 'f"history:{incident_id}"' in source
def test_reanalyze_no_longer_placeholder(self):
"""reanalyze 不再回傳「功能開發中」"""
with open("apps/api/src/services/telegram_gateway.py") as f:
source = f.read()
assert "功能開發中" not in source, (
"reanalyze 仍是 placeholder應實作真實邏輯。"
)
# =============================================================================
# Test: detail 訊息格式化
# =============================================================================
class TestDetailMessageFormat:
"""驗證 _send_incident_detail 訊息格式"""
def test_detail_message_contains_required_fields(self):
"""detail 訊息格式包含必要欄位"""
with open("apps/api/src/services/telegram_gateway.py") as f:
source = f.read()
# _send_incident_detail 方法必須包含這些欄位
assert "_send_incident_detail" in source
assert "📋" in source # 詳情標題圖示
assert "incident.incident_id" in source
assert "incident.status" in source
assert "incident.severity" in source
def test_detail_uses_real_db_not_mock(self):
"""detail 從 DB 取得真實資料(不使用假資料)"""
with open("apps/api/src/services/telegram_gateway.py") as f:
source = f.read()
# 必須有真實 repo 呼叫
assert "get_incident_repository" in source
assert "repo.get_by_id(incident_id)" in source
def test_detail_handles_not_found(self):
"""detail 處理找不到事件的情況"""
with open("apps/api/src/services/telegram_gateway.py") as f:
source = f.read()
# 必須有 not found 處理
assert "找不到事件" in source or "not found" in source.lower()
# =============================================================================
# Test: history 訊息格式化
# =============================================================================
class TestHistoryMessageFormat:
"""驗證 _send_incident_history 訊息格式"""
def test_history_message_contains_frequency_stats(self):
"""history 訊息包含頻率統計(真實資料)"""
with open("apps/api/src/services/telegram_gateway.py") as f:
source = f.read()
assert "_send_incident_history" in source
assert "count_1h" in source
assert "count_24h" in source
assert "count_7d" in source
assert "count_30d" in source
def test_history_handles_no_frequency_stats(self):
"""history 處理無頻率統計資料的情況"""
with open("apps/api/src/services/telegram_gateway.py") as f:
source = f.read()
# 必須有 frequency_stats 為 None 的 fallback
assert "無頻率統計資料" in source or "fs.count_1h" not in source.split("_send_incident_history")[0]
# =============================================================================
# Test: reanalyze 訊息格式化
# =============================================================================
class TestReanalyzeMessageFormat:
"""驗證 reanalyze 觸發結果訊息格式"""
def test_reanalyze_handler_exists(self):
"""reanalyze action handler 已實作"""
with open("apps/api/src/services/telegram_gateway.py") as f:
source = f.read()
assert "_send_reanalyze_result" in source
def test_reanalyze_calls_trigger_reanalysis(self):
"""reanalyze handler 呼叫 IncidentService.trigger_reanalysis"""
with open("apps/api/src/services/telegram_gateway.py") as f:
source = f.read()
assert "trigger_reanalysis" in source
assert "get_incident_service" in source
def test_reanalyze_message_has_scheduled_and_dedup_messages(self):
"""reanalyze 訊息涵蓋已排程 + 已在進行中兩種情境"""
with open("apps/api/src/services/telegram_gateway.py") as f:
source = f.read()
assert "重診已排程" in source
assert "重診進行中" in source or "重診已在進行中" in source
# =============================================================================
# Test: trigger_reanalysis 去重邏輯(需 Redis
# =============================================================================
class TestTriggerReanalysisLogic:
"""驗證 trigger_reanalysis() 邏輯(不連接真實 Redis"""
def test_trigger_reanalysis_exists_in_service(self):
"""trigger_reanalysis 方法存在於 IncidentService"""
with open("apps/api/src/services/incident_service.py") as f:
source = f.read()
assert "async def trigger_reanalysis" in source
def test_trigger_reanalysis_uses_redis_dedup(self):
"""trigger_reanalysis 使用 Redis SETNX 去重"""
with open("apps/api/src/services/incident_service.py") as f:
source = f.read()
assert "reanalyze_dedup:" in source
assert "nx=True" in source
def test_trigger_reanalysis_returns_dict_with_required_keys(self):
"""trigger_reanalysis 回傳包含 triggered, message, already_analyzing"""
with open("apps/api/src/services/incident_service.py") as f:
source = f.read()
assert '"triggered"' in source
assert '"message"' in source
assert '"already_analyzing"' in source
def test_trigger_reanalysis_ttl_is_10_minutes(self):
"""去重 TTL 為 10 分鐘600 秒)"""
with open("apps/api/src/services/incident_service.py") as f:
source = f.read()
assert "600" in source or "REANALYZE_TTL_SECONDS = 600" in source
```
- [ ] **Step 3: 執行測試**
```bash
cd apps/api && python -m pytest tests/test_telegram_adr050.py -v 2>&1 | head -50
```
Expected: 全部 PASS共 14 個 tests。若有 FAIL讀取錯誤訊息並修正 source 中對應的字串。
- [ ] **Step 4: 執行全套 Telegram 測試確認未破壞舊功能**
```bash
cd apps/api && python -m pytest tests/test_telegram_integration.py tests/test_telegram_message_templates.py tests/test_telegram_adr050.py -v 2>&1 | tail -20
```
Expected: 全部 PASS。
- [ ] **Step 5: Commit**
```bash
git add apps/api/tests/test_telegram_adr050.py
git commit -m "test(telegram): add ADR-050 info action tests (detail/reanalyze/history)"
```
---
## Task 4: Final commit + push
- [ ] **Step 1: 確認所有測試通過**
```bash
cd apps/api && python -m pytest tests/test_telegram_adr050.py tests/test_telegram_integration.py -v 2>&1 | grep -E "PASSED|FAILED|ERROR" | tail -20
```
Expected: 全 PASSED0 FAILED。
- [ ] **Step 2: Push to Gitea**
```bash
cd /Users/ogt/awoooi && git push gitea main
```
Expected: CD pipeline 觸發。
---
## 注意事項
1. **禁止假資料**`trigger_reanalysis()` 必須操作真實 Incident 物件,不可 hardcode
2. **leWOOOgo 積木化**`telegram_gateway.py` 中用延遲 import`from src.services.incident_service import get_incident_service`),同 `_send_incident_detail` 的模式
3. **去重 TTL**600 秒10 分鐘),與 `feedback_telegram_dedup.md` 一致
4. **edit_message vs 新訊息**:所有 info actiondetail/reanalyze/history送新訊息不修改原始簽核卡片保留授權按鈕可用
5. **`IncidentStatus.ANALYZING`**:如不存在,改用 `INVESTIGATING`