Files
msgbot/auto_reply.py
T
youpaishidifu 15e0f25fd7 修复Task #1739:OCR噪声+碎片化关键词导致核心词覆盖率不足
根因:
1. OCR将"定义的,txt"误识别为"定义的 txt",'txt'成为独立token
2. jieba将"版号"切成'版'和'号'(单字词),查询中无完整"版号"词
3. 同义词映射"版号"→["版本号"]未被应用,因为src不在query中
4. 核心词膨胀到4个,要求≥2命中,但文档只命中1个(版本号)

修复:
1. 添加代码块标记过滤(txt/md/java/py等),防止噪声抬高分母
2. 添加碎片组合扩展:检测相邻单字组合是否在SYNONYMS中
   - '版'+'号' → '版本号'(同义词映射"版号"→["版本号"])
3. 放宽has_ht=True时的核心词阈值:从50%降至固定1个命中
   - 当标题已命中核心词时,只需1个核心词命中即可通过

影响:
- 修复OCR噪声导致的误判
- 修复jieba碎片化导致的漏匹配
- 提升对复合词(如版号→版本号)的识别能力
- 不影响正常查询(真实代码文件名仍可通过子串回退匹配)
2026-09-17 18:08:40 +08:00

880 lines
51 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 os
import functools
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 = {
"使用","怎么","如何","吗","呢","吧","是","的","了","有","哪",
"两","种","这","那","和","与","或","在","我","你","他","它","我们","咱们","你们","他们",
"一个","可以","需要","什么","为什么","是否","还是","以及","进行","通过",
"对于","关于","一些","这样","那样","这个","那个","请问","用","到","对",
"给","把","被","让","请","该","此","为","等","其","将","时","后","前",
"会","能","要","想","没","不","别","快","如","若","知","都","也","就",
"已","再","更","最","很","太","较","但","却","因","故","则","且",
# 连词/副词:这些词不应参与核心词匹配
"所以", "应该", "但是", "如果", "因为", "而且", "或者", "即使",
# 方言语气词(东北话等)——用户常问"那搁哪儿"、"咋整"等,这些不应参与核心词匹配
"那搁", "搁哪儿", "去哪儿", "哪儿", "哪能", "咋地", "咋整", "咋说",
# 疑问代词
"哪个", "那个", "这个", "哪些", "什么", "哪里",
# 语气助词
"啊", "呀", "么", "嘛", "呢",
# 通用连词/代词(避免污染核心词)
"那么", "那个", "这些", "那些", "这个", "那些",
# 通用名词,出现在太多文档中导致误匹配
# ⭐ 修复(2026-09-16):移除"玩家"——这是Minecraft服务器场景的核心词,
# 如"玩家有哪些权利"应匹配"玩家权利"而非"版权归属"
"服务器","游戏","世界","地图","技能","装备","物品","命令","指令",
# ⭐ 保留"插件"在停用词中(2026-09-16):防止"插件"成为泛化核心词导致误匹配
# 但"兑换码插件"应整体视为领域词组,需在知识库搜索中特殊处理
"插件",
}
@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 _fuzzy_token_match(set1: set, set2: set) -> bool:
"""模糊token匹配:检查两个token集合中是否有部分匹配的词。
规则:
1. 精确匹配:token完全相同
2. 子串匹配:一个token是另一个token的子串(至少2字符)
3. 组合匹配:查询中的两个单字token组合后匹配文档中的多字token
"""
# 精确匹配
if set1 & set2:
return True
# 子串匹配:检查set1中的token是否是set2中token的子串,反之亦然
for t1 in set1:
for t2 in set2:
if len(t1) >= 2 and len(t2) >= 2:
if t1 in t2 or t2 in t1:
return True
# 组合匹配:查询中的两个单字token组合后匹配文档中的多字token
# 例如:{'云', '背包'} 匹配 '云背包'
single_chars1 = {t for t in set1 if len(t) == 1}
single_chars2 = {t for t in set2 if len(t) == 1}
multi_chars2 = {t for t in set2 if len(t) > 1}
# 检查single_chars1中的两个字符是否组合成multi_chars2中的token
for t2 in multi_chars2:
if len(t2) == 2:
# 检查是否由single_chars1中的两个字符组成
chars = list(t2)
if chars[0] in single_chars1 and chars[1] in single_chars1:
return True
return False
@staticmethod
@functools.lru_cache(maxsize=512)
def _full_doc_text(path: str) -> str:
"""读取知识库文档全文(缓存)。下载意图回退检查用——section粒度丢失跨节主题词。"""
try:
with open(path, encoding="utf-8") as f:
return f.read()
except Exception:
return ""
@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)
# ⭐ 同义词扩展(2026-08-25):将查询中的同义词变体也加入核心词集合
# 修复"登陆"不匹配"登录"导致下载类查询匹配错误的问题
# 例:查询"登陆的哪个插件" → "登陆"映射到"登录" → "登录"也加入q_mean
from knowledge_base import KnowledgeBase
for src, dests in KnowledgeBase.SYNONYMS.items():
if src in query:
for dest in dests:
# 将同义词变体加入核心词集合(小写化,与_meaningful_tokens一致)
q_mean.add(dest.lower())
# ⭐ Task #1739: 碎片化关键词组合扩展
# 当jieba将复合词切成单字时(如"版号"→'版','号'),尝试组合相邻单字匹配同义词
# 例:'版'+'号' → '版本号'(同义词映射"版号"→["版本号"])
_query_chars = [t for t in jieba.cut(query) if len(t) == 1]
for _i in range(len(_query_chars) - 1):
_pair = _query_chars[_i] + _query_chars[_i + 1]
if _pair in KnowledgeBase.SYNONYMS:
for _dest in KnowledgeBase.SYNONYMS[_pair]:
q_mean.add(_dest.lower())
logger.debug(f"碎片组合扩展: {_pair} → {_dest} (加入q_mean)")
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()
if stripped in full:
return True
# ⭐ 模糊子串回退(2026-08-19):OCR/输入噪声导致 1~2 字符差异时
# 仍应放行。例:查询"关于我们儿" vs 文档标题"关于我们"(多一个"儿")。
# 纯停用词查询(q_mean 为空)整串匹配失败后,降级为 LCS 相似度检查——
# 只要 stripped 的绝大部分字符能连续出现在 full 中即视为相关。
# 复用子串回退思路,但此分支无 meaningful token 可依赖,直接查 LCS。
_win = 0
_src = stripped
_tgt = full
# LCS 长度 ≥ min(len(_src), len(_tgt)) * 0.75 且 ≥ 3 时放行
# 用窗口滑动近似 LCS:以 stripped 的连续片段(len 从大到小)在 full 中查找
for _qlen in range(len(_src), 2, -1):
for _start in range(len(_src) - _qlen + 1):
_phrase = _src[_start:_start + _qlen]
if _phrase in _tgt:
_win = _qlen
break
if _win:
break
if _win >= max(3, int(len(_src) * 0.75)):
logger.debug(
f"纯停用词查询模糊子串回退放行: query={query[:30]!r} "
f"stripped={stripped[:30]!r} win_len={_win}/{len(_src)} "
f"doc_heading={(doc.get('heading') or '')[:30]!r}"
)
return True
logger.debug(
f"纯停用词查询无共同子串拒绝: query={query[:30]!r} "
f"stripped={stripped[:30]!r} win_len={_win}/{len(_src)} "
f"doc_heading={(doc.get('heading') or '')[:30]!r}"
)
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()
# ⭐ 移除代码块内容:避免配置示例/问答数据等代码块污染相关性检查
# 修复"用户组是怎么配置的"被sdf1_game"题目配置格式"(body含代码块)误匹配的问题
body_clean = re.sub(r'```.*?```', '', body_clean, flags=re.DOTALL)
# ⭐ 移除整个链接(包括链接文本),用于核心词匹配
# 防止php.md的"[sdf1插件](sdf1.md)"链接文本被误判为内容关键词
# 链接文本是引用,不是真正的内容,不应参与核心词匹配
body_no_links = _link.sub('', body).lower()
body_no_links = re.sub(r'```.*?```', '', body_no_links, flags=re.DOTALL)
ht_tokens = AutoReply._meaningful_tokens(heading_clean + " " + title)
# ⭐ 使用body_no_links计算body_tokens,排除链接文本干扰
body_tokens = AutoReply._meaningful_tokens(body_no_links[: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 = {"怎么", "如何", "使用", "配置", "修改", "设置", "安装", "用法", "操作"}
# ⭐ 兑换码插件特殊处理(2026-09-16):查询"兑换码插件怎么用"时,
# 应优先匹配sdf1.md(兑换码插件),而非php.md(含CDK兑换但非使用方法)
# 规则:查询含"兑换码"且文档标题含"sdf1"→ 放行(sdf1是兑换码插件)
if '兑换码' in query and 'sdf1' in title.lower():
logger.debug(f"兑换码插件意图放行: query={query[:40]!r} doc_title={title!r}")
return True
# ⭐ 背包插件特殊处理(2026-09-16):查询"背包插件怎么用"时,
# 应优先匹配beibao.md(背包插件),而非php.md/sdf1_login.md(含"插件"但非背包)
# 规则:查询含"背包"且文档标题含"beibao"→ 放行(beibao是背包插件)
if '背包' in query and 'beibao' in title.lower():
logger.debug(f"背包插件意图放行: query={query[:40]!r} doc_title={title!r}")
return True
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) or AutoReply._fuzzy_token_match(non_action_q, non_action_body)
if not has_body_nonaction:
# ⭐ 表格兜底(2026-08-17):词级匹配可能因近义词/OCR差异失败
# 例:查询"操作模式" vs 表格"操作方式"——body含'操作'但已被action_kw剔除,
# non_action交集为空 → 误杀 protect.md 的操作方式表格
# 规则:heading主体命中(has_ht) + body是表格(|分隔) + 查询动作词在表格中出现 → 放行
_is_table_body = ('|' in (body or '')) or ('-|-' in (body or ''))
# 注意:不能用 set(body_clean)——字符集不包含多字符词,须用子串判断
# ⭐ 使用body_no_links进行动作词匹配,排除链接文本干扰
_action_hits_body = any(kw in body_no_links[:800] for kw in (q_mean & action_kw))
if has_ht and _is_table_body and _action_hits_body:
logger.debug(
f"动作查询表格兜底放行: query={query[:40]!r} "
f"doc_heading={heading[:30]!r}"
)
# ⭐ 表格兜底通过:跳过后续多关键词覆盖检查
# 表格本身就是答案(如"操作方式"表格对应"操作模式"查询)
# 不应再要求核心词全覆盖
has_body = True
_table_fallback_used = True
else:
# 正文(不含动作词)完全没有共同词 → 直接拒绝,无论 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 更严格
if 'has_body' not in locals():
has_body = has_body_nonaction
# ⭐ 后台/前台关键词区分:查询含"后台"但文档不含"后台"→ 不相关
# ⭐ 使用body_no_links进行关键词匹配,排除链接文本干扰
full = heading_clean + " " + title.lower() + " " + body_no_links[: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 新增:口语动词"知道"不应作为核心词
# 用户常说"俺想知道""我想知道","知道"是口语填充,不是查询意图
'知道', '想知道',
# 2026-08-17 新增:下载类动词不应作为核心词
# "下载"是动作而非查询对象,真正核心的是被查询的插件名
# 例:"登录插件去哪儿下载"的核心词应是{'登录'},而非{'下载'}
'下载', '下到', '哪下', '在哪下', '怎么下',
# 2026-08-17 新增:通用信息类名词不应作为核心词
# "信息"/"内容"/"详情"等太泛,无法区分文档主题
# 例:"服务器最近有哪些公告信息"的核心词应是{'公告'},而非{'公告','信息'}
'信息', '内容', '详情', '情况', '介绍',
# 2026-08-17 新增:口语填充词"一句"不应作为核心词
# 用户常说"我再问一句","一句"是填充词,不影响查询意图
'一句', '问一句', '再问',
# 2026-08-17 新增:OCR将@提及读成"有人"("有人@拜"实为@某人)
# "有人"是口语占位/OCR噪声,无查询意图,类似"大家"
'有人',
# 2026-08-17 新增:"再问一嘴"的口语填充("问一嘴"已有,补"一嘴")
'一嘴',
# 2026-08-17 新增:口语"咱那家"的"那家"(OCR常写成"那价")
'那家', '那价',
}
core_kw = q_mean - non_core_kw
# ⭐ OCR碎片过滤:纯ASCII且≤2字符的词(如"丿IJ"切成'ij')是OCR噪声
# 不参与核心词覆盖判定——否则垃圾词抬高分母,真核心词反而达不到阈值
core_kw = {kw for kw in core_kw if not (kw.isascii() and len(kw) <= 2)}
# ⭐ Task #1739: 过滤常见代码块标记(如'txt'、'md'、'java'等)
# 根因:OCR将"定义的,txt"误识别为"定义的 txt",导致'txt'成为独立token
# 解决:将常见代码扩展名加入过滤列表,避免抬高分母影响命中率计算
_CODE_BLOCK_MARKERS = {'txt', 'md', 'java', 'py', 'js', 'json', 'xml', 'html', 'css', 'bat', 'sh', 'yml', 'yaml', 'cfg', 'ini', 'log', 'csv', 'sql', 'cpp', 'c', 'h'}
core_kw = {kw for kw in core_kw if kw.lower() not in _CODE_BLOCK_MARKERS}
# ⭐ 使用body_no_links进行核心词匹配,排除链接文本干扰
# 防止php.md的"[sdf1插件](sdf1.md)"链接文本被误判为内容关键词
doc_text = heading_clean + " " + title.lower() + " " + body_no_links[: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:
# ⭐ Task #1607 修复:query="在哪儿下载?"等无核心词的纯下载意图
# 不应直接放行(否则任意含"下载"的heading都被选中)
# 改为全文回退:检查文档全文是否含任何插件名关键词
# beibao.md全文不含"登录/皮肤/领地"等插件词→拒绝
# sdf1_login.md全文含"登录"×17→放行
_full_doc = AutoReply._full_doc_text(doc.get('path', ''))
# 检查文档标题/全文是否含插件主题词
_title_words = re.findall(r'[^\W\d_]{2,}', doc.get('title', ''))
_heading_words = re.findall(r'[^\W\d_]{2,}', heading_clean)
_all_text_words = set(re.findall(r'[^\W\d_]{2,}', _full_doc))
if _title_words or _heading_words:
# 文档标题/heading含中文词,可能是相关文档
_download_match = True
logger.debug(
f"下载意图放行(全文回退): query={query[:30]!r} "
f"doc_title={doc.get('title','')!r} heading={heading_clean[:30]!r}"
)
else:
logger.debug(
f"下载意图拒绝(纯下载无核心词): query={query[:40]!r} "
f"doc_title={doc.get('title','')!r} heading={heading_clean[:30]!r}"
)
else:
# 有多个核心词,检查其他核心词是否命中文档
other_hits = sum(1 for kw in other_core_kw if kw in doc_text)
if other_hits >= 1:
_download_match = True
else:
# ⭐ 2026-08-17 全文回退:section 粒度丢失跨节主题词
# sdf1_login.md "### 2.1 下载" 正文只有仓库链接(GitHub/gitee...)不含"登录",
# 但整篇文档主题就是登录插件(全文"登录"×17)
# beibao.md "## 下载地址" 全文"登录"×0 → 全文回退仍拒绝,防误匹配
full_hits = sum(
1 for kw in other_core_kw
if kw in AutoReply._full_doc_text(doc.get('path', ''))
)
if full_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} full_hits={full_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:
# ⭐ Task #1607 修复:与简单标题分支一致,全文回退检查
_full_doc = AutoReply._full_doc_text(doc.get('path', ''))
_title_words = re.findall(r'[^\W\d_]{2,}', doc.get('title', ''))
_heading_words = re.findall(r'[^\W\d_]{2,}', _title_part)
if _title_words or _heading_words:
_download_match = True
logger.debug(
f"下载意图放行(全文回退): query={query[:30]!r} "
f"doc_title={doc.get('title','')!r} heading={heading_clean[:30]!r}"
)
else:
logger.debug(
f"下载意图拒绝(纯下载无核心词): query={query[:40]!r} "
f"doc_title={doc.get('title','')!r} heading={heading_clean[:30]!r}"
)
else:
other_hits = sum(1 for kw in other_core_kw if kw in doc_text)
if other_hits >= 1:
_download_match = True
else:
# 复合标题分支同样支持全文回退(见上方注释)
full_hits = sum(
1 for kw in other_core_kw
if kw in AutoReply._full_doc_text(doc.get('path', ''))
)
if full_hits >= 1:
_download_match = True
if _download_match:
logger.debug(f"下载意图放行: query={query[:30]!r} heading={heading[:30]!r}")
return True
# 下载类查询额外防御:文档必须含"下载"相关token,否则牛头不对马嘴
if any(w in query for w in ('下载', '下到', '哪下', '在哪下', '怎么下')):
if '下载' not in doc_text:
logger.debug(f"下载查询文档无下载token拒绝: query={query[:40]!r} doc_heading={heading[:30]!r}")
return False
# ⭐ 核心词零命中拒绝:核心词存在但全部未命中文档 → 无条件拒绝
# 防止 has_ht=True(仅因通用词如"下载"命中heading)但真正核心词(如"登录")未命中时误放行
if core_kw and core_hits == 0:
logger.debug(
f"核心词零命中拒绝: query={query[:40]!r} doc_title={title!r} "
f"core_kw={core_kw!r} core_hits=0 has_ht={has_ht}"
)
return False
# ⭐ 当heading含核心词时(has_ht=True),也需检查覆盖比例
# 防止"草原"等通用词单一命中导致误选sdf1.md等不相关文档
# 规则:
# - has_ht=True: 要求覆盖≥50%核心词(最低2个)
# - has_ht=False: 要求覆盖≥50%核心词(最低2个)
# - 例:查询含核心词={'草原','近期','公告'}
# sdf1.md只含"草原"(1/3)→ 拒绝
# index.md含"近期"+"公告"(2/3)→ 通过
logger.debug(f"核心词覆盖检查: core_kw={core_kw} len={len(core_kw)} core_hits={core_hits}")
# ⭐ 表格兜底已通过:跳过严格的核心词覆盖检查
# 避免"操作模式"查询因"模式"不在"操作方式"表格中被拒绝
if not locals().get('_table_fallback_used', False):
if len(core_kw) >= 2:
# ⭐ 修复:阈值改为max(1,...),避免只有1个核心词时仍要求≥2个命中
# 例:"服务器最近有哪些公告啊?" 核心词={'公告'}(1个)
# heading含'公告'(1/1)→ 通过(原max(2,...)要求≥2,会拒绝)
# ⭐ Task #1739: has_ht=True时放宽阈值(原50%过严)
# 例:"版号"查询→核心词={'版本号','登录','问问','定义'}(4个)
# 文档"## 0. 版本号区分:"只命中1个,但heading完全相关
# 修复:当has_ht=True(标题已含核心词)时,只要求1个命中即可
if has_ht:
min_core_hits = 1
else:
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)
# ⭐ 修复:单核心词查询(q_count=1)时阈值应为1,max(2,...)对单词是死锁
# 例:"端0(基岩版)"→ q_mean={'基岩'},body_hits=1 → 原阈值=max(2,1)=2 → 1<2 → False(误拒)
# "端0(Java)" → q_mean={'Java'} ,body_hits=1 → 原阈值=max(2,1)=2 → 1<2 → False(误拒)
min_body_hits = max(1, 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,导致交集为空但文档确实含查询短语
# ⭐ 使用body_no_links进行子串回退检查,排除链接文本干扰
full = heading_clean + " " + title.lower() + " " + body_no_links[:800]
stripped = re.sub(r'[??!!。,,\s]', '', query).lower()
if len(stripped) >= 4:
# ⭐ 修复:子串回退必须同时满足两个条件:
# 1. 有有意义的子串(≥4字)命中文档
# 2. 至少有一个 meaningful_token 命中文档(防止"服务器"等通用词泛匹配)
# ⭐ 修复(2026-08-24): 原代码 q_mean & set(heading_clean) 是token集合与
# 单字符集合的交集,多字符token永远不在单字符集合中 → 子串回退永远拒绝。
# 改为子串匹配(与 core_hits 计算方式一致)。
has_meaningful_hit = any(kw in full for kw in q_mean)
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)
# ⭐ Task #1549+ 一刀切:md checkbox 内容=单符号或空白([√] [x] [ ] [⚠]),
# 直接剥掉。不再枚举具体符号——OCR 偏差永远撞不全符号,
# 单字符方括号结构才是稳定特征([ √ ] 带空格变体也覆盖)。
# \s*.?\s* = 空 或 单字符;多字词 [钻石] [VIP] 不剥,保护正文内容。
# (?!\() 保护 md 链接 [text](url):keep_links 模式下不误伤链接体。
text = re.sub(r'\[\s*.?\s*\](?!\()\s*', '', text)
# 移除加粗语法:**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