Files
kefu/wechat_rpa/conversation_store.py
T
2026-08-19 17:35:59 +08:00

306 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
会话档案存储
============
按「会话指纹」持久化每个客户会话的对话档案(客户消息 + 我方回复 + 上次画面快照),
写入 conversations.json,机器人重启后上下文不丢失。
有了档案之后,机器人的提取策略从「每次翻屏复制大段历史」变为:
- 首次遇到某会话:翻屏提取一次可见历史,建档;
- 之后每次:只提取最新一屏,与档案中的上次画面快照做增量比对,
只把【新增的消息】发给 AI,完整上下文由档案历史直接提供。
"""
import json
import os
import time
import threading
import copy
# 每个会话最多留存的消息条数(防止档案无限膨胀;AI 实际使用条数由 AI_CONTEXT_MAX_ROUNDS 决定)
MAX_MESSAGES_PER_SESSION = 200
# 画面快照最多留存的行数(用于增量比对)
MAX_SNAPSHOT_LINES = 80
class ConversationStore:
def __init__(self, path: str):
self.path = path
self._lock = threading.Lock()
self._data = {}
self._mtime = 0
self._load()
# ── 持久化 ────────────────────────────────────────────────────────────────
def _load(self):
try:
if os.path.exists(self.path):
with open(self.path, encoding="utf-8") as f:
self._data = json.load(f)
try:
self._mtime = os.path.getmtime(self.path)
except Exception:
self._mtime = 0
else:
self._data = {}
self._mtime = 0
except Exception:
self._data = {}
self._mtime = 0
def _maybe_reload(self):
"""GUI 删除档案后,监听进程下次读写前自动同步磁盘。"""
try:
mt = os.path.getmtime(self.path) if os.path.exists(self.path) else 0
except Exception:
return
if mt != self._mtime:
self._load()
def save(self):
"""原子写入磁盘(先写临时文件再替换,避免中途崩溃损坏档案)。"""
with self._lock:
tmp = self.path + ".tmp"
try:
with open(tmp, "w", encoding="utf-8") as f:
json.dump(self._data, f, ensure_ascii=False, indent=1)
os.replace(tmp, self.path)
try:
self._mtime = os.path.getmtime(self.path)
except Exception:
pass
except Exception:
pass
# ── 档案访问 ──────────────────────────────────────────────────────────────
def _entry(self, fp_hex: str) -> dict:
self._maybe_reload()
return self._data.setdefault(fp_hex, {
"history": [], # [{"role": "user"/"assistant", "content": str, "ts": float}]
"last_lines": [], # 上次提取时聊天框的画面快照(按行),用于增量比对
"updated": 0,
})
def has_record(self, fp_hex: str) -> bool:
self._maybe_reload()
e = self._data.get(fp_hex)
return bool(e and (e.get("history") or e.get("last_lines")))
def history(self, fp_hex: str) -> list:
"""返回该会话的消息历史(列表为活引用,请勿直接修改,用 append)。"""
return self._entry(fp_hex)["history"]
def append(self, fp_hex: str, role: str, content: str):
e = self._entry(fp_hex)
e["history"].append({"role": role, "content": content, "ts": time.time()})
if len(e["history"]) > MAX_MESSAGES_PER_SESSION:
e["history"] = e["history"][-MAX_MESSAGES_PER_SESSION:]
e["updated"] = time.time()
def append_exchange_once(
self,
fp_hex: str,
user_text: str,
assistant_text: str,
exchange_id: str,
user_image: str = "",
) -> bool:
"""Atomically append one user/assistant pair once across crash recovery.
`user_image` 是 media/ 目录下的文件名(不是绝对路径),只在客户发来图片、
表情这类没有可复制文本的消息时才有值。存文件名而不是路径,档案换台机器
打开也还能对上。
"""
key = str(fp_hex or "")
txid = str(exchange_id or "")
if not key or not txid:
return False
self._maybe_reload()
appended = False
with self._lock:
entry = self._data.setdefault(key, {
"history": [],
"last_lines": [],
"updated": 0,
})
committed = entry.setdefault("exchange_ids", [])
if txid not in committed:
now = time.time()
user_message = {
"role": "user",
"content": str(user_text or ""),
"ts": now,
}
# 只有存下画面时才写这个键:老档案里没有它,读取方一律按缺省处理
if str(user_image or "").strip():
user_message["image"] = str(user_image).strip()
entry["history"].extend([
user_message,
{
"role": "assistant",
"content": str(assistant_text or ""),
"ts": now,
},
])
if len(entry["history"]) > MAX_MESSAGES_PER_SESSION:
entry["history"] = entry["history"][-MAX_MESSAGES_PER_SESSION:]
committed.append(txid)
entry["exchange_ids"] = committed[-MAX_MESSAGES_PER_SESSION:]
entry["updated"] = now
appended = True
if appended:
self.save()
return appended
def last_lines(self, fp_hex: str) -> list:
return self._entry(fp_hex)["last_lines"]
def set_last_lines(self, fp_hex: str, lines: list):
e = self._entry(fp_hex)
e["last_lines"] = list(lines)[-MAX_SNAPSHOT_LINES:]
e["updated"] = time.time()
def outgoing_speakers(self, fp_hex: str) -> list[str]:
"""Return sender labels previously proven by right-side bubble geometry."""
entry = self._entry(fp_hex)
return [
str(value).strip()
for value in entry.get("outgoing_speakers") or []
if str(value).strip()
]
def add_outgoing_speaker(self, fp_hex: str, speaker: str) -> bool:
"""Persist one visually proven local sender label for this conversation."""
name = str(speaker or "").strip()
if not name:
return False
entry = self._entry(fp_hex)
known = {
str(value).strip()
for value in entry.get("outgoing_speakers") or []
if str(value).strip()
}
if name in known:
return False
known.add(name)
entry["outgoing_speakers"] = sorted(known)
entry["updated"] = time.time()
self.save()
return True
def set_display_name(self, fp_hex: str, name: str) -> bool:
"""Remember the WeCom nickname seen for this session, if it is new."""
label = str(name or "").strip()
key = str(fp_hex or "")
if not key or not label:
return False
self._maybe_reload()
with self._lock:
entry = self._data.get(key)
if not isinstance(entry, dict):
return False
if str(entry.get("display_name") or "").strip() == label:
return False
entry["display_name"] = label
entry["updated"] = time.time()
self.save()
return True
def list_sessions(self, limit: int = 200) -> list:
"""按最近更新排序,返回会话摘要列表。"""
self._maybe_reload()
items = []
for sid, entry in (self._data or {}).items():
if not isinstance(entry, dict):
continue
hist = entry.get("history") or []
preview = ""
last_role = ""
user_bits = []
for m in reversed(hist):
if not isinstance(m, dict):
continue
c = (m.get("content") or "").strip()
role = str(m.get("role") or "").lower()
if c and not last_role:
last_role = role
preview = c.replace("\n", " ")[:80]
if c and role == "user" and len(user_bits) < 3:
user_bits.append(c.replace("\n", " ")[:40])
if not preview:
lines = entry.get("last_lines") or []
preview = " ".join(lines[-3:])[:80] if lines else ""
items.append({
"session_id": sid,
"updated": entry.get("updated") or 0,
"message_count": len(hist),
"preview": preview,
"display_name": str(entry.get("display_name") or "").strip(),
"last_role": last_role,
"memory_preview": "".join(reversed(user_bits)),
})
items.sort(key=lambda x: x.get("updated") or 0, reverse=True)
return items[: max(1, min(limit, 500))]
def delete(self, fp_hex: str) -> bool:
"""删除指定会话档案。"""
self._maybe_reload()
with self._lock:
if fp_hex not in self._data:
return False
del self._data[fp_hex]
self.save()
return True
def migrate_key(self, old_fp_hex: str, new_fp_hex: str) -> bool:
"""Atomically move one legacy session entry to a stronger identity key."""
old_key = str(old_fp_hex or "")
new_key = str(new_fp_hex or "")
if not old_key or not new_key or old_key == new_key:
return False
self._maybe_reload()
migrated = False
with self._lock:
current = self._data.get(new_key)
current_is_empty_shell = bool(
isinstance(current, dict)
and not current.get("history")
and not current.get("last_lines")
and not current.get("exchange_ids")
)
if old_key in self._data and (
new_key not in self._data or current_is_empty_shell
):
if current_is_empty_shell:
self._data.pop(new_key, None)
self._data[new_key] = self._data.pop(old_key)
migrated = True
if migrated:
self.save()
return migrated
def entry_snapshot(self, fp_hex: str) -> dict | None:
"""Return a detached legacy entry for identity checks without creating it."""
self._maybe_reload()
entry = self._data.get(str(fp_hex or ""))
if not isinstance(entry, dict):
return None
return copy.deepcopy(entry)
def keys(self) -> list[str]:
"""Return a detached list of stored session keys without exposing live data."""
self._maybe_reload()
return [str(key) for key in (self._data or {}).keys()]
def clear_all(self) -> int:
"""清空全部会话档案,返回删除条数。"""
self._maybe_reload()
with self._lock:
n = len(self._data)
self._data = {}
self.save()
return n
def count(self) -> int:
self._maybe_reload()
return len(self._data or {})