# 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 使用 nonce，detail/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: 全 PASSED，0 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 action（detail/reanalyze/history）送新訊息，不修改原始簽核卡片，保留授權按鈕可用
5. **`IncidentStatus.ANALYZING`**：如不存在，改用 `INVESTIGATING`
