Files
kefu/wechat_rpa/reconcile_session_archive.py
2026-07-31 11:48:16 +08:00

278 lines
9.8 KiB
Python
Raw Permalink 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.
"""会话档案收口工具
==================
行锚点漂移曾让同一个联系人被铸造出多个会话键(头像哈希相差 9~31 位,容差只有
6 位)。锚点已按头像方块本身定位修好,但磁盘上残留的分裂档案和空壳待回复任务
不会自动消失,本工具做一次性收口:
1. 把只能唯一证明属于同一联系人的旧档案键合并进当前键,按时间戳归并历史,
保留当前键的画面快照(旧快照会污染增量比对基线);
2. 删除既没有聊天内容、也没有待发回复的空壳待回复任务——它们驱动不了任何
重试,只会让机器人以为有幽灵会话欠着回复。
判定“同一联系人”沿用 wechat_bot 的标准:两份档案各自的客户说话人集合都只有
一个人,且是同一个人。证据不唯一时一律保留,绝不猜测合并。
用法(先停掉监听,避免运行中的进程回写覆盖结果):
python reconcile_session_archive.py # 只报告,不改动
python reconcile_session_archive.py --apply # 执行收口,改动前自动备份
"""
import argparse
import json
import os
import re
import shutil
import time
_HERE = os.path.dirname(os.path.abspath(__file__))
_SPEAKER_HEADER = re.compile(
r"^(?P<speaker>.{1,40}?)\s+\d{1,2}/\d{1,2}\s+\d{1,2}:\d{2}(:\d{2})?$"
)
def _load(path: str) -> dict:
if not os.path.exists(path):
return {}
try:
with open(path, encoding="utf-8") as handle:
data = json.load(handle)
except (OSError, ValueError) as error:
print(f"[-] 读取 {os.path.basename(path)} 失败: {error}")
return {}
return data if isinstance(data, dict) else {}
def _agent_name() -> str:
try:
from ai_config import AI_AGENT_NAME
except ImportError:
return ""
return str(AI_AGENT_NAME or "").strip()
def _outgoing_speakers(archive: dict) -> set:
"""收集全部档案里已证实的我方发言人名。
企业微信里我方气泡用的是账号显示名(例如“高兴亮”),与配置的 AI 人设名
AI_AGENT_NAME)通常不同;档案发送成功时会把它记进 outgoing_speakers。
这个名字对所有会话都一样,因此可以跨档案排除。
"""
names = set()
for entry in archive.values():
if not isinstance(entry, dict):
continue
names.update(
str(speaker).strip()
for speaker in entry.get("outgoing_speakers") or []
if str(speaker).strip()
)
return names
def _speakers(entry: dict, agent_name: str, outgoing: set = frozenset()) -> set:
"""收集该档案里出现过的客户说话人(排除我方坐席)。"""
history = [item for item in (entry.get("history") or []) if isinstance(item, dict)]
assistant_bodies = {
" ".join(str(item.get("content") or "").split())
for item in history
if item.get("role") == "assistant"
}
sources = ["\n".join(entry.get("last_lines") or [])]
sources.extend(str(item.get("content") or "") for item in history)
blocks = []
for source in sources:
speaker, body = "", []
for raw_line in str(source).splitlines():
line = raw_line.strip()
match = _SPEAKER_HEADER.match(line)
if match:
if speaker:
blocks.append((speaker, "\n".join(body).strip()))
speaker, body = match.group("speaker").strip(), []
elif speaker and line:
body.append(line)
if speaker:
blocks.append((speaker, "\n".join(body).strip()))
# 内容与我方回复对得上的说话人就是坐席自己,不能算客户。
agents = {
speaker
for speaker, body in blocks
if " ".join(body.split()) in assistant_bodies and body
}
if agent_name:
agents.update(speaker for speaker, _ in blocks if agent_name in speaker)
agents.update(outgoing)
return {
speaker
for speaker, _ in blocks
if speaker and speaker not in agents
and not (agent_name and agent_name in speaker)
}
def _describe(key: str, entry: dict) -> str:
updated = entry.get("updated") or 0
stamp = (
time.strftime("%m-%d %H:%M", time.localtime(updated)) if updated else "未记录"
)
return "%s…(%d 字节键) 最后更新 %s,历史 %d 条,快照 %d 行" % (
key[:16],
len(key) // 2,
stamp,
len(entry.get("history") or []),
len(entry.get("last_lines") or []),
)
def plan_archive_merges(archive: dict, agent_name: str) -> list:
"""找出可以唯一证明归属的分裂档案,返回 (旧键, 新键, 客户名) 列表。"""
outgoing = _outgoing_speakers(archive)
profiles = {}
for key, entry in archive.items():
if not isinstance(entry, dict):
continue
names = _speakers(entry, agent_name, outgoing)
if len(names) == 1:
profiles[key] = next(iter(names))
merges = []
for name in set(profiles.values()):
owners = sorted(
(key for key, value in profiles.items() if value == name),
key=lambda key: (
len(key),
float(archive[key].get("updated") or 0),
),
)
if len(owners) < 2:
continue
# 键最长、最近更新的那份是当前生效的身份,其余合并进它。
target = owners[-1]
merges.extend((old, target, name) for old in owners[:-1])
return merges
def merge_archive_entry(archive: dict, old_key: str, new_key: str) -> None:
"""把旧档案的历史按时间戳并入新档案,快照沿用新档案的。"""
old_entry = archive.get(old_key) or {}
new_entry = archive.get(new_key) or {}
history = [
item
for item in list(old_entry.get("history") or [])
+ list(new_entry.get("history") or [])
if isinstance(item, dict)
]
seen = set()
merged = []
for item in sorted(history, key=lambda item: float(item.get("ts") or 0)):
marker = (
str(item.get("role") or ""),
" ".join(str(item.get("content") or "").split()),
)
if marker in seen:
continue
seen.add(marker)
merged.append(item)
new_entry["history"] = merged[-200:]
archive[new_key] = new_entry
archive.pop(old_key, None)
def plan_pending_drops(pending: dict) -> list:
"""列出既无聊天内容、也无待发回复的空壳任务。"""
drops = []
for key, value in pending.items():
if not isinstance(value, dict):
continue
if str(value.get("send_state") or "").strip():
continue # 发送中的任务必须保留,可能已经发出去了
actionable = (
str(value.get("chat_text") or "").strip()
or (value.get("last_lines") or [])
or str(value.get("reply_text") or "").strip()
or str(value.get("staged_reply_text") or "").strip()
or str(value.get("staged_user_text") or "").strip()
or str(value.get("exchange_id") or "").strip()
)
if not actionable:
drops.append(key)
return drops
def main() -> None:
parser = argparse.ArgumentParser(description="会话档案与待回复任务收口")
parser.add_argument("--apply", action="store_true", help="执行改动(默认只报告)")
args = parser.parse_args()
archive_path = os.path.join(_HERE, "conversations.json")
pending_path = os.path.join(_HERE, "pending_replies.json")
archive = _load(archive_path)
pending = _load(pending_path)
agent_name = _agent_name()
merges = plan_archive_merges(archive, agent_name)
drops = plan_pending_drops(pending)
print("会话档案 %d 份,待回复任务 %d 个。" % (len(archive), len(pending)))
if merges:
print("\n[分裂档案] 可唯一证明属于同一联系人,将合并:")
for old, new, name in merges:
print(" 客户「%s」" % name)
print(" 旧 %s" % _describe(old, archive.get(old) or {}))
print(" 新 %s" % _describe(new, archive.get(new) or {}))
else:
print("\n[分裂档案] 没有能唯一证明归属的重复档案。")
if drops:
print("\n[空壳任务] 无内容也无待发回复,将删除:")
for key in drops:
created = (pending.get(key) or {}).get("created_at") or 0
print(
" %s… 建立于 %s"
% (
key[:16],
time.strftime("%m-%d %H:%M", time.localtime(created))
if created
else "未记录",
)
)
else:
print("\n[空壳任务] 没有需要清理的空壳任务。")
if not merges and not drops:
print("\n无需收口。")
return
if not args.apply:
print("\n以上为预演。加 --apply 执行(会先备份两个 json)。")
return
stamp = time.strftime("%Y%m%d_%H%M%S")
for path in (archive_path, pending_path):
if os.path.exists(path):
backup = f"{path}.{stamp}.bak"
shutil.copy2(path, backup)
print("已备份 %s" % os.path.basename(backup))
for old, new, _name in merges:
merge_archive_entry(archive, old, new)
for key in drops:
pending.pop(key, None)
for path, data in ((archive_path, archive), (pending_path, pending)):
tmp_path = f"{path}.tmp"
with open(tmp_path, "w", encoding="utf-8") as handle:
json.dump(data, handle, ensure_ascii=False, indent=2)
os.replace(tmp_path, path)
print(
"\n收口完成:合并 %d 份分裂档案,删除 %d 个空壳任务。"
% (len(merges), len(drops))
)
if __name__ == "__main__":
main()