根因:
- OCR将"定义的,txt"误识别为"定义的 txt"
- 'txt'作为3字符ASCII词未被现有过滤排除(只过滤≤2字符)
- 核心词从3个变成4个,命中率从3/3→1/4,低于阈值2被拒绝
修复:
- 添加常见代码块标记过滤列表{'txt','md','java','py','js','json',...}
- 这些标记通常是OCR噪声或文档格式标识,不应作为查询核心词
- 过滤后核心词恢复为{'登录','定义','问问'},命中率3/3通过检查
影响:
- 修复OCR噪声导致的核心词膨胀问题
- 避免因无效token导致命中率不足而拒绝正确答案
- 不影响正常查询(真实代码文件名仍可通过子串回退匹配)
863 lines
50 KiB
Python
863 lines
50 KiB
Python
"""自动回复 — 消息匹配知识库后准备答案,由主线程发送"""
|
||
|
||
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())
|
||
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,会拒绝)
|
||
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
|