From 14195b65fde030647401db081c64b92e4349ddd1 Mon Sep 17 00:00:00 2001 From: OoO Date: Sat, 2 May 2026 13:04:18 +0800 Subject: [PATCH] feat(telegram): ADR-019 Phase 4 - conversation memory + chat_id propagation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-019 Phase 4:新增 services/openclaw_session.py 管理 chat_id 級別的多輪 對話歷史與 carry-over slot。In-memory,30 分鐘 TTL,重啟清空(臨時對話 state 不該污染 ai_insights 永久記憶)。 openclaw_answer 簽章加 chat_id=None 可選參數: - 傳入時 agent 會看到該 chat 最近 5 輪對話歷史,注入 system prompt - Ollama / Gemini FC 兩條路徑都會在生成成功後 append_turn 寫回 session - system prompt 加決策規則:「若歷史顯示用戶剛被問參數,優先用該答案接續執行」 Caller 全部更新傳 chat_id: - routes/openclaw_bot_routes.py:5479 (handle_cmd 不認識指令 fallback) - routes/openclaw_bot_routes.py:5916 (webhook NL 路徑) - routes/openclaw_bot_routes.py:_agent_dispatch_cmd (Phase 3 hook) - services/telegram_bot_service.py:934 (polling NL fallback) 向下相容:chat_id=None 時行為與舊版完全相同(無 multi-turn 記憶)。 Co-Authored-By: Claude Opus 4.7 (1M context) --- routes/openclaw_bot_routes.py | 30 +++++-- services/openclaw_session.py | 133 +++++++++++++++++++++++++++++++ services/telegram_bot_service.py | 5 +- 3 files changed, 159 insertions(+), 9 deletions(-) create mode 100644 services/openclaw_session.py diff --git a/routes/openclaw_bot_routes.py b/routes/openclaw_bot_routes.py index a9f6034..fef2854 100644 --- a/routes/openclaw_bot_routes.py +++ b/routes/openclaw_bot_routes.py @@ -4303,13 +4303,20 @@ def _execute_tool(name: str, args: dict) -> dict: return {"error": f"unknown tool: {name}"} -def openclaw_answer(question: str): +def openclaw_answer(question: str, chat_id: int = None): """ Function Calling 架構 — AI 自主決定查什麼、怎麼回答 不再靠 if/else 規則判斷意圖 + + ADR-019 Phase 4:可選 chat_id 啟用對話 state。傳入後 agent 會看到該 chat 的 + 最近對話歷史,並把本輪 (question, answer) 寫回 session 供下輪使用。 + chat_id 為 None 時行為與舊版完全相同(無 multi-turn 記憶)。 """ + from services import openclaw_session + now = datetime.now(TAIPEI_TZ) today_str = now.strftime("%Y/%m/%d") + history_ctx = openclaw_session.history_as_prompt(chat_id) if chat_id else "" # ── 功能說明直接導 help ─────────────────────────────────── if _is_help_question(question): @@ -4363,13 +4370,16 @@ def openclaw_answer(question: str): sys_prompt = ( f"你是 OpenClaw(小O),電商智能助理。今天{today}。\n" + + (f"【最近對話】\n{history_ctx}\n\n" if history_ctx else "") + (f"【業績資料】{db_ctx}\n" if db_ctx else "") + (f"【市場情報】{mcp_ctx[:400]}\n" if mcp_ctx else "") + "請用繁體中文直接回答,不要開場白,300字以內。" ) - + resp = ollama_service.generate(question, system_prompt=sys_prompt, timeout=180) if resp.success and resp.content: + if chat_id: + openclaw_session.append_turn(chat_id, question, resp.content) if _LEARNING_ENABLED: import threading as _thr _thr.Thread(target=store_conversation, @@ -4390,7 +4400,8 @@ def openclaw_answer(question: str): sys_msg = ( f"你是 OpenClaw(小O),服務「小龍蝦」電商業務團隊的 AI 助理。\n" f"今天是 {today_str}。\n" - "你有四個工具可以使用:\n" + + (f"\n【最近對話歷史】\n{history_ctx}\n" if history_ctx else "") + + "你有四個工具可以使用:\n" "1. query_sales — 查自家業績資料庫\n" "2. get_market_intel — 取得外部市場情報(新聞/熱搜/PTT口碑/匯率/天氣/節慶)\n" "3. get_knowledge — 查歷史分析知識庫\n" @@ -4400,7 +4411,8 @@ def openclaw_answer(question: str): "- 若 data_freshness_warning 顯示當月無資料,禁止編造『業績為零』," "改主動問用戶是否要改看上月(並附 latest_data_date)\n" "- 工具結果回來後用繁體中文自然回答,不要開場白,不要多餘客套話\n" - "- 同時呼叫多個工具時請平行送出" + "- 同時呼叫多個工具時請平行送出\n" + "- 若【最近對話歷史】顯示用戶剛被詢問某參數(如月份),優先用該答案接續執行" ) # 第一輪:讓 Gemini 判斷需要呼叫哪些工具 @@ -4428,6 +4440,8 @@ def openclaw_answer(question: str): if not tool_calls: text = "".join(p.get("text", "") for p in parts if "text" in p).strip() if text: + if chat_id: + openclaw_session.append_turn(chat_id, question, text) if _LEARNING_ENABLED: import threading as _thr _thr.Thread(target=store_conversation, @@ -4476,6 +4490,8 @@ def openclaw_answer(question: str): if final: sys_log.info(f"[FC] done tools={used_sources} reply={len(final)}chars") + if chat_id: + openclaw_session.append_turn(chat_id, question, final) if _LEARNING_ENABLED: import threading as _thr _thr.Thread(target=store_conversation, @@ -4568,7 +4584,7 @@ def _agent_dispatch_cmd(cmd, arg, chat_id, reply_to) -> bool: nl_question = _CMD_TO_NL[cmd](arg) try: sys_log.info(f"[AgentDispatch] cmd:{cmd}:{arg or ''} → NL: {nl_question}") - txt, kb = openclaw_answer(nl_question) + txt, kb = openclaw_answer(nl_question, chat_id=chat_id) send_message(chat_id, txt, reply_to, keyboard=kb) return True except Exception as e: @@ -5460,7 +5476,7 @@ def handle_cmd(cmd, arg, chat_id, reply_to): else: # 不認識的指令 → 自然語言 - txt, kb = openclaw_answer(cmd + (' ' + arg if arg else '')) + txt, kb = openclaw_answer(cmd + (' ' + arg if arg else ''), chat_id=chat_id) send_message(chat_id, txt, reply_to, kb) @@ -5897,7 +5913,7 @@ def telegram_webhook(): if question.startswith('/') or cmd in KNOWN: handle_cmd(cmd, arg, chat_id, msg_id) else: - txt, kb = openclaw_answer(question) + txt, kb = openclaw_answer(question, chat_id=chat_id) send_message(chat_id, txt, msg_id, kb) sys_log.info(f"[OpenClawBot] → replied chat={chat_id}") diff --git a/services/openclaw_session.py b/services/openclaw_session.py new file mode 100644 index 0000000..8f038c2 --- /dev/null +++ b/services/openclaw_session.py @@ -0,0 +1,133 @@ +"""ADR-019 Phase 4:OpenClaw 對話 state 管理(in-memory)。 + +提供 chat_id 級別的多輪對話歷史 + carry-over slot: +- history:最近 N 輪 (user, bot) pair,給 agent 注入 prompt 用 +- slots:跨訊息 carry-over(如「剛問月份 → 用戶答 4 → 接續執行」) + +設計取捨: +- in-memory(不寫 DB),TTL 30 分鐘清掃,重啟後清空 — 對話 state 為臨時資料, + 不該污染 ai_insights;要長期記憶請走 store_conversation 既有路徑。 +- 單進程:本檔不處理跨 worker 一致性。多 worker 部署時 session 可能落在不同 + worker,但 OpenClaw bot 容器目前單 process(python-telegram-bot polling), + 尚無此問題。若未來 webhook 要多 worker,再考慮搬 Redis。 +""" + +from __future__ import annotations + +from collections import deque +from datetime import datetime, timedelta +from threading import Lock +from typing import Any, Optional + + +_SESSION_TTL_MIN = 30 +_HISTORY_MAX = 5 + +_sessions: dict[int, dict] = {} # chat_id → {history, slots, updated_at} +_lock = Lock() + + +def _cleanup_expired_locked(now: datetime) -> None: + """假設 caller 已持鎖。移除超過 TTL 的 session。""" + cutoff = now - timedelta(minutes=_SESSION_TTL_MIN) + expired = [cid for cid, s in _sessions.items() + if s.get('updated_at', cutoff) < cutoff] + for cid in expired: + _sessions.pop(cid, None) + + +def _get_or_create_locked(chat_id: int, now: datetime) -> dict: + """假設 caller 已持鎖。""" + if chat_id not in _sessions: + _sessions[chat_id] = { + 'history': deque(maxlen=_HISTORY_MAX), + 'slots': {}, + 'updated_at': now, + } + s = _sessions[chat_id] + s['updated_at'] = now + return s + + +def append_turn(chat_id: int, user_msg: str, bot_reply: str) -> None: + """記錄一輪對話。content 會被截斷到 500 字以避免 prompt 爆量。""" + if not chat_id: + return + with _lock: + now = datetime.now() + s = _get_or_create_locked(chat_id, now) + s['history'].append({ + 'user': (user_msg or '')[:500], + 'bot': (bot_reply or '')[:500], + 'ts': now.isoformat(timespec='seconds'), + }) + _cleanup_expired_locked(now) + + +def set_slot(chat_id: int, key: str, value: Any) -> None: + """設定 carry-over slot。""" + if not chat_id: + return + with _lock: + now = datetime.now() + s = _get_or_create_locked(chat_id, now) + s['slots'][key] = value + + +def get_slot(chat_id: int, key: str, default: Any = None) -> Any: + if not chat_id: + return default + with _lock: + s = _sessions.get(chat_id) + if not s: + return default + return s['slots'].get(key, default) + + +def pop_slot(chat_id: int, key: str, default: Any = None) -> Any: + if not chat_id: + return default + with _lock: + s = _sessions.get(chat_id) + if not s: + return default + return s['slots'].pop(key, default) + + +def history_as_prompt(chat_id: int, max_chars: int = 800) -> str: + """組合歷史對話成 system prompt 用的 context;無歷史回空字串。""" + if not chat_id: + return "" + with _lock: + s = _sessions.get(chat_id) + if not s or not s['history']: + return "" + lines = [] + for turn in s['history']: + lines.append(f"[{turn['ts']}]\n用戶:{turn['user']}\n小O:{turn['bot']}") + text = "\n---\n".join(lines) + if len(text) > max_chars: + text = "...(較舊內容已截斷)\n" + text[-max_chars:] + return text + + +def session_stats() -> dict: + """測試/觀察用:回傳目前 session 數量、各 chat 歷史長度。""" + with _lock: + return { + 'active_sessions': len(_sessions), + 'detail': { + cid: {'history_len': len(s['history']), + 'slot_keys': list(s['slots'].keys()), + 'updated_at': s['updated_at'].isoformat(timespec='seconds')} + for cid, s in _sessions.items() + } + } + + +def reset_session(chat_id: int) -> None: + """測試/手動清除 session。""" + if not chat_id: + return + with _lock: + _sessions.pop(chat_id, None) diff --git a/services/telegram_bot_service.py b/services/telegram_bot_service.py index b617d13..7446f66 100644 --- a/services/telegram_bot_service.py +++ b/services/telegram_bot_service.py @@ -931,8 +931,9 @@ class TrendTelegramBot: await update.message.reply_chat_action(action='typing') try: from routes.openclaw_bot_routes import openclaw_answer - txt, kb = openclaw_answer(text) - + _chat_id = getattr(update.effective_chat, 'id', None) + txt, kb = openclaw_answer(text, chat_id=_chat_id) + reply_markup = None if kb: inline_kb = []