更新bug

This commit is contained in:
Your Name
2026-07-31 11:48:16 +08:00
parent f913a57529
commit f22cc1a70d
109 changed files with 37586 additions and 927 deletions
+277
View File
@@ -0,0 +1,277 @@
"""会话档案收口工具
==================
行锚点漂移曾让同一个联系人被铸造出多个会话键(头像哈希相差 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()