Files
awoooi/docs/superpowers/plans/2026-04-01-telegram-reanalyze.md
OG T 25889d4b8e
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
docs: 歸檔 ADR-050 reanalyze 實作計畫 (已完成)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 09:38:03 +08:00

19 KiB
Raw Blame History

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 去重 + 標記 statustelegram_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() 位置

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 方法後的位置,加入:

    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 已在檔案頂部 importIncidentStatus 需延遲 import避免循環依賴同其他方法的做法

  • Step 3: 確認 IncidentStatus.ANALYZING 存在
grep -n "ANALYZING\|class IncidentStatus" apps/api/src/models/incident.py

若不存在 ANALYZING,改用現有的對應 statusINVESTIGATING)。

  • Step 4: Commit
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 位置

grep -n "reanalyze\|功能開發中" apps/api/src/services/telegram_gateway.py

Expected: 看到類似這樣的行:

else:
    # reanalyze: 開發中
    await self._answer_callback(callback_query_id, action, text="🔄 功能開發中")
  • Step 2: 替換 reanalyze placeholder

找到 handle_callback 方法中的 reanalyze 分支,替換為:

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 結尾,在其後加入:

    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
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 了解測試結構

head -40 apps/api/tests/test_telegram_integration.py
  • Step 2: 建立 test_telegram_adr050.py
"""
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: 執行測試
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 測試確認未破壞舊功能
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
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: 確認所有測試通過
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
cd /Users/ogt/awoooi && git push gitea main

Expected: CD pipeline 觸發。


注意事項

  1. 禁止假資料trigger_reanalysis() 必須操作真實 Incident 物件,不可 hardcode
  2. leWOOOgo 積木化telegram_gateway.py 中用延遲 importfrom src.services.incident_service import get_incident_service),同 _send_incident_detail 的模式
  3. 去重 TTL600 秒10 分鐘),與 feedback_telegram_dedup.md 一致
  4. edit_message vs 新訊息:所有 info actiondetail/reanalyze/history送新訊息不修改原始簽核卡片保留授權按鈕可用
  5. IncidentStatus.ANALYZING:如不存在,改用 INVESTIGATING