From db02ecf2cf51a09072a14ac158f672abfcb29005 Mon Sep 17 00:00:00 2001 From: OoO Date: Sat, 2 May 2026 12:52:45 +0800 Subject: [PATCH] feat(telegram): ADR-019 Phase 1 - PPT data freshness gate + store_insight fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-019 Phase 1 (止血):PPT 生成前 probe 資料新鮮度。月初/缺資料期間用戶按 ppt:monthly/daily 不再產出空白報告,改主動 inline keyboard 詢問: - 改看最新有資料的月份/日期(一鍵) - 自訂月份/日期(接 await:date_ppt_*) - 取消 新增 PPTDataInsufficientError exception + _ppt_check_data_freshness() helper。 _generate_ppt_cmd 簽章加 _reply_to=None;_ppt_background 靜默吞此例外。 順手修同檔 :1976/:1993 兩處 store_insight() positional args 錯位 bug — 原本 (date, report_type, ai_text) 對應 signature (insight_type, content, period) 完全錯位,導致 period varchar(50) 被 2000 字 AI 內容截斷、INSERT 失敗、 ai_insights 表寫入永久失敗。改成 kwargs 呼叫。 ADR-019 (Telegram Bot Agentic Conversation Layer) 同步落地,Status: Accepted。 六 Phase 路線圖見 ADR 文件,本 commit 完成 Phase 1。 Co-Authored-By: Claude Opus 4.7 (1M context) --- ...telegram-bot-agentic-conversation-layer.md | 108 ++++++++++++++++++ docs/adr/README.md | 1 + routes/openclaw_bot_routes.py | 97 ++++++++++++++-- 3 files changed, 199 insertions(+), 7 deletions(-) create mode 100644 docs/adr/ADR-019-telegram-bot-agentic-conversation-layer.md diff --git a/docs/adr/ADR-019-telegram-bot-agentic-conversation-layer.md b/docs/adr/ADR-019-telegram-bot-agentic-conversation-layer.md new file mode 100644 index 0000000..94fc8b7 --- /dev/null +++ b/docs/adr/ADR-019-telegram-bot-agentic-conversation-layer.md @@ -0,0 +1,108 @@ +# ADR-019: Telegram Bot Agentic Conversation Layer + +- **Status**: Accepted +- **Date**: 2026-05-02 +- **Deciders**: 統帥 +- **Related**: ADR-001(三 Agent 學習分工)、ADR-012(Agent Action Ladder)、ADR-015(Telegram Menu Restoration)、ADR-018(四 Agent Control Plane) + +## Context + +ADR-018 已定義 Hermes / NemoTron / OpenClaw / ElephantAlpha 四 Agent control plane 的責任邊界。OpenClaw 是 L3 Strategist,負責高品質策略、報告、長期洞察。 + +但實作上,Telegram Bot 的互動層**完全繞過 OpenClaw 的決策能力**: + +1. **菜單按鈕走快車道** — `cmd:X` callback ([routes/openclaw_bot_routes.py:4412](../../routes/openclaw_bot_routes.py#L4412)) 直接呼叫寫死 handler(如 `_generate_ppt_cmd:2189`),不經 agent。 +2. **NL chat 是 last-resort fallback** — `openclaw_answer:4176` 已具備 Gemini Function Calling + NIM tool use 能力,但只在用戶輸入文字「沒匹配任何 keyword/state」時才被呼叫([telegram_bot_service.py:933](../../services/telegram_bot_service.py#L933))。 +3. **Rigid default + 靜默空白** — `_generate_ppt_cmd` 月報分支 [line 2293](../../routes/openclaw_bot_routes.py#L2293) 用 `now.year, now.month` 當預設,若 ETL 未進當月資料,PPT 生成「本月業績為零」的虛文,無提示無詢問。 +4. **無對話 state** — 每條訊息獨立,無 multi-turn confirmation、無 carry-over slot。 +5. **EventRouter 覆蓋 1.8%** — 21 個 telegram 發送點各自為政(記憶 `feedback_agent_action_ladder.md`、`reference_telegram_endpoints_map.md`)。 + +**痛點觸發**(2026-05-02 真實事件):用戶在月初 5/2 點 ppt:monthly → 系統預設查 5 月 → DB 最新資料只到 4/25 → AI 老實寫「業績為零」→ PPT 內容大量空白 → 用戶反映「老是異常你都無感嗎」。 + +## Decision + +採用「**Agent-First Conversation Layer**」原則:Telegram Bot 互動層的所有用戶輸入(cmd / menu / NL)都經過 OpenClaw agent 決策層,菜單按鈕降級為「agent suggestion shortcuts」,而非繞過 agent 的快車道。 + +### 五條互動原則 + +| # | 原則 | 含意 | +|---|------|------| +| 1 | **單一決策入口** | 所有 cmd:X / menu:X / NL message 統一進 `openclaw_decide(intent, args, ctx)`,agent 自決下一步動作 | +| 2 | **資料缺口優先告知,不靜默** | Agent 在執行任何資料查詢前,先 probe `latest_date()` / `query_available_*()`;缺資料時主動 inline ask,不出空白報告 | +| 3 | **NL 對話一等公民** | 用戶可用「我要看上個月月報」「最近一週的銷售」等 NL 觸發,與按鈕等價,agent 用 tool calling 解析 | +| 4 | **Multi-turn state** | 加 `services/openclaw_session.py` 管 chat_id 對話狀態,支援 carry-over slot(如「剛問月份 → 用戶答 4 → 接續執行」)| +| 5 | **EventRouter 統一出口** | 所有 telegram 發送經 EventRouter;agent 的 ask / answer / report 也走同管道,便於去重/降級/重送 | + +### 邊界(不做什麼) + +- 不取代 ADR-018 的 control plane 責任分工(仍是四 agent) +- 不繞過 ADR-012 的 L0/L1/L2/L3 信任邊界(高風險 action 仍走 HITL) +- 不重寫 telegram_templates.py(31 個模板繼續用) +- 不改 webhook/polling 雙路徑架構(ADR-008 已定) +- 不改 dedup guard(commit 1a886d9 已部署) + +### 範圍 + +- ✅ `routes/openclaw_bot_routes.py` cmd dispatch 改造 +- ✅ `services/telegram_bot_service.py` handle_message NL → agent 路徑 +- ✅ 新增 `services/openclaw_session.py` 對話 state +- ✅ 新增 `services/openclaw_tools.py` agent tool registry +- ✅ EventRouter 21 個發送點遷移(refactor-specialist 範圍) +- ❌ Hermes embedding worker 不動 +- ❌ NemoTron / ElephantAlpha 內部邏輯不動 + +## Alternatives Considered + +| 方案 | 不採用原因 | +|------|----------| +| A. 只補 freshness gate(band-aid) | 同類 bug 散在 daily/weekly/strategy/competitor/promo 多處,補不完,治標不治本 | +| B. 引入外部 agent framework (LangGraph/AutoGen) | 黑盒、難審計、跨 agent 通訊已有 EventRouter 能擴展,不增加依賴 | +| C. 直接 deprecate cmd:X 路徑只留 NL | 用戶習慣按鈕互動,移除會破壞 UX;菜單可以保留為 agent suggestion | +| D. 把 OpenClaw 換成 ElephantAlpha 做對話入口 | ADR-018 已分工:ElephantAlpha 是 orchestrator 不是對話 agent;OpenClaw 才是 | + +## Consequences + +正面: +- PPT 空白等「rigid default」類 bug 一次性根除(agent 主動 detect 缺口) +- NL 對話和按鈕互動統一,用戶體驗一致 +- Multi-turn state 讓複雜場景(如「上個月 vs 過去 3 個月」對比)變可能 +- EventRouter 覆蓋率 1.8% → 80%,系統可觀測性大幅提升 + +風險: +- Phase 3 影響 21 個 cmd 行為,需灰度 + feature flag + e2e regression +- Agent 多一層決策,每個 cmd 增加 200-500ms latency(NIM/Gemini call) +- 對話 state 引入新故障面(session race / memory leak) +- 全部完成需 4-6 週工程,期間需保持向下相容 + +降級策略: +- Agent 失敗 → 回退原 cmd:X 寫死 handler(feature flag off) +- Tool call timeout → 用 cached response or template fallback +- Session lookup fail → 視為新對話,不阻斷主流程 + +## Implementation Plan + +分 6 Phase 漸進交付,每 Phase 可獨立上線/回滾: + +| Phase | 目標 | 工時 | 可獨立 ship | +|-------|------|------|-----------| +| 0 | 本 ADR + 計畫對齊 | 1h | ✅ | +| 1 | PPT 3 個 handler 補 freshness gate(止血)| 2h | ✅ | +| 2 | OpenClaw tool registry(query_*, ppt 等變 tool)| 1d | ✅ | +| 3 | cmd:X dispatch 接入 agent.decide()(feature flag 灰度)| 2d | ✅ | +| 4 | Multi-turn state(services/openclaw_session.py)| 1d | ✅ | +| 5 | EventRouter 21 發送點遷移(refactor-specialist)| 2-3d | ✅ | +| 6 | Proactive 09:00 資料新鮮度 probe + agent 主動通知 | 1d | ✅ | + +每 Phase 後驗收: +- Phase 1: PPT 空白事件 = 0 +- Phase 3: 21 cmd regression test pass + agent 接管率 100% +- Phase 5: EventRouter coverage 1.8% → 80% + +## References + +- ADR-001 三 Agent 學習分工 +- ADR-012 Agent Action Ladder +- ADR-015 Telegram Menu Restoration +- ADR-018 四 Agent Control Plane +- 記憶:`feedback_agent_action_ladder.md`、`reference_telegram_endpoints_map.md`、`feedback_iterative_rollout.md` +- 觸發事件:2026-05-02 用戶 PPT 月報空白回報 diff --git a/docs/adr/README.md b/docs/adr/README.md index b4f4244..36a9e40 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -40,6 +40,7 @@ | [016](ADR-016-daily-sales-cache-fingerprint.md) | daily_sales cache fingerprint(gunicorn 多 worker 一致性) | Accepted | 2026-04-29 | | [017](ADR-017-modularization-cleanup-roadmap.md) | 模組化收尾路線圖(Phase 3f) | Accepted | 2026-04-29 | | [018](ADR-018-four-agent-ai-automation-control-plane.md) | 四 AI Agent 自動化控制面(Hermes/NemoTron/OpenClaw/ElephantAlpha) | Accepted | 2026-04-29 | +| [019](ADR-019-telegram-bot-agentic-conversation-layer.md) | Telegram Bot Agentic Conversation Layer(菜單→Agent 決策統一入口) | Accepted | 2026-05-02 | ## 規範 diff --git a/routes/openclaw_bot_routes.py b/routes/openclaw_bot_routes.py index 6b58598..b30c7a4 100644 --- a/routes/openclaw_bot_routes.py +++ b/routes/openclaw_bot_routes.py @@ -1975,8 +1975,11 @@ def _ppt_ai_analysis(prompt_data: str, report_type: str = '') -> str: import threading as _thr _thr.Thread( target=store_insight, - args=(datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d'), - report_type or 'analysis', result_text), + kwargs={ + 'insight_type': report_type or 'analysis', + 'content': result_text, + 'period': datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d'), + }, daemon=True ).start() return result_text @@ -1992,8 +1995,11 @@ def _ppt_ai_analysis(prompt_data: str, report_type: str = '') -> str: import threading as _thr _thr.Thread( target=store_insight, - args=(datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d'), - report_type or 'analysis', result_text), + kwargs={ + 'insight_type': report_type or 'analysis', + 'content': result_text, + 'period': datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d'), + }, daemon=True ).start() return result_text @@ -2027,6 +2033,69 @@ def _ppt_fallback_insight(report_type: str, data_summary: str, mcp_text: str = ' return "\n".join(lines) +class PPTDataInsufficientError(Exception): + """ADR-019 Phase 1:請求的 PPT 期間缺資料。raise 前已主動 inline-keyboard 詢問用戶。""" + pass + + +def _ppt_check_data_freshness(report_type: str, chat_id: int, reply_to: int, + requested_yr: int = None, requested_mo: int = None, + requested_date: str = None) -> None: + """ADR-019 Phase 1:PPT 生成前 probe 資料新鮮度。資料缺口時主動 inline keyboard + 詢問用戶(自訂日期 / 改看最新 / 取消),並 raise PPTDataInsufficientError 中止流程。 + + daily / strategy(單日):傳 requested_date='YYYY/MM/DD' + monthly:傳 requested_yr, requested_mo + """ + latest = latest_date() # 'YYYY-MM-DD' 或 None + if not latest: + return # DB 連不到,原樣繼續,由 caller 處理 + + try: + latest_dt = datetime.strptime(latest.replace('/', '-'), '%Y-%m-%d') + except (ValueError, AttributeError): + return + + if report_type in ('monthly', '月報') and requested_yr and requested_mo: + has_data = (latest_dt.year > requested_yr or + (latest_dt.year == requested_yr and latest_dt.month >= requested_mo)) + if not has_data: + prev_yr, prev_mo = latest_dt.year, latest_dt.month + kb = [ + _row((f'📊 改看 {prev_yr}/{prev_mo:02d} 月報', + f'cmd:ppt:monthly {prev_yr}/{prev_mo:02d}')), + _row(('📅 自訂月份', 'await:date_ppt_monthly')), + _row(('❌ 取消', 'menu:reports')), + ] + send_message( + chat_id, + f"⚠️ *{requested_yr}/{requested_mo:02d}* 尚無業績資料\n" + f"目前最新資料截至:`{latest}`\n\n請選擇:", + reply_to, + keyboard=kb, + parse_mode='Markdown', + ) + raise PPTDataInsufficientError(f'monthly {requested_yr}/{requested_mo:02d}') + + elif report_type in ('daily', '日報', 'strategy', '策略') and requested_date: + sales = query_sales(requested_date) + if not sales.get('found'): + kb = [ + _row((f'📊 改看 {latest} 日報', f'cmd:ppt:{report_type or "daily"} {latest}')), + _row(('📅 自訂日期', 'await:date_ppt_daily')), + _row(('❌ 取消', 'menu:reports')), + ] + send_message( + chat_id, + f"⚠️ *{requested_date}* 尚無業績資料\n" + f"目前最新資料:`{latest}`\n\n請選擇:", + reply_to, + keyboard=kb, + parse_mode='Markdown', + ) + raise PPTDataInsufficientError(f'{report_type} {requested_date}') + + def _normalize_ppt_parameters(parameters: dict) -> str: """將快取參數轉成穩定字串,避免 key 順序造成命中錯誤。""" try: @@ -2186,8 +2255,13 @@ def _fetch_mcp_context() -> str: return '' -def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str) -> str: - """依 sub_type 生成對應 pptx,回傳檔案路徑""" +def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, + _reply_to: int = None) -> str: + """依 sub_type 生成對應 pptx,回傳檔案路徑。 + + ADR-019 Phase 1:在生成前 probe 資料新鮮度,缺資料時 raise + PPTDataInsufficientError(已主動詢問用戶),由 _ppt_background 靜默吞掉。 + """ try: from services.ppt_generator import ( generate_daily_ppt, generate_weekly_ppt, @@ -2209,6 +2283,8 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str) - else: date_str = latest_date() or now.strftime('%Y/%m/%d') + _ppt_check_data_freshness('daily', _chat_id, _reply_to, requested_date=date_str) + params = {'report_type': 'daily', 'date': date_str} cached, cached_ai = _load_cached_ppt_path_and_analysis('daily', params) if cached: @@ -2292,6 +2368,9 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str) - else: yr, mo = now.year, now.month + _ppt_check_data_freshness('monthly', _chat_id, _reply_to, + requested_yr=yr, requested_mo=mo) + params = {'report_type': 'monthly', 'month': f'{yr}/{mo:02d}'} cached, cached_ai = _load_cached_ppt_path_and_analysis('monthly', params) if cached: @@ -4987,7 +5066,8 @@ def handle_cmd(cmd, arg, chat_id, reply_to): def _ppt_background(_sub_type, _sub_arg, _chat_id, _target, _reply_to): try: - ppt_path = _generate_ppt_cmd(_sub_type, _sub_arg, _chat_id, _target) + ppt_path = _generate_ppt_cmd(_sub_type, _sub_arg, _chat_id, _target, + _reply_to=_reply_to) if ppt_path and os.path.exists(ppt_path): type_labels = { 'daily': '📊 日報', 'weekly': '📈 週報', @@ -5005,6 +5085,9 @@ def handle_cmd(cmd, arg, chat_id, reply_to): pass else: send_message(_chat_id, "⚠️ 簡報生成失敗,請稍後再試", _reply_to) + except PPTDataInsufficientError as e: + # ADR-019 Phase 1:用戶已收到 inline keyboard 詢問,靜默結束 + sys_log.info(f"[OpenClawBot] /ppt skipped due to data gap: {e}") except Exception as e: sys_log.error(f"[OpenClawBot] /ppt bg error: {e}", exc_info=True) send_message(_chat_id, f"⚠️ 簡報生成失敗:{str(e)[:100]}", _reply_to)