v1.7: session-id mapping + conversation summary + contact profiling + exponential backoff reconnect
This commit is contained in:
@@ -1,5 +1,32 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.7 (2026-06-05) — 对话上下文 + 客户画像 + 指数退避重连
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
- **`--session-id` 对话上下文** — WS Agent 维护 `conv_id → session_id` 映射,每次调 `qwenpaw agents chat` 时传入 `--session-id`,AI 获得完整对话历史
|
||||||
|
- 同一会话的连续消息不再断开上下文,AI 知道"刚才说过什么"
|
||||||
|
- 持久化到状态文件,重启不丢失
|
||||||
|
- 自动清理超过 10000 条的大映射表
|
||||||
|
- **对话摘要** — 每 15 轮 AI 回复后自动调用 AI 压缩历史,生成 1-2 句话摘要
|
||||||
|
- 下次请求时将摘要注入 prompt 开头,减少 token 消耗
|
||||||
|
- 长对话 AI 仍能记住核心信息(客户需求、讨论过的产品)
|
||||||
|
- **客户画像** — 维护 `contact_id → 画像` 映射(姓名、最近 3 次交互记录)
|
||||||
|
- 每次 AI 回复后自动更新画像
|
||||||
|
- 下次对话自动注入客户历史上下文
|
||||||
|
- 持久化到状态文件,重启不丢失
|
||||||
|
- **WebSocket 指数退避重连** — 断线重连从固定 5s 改为指数退避
|
||||||
|
- 初始 5s → 10s → 20s → 40s → 最大 60s
|
||||||
|
- 重连前自动续期 Chatwoot session(防止长时间断线后 token 过期)
|
||||||
|
- 失败时继续尝试,不会停止
|
||||||
|
|
||||||
|
### 文件
|
||||||
|
- `chatwoot_ws_agent.py` 从 1294 行增至 1459 行(+165 行)
|
||||||
|
- 新增 9 个函数:`_get_or_create_session`、`_prune_sessions`、`_summarize_conversation`、`_get_conversation_context`、`_update_contact_profile`、`_get_contact_context`
|
||||||
|
- 修改 4 个函数:`call_qwenpaw_ai`(+session_id)、`generate_ai_reply`(+session_id)、`handle_incoming_message`(+3 层 context)、`save_state/load_state`(+3 个持久化字段)
|
||||||
|
- 重写 1 个函数:`WSAgent.start`(指数退避重连)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v1.6 (2026-06-05) — Platform Gateway + 5 平台 API 集成
|
## v1.6 (2026-06-05) — Platform Gateway + 5 平台 API 集成
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ chatwoot-ai-agent/
|
|||||||
| v1.4 | 多租户架构,Provision Server,状态持久化,安全性重构 |
|
| v1.4 | 多租户架构,Provision Server,状态持久化,安全性重构 |
|
||||||
| v1.5 | 消息防抖(5s 累积合并),AI 错误重试(指数退避)|
|
| v1.5 | 消息防抖(5s 累积合并),AI 错误重试(指数退避)|
|
||||||
| v1.6 | Platform Gateway 库——Amazon/JD/Taobao/PDD/TikTok 5 平台统一 API 集成 |
|
| v1.6 | Platform Gateway 库——Amazon/JD/Taobao/PDD/TikTok 5 平台统一 API 集成 |
|
||||||
|
| v1.7 | 对话上下文(`--session-id`)+ 对话摘要 + 客户画像 + WebSocket 指数退避重连 |
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
|
|||||||
+183
-18
@@ -484,6 +484,16 @@ def save_state():
|
|||||||
"ai_pending_convs": list(_ai_pending_convs),
|
"ai_pending_convs": list(_ai_pending_convs),
|
||||||
"saved_at": time.time()
|
"saved_at": time.time()
|
||||||
}
|
}
|
||||||
|
# Add session map
|
||||||
|
with _conv_session_lock:
|
||||||
|
state["conv_session_map"] = _conv_session_map.copy()
|
||||||
|
# Add summaries & rounds
|
||||||
|
with _conv_conv_lock:
|
||||||
|
state["conv_summaries"] = _conv_summaries.copy()
|
||||||
|
state["conv_rounds"] = _conv_rounds.copy()
|
||||||
|
# Add contact profiles
|
||||||
|
with _contact_profile_lock:
|
||||||
|
state["contact_profiles"] = _contact_profiles.copy()
|
||||||
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
|
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(f"save_state error: {e}", "WARN")
|
log(f"save_state error: {e}", "WARN")
|
||||||
@@ -511,8 +521,20 @@ def load_state():
|
|||||||
human_active_convs = state.get("human_active_convs", {})
|
human_active_convs = state.get("human_active_convs", {})
|
||||||
with _ai_pending_lock:
|
with _ai_pending_lock:
|
||||||
_ai_pending_convs = set(state.get("ai_pending_convs", []))
|
_ai_pending_convs = set(state.get("ai_pending_convs", []))
|
||||||
|
# Restore session map
|
||||||
|
with _conv_session_lock:
|
||||||
|
_conv_session_map.update(state.get("conv_session_map", {}))
|
||||||
|
# Restore summaries & rounds
|
||||||
|
with _conv_conv_lock:
|
||||||
|
_conv_summaries.update(state.get("conv_summaries", {}))
|
||||||
|
_conv_rounds.update(state.get("conv_rounds", {}))
|
||||||
|
# Restore contact profiles
|
||||||
|
with _contact_profile_lock:
|
||||||
|
_contact_profiles.update(state.get("contact_profiles", {}))
|
||||||
|
|
||||||
log(f"State restored: {len(ai_sent_msg_ids)} msg_ids, {len(human_active_convs)} active convs, {len(_ai_pending_convs)} pending")
|
log(f"State restored: {len(ai_sent_msg_ids)} msg_ids, {len(human_active_convs)} active convs, "
|
||||||
|
f"{len(_conv_session_map)} sessions, {len(_conv_summaries)} summaries, "
|
||||||
|
f"{len(_contact_profiles)} profiles")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(f"load_state error: {e} (starting fresh)", "WARN")
|
log(f"load_state error: {e} (starting fresh)", "WARN")
|
||||||
STATE_FILE.unlink(missing_ok=True)
|
STATE_FILE.unlink(missing_ok=True)
|
||||||
@@ -527,13 +549,105 @@ _debounce_msgs: dict[int, list[str]] = {} # conv_id → accumulated messages
|
|||||||
_debounce_lock = Lock()
|
_debounce_lock = Lock()
|
||||||
|
|
||||||
|
|
||||||
|
# ===== SESSION-ID MAPPING (per-conversation QwenPaw context) =====
|
||||||
|
_conv_session_map: dict[int, str] = {} # conv_id → qwenpaw session_id
|
||||||
|
_conv_session_lock = Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_session(conv_id):
|
||||||
|
"""Return existing session_id for a conversation, or create a new one."""
|
||||||
|
with _conv_session_lock:
|
||||||
|
sid = _conv_session_map.get(conv_id)
|
||||||
|
if not sid:
|
||||||
|
sid = f"conv_{conv_id}_{int(time.time())}"
|
||||||
|
_conv_session_map[conv_id] = sid
|
||||||
|
return sid
|
||||||
|
|
||||||
|
|
||||||
|
def _prune_sessions():
|
||||||
|
"""Trim session map when it grows too large."""
|
||||||
|
with _conv_session_lock:
|
||||||
|
if len(_conv_session_map) > 10000:
|
||||||
|
_conv_session_map.clear()
|
||||||
|
log("🧹 Session map too large, cleared", "WARN")
|
||||||
|
|
||||||
|
|
||||||
|
# ===== CONVERSATION SUMMARIZATION =====
|
||||||
|
_conv_rounds: dict[int, int] = {} # conv_id → AI reply count
|
||||||
|
_conv_summaries: dict[int, str] = {} # conv_id → latest AI-generated summary
|
||||||
|
_conv_conv_lock = Lock()
|
||||||
|
SUMMARY_THRESHOLD = 15 # generate summary after every N AI replies
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize_conversation(conv_id, prompt, reply, target_agent):
|
||||||
|
"""Ask AI to produce a short summary of this conversation so far."""
|
||||||
|
with _conv_conv_lock:
|
||||||
|
old_summary = _conv_summaries.get(conv_id, "")
|
||||||
|
summary_prompt = (
|
||||||
|
f"Existing summary: {old_summary}\n\n"
|
||||||
|
f"Latest customer message: {prompt[:200]}\n"
|
||||||
|
f"Your reply: {reply[:500]}\n\n"
|
||||||
|
"Produce a concise 1-2 sentence summary (in English) of the "
|
||||||
|
"entire conversation so far. Focus on customer needs, products "
|
||||||
|
"discussed, and any decisions made. Keep it under 100 words."
|
||||||
|
)
|
||||||
|
summary = call_qwenpaw_ai(summary_prompt, target_agent=target_agent)
|
||||||
|
if summary:
|
||||||
|
with _conv_conv_lock:
|
||||||
|
_conv_summaries[conv_id] = summary
|
||||||
|
|
||||||
|
|
||||||
|
def _get_conversation_context(conv_id):
|
||||||
|
"""Return summary string for this conv, or empty string."""
|
||||||
|
with _conv_conv_lock:
|
||||||
|
return _conv_summaries.get(conv_id, "")
|
||||||
|
|
||||||
|
|
||||||
|
# ===== CONTACT PROFILING =====
|
||||||
|
_contact_profiles: dict[int, dict] = {} # contact_id → profile dict
|
||||||
|
_contact_profile_lock = Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _update_contact_profile(contact_id, sender_name, msg, reply):
|
||||||
|
"""Extract relevant info from conversation and update contact profile."""
|
||||||
|
if not contact_id:
|
||||||
|
return
|
||||||
|
with _contact_profile_lock:
|
||||||
|
profile = _contact_profiles.get(contact_id, {})
|
||||||
|
profile["name"] = sender_name
|
||||||
|
profile["last_seen"] = time.time()
|
||||||
|
# Keep a rolling log of last 3 interactions
|
||||||
|
interactions = profile.get("interactions", [])
|
||||||
|
interactions.append({"msg": msg[:100], "reply": reply[:100], "ts": time.time()})
|
||||||
|
profile["interactions"] = interactions[-3:]
|
||||||
|
_contact_profiles[contact_id] = profile
|
||||||
|
|
||||||
|
|
||||||
|
def _get_contact_context(contact_id):
|
||||||
|
"""Return a prompt-friendly profile string for this contact."""
|
||||||
|
if not contact_id:
|
||||||
|
return ""
|
||||||
|
with _contact_profile_lock:
|
||||||
|
profile = _contact_profiles.get(contact_id)
|
||||||
|
if not profile:
|
||||||
|
return ""
|
||||||
|
parts = [f"Contact name: {profile.get('name', 'Unknown')}"]
|
||||||
|
interactions = profile.get("interactions", [])
|
||||||
|
if interactions:
|
||||||
|
parts.append(f"Last {len(interactions)} interaction(s):")
|
||||||
|
for i, ix in enumerate(interactions):
|
||||||
|
parts.append(f" [{i+1}] \"{ix['msg']}\" → \"{ix['reply'][:80]}\"")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
# ===== AI HELPERS =====
|
# ===== AI HELPERS =====
|
||||||
|
|
||||||
import re as _re # used for session-id stripping
|
import re as _re # used for session-id stripping
|
||||||
|
|
||||||
GATEWAY_ENABLED = os.environ.get("GATEWAY_ENABLED", "1") == "1"
|
GATEWAY_ENABLED = os.environ.get("GATEWAY_ENABLED", "1") == "1"
|
||||||
|
|
||||||
def call_qwenpaw_ai(prompt, system_prompt=None, target_agent="sourcing-agent", retries=2):
|
def call_qwenpaw_ai(prompt, system_prompt=None, target_agent="sourcing-agent",
|
||||||
|
retries=2, session_id=None):
|
||||||
"""Call QwenPaw AI via agents chat, with retries on failure.
|
"""Call QwenPaw AI via agents chat, with retries on failure.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -541,18 +655,19 @@ def call_qwenpaw_ai(prompt, system_prompt=None, target_agent="sourcing-agent", r
|
|||||||
system_prompt: Optional system instructions (appended before prompt)
|
system_prompt: Optional system instructions (appended before prompt)
|
||||||
target_agent: Which agent to call (default:sourcing-agent, translation:default)
|
target_agent: Which agent to call (default:sourcing-agent, translation:default)
|
||||||
retries: Number of retries on failure (exponential backoff)
|
retries: Number of retries on failure (exponential backoff)
|
||||||
|
session_id: Optional QwenPaw session ID for conversation continuity
|
||||||
"""
|
"""
|
||||||
full_prompt = (system_prompt + "\n\n" + prompt) if system_prompt else prompt
|
full_prompt = (system_prompt + "\n\n" + prompt) if system_prompt else prompt
|
||||||
|
cmd = ["qwenpaw", "agents", "chat",
|
||||||
|
"--from-agent", "wordpress",
|
||||||
|
"--to-agent", target_agent,
|
||||||
|
"--text", full_prompt]
|
||||||
|
if session_id:
|
||||||
|
cmd.extend(["--session-id", session_id])
|
||||||
last_error = None
|
last_error = None
|
||||||
for attempt in range(1 + retries):
|
for attempt in range(1 + retries):
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=90)
|
||||||
["qwenpaw", "agents", "chat",
|
|
||||||
"--from-agent", "wordpress",
|
|
||||||
"--to-agent", target_agent,
|
|
||||||
"--text", full_prompt],
|
|
||||||
capture_output=True, text=True, timeout=90
|
|
||||||
)
|
|
||||||
if result.returncode == 0 and result.stdout.strip():
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
# Clean output: remove INFO/SESSION/Agent lines
|
# Clean output: remove INFO/SESSION/Agent lines
|
||||||
raw = result.stdout.strip()
|
raw = result.stdout.strip()
|
||||||
@@ -594,7 +709,7 @@ def translate_to_chinese(text):
|
|||||||
result = call_qwenpaw_ai(prompt, target_agent="default")
|
result = call_qwenpaw_ai(prompt, target_agent="default")
|
||||||
return result if result else text
|
return result if result else text
|
||||||
|
|
||||||
def generate_ai_reply(customer_msg, sender_name, inbox_id, context=""):
|
def generate_ai_reply(customer_msg, sender_name, inbox_id, context="", session_id=None):
|
||||||
config = INBOX_CONFIG.get(inbox_id)
|
config = INBOX_CONFIG.get(inbox_id)
|
||||||
if not config:
|
if not config:
|
||||||
log(f"No config for inbox #{inbox_id}, skipping", "WARN")
|
log(f"No config for inbox #{inbox_id}, skipping", "WARN")
|
||||||
@@ -606,7 +721,8 @@ def generate_ai_reply(customer_msg, sender_name, inbox_id, context=""):
|
|||||||
customer_msg=body
|
customer_msg=body
|
||||||
)
|
)
|
||||||
reply = call_qwenpaw_ai(prompt, config["system_prompt"],
|
reply = call_qwenpaw_ai(prompt, config["system_prompt"],
|
||||||
target_agent=config["target_agent"])
|
target_agent=config["target_agent"],
|
||||||
|
session_id=session_id)
|
||||||
|
|
||||||
if not reply:
|
if not reply:
|
||||||
return reply
|
return reply
|
||||||
@@ -826,11 +942,30 @@ def handle_incoming_message(msg_data, headers):
|
|||||||
|
|
||||||
log(f"📩 [{config['name']}] New msg #{msg_id} from '{sender_name}' in conv #{conv_id}: {content[:100]}", inbox_name=config['name'])
|
log(f"📩 [{config['name']}] New msg #{msg_id} from '{sender_name}' in conv #{conv_id}: {content[:100]}", inbox_name=config['name'])
|
||||||
|
|
||||||
|
# ===== SESSION-ID: maintain conversation continuity =====
|
||||||
|
session_id = _get_or_create_session(conv_id)
|
||||||
|
# ===== CONVERSATION SUMMARY: inject past context =====
|
||||||
|
conv_context = _get_conversation_context(conv_id)
|
||||||
|
# ===== CONTACT PROFILE: inject customer history =====
|
||||||
|
contact_id = msg_data.get("sender", {}).get("id")
|
||||||
|
contact_context = _get_contact_context(contact_id)
|
||||||
|
|
||||||
|
# Build enriched context
|
||||||
|
context_parts = []
|
||||||
|
gateway_ctx = _enrich_context(inbox_id, content, config)
|
||||||
|
if gateway_ctx:
|
||||||
|
context_parts.append(gateway_ctx)
|
||||||
|
if conv_context:
|
||||||
|
context_parts.append(f"[Conversation so far: {conv_context}]")
|
||||||
|
if contact_context:
|
||||||
|
context_parts.append(f"[Customer history: {contact_context}]")
|
||||||
|
all_context = "\n".join(context_parts)
|
||||||
|
|
||||||
# Generate AI reply with inbox-specific config
|
# Generate AI reply with inbox-specific config
|
||||||
log(f"🤖 [{config['name']}] Generating AI reply...", inbox_name=config['name'])
|
log(f"🤖 [{config['name']}] Generating AI reply...", inbox_name=config['name'])
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
context = _enrich_context(inbox_id, content, config)
|
reply = generate_ai_reply(content, sender_name, inbox_id,
|
||||||
reply = generate_ai_reply(content, sender_name, inbox_id, context=context)
|
context=all_context, session_id=session_id)
|
||||||
duration_ms = (time.time() - start_time) * 1000
|
duration_ms = (time.time() - start_time) * 1000
|
||||||
|
|
||||||
if not reply:
|
if not reply:
|
||||||
@@ -840,6 +975,22 @@ def handle_incoming_message(msg_data, headers):
|
|||||||
|
|
||||||
log(f"💬 [{config['name']}] AI reply ({duration_ms:.0f}ms): {reply[:150]}", inbox_name=config['name'])
|
log(f"💬 [{config['name']}] AI reply ({duration_ms:.0f}ms): {reply[:150]}", inbox_name=config['name'])
|
||||||
|
|
||||||
|
# ===== POST-REPLY: update conversation summary & contact profile =====
|
||||||
|
with _conv_conv_lock:
|
||||||
|
rounds = _conv_rounds.get(conv_id, 0) + 1
|
||||||
|
_conv_rounds[conv_id] = rounds
|
||||||
|
if rounds > 0 and rounds % SUMMARY_THRESHOLD == 0:
|
||||||
|
# Generate summary every SUMMARY_THRESHOLD AI replies
|
||||||
|
_summarize_conversation(conv_id, content, reply, config["target_agent"])
|
||||||
|
_update_contact_profile(contact_id, sender_name, content, reply)
|
||||||
|
|
||||||
|
if not reply:
|
||||||
|
log(f"⚠️ [{config['name']}] Empty AI reply, skipping", "WARN", config['name'])
|
||||||
|
metrics.record_reply(inbox_id, config['name'], False, duration_ms)
|
||||||
|
return
|
||||||
|
|
||||||
|
log(f"💬 [{config['name']}] AI reply ({duration_ms:.0f}ms): {reply[:150]}", inbox_name=config['name'])
|
||||||
|
|
||||||
# Check if AI thinks this needs human handoff
|
# Check if AI thinks this needs human handoff
|
||||||
needs_handoff = False
|
needs_handoff = False
|
||||||
if "[HANDOFF]" in reply:
|
if "[HANDOFF]" in reply:
|
||||||
@@ -1021,10 +1172,25 @@ class WSAgent:
|
|||||||
metrics.ws_disconnected(f"status={status}, msg={msg}")
|
metrics.ws_disconnected(f"status={status}, msg={msg}")
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
"""Start WebSocket connection (blocking)."""
|
"""Start WebSocket connection with exponential backoff reconnect."""
|
||||||
self.headers = ensure_session()
|
self.headers = ensure_session()
|
||||||
log(f"Connecting to {CW_WS_URL}")
|
delay = 5 # start at 5s
|
||||||
|
max_delay = 60
|
||||||
|
attempt = 0
|
||||||
|
|
||||||
|
while self.running.is_set():
|
||||||
|
if attempt > 0:
|
||||||
|
log(f"🔄 Reconnecting in {delay}s (attempt #{attempt})...")
|
||||||
|
time.sleep(delay)
|
||||||
|
delay = min(delay * 2, max_delay) # 5 → 10 → 20 → 40 → 60
|
||||||
|
# Re-fetch session headers in case they expired during downtime
|
||||||
|
try:
|
||||||
|
self.headers = ensure_session()
|
||||||
|
except Exception as e:
|
||||||
|
log(f"Session renewal failed before reconnect: {e}", "WARN")
|
||||||
|
continue
|
||||||
|
|
||||||
|
log(f"Connecting to {CW_WS_URL}")
|
||||||
self.ws = websocket.WebSocketApp(
|
self.ws = websocket.WebSocketApp(
|
||||||
CW_WS_URL,
|
CW_WS_URL,
|
||||||
on_open=self.on_open,
|
on_open=self.on_open,
|
||||||
@@ -1032,14 +1198,13 @@ class WSAgent:
|
|||||||
on_error=self.on_error,
|
on_error=self.on_error,
|
||||||
on_close=self.on_close
|
on_close=self.on_close
|
||||||
)
|
)
|
||||||
|
|
||||||
# Run forever (blocks)
|
|
||||||
self.ws.run_forever(
|
self.ws.run_forever(
|
||||||
sslopt={"cert_reqs": ssl.CERT_NONE},
|
sslopt={"cert_reqs": ssl.CERT_NONE},
|
||||||
ping_interval=15,
|
ping_interval=15,
|
||||||
ping_timeout=5,
|
ping_timeout=5,
|
||||||
reconnect=5 # built-in reconnect
|
reconnect=0 # disable internal reconnect; we handle it
|
||||||
)
|
)
|
||||||
|
attempt += 1
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
"""Stop the WebSocket connection."""
|
"""Stop the WebSocket connection."""
|
||||||
|
|||||||
Reference in New Issue
Block a user