feat(telegram): ADR-019 Phase 1 - PPT data freshness gate + store_insight fix
All checks were successful
CD Pipeline / deploy (push) Successful in 2m55s
All checks were successful
CD Pipeline / deploy (push) Successful in 2m55s
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) <noreply@anthropic.com>
This commit is contained in:
108
docs/adr/ADR-019-telegram-bot-agentic-conversation-layer.md
Normal file
108
docs/adr/ADR-019-telegram-bot-agentic-conversation-layer.md
Normal file
@@ -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 月報空白回報
|
||||
@@ -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 |
|
||||
|
||||
## 規範
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user