306 lines
12 KiB
Python
306 lines
12 KiB
Python
"""
|
||
会话档案存储
|
||
============
|
||
按「会话指纹」持久化每个客户会话的对话档案(客户消息 + 我方回复 + 上次画面快照),
|
||
写入 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 {})
|