Files
msgbot/auto_reply.py
T
youpaishidifu f9c5ab2142 fix: 新增'知道'/'想知道'为口语填充词,不作为核心词
问题:
- 用户问"俺想知道登录插件在哪儿下载"时,核心词提取为{'知道', '登录', '下载'}
- "知道"是口语填充词,不应参与核心词匹配
- 导致核心词覆盖检查失败:core_hits=1/3, missing=['登录', '知道']
- sdf1_login.md被错误拒绝

修复:
- 将'知道'和'想知道'加入non_core_kw停用词列表
- 预期效果:核心词变为{'登录', '下载'},覆盖检查通过

测试预期:
- '俺想知道登录插件在哪儿下载' → core_kw={'登录', '下载'} → sdf1_login通过
2026-08-17 09:40:58 +08:00

614 lines
33 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.
"""自动回复 — 消息匹配知识库后准备答案,由主线程发送"""
import logging
import re
import threading
import time
import jieba
logger = logging.getLogger("msgbot.autoreply")
class AutoReply:
"""自动回复控制器"""
def __init__(self, monitor):
self.monitor = monitor
self._enabled = False
self._min_score = 0.30
# 已回复的消息指纹(防重复)
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(10.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
# 内容相关性检查:查询词与匹配文档至少有一个共同2字+词,防偶然匹配
if not self._has_content_relevance(msg, top_doc, score):
heading = top_doc.get("heading", "")[:40]
body_preview = (top_doc.get("content", "") or top_doc.get("body", ""))[:60]
logger.debug(
f"[{platform}] 内容相关性检查失败,跳过: "
f"msg={msg[:40]!r} score={score:.2f} doc_heading={heading!r} "
f"doc_body={body_preview!r}"
)
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, cleaned_msg: str = None) -> dict or None:
"""返回 {platform, answer, sender, msg} 供主线程发送。
cleaned_msg 为去噪后的查询(用于相关性校验与去重指纹),
msg 保留原始文本(含时间戳)供主线程做消息新鲜度检查。
"""
query = cleaned_msg if cleaned_msg else msg
answer = self.on_kb_match(platform, query, 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, "msg": msg}
# 相关性检查用的停用词(通用/无区分度的词),这些词命中不算"有内容相关性"
_STOPWORDS = {
"插件","使用","怎么","如何","吗","呢","吧","是","的","了","有","哪",
"两","种","这","那","和","与","或","在","我","你","他","它","我们","咱们","你们","他们",
"一个","可以","需要","什么","为什么","是否","还是","以及","进行","通过",
"对于","关于","一些","这样","那样","这个","那个","请问","用","到","对",
"给","把","被","让","请","该","此","为","等","其","将","时","后","前",
"会","能","要","想","没","不","别","快","如","若","知","都","也","就",
"已","再","更","最","很","太","较","但","却","因","故","则","且",
# 连词/副词:这些词不应参与核心词匹配
"所以", "应该", "但是", "如果", "因为", "而且", "或者", "即使",
# 方言语气词(东北话等)——用户常问"那搁哪儿"、"咋整"等,这些不应参与核心词匹配
"那搁", "搁哪儿", "去哪儿", "哪儿", "哪能", "咋地", "咋整", "咋说",
# 疑问代词
"哪个", "那个", "这个", "哪些", "什么", "哪里",
# 语气助词
"啊", "呀", "么", "嘛", "呢",
# 通用连词/代词(避免污染核心词)
"那么", "那个", "这些", "那些", "这个", "那些",
# 通用名词,出现在太多文档中导致误匹配
"服务器","玩家","游戏","世界","地图","技能","装备","物品","命令","指令",
}
@staticmethod
def _meaningful_tokens(text: str) -> set:
"""提取有区分度的关键词(长度≥2 且非停用词),全部小写便于匹配。"""
toks = set(t.lower() for t in jieba.cut(text) if len(t) >= 2)
return toks - AutoReply._STOPWORDS
@staticmethod
def _has_content_relevance(query: str, doc: dict, score: float = 0.0) -> bool:
"""查询词与匹配文档必须共享至少一个有区分度的核心词,防偶然/牛头不对马嘴匹配。
判定顺序:
1. 提取 query 的有区分度关键词(_meaningful_tokens,去停用词、小写)。
2. 检查这些词是否出现在 doc 的 heading+title 或 body(前 800 字)中。
3. 动作/配置类查询(怎么/如何/使用/配置…)额外要求正文也含核心词,
不能只命中标题(否则 "# Sdf1系列登录插件使用说明" 会因标题含词被误放)。
4. 子串回退:连续 4~6 字子串命中文档(应对 jieba 切碎的短语,如"资源包")。
5. 高匹配度(≥10)但无任何共同词:TF-IDF 误匹配,拒绝。
"""
import re
q_mean = AutoReply._meaningful_tokens(query)
if not q_mean:
# 极短/纯停用词查询:要求整个 query 子串命中文档
stripped = re.sub(r'[??!!。,,\s]', '', query).lower()
if len(stripped) >= 2:
full = (doc.get("heading", "") + " " + doc.get("title", "")
+ " " + (doc.get("content", "") or doc.get("body", ""))).lower()
return stripped in full
return False
_link = re.compile(r'\[([^\]]*)\]\([^\)]+\)')
heading = doc.get("heading", "")
title = doc.get("title", "")
# 清理 Markdown 链接文本,避免 "[sdf1_login登录插件](sdf1_login.md)" 误匹配
heading_clean = _link.sub(r'\1', heading).lower()
body = doc.get("content", "") or doc.get("body", "")
body_clean = _link.sub(r'\1', body).lower()
ht_tokens = AutoReply._meaningful_tokens(heading_clean + " " + title)
body_tokens = AutoReply._meaningful_tokens(body_clean[:800])
has_ht = bool(q_mean & ht_tokens)
has_body = bool(q_mean & body_tokens)
# ⭐ 复合标题检测(2026-08-13):如果 heading 含 <br> 或列表标记,
# 说明是 Markdown 链接列表(如"- [登录]: url"),只检查标题主体部分(<br>之前)
# 的 meaningful tokens,而非整个复合 heading
# 防止 protect.md 的"温馨提示"body 含"登录"+"下载"被误选
_is_composite_heading = ('<br>' in heading or '- [' in heading or '|-' in heading)
if _is_composite_heading:
# 提取标题主体部分(<br>或列表之前的部分)
_title_part = re.split(r'<br>|- |\|-', heading_clean)[0]
_body_part_tokens = set(AutoReply._meaningful_tokens(_title_part))
# ⚠ 如果标题主体部分不含任何 q_mean 中的词,直接拒绝
# 防止"温馨提示"类 body 含核心词但非真正标题的文档被误选
if not (q_mean & _body_part_tokens):
logger.debug(
f"复合标题无主体命中拒绝: query={query[:40]!r} "
f"heading={heading[:40]!r} title_part={_title_part!r}"
)
return False
# 重新计算 has_ht 为仅主体部分命中
has_ht = bool(q_mean & _body_part_tokens)
logger.debug(f"复合标题检测: heading={heading[:40]!r} title_part={_title_part!r} has_ht={has_ht}")
# 动作/配置类查询:标题和正文都必须含核心词,防止"牛头不对马嘴"误匹配
# ⭐ 扩展:has_body=False(正文完全无共同词)时直接拒绝,无论 heading 是否命中
# 例:"领地系统有哪些操作模式?" vs sdf1_login CLI段落 → 拒绝
# "领地系统有哪些操作模式?" vs protect 权限段落 → 正常(有body命中)
action_kw = {"怎么", "如何", "使用", "配置", "修改", "设置", "安装", "用法", "操作"}
if q_mean & action_kw:
# ⭐ 排除 action_kw 词参与 body 覆盖统计,避免"使用"(verb)误命中
non_action_q = q_mean - action_kw
non_action_body = body_tokens - action_kw
has_body_nonaction = bool(non_action_q & non_action_body)
if not has_body_nonaction:
# 正文(不含动作词)完全没有共同词 → 直接拒绝,无论 heading 是否命中
logger.debug(
f"动作查询正文无共同词拒绝: query={query[:40]!r} "
f"doc_heading={heading[:30]!r} body_hits=0 ht_hit={has_ht}"
)
return False
if not has_ht:
# 正文有非动作词命中但 heading 无命中 → 检查 body 覆盖量(见下方正文覆盖不足防御)
pass
# has_ht and has_body_nonaction → 通过
# 注:原 has_body 已包含 action_kw 词,此处用 has_body_nonaction 更严格
has_body = has_body_nonaction
# ⭐ 后台/前台关键词区分:查询含"后台"但文档不含"后台"→ 不相关
full = heading_clean + " " + title.lower() + " " + body_clean[:800]
# 注意:"管理后台"是整体概念,拆分后"后台"可能不在heading中,但"管理员"+"后端"已表达管理意图
# 所以当heading含"管理员"或"后端"时,不应因缺"后台"而拒绝
if "后台" in query and "后台" not in full:
# 检查是否已通过其他语义规则确认是管理端查询
is_admin_intent = ('管理员' in query) or ('管理' in query and '密码' in query)
if not is_admin_intent and ('管理员' not in heading_clean and '后端' not in heading_clean):
logger.debug(f"后台关键词缺失: query={query[:40]!r} doc={doc.get('heading', '')[:30]!r}")
return False
if "前台" in query and "前台" not in full:
logger.debug(f"前台关键词缺失: query={query[:40]!r} doc={doc.get('heading', '')[:30]!r}")
return False
# ⭐ 语义意图验证:OCR查询可能含错字(如"录"→"登"、"php"→"pHp"),
# 需要结合heading确认查询意图,防止"云背包管理员密码"压过"php管理员后端"
# 查询含"php"时,heading必须含"php"或满足以下例外规则:
if 'php' in query.lower():
if 'php' not in heading_clean:
# 例外1:查询含"管理"+"密码"类词,heading含"管理"即可(指向phpadmin的管理功能)
# 例:"php用户管理可以看到哪些信息" → 允许返回"### 用户管理"
has_management_intent = ('管理' in query and '管理' in heading_clean)
# 例外2:查询含"后端"概念,heading含"后端"即可
# 例:"那pHP后端的那管理员默认密码是多少" → 允许返回"## php管理员后端"
has_backend_intent = ('后端' in query and '后端' in heading_clean)
if not has_management_intent and not has_backend_intent:
logger.debug(f"php查询意图不匹配: query={query[:40]!r} heading={heading[:30]!r}")
return False
# ⭐ 增强:区分php.md(玩家端)和phpadmin.md(管理端)
# 当查询含"密码"且含"php"时,heading必须含"管理员"或"后端"(防止php.md玩家端被误选)
if '密码' in query and 'php' in query.lower():
if '管理员' not in heading_clean and '后端' not in heading_clean:
logger.debug(
f"php密码查询意图不匹配(疑似玩家端): query={query[:40]!r} "
f"heading={heading[:30]!r}"
)
return False
# ⭐ 新增:当query含"后端"或"管理员端"但heading不含"后端"也不含"php"时,可能是云背包等无关文档
# 例:"那pHP后端的那管理员默认密码是多少" vs beibao.md的"## 使用方法-管理员"
# beibao不含"php"也不含"后端"→ 拒绝
# 例:"那pHP的那个管理员端的密码" vs beibao.md的"## 使用方法-管理员"
# 查询含"管理员端",heading只含"管理员"→ 拒绝(必须含"php"或"后端")
if ('后端' in query or '管理员端' in query) and '后端' not in heading_clean and 'php' not in heading_clean:
logger.debug(
f"php后端/管理员端查询意图不匹配: query={query[:40]!r} "
f"heading={heading[:30]!r} (heading不含'后端'或'php')"
)
return False
if has_ht or has_body:
# ⭐ 多关键词覆盖防御(2026-08-13):
# 区分核心词与非核心词,防止通用短语因单一关键词误选不相关文档。
# 核心词:实词(名词/动词/形容词),排除疑问词、量词、方位词等
# 规则:
# 1. 如果heading含核心词命中(has_ht=True)→ 放宽检查,不强制覆盖全部
# 2. 如果heading无命中(has_ht=False)且正文有多关键词 → 要求覆盖≥50%
# 例:"草原深险服务器备用号服务器近期的一些公告有哪些"
# 核心词={'草原','近期','公告'}(排除'哪些','备用','深险'等OCR碎片)
# sdf1.md"注意事项"只含"草原"(1/3)→ 若has_ht=False则拒绝
# index.md"近期公告"含"近期"+"公告"(2/3)→ 通过
non_core_kw = {'多少', '哪些', '哪个', '什么', '怎么', '如何', '吗', '呢', '吧',
'备用', '深险', '端的', '那', '这', '些', '个', '种', '类',
# 时间/程度副词:通用且难以区分文档
'最近', '近期', '刚才', '现在', '当前', '刚刚', '以前', '之后', '当天',
'第一', '最新', '全部', '所有', '一起', '各种', '各种各样',
# 疑问代词变体
'哪些', '哪个', '什么', '怎么', '如何', '多少',
# 连接/修饰词:不应参与核心词匹配
'相关联', '相关', '有关', '以及', '以及',
# 口语语气词/助动词(用户常用"问问""能够""可到"等口语表达)
'问问', '问一嘴', '打听一下', '能够', '可以到',
# 口语填充词/主谓占位(2026-08-16 新增)
# "有个问题"——"有个""问题"是语气填充,不应计入核心词
# "他们常用的"——"他们""常用"是语法修饰,不影响查询意图
'有个', '问题', '常用', '他们', '各位', '大家', '小伙伴',
# 2026-08-17 新增:口语动词"知道"不应作为核心词
# 用户常说"俺想知道""我想知道","知道"是口语填充,不是查询意图
'知道', '想知道',
}
core_kw = q_mean - non_core_kw
doc_text = heading_clean + " " + title.lower() + " " + body_clean[:800]
core_hits = sum(1 for kw in core_kw if kw in doc_text)
# ⭐ 下载意图检查提前到核心词检查之前
# 原因:当查询含"下载"且heading也含"下载"时,即使其他核心词未命中,
# 也应放行(如"哪个地方下载登录插件"→ "## 下载地址"应匹配)
# 原逻辑在核心词检查后执行,核心词覆盖不足时直接return False,
# 导致下载意图检查永远无法触发
_download_match = False
if any(w in query for w in ('下载', '下到', '哪下', '在哪下', '怎么下')) and '下载' in heading_clean:
if '<br>' not in heading and '- [' not in heading and '|-' not in heading:
# ⭐ 修复:下载意图检查不能只靠"下载"放行,必须有其他核心词支撑
# 否则"登录插件下载地址"会错误匹配到beibao.md的"下载地址"
# 规则:
# 1. 如果查询只有"下载"一个核心词 → 放宽检查(普通下载类查询)
# 2. 如果查询有多个核心词(如"登录插件下载地址")→ 要求至少一个其他核心词命中
other_core_kw = core_kw - {'下载', '下到', '哪下', '在哪下', '怎么下'}
if not other_core_kw:
# 只有"下载"一个核心词,直接放行
_download_match = True
else:
# 有多个核心词,检查其他核心词是否命中文档
other_hits = sum(1 for kw in other_core_kw if kw in doc_text)
if other_hits >= 1:
_download_match = True
else:
logger.debug(
f"下载意图拒绝(核心词未命中): query={query[:40]!r} "
f"doc_title={title!r} other_kw={other_core_kw!r} hits={other_hits}"
)
else:
# 复合标题:检查标题主体部分(<br>或列表之前)是否含"下载"
_title_part = re.split(r'<br>|- |\|-', heading_clean)[0]
if '下载' in _title_part:
other_core_kw = core_kw - {'下载', '下到', '哪下', '在哪下', '怎么下'}
if not other_core_kw:
_download_match = True
else:
other_hits = sum(1 for kw in other_core_kw if kw in doc_text)
if other_hits >= 1:
_download_match = True
if _download_match:
logger.debug(f"下载意图放行: query={query[:30]!r} heading={heading[:30]!r}")
return True
# ⭐ 当heading含核心词时(has_ht=True),也需检查覆盖比例
# 防止"草原"等通用词单一命中导致误选sdf1.md等不相关文档
# 规则:
# - has_ht=True: 要求覆盖≥50%核心词(最低2个)
# - has_ht=False: 要求覆盖≥50%核心词(最低2个)
# - 例:查询含核心词={'草原','近期','公告'}
# sdf1.md只含"草原"(1/3)→ 拒绝
# index.md含"近期"+"公告"(2/3)→ 通过
if len(core_kw) >= 2:
# ⭐ 修复:阈值改为max(1,...),避免只有1个核心词时仍要求≥2个命中
# 例:"服务器最近有哪些公告啊?" 核心词={'公告'}(1个)
# heading含'公告'(1/1)→ 通过(原max(2,...)要求≥2,会拒绝)
min_core_hits = max(1, (len(core_kw) + 1) // 2)
if core_hits < min_core_hits:
logger.debug(
f"多关键词覆盖不足跳过(防牛头不对马嘴): query={query[:40]!r} "
f"doc_heading={heading[:30]!r} core_hits={core_hits}/{len(core_kw)} "
f"(阈值≥{min_core_hits}), missing={ [kw for kw in core_kw if kw not in doc_text]!r}, has_ht={has_ht}"
)
return False
# 原有正文覆盖不足防御(保留作为兜底)
if not has_ht and has_body:
q_count = len(q_mean)
body_hits = len(q_mean & body_tokens)
min_body_hits = min(q_count, (q_count * 3 + 4) // 5)
min_body_hits = max(2, min_body_hits)
if body_hits < min_body_hits:
logger.debug(
f"正文覆盖不足跳过(防牛头不对马嘴): query={query[:40]!r} "
f"doc_heading={heading[:30]!r} body_hits={body_hits}/{q_count} "
f"(阈值≥{min_body_hits}), ht_hit={has_ht}"
)
return False
return True
# 子串回退:jieba 可能产生碎片 token,导致交集为空但文档确实含查询短语
full = heading_clean + " " + title.lower() + " " + body_clean[:800]
stripped = re.sub(r'[??!!。,,\s]', '', query).lower()
if len(stripped) >= 4:
# ⭐ 修复:子串回退必须同时满足两个条件:
# 1. 有有意义的子串(≥4字)命中文档
# 2. 至少有一个 meaningful_token 命中文档(防止"服务器"等通用词泛匹配)
has_meaningful_hit = bool(q_mean & (set(heading_clean) | set(title.lower()) | set(body_clean[:800])))
if not has_meaningful_hit:
# 没有 meaningful_token 命中,即使有子串匹配也拒绝
logger.debug(f"无有意义token命中拒绝: query={query[:40]!r} doc_heading={heading[:30]!r}")
return False
# ⭐ 新增:下载类查询的特殊处理(2026-08-17 修复牛头不对马嘴)
# 如果查询含"下载"且已有明确的"下载意图拒绝"日志,说明核心词检查已通过但语义不匹配
# 此时子串回退不应放行(防止"插件"等通用词在URL中误匹配)
_is_download_query = any(w in query for w in ('下载', '下到', '哪下', '在哪下', '怎么下'))
if _is_download_query:
# 下载类查询要求:子串必须包含查询的核心名词(如"登录插件")
# 不能只是"插件"这种通用词在URL路径中命中
_download_core_phrases = []
for kw in q_mean:
if kw not in {'下载', '下到', '哪下', '在哪下', '怎么下', '哪个', '哪里', '去哪'}:
_download_core_phrases.append(kw)
if _download_core_phrases:
# 检查是否有核心名词短语在文档中完整出现
_has_core_noun_match = any(
phrase in full
for phrase in _download_core_phrases
if len(phrase) >= 2
)
if not _has_core_noun_match:
logger.debug(
f"下载查询子串回退拒绝(核心名词未命中): query={query[:40]!r} "
f"doc_heading={heading[:30]!r} core_nouns={_download_core_phrases!r}"
)
return False
for qlen in range(min(len(stripped), 6), 3, -1):
for start in range(len(stripped) - qlen + 1):
phrase = stripped[start:start + qlen]
if phrase in full:
# ⭐ 新增:下载类查询跳过URL路径中的匹配
# 防止"插件"在/plugin/路径中误匹配
if _is_download_query and 'http' in full:
# 检查phrase是否在URL路径中(而非正文)
_url_pattern = r'https?://[^\s\)]+'
_urls = re.findall(_url_pattern, full)
_in_url_only = all(phrase in url for url in _urls if phrase in url)
if _in_url_only:
logger.debug(
f"下载查询子串回退拒绝(URL路径匹配): query={query[:40]!r} "
f"phrase={phrase!r} in URL only"
)
continue
logger.debug(
f"子串回退命中: phrase={phrase!r} in doc={doc.get('heading', '')[:30]!r}"
)
return True
# 高匹配度但无共同词:TF-IDF 误匹配,拒绝
if score >= 10.0 and not (has_ht or has_body):
return False
return False
def _get_answer(self, platform: str, msg: str, doc: dict, score: float) -> str or None:
"""提取答案 + 防重复检查。
优先返回 doc['answer'](Q&A 段),兜底使用 doc['body'](仅正文,不含 heading)。
KB 搜索已按 section 切分,doc['body'] 即最相关的片段正文。
只需截断到 300 字符避免消息过长。
"""
answer = doc.get("answer")
if answer:
answer = answer.strip()
else:
# 优先用 body(纯正文),兜底用 content(heading+正文)
body = doc.get("body", "").strip()
if body and len(body) > 20:
answer = self._extract_table_answer(body)
else:
content = doc.get("content", "").strip()
answer = self._extract_table_answer(content) if content else ""
if not answer:
return None
# 清理 HTML 换行标签(纯文本消息里 <br> 会原形显示)
import re
answer = re.sub(r'</?br\s*/?>', '\n', answer, flags=re.IGNORECASE)
answer = answer.strip()
# ⭐ 下载类回复保留 Markdown 链接:检测是否含下载/地址关键词且含 URL
_is_download_intent = (
any(kw in answer for kw in ('下载', '地址', '链接'))
or any(kw in msg for kw in ('下载', '下载地址', '去哪下'))
)
_has_url = bool(re.search(r'https?://', answer))
_keep_links = _is_download_intent and _has_url
if _keep_links:
logger.debug(f"[{platform}] 下载意图,保留链接格式")
# 清理 Markdown 格式,返回纯文本(下载类保留链接)
answer = self._clean_markdown(answer, keep_links=_keep_links)
if not answer:
return None
# 拒绝把纯标题/免责声明当作答案发送
if answer.startswith("# ") and len(answer) < 60:
logger.debug(f"[{platform}] 答案为纯标题,跳过: {answer[:60]!r}")
return None
if re.search(r'特别说明|免责声明|本插件.*自行修改|仅供参考|版权所有', answer):
logger.debug(f"[{platform}] 答案为免责声明/通用说明,跳过: {answer[:60]!r}")
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)
# ⭐ 语义去重:相同意图的不同表达(如"在哪下载登录插件"vs"登录插件下载地址")
# 使用Jaccard相似度(CJK bigram)检查,阈值>=0.3视为重复
# 防止OCR碎片导致的同义重复回复
_now = time.time()
_semantic_cache = getattr(self, '_semantic_reply_cache', [])
# 清理过期条目(TTL=30秒)
self._semantic_reply_cache = [(t, ts) for t, ts in _semantic_cache if _now - ts <= 30.0]
_SEMANTIC_SIM_THRESHOLD = 0.3
_duplicated = False
for semantic_text, _ts in self._semantic_reply_cache:
# 提取纯中文并计算Jaccard相似度
def _cjk_bigrams(text):
cjk = ''.join(c for c in text if '\u4e00' <= c <= '\u9fff')
return set(cjk[i:i+2] for i in range(len(cjk) - 1))
sa, sb = _cjk_bigrams(msg), _cjk_bigrams(semantic_text)
if sa and sb:
intersection = len(sa & sb)
union = len(sa | sb)
sim = intersection / union if union > 0 else 0.0
if sim >= _SEMANTIC_SIM_THRESHOLD:
logger.debug(
f"[{platform}] 语义重复,跳过: {msg[:30]!r} vs {semantic_text[:30]!r} (sim={sim:.2f})"
)
_duplicated = True
break
if _duplicated:
return None
self._semantic_reply_cache.append((msg, _now))
# 控制缓存上限
if len(replied_set) > 200:
self._replied[platform] = set(list(replied_set)[-100:])
if len(self._semantic_reply_cache) > 50:
self._semantic_reply_cache = self._semantic_reply_cache[-30:]
logger.info(
f"[{platform}] 🎯 自动回复触发 | 匹配度 {score:.2f} | "
f"消息: {msg[:40]}"
)
return answer
@staticmethod
def _clean_markdown(text: str, keep_links: bool = False) -> str:
"""清理文本中的 Markdown 格式,返回纯文本。
处理:
- 标题语法:## 标题 → 标题
- 链接语法:[text](url) → text(keep_links=False)或保留原样(keep_links=True)
- 列表语法:- item → item
- 加粗语法:**text** → text
- 引用语法:> text → text
- 编号列表:1. item → item
keep_links: 是否保留 Markdown 链接格式。用于下载类回复,需要保留URL。
"""
import re
if not text:
return text
# 移除标题语法:## 标题 → 标题
text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
# 链接处理:根据 keep_links 参数决定是否保留
if keep_links:
# 保留链接格式:[text](url) → [text](url)
pass
else:
# 移除链接语法:[text](url) → text
text = re.sub(r'\[([^\]]*)\]\([^\)]+\)', r'\1', text)
# 移除列表语法:- item → item
text = re.sub(r'^[\-\*]\s+', '', text, flags=re.MULTILINE)
# 移除加粗语法:**text** → text
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
# 移除引用语法:> text → text
text = re.sub(r'^>\s+', '', text, flags=re.MULTILINE)
# 移除编号列表:1. item → item
text = re.sub(r'^\d{1,3}[\.\)、]\s+', '', text, flags=re.MULTILINE)
# 移除多余空行
text = re.sub(r'\n\s*\n', '\n', text)
return text.strip()
@staticmethod
def _extract_table_answer(text: str, max_chars: int = 800) -> str:
"""从含表格的文本中提取表格 + 紧邻上下文,避免返回整段 section。
问题背景:知识库含表格的文档(如 php.md "## php后台玩家端")被匹配后,
原逻辑把整个 section(表格 + 其后大量无关图文)全部塞入回复。
实际答案只是表格附近的几句话。
提取规则:
- 表格块:连续以 `|` 开头且含 ≥2 个 `|` 的行(含表头/分隔/数据行)
⭐ 放宽:只要行首含 `|` 且含 ≥2 个 `|` 就算表格行,不要求行尾必须有 `|`
(避免 OCR 识别不完整或 Markdown 表格未闭合时遗漏数据行)
- 上文:表格前最近 2 个非空行(通常是说明句),给答案提供语境
- 下文:不保留。表格之后的内容往往是下一个话题的开头(如"仪表盘:…"
"商城:…"),保留会重新引入整段无关图文,违背"只返回表格附近的几句话"
- 截断到 max_chars 字符
若文本中无完整表格,则退回前 max_chars 字符。
"""
if not text:
return ""
lines = text.split('\n')
table_start = None
table_end = None
for i, ln in enumerate(lines):
s = ln.strip()
# ⭐ 2026-08-14 修复:放宽表格行检测,不要求行尾必须有 |
# 原因:Markdown 表格可能未闭合(如 `| 2026年6月7日 | 🎮 升级服务器到26.1.2`)
# 或 OCR 识别不完整,导致数据行被误判为非表格行
if '|' in s and s.count('|') >= 2:
if table_start is None:
table_start = i
table_end = i
# 无表格或表格过短:退回前 max_chars 字符
if table_start is None:
return text[:max_chars]
# 上文:向前找最多 2 个非空行(说明句)
pre: list = []
j = table_start - 1
while j >= 0 and len(pre) < 2:
if lines[j].strip():
pre.insert(0, lines[j].strip())
j -= 1
block = pre + lines[table_start:table_end + 1]
out = '\n'.join(block)
if len(out) > max_chars:
out = out[:max_chars] + "..."
return out