1. cap_left 38%→48%(完全避开会话列表~30-55%)
38%仍捕获到左侧KB预览文字(问题1/2…)混入OCR
48%只捕获消息面板正文,KB预览不再出现
2. &清理移到时间戳分割前+全角:→半角:
原来&在分割后才清理,"13:5&07"无法被时间戳正则捕获
分割前清理→"13:5 07"可匹配时间戳→分割出独立段
全角:统一半角,后续正则只匹配半角
3. 时间戳段用\d{1,2}(允许单数字分钟)
分割后"07""15"等纯数字段被^[\d:\s]+$过滤
之前"13:5 07 14:2 15"整段因含空格,^\d{1,2}[::]\d{2}不匹配
4. auto_reply非Q&A文档回复限200字(之前500仍过多)
子串匹配触发的全文返回只回复前200字符
5. KB行级预览过滤移除(与用户消息混行时误杀)
段级别^问题\d+过滤器已足够,配合新裁剪基本不会触发
101 lines
3.4 KiB
Python
101 lines
3.4 KiB
Python
"""自动回复 — 消息匹配知识库后准备答案,由主线程发送"""
|
||
|
||
import logging
|
||
import threading
|
||
|
||
logger = logging.getLogger("msgbot.autoreply")
|
||
|
||
|
||
class AutoReply:
|
||
"""自动回复控制器"""
|
||
|
||
def __init__(self, monitor):
|
||
self.monitor = monitor
|
||
self._enabled = False
|
||
self._min_score = 0.35
|
||
|
||
# 已回复的消息指纹(防重复)
|
||
self._replied: dict = {}
|
||
self._lock = threading.Lock()
|
||
|
||
def set_enabled(self, enabled: bool):
|
||
self._enabled = enabled
|
||
logger.info(f"自动回复 {'已开启' if enabled else '已关闭'}")
|
||
|
||
def set_min_score(self, score: float):
|
||
self._min_score = max(0.0, min(1.0, score))
|
||
|
||
@property
|
||
def enabled(self) -> bool:
|
||
return self._enabled
|
||
|
||
@property
|
||
def min_score(self) -> float:
|
||
return self._min_score
|
||
|
||
def on_kb_match(self, platform: str, msg: str, top_doc: dict, score: float) -> str or None:
|
||
"""知识库匹配命中时调用。线程安全。
|
||
返回要发送的回答文本,返回 None 表示不发送。
|
||
"""
|
||
if not self._enabled:
|
||
return None
|
||
if score < self._min_score:
|
||
logger.debug(
|
||
f"[{platform}] 匹配度 {score:.2f} < 阈值 {self._min_score},跳过回复"
|
||
)
|
||
return None
|
||
with self._lock:
|
||
return self._get_answer(platform, msg, top_doc, score)
|
||
|
||
def get_reply_data(self, platform: str, msg: str, top_doc: dict, score: float) -> dict or None:
|
||
"""返回 {platform, answer, sender} 供主线程发送。"""
|
||
answer = self.on_kb_match(platform, msg, top_doc, score)
|
||
if not answer:
|
||
return None
|
||
|
||
# 获取发送者(在同一线程提取,不涉及窗口操作)
|
||
raw = self.monitor.get_raw_clipboard(platform)
|
||
sender = self.monitor._parse_last_sender(raw)
|
||
# 只有发送者是有效姓名且不在消息内容中时才切会话
|
||
if sender and sender not in msg and score >= self._min_score + 0.05:
|
||
pass
|
||
else:
|
||
sender = None
|
||
|
||
return {"platform": platform, "answer": answer, "sender": sender}
|
||
|
||
def _get_answer(self, platform: str, msg: str, doc: dict, score: float) -> str or None:
|
||
"""提取答案 + 防重复检查。
|
||
优先返回 doc['answer'](Q&A 段),兜底用整段 content。
|
||
|
||
非 Q&A 文档限制返回内容首 200 字符(之前 500 仍过多),
|
||
避免子串匹配触发后把一整个文件塞回去。
|
||
"""
|
||
answer = doc.get("answer")
|
||
if answer:
|
||
answer = answer.strip()
|
||
else:
|
||
# 非 Q&A 文档 → 只取前 200 字符
|
||
content = doc.get("content", "").strip()
|
||
answer = content[:197] + "..." if len(content) > 200 else content
|
||
|
||
if not answer:
|
||
return None
|
||
|
||
# 防重复指纹
|
||
fp = hash(f"{platform}:{msg}:{answer[:80]}")
|
||
replied_set = self._replied.setdefault(platform, set())
|
||
if fp in replied_set:
|
||
logger.debug(f"[{platform}] 重复命中,跳过: {msg[:40]}")
|
||
return None
|
||
replied_set.add(fp)
|
||
# 控制缓存上限
|
||
if len(replied_set) > 200:
|
||
self._replied[platform] = set(list(replied_set)[-100:])
|
||
|
||
logger.info(
|
||
f"[{platform}] 🎯 自动回复触发 | 匹配度 {score:.2f} | "
|
||
f"消息: {msg[:40]}"
|
||
)
|
||
return answer
|