From 7fc737d80fb852809846a9369be94fb6145b7ad9 Mon Sep 17 00:00:00 2001 From: youpaishidifu Date: Fri, 7 Aug 2026 22:42:14 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20#1448=20=E7=9B=B8=E5=85=B3=E6=80=A7?= =?UTF-8?q?=E7=9F=AD=E6=9F=A5=E8=AF=A2=E6=94=BE=E8=A1=8C=20+=20#1449=20?= =?UTF-8?q?=E4=B8=A5=E6=A0=BC300s=E6=8A=A5=E5=BA=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1448 auto_reply: _has_content_relevance() 不再对≤2token短查询无条件放行 - 短查询必须整个query作为子串出现在文档中才放行 - 修复下载→前置依赖误匹配(无任何共同token/token交集) #1449 monitor: 严格300s报废策略 - stage splitting: 继承时间戳>300s一律丢弃,移除新鲜内容/提问豁免 - 区块级: 硬截止从1800s→300s,移除CJK内容/新鲜特征bypass - 修复16:07消息在21:08仍被处理的问题 --- auto_reply.py | 14 ++- monitor.py | 256 ++++++++++++++++++++++++++++++-------------------- 2 files changed, 165 insertions(+), 105 deletions(-) diff --git a/auto_reply.py b/auto_reply.py index 478fb3a..a792eb8 100644 --- a/auto_reply.py +++ b/auto_reply.py @@ -85,8 +85,9 @@ class AutoReply: """ import re q_tokens = set(t for t in jieba.cut(query) if len(t) >= 2) - if len(q_tokens) <= 2: - return True # 短查询不拦截 + # ⭐ 修复:短查询不再无条件放行,改为必须有子串命中才放行 + # 原逻辑导致"下载"(1 token)→"前置依赖"误匹配(无任何共同token却放行) + _is_short_query = len(q_tokens) <= 2 # 1. 检查 heading + title(排除链接文本) heading = doc.get("heading", "") @@ -130,9 +131,18 @@ class AutoReply: # 3. 子串回退:jieba分词可能产生"包是""还是"等碎片token, # 导致token交集为空但文档确实包含查询核心词(如"资源包")。 # 检查查询中任意2~6字子串是否出现在文档文本中。 + # ⭐ 短查询(≤2 token):检查整个query是否出现在文档中("下载"必须在文档中出现) # ⭐ 使用清理后的文本(排除链接文本) full_text = doc_text + " " + body_no_link[:500] if body else doc_text stripped_query = re.sub(r'[??!!。,,\s]', '', query) + # 短查询特殊处理:整个query必须作为子串出现在文档中 + if _is_short_query and len(stripped_query) >= 2: + if stripped_query in full_text: + logger.debug(f"短查询子串命中: query={stripped_query!r} in doc={doc.get('heading','')[:30]!r}") + return True + else: + logger.debug(f"短查询子串未命中: query={stripped_query!r} 不在文档中,拒绝") + return False if len(stripped_query) >= 4: for qlen in range(min(len(stripped_query), 6), 1, -1): for start in range(len(stripped_query) - qlen + 1): diff --git a/monitor.py b/monitor.py index 19edbb5..6e36203 100644 --- a/monitor.py +++ b/monitor.py @@ -405,6 +405,11 @@ class MessageMonitor: # EasyOCR 子进程(GPU crash 隔离,崩溃只杀子进程不冻电脑) self._easyocr_proc: Optional['subprocess.Popen'] = None self._easyocr_broken: bool = False + # ⭐ Task #1446:worker 是否已完成 ready 握手(就绪感知,避免未就绪即派发请求) + self._easyocr_ready: bool = False + self._easyocr_ready_event = threading.Event() # 握手完成信号,并发等待线程用 + self._easyocr_ready_timeout: float = 60.0 # 等待握手的最长预算(=初始化预算,不计入请求超时) + self._easyocr_starting: bool = False # 是否有线程正在拉起+Popen 子进程 # EasyOCR 子进程崩溃冷却:崩溃后 30s 内不重试(防快速崩溃循环刷屏) self._easyocr_retry_until: float = 0.0 # EasyOCR 子进程调用计数:累积 GPU 内存泄漏,每 MAX 次主动重启清缓存 @@ -445,6 +450,8 @@ class MessageMonitor: # EasyOCR 子进程锁(QQ/微信独立线程共用同一子进程) self._easyocr_lock = threading.Lock() + # ⭐ Task #1446:串行化子进程启动/Popen,防并发双 Popen 与「未就绪快路径」竞态 + self._easyocr_startup_lock = threading.Lock() # QQ 独立轮询线程(不阻塞微信主循环) self._qq_thread: Optional[threading.Thread] = None # 双击 ESC 急停线程 @@ -502,6 +509,9 @@ class MessageMonitor: logger.info(f"[ocr] 软重启:保持上次EasyOCR状态(cuda_oom_count={self._easyocr_cuda_oom_count})") self._easyocr_proc = None # 确保 clean state + self._easyocr_ready = False + self._easyocr_ready_event.clear() + self._easyocr_starting = False # ⭐ OCR 状态清零:截图hash/垃圾计数/引擎选择/冷却等,确保重启不残留上一轮状态 # (上次 CUDA 报错降级 CPU 的计数、超时计数、截图指纹都从头开始) self._last_screenshot_hash.clear() @@ -671,6 +681,10 @@ class MessageMonitor: logger.debug(f"{_tag}: 无可用诊断信息(worker 未留下状态文件/崩溃日志)") self._easyocr_proc = None + # ⭐ Task #1446:进程已销毁 → 清除就绪标志与握手信号,强制后续调用重新等待/握手 + self._easyocr_ready = False + self._easyocr_ready_event.clear() + self._easyocr_starting = False # 3) 主进程 GC:释放 PIL Image / numpy array 等残留引用 try: gc.collect() @@ -1669,16 +1683,83 @@ class MessageMonitor: is_normal_restart = True self._easyocr_normal_restart = False # 重置标志 logger.debug("[ocr] 检测到正常调度重启标志,跳过内存检测") - if self._easyocr_proc is not None: - if self._easyocr_proc.poll() is None: - return True # 还在跑 + # ── Task #1446 修复:就绪感知 + 启动串行化 ───────────────────── + # 旧快路径只看 poll() is None(进程存活),并发线程在初始化窗口抢到 + # 「进程已存活但未完成 ready 握手」的快路径 return True,导致在 worker + # 未就绪时就派发 OCR 请求、触发 10s 请求超时("刚就绪即超时"竞态)。 + # 新逻辑: + # 1) 快路径要求「进程存活【且】_easyocr_ready(已握手)」; + # 2) 进程存活但未就绪 → 说明另一线程正在握手,等待握手事件(≤60s + # 初始化预算),绝不提前派发请求; + # 3) 需要启动时用 _easyocr_startup_lock 串行化 Popen,仅一个线程真正 + # 拉起,其余线程等待其握手结果。于是 _call_easyocr_safe 的 10s 请求 + # 计时只会在 worker 真正就绪后才开始。 + + # 快路径(稳态):进程存活且已完成 ready 握手 + if self._easyocr_proc is not None and self._easyocr_proc.poll() is None and self._easyocr_ready: + return True + + # 进程存活但未就绪:另一线程正在握手,等待其完成(≤60s),不重 Popen、不提前派发 + if self._easyocr_proc is not None and self._easyocr_proc.poll() is None and not self._easyocr_ready: + logger.debug("[ocr] EasyOCR 子进程存活但握手未完成,等待就绪(≤60s)...") + self._easyocr_ready_event.wait(timeout=self._easyocr_ready_timeout) + if self._easyocr_proc is not None and self._easyocr_proc.poll() is None and self._easyocr_ready: + return True + # 等待超时或进程已死:清理卡住的未就绪子进程,落到重启逻辑 + if self._easyocr_proc is not None and self._easyocr_proc.poll() is None and not self._easyocr_ready: + logger.warning("[ocr] 等待握手超时,杀掉未就绪子进程并重试") + self._kill_easyocr_proc("握手等待超时") + # 继续往下走重启 + + # 进程已退出(poll 非 None)→ 清理后走重启逻辑 + if self._easyocr_proc is not None and self._easyocr_proc.poll() is not None: logger.warning("[ocr] EasyOCR 子进程已退出,重启...") self._easyocr_proc = None + self._easyocr_ready = False + self._easyocr_ready_event.clear() # 冷却期:子进程刚崩溃,30s 内不立即重试(防快速崩溃循环刷屏) if time.time() < self._easyocr_retry_until: return False + # 串行化启动:仅一个线程负责 Popen(防「proc 为 None」时并发双 Popen), + # 其余线程等待其握手结果。Popen 在锁内完成;握手在锁外执行,避免持锁 60s。 + _i_am_starter = False + with self._easyocr_startup_lock: + if self._easyocr_starting or (self._easyocr_proc is not None and self._easyocr_proc.poll() is None): + _i_am_starter = False # 另一线程已拉起/正在握手 → 本线程不 Popen + else: + self._easyocr_starting = True # 持锁标记本线程负责启动 + _i_am_starter = True + _started = self._start_easyocr_proc_locked(is_normal_restart) + if not _started: + self._easyocr_starting = False + # 锁已释放 + if not _i_am_starter: + # 另一线程正在握手(或本线程未启动但进程存在)→ 等待其握手结果 + if self._easyocr_proc is not None and self._easyocr_proc.poll() is None: + self._easyocr_ready_event.wait(timeout=self._easyocr_ready_timeout) + if self._easyocr_proc is not None and self._easyocr_proc.poll() is None and self._easyocr_ready: + return True + return False + # 本线程成功拉起进程 → 执行握手(锁外,不持锁 60s) + return self._handshake_easyocr_subprocess(is_normal_restart) + + def _start_easyocr_proc_locked(self, is_normal_restart: bool) -> bool: + """(在 _easyocr_startup_lock 内调用)启动 EasyOCR 子进程:worker 路径检查 + 内存预检 + Popen。 + + 返回 True = 子进程已拉起(self._easyocr_proc 存活);False = 启动失败 + (内部已记录日志 / 设置 _easyocr_broken / 冷却)。 + """ + worker_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + 'easyocr_worker.py' + ) + if not os.path.exists(worker_path): + logger.error(f"[ocr] EasyOCR worker 不存在: {worker_path}") + self._easyocr_broken = True + return False + # ⭐ 内存预检:物理内存 + 页面文件(虚拟内存)双重检查 # 正常调度重启时跳过内存检测,避免正常资源释放后重启被误判为错误 if not is_normal_restart: @@ -1748,15 +1829,6 @@ class MessageMonitor: else: logger.debug("[ocr] 正常调度重启:跳过内存检测,直接启动子进程") - worker_path = os.path.join( - os.path.dirname(os.path.abspath(__file__)), - 'easyocr_worker.py' - ) - if not os.path.exists(worker_path): - logger.error(f"[ocr] EasyOCR worker 不存在: {worker_path}") - self._easyocr_broken = True - return False - try: _worker_env = dict(os.environ) # ⭐ CUDA_LAUNCH_BLOCKING=1: CUDA 操作同步化,access violation 时只杀子进程不冻整机 @@ -1818,13 +1890,19 @@ class MessageMonitor: env=_worker_env, ) logger.debug("[ocr] EasyOCR 子进程已启动,等待初始化握手...") + return True except Exception as e: logger.error(f"[ocr] 启动 EasyOCR 子进程失败: {e}") self._easyocr_broken = True self._easyocr_proc = None return False - # ⭐ 等待 ready 握手(60s 初始化预算,不计入请求超时) + def _handshake_easyocr_subprocess(self, is_normal_restart: bool) -> bool: + """(本线程成功拉起进程后调用)等待 worker 的 {"ready": true} 握手(最长 60s 初始化预算)。 + + 握手成功:设置 _easyocr_ready + 触发 _easyocr_ready_event,返回 True。 + 握手失败/超时/崩溃:杀进程 + 冷却 + 清除就绪信号,返回 False。 + """ try: from queue import Queue, Empty as _QueueEmpty _q = Queue() @@ -1874,15 +1952,24 @@ class MessageMonitor: else: if not is_normal_restart: self._easyocr_retry_until = time.time() + 30 + # ⭐ Task #1446:进程已死,清除就绪信号与握手事件 self._easyocr_proc = None + self._easyocr_ready = False + self._easyocr_ready_event.clear() + self._easyocr_starting = False return False try: payload = json.loads(line) if payload.get("ready"): - logger.debug("[ocr] EasyOCR 子进程就绪") + logger.info("[ocr] EasyOCR 子进程就绪") self._easyocr_consecutive_startup_failures = 0 # 成功启动,重置启动失败计数 # ⭐ 截断崩溃日志:清除旧条目,防止旧记录混淆用户 _truncate_easyocr_crash_log() + # ⭐ Task #1446:握手完成 → 标记就绪并广播事件,唤醒等待中的并发线程, + # 保证 _call_easyocr_safe 的 10s 请求计时只在 worker 真正就绪后才开始 + self._easyocr_ready = True + self._easyocr_ready_event.set() + self._easyocr_starting = False # 启动完成,清除「正在启动」标志 return True except Exception: pass @@ -2887,48 +2974,32 @@ class MessageMonitor: for part in parts: if re.match(r'^\d{1,2}:\d{1,2}', part): if buf.strip(): - _fresh = self._looks_fresh_content(buf.strip()) if not last_ts: # ⭐ 无前置时间戳:位于首个时间戳之前的消息。 # ⚠ 不再整段丢弃:可能是被裁切/漏识别时间戳的新消息, # 交由下游 seen/_replied_texts 去重(基线轮会吸收历史)。 logger.debug(f"[ocr] stage[{i}] 无前置时间戳归属,交由下游去重: {buf.strip()[:60]!r}") elif self._is_timestamp_old(last_ts, 300, skip_future_check=_inherited_past): - # ⚠ 修复漏消息:buf 无独立时间戳(时间戳已被 re.split 分离到 part), - # 此处 last_ts 是"继承"的旧时间戳,无法据此判断 buf 年龄。 - # QQ/微信最新消息常无独立时间戳,仅因继承时间戳较老就丢弃会漏消息。 - # 不再按年龄丢弃,交由下游 seen 去重;仅当继承时间戳极老(>2h)且 - # 内容无新鲜特征时视为"翻看远古历史"丢弃,避免翻看旧记录被误回复。 - if self._is_timestamp_old(last_ts, 7200, skip_future_check=_inherited_past) and not _fresh: - # ⭐ 提问豁免:消息本身是明确提问(以?/?结尾、含中文、非md/噪声), - # 即使继承的时间戳极老(来自截图内其他内容如历史/文档时间戳), - # 也应放行交由下游去重,避免把用户刚发的提问误判为远古历史永久丢弃。 - # ⚠ 限制:继承时间戳>24h的老消息不再豁免(截图内远古历史不应触发回复) - if self._is_question_message(buf.strip()) and not self._is_timestamp_old(last_ts, 86400, skip_future_check=_inherited_past): - logger.debug(f"[ocr] stage[{i}] 继承时间戳{last_ts}极老但消息为明确提问,放行交由下游去重: {buf.strip()[:60]!r}") - else: - logger.debug(f"[ocr] stage[{i}] 继承时间戳{last_ts}极老(>2h)且非新鲜内容,视为远古历史跳过: {buf.strip()[:60]!r}") - buf = '' - last_ts = part - continue - # ⭐ Task #1440: 继承时间戳超过5分钟(300s)的消息直接丢弃 - # 修复:12:09的消息能通过13:49的过滤器(差100分钟) - # 根因:无独立时间戳的消息被放行交由下游去重,但下游去重不够严格 - logger.debug(f"[ocr] stage[{i}] 继承时间戳{last_ts}较老(>5min)且消息无独立时间戳,丢弃: {buf.strip()[:60]!r}") + # ⭐ 严格300s报废:继承时间戳>5min一律丢弃,无新鲜特征/提问豁免 + # 原逻辑允许新鲜内容/提问绕过,导致16:07消息在21:08仍被处理 + logger.info(f"[ocr] stage[{i}] [age] DROP 继承时间戳{last_ts}较老(>300s),严格报废: {buf.strip()[:60]!r}") buf = '' + last_ts = part + continue + # —— 过滤器层(始终运行,绝不静默吞噬;每层放行/打回都记录)—— if self._is_markdown_document(buf.strip()): - logger.debug(f"[ocr] stage[{i}] md文档直接跳过: {buf.strip()[:80]!r}") + logger.info(f"[ocr] stage[{i}] [filter3/md] DROP md文档跳过: {buf.strip()[:80]!r}") elif self._is_noise_text(buf.strip()): - logger.debug(f"[ocr] stage[{i}] 噪声文本跳过: {buf.strip()[:80]!r}") + logger.info(f"[ocr] stage[{i}] [filter2/噪声] DROP 噪声文本跳过: {buf.strip()[:80]!r}") else: msg = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', buf.strip()) if msg: msg = re.sub(r'\s+#[\u4e00-\u9fff][^\s#\n]{0,30}$', '', msg).strip() if msg: if self._is_reply_fragment(msg): - logger.debug(f"[ocr] stage[{i}] 回复残片跳过: {msg[:60]!r}") + logger.info(f"[ocr] stage[{i}] [filter4/回复残片] DROP 回复残片跳过: {msg[:60]!r}") else: - logger.debug(f"[ocr] stage[{i}] 消息通过所有过滤器,添加到expanded: {msg[:60]!r}") + logger.info(f"[ocr] stage[{i}] [filter/放行] 通过所有过滤器,添加到expanded: {msg[:60]!r}") expanded.append(msg) buf = '' last_ts = part @@ -2937,49 +3008,41 @@ class MessageMonitor: buf += part if buf.strip(): # 尾部检查:尾部消息位于最后一个可见时间戳之后,无独立时间戳。 - # ⚠ 修复漏消息:聊天软件最新消息常无独立时间戳、排在最后时间戳之后, - # 仅因继承的最后时间戳较老就丢弃会连坐丢失用户刚发的新消息。 - # 不再按年龄丢弃,交由下游 seen/_replied_texts 去重(基线轮吸收历史); - # 仅当继承时间戳极老(>2h)且内容无新鲜特征时视为远古历史丢弃。 + # 关键修复:过滤器层(md/噪声/回复残片)必须【始终运行】,无论继承时间戳新旧, + # 之前错误地嵌在"时间戳较老"分支内,导致新鲜尾部消息(如用户刚发的提问) + # 被静默吞噬、永不进入 expanded。 + # 仅当继承时间戳较老(>5min)且无新鲜特征且非提问豁免时才丢弃(Task #1440 的5分钟窗口), + # 其余一律保留交给过滤器层,绝不静默吞噬。 if not last_ts: # 尾部无任何时间戳(整行无时间戳):交给下游去重,不整段丢弃 - logger.debug(f"[ocr] stage[{i}] 尾部无时间戳归属,交由下游去重: {buf.strip()[:60]!r}") + logger.debug(f"[ocr] stage[{i}] [age] PASS 尾部无时间戳归属,交由下游去重: {buf.strip()[:60]!r}") else: - _fresh_tail = self._looks_fresh_content(buf.strip()) - if last_ts and self._is_timestamp_old(last_ts, 300, skip_future_check=_inherited_past): - if self._is_timestamp_old(last_ts, 7200, skip_future_check=_inherited_past) and not _fresh_tail: - # ⭐ 提问豁免(同中段逻辑):明确提问即使是尾部、继承极老时间戳也放行 - # ⚠ 限制:继承时间戳>24h的老消息不再豁免 - if self._is_question_message(buf.strip()) and not self._is_timestamp_old(last_ts, 86400, skip_future_check=_inherited_past): - logger.debug(f"[ocr] stage[{i}] 尾部时间戳{last_ts}极老但消息为明确提问,放行交由下游去重: {buf.strip()[:60]!r}") - else: - logger.debug(f"[ocr] stage[{i}] 尾部时间戳{last_ts}极老(>2h)且非新鲜内容,视为远古历史跳过尾部: {buf.strip()[:60]!r}") - buf = '' - else: - # ⭐ Task #1440: 尾部继承时间戳超过5分钟(300s)的消息直接丢弃 - # 修复:与中段逻辑一致,超时一律丢弃 - logger.debug(f"[ocr] stage[{i}] 尾部时间戳{last_ts}较老(>5min)且消息无独立时间戳,丢弃: {buf.strip()[:60]!r}") + # ⭐ 严格300s报废:继承时间戳>5min一律丢弃,无新鲜特征/提问豁免 + if self._is_timestamp_old(last_ts, 300, skip_future_check=_inherited_past): + logger.info(f"[ocr] stage[{i}] [age] DROP 尾部时间戳{last_ts}较老(>300s),严格报废: {buf.strip()[:60]!r}") buf = '' - if buf.strip(): - if self._is_markdown_document(buf.strip()): - logger.debug(f"[ocr] stage[{i}] md文档直接跳过(尾部): {buf.strip()[:80]!r}") - elif self._is_noise_text(buf.strip()): - logger.debug(f"[ocr] stage[{i}] 噪声文本跳过(尾部): {buf.strip()[:80]!r}") - else: - msg = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', buf.strip()) + # else: 时间戳新鲜(<5min),直接保留 + # —— 过滤器层(始终运行,绝不静默吞噬;每层放行/打回都记录)—— + if buf.strip(): + if self._is_markdown_document(buf.strip()): + logger.info(f"[ocr] stage[{i}] [filter3/md] DROP md文档跳过(尾部): {buf.strip()[:80]!r}") + elif self._is_noise_text(buf.strip()): + logger.info(f"[ocr] stage[{i}] [filter2/噪声] DROP 噪声文本跳过(尾部): {buf.strip()[:80]!r}") + else: + msg = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', buf.strip()) + if msg: + msg = re.sub(r'\s+#[\u4e00-\u9fff][^\s#\n]{0,30}$', '', msg).strip() if msg: - msg = re.sub(r'\s+#[\u4e00-\u9fff][^\s#\n]{0,30}$', '', msg).strip() - if msg: - if self._is_reply_fragment(msg): - logger.debug(f"[ocr] stage[{i}] 回复残片跳过: {msg[:60]!r}") - else: - logger.debug(f"[ocr] stage[{i}] 尾部消息通过所有过滤器,添加到expanded: {msg[:60]!r}") - # EasyOCR 常把横向相邻的两条消息(用户消息+群昵称/UI文字) - # 检测为同一行,导致"兑换码导入错误时如何处置?游戏专用小筑憩-草一" - # 这种合并文本。尝试在中文句尾标点处拆分为多条。 - split_msgs = self._split_merged_ocr_text(msg) - for sm in split_msgs: - expanded.append(sm) + if self._is_reply_fragment(msg): + logger.info(f"[ocr] stage[{i}] [filter4/回复残片] DROP 回复残片跳过: {msg[:60]!r}") + else: + logger.info(f"[ocr] stage[{i}] [filter/放行] 尾部消息通过所有过滤器,添加到expanded: {msg[:60]!r}") + # EasyOCR 常把横向相邻的两条消息(用户消息+群昵称/UI文字) + # 检测为同一行,导致"兑换码导入错误时如何处置?游戏专用小筑憩-草一" + # 这种合并文本。尝试在中文句尾标点处拆分为多条。 + split_msgs = self._split_merged_ocr_text(msg) + for sm in split_msgs: + expanded.append(sm) # 更新跨行继承时间戳 if last_ts: _inherited_ts = last_ts @@ -4257,7 +4320,7 @@ class MessageMonitor: pass if not newest_fresh_enough: # ⭐ 硬截止:最新时间戳 >30min → 无论是否含 CJK 均丢弃(同主路径) - _hard_cutoff_s = 1800 + _hard_cutoff_s = 300 # 严格5分钟报废 _newest_age_s = None for ts in timestamps: try: @@ -4267,18 +4330,13 @@ class MessageMonitor: except Exception: pass if _newest_age_s is not None and _newest_age_s > _hard_cutoff_s: - logger.debug(f"[ocr] 过滤器1结果: 过去日期+最新时间戳已过期>{_hard_cutoff_s}s({_newest_age_s:.0f}s),硬截止丢弃: {text[:80]!r}") + logger.debug(f"[ocr] 过滤器1结果: 过去日期+最新时间戳已过期>{_hard_cutoff_s}s({_newest_age_s:.0f}s),严格报废丢弃: {text[:80]!r}") return False - if self._block_has_cjk_message(normalized): - logger.debug(f"[ocr] 过滤器1结果: 过去日期+时间戳全过期但含中文消息内容(潜在新消息),放行交由下游去重: {text[:80]!r}") - return True - logger.debug(f"[ocr] 过滤器1结果: 过去日期+最新时间戳已过期≥600s且无中文消息内容,丢弃: {text[:80]!r}") + # ⭐ 严格300s:有时间戳且全过期→直接丢弃 + logger.debug(f"[ocr] 过滤器1结果: 过去日期+时间戳全过期(>300s),丢弃: {text[:80]!r}") return False - # 最新时间戳在 300-600s 之间,且文本含明确新鲜特征 → 放行 - if self._looks_fresh_content(text): - logger.debug(f"[ocr] 过滤器1结果: 过去日期+时间戳全过期但最新≤600s且含新鲜特征,放行: {text[:80]!r}") - return True - logger.debug(f"[ocr] 过滤器1结果: 过去日期+时间戳全过期且无新鲜特征,丢弃: {text[:80]!r}") + # ⭐ 严格300s:最新时间戳在300-600s之间也视为过期,直接丢弃 + logger.debug(f"[ocr] 过滤器1结果: 过去日期+最新时间戳已过期(>300s),丢弃: {text[:80]!r}") return False # 有新鲜时间戳 → 确定有新消息 for ts in timestamps: @@ -4305,7 +4363,7 @@ class MessageMonitor: if not newest_fresh_enough: # ⭐ 硬截止:最新时间戳 >30min → 无论是否含 CJK 均丢弃 # 修复:15:09 看到 13:34 消息(90min 前)因含 CJK 被放行触发回复 - _hard_cutoff_s = 1800 # 30 分钟 + _hard_cutoff_s = 300 # 严格5分钟报废 # 30 分钟 _newest_age_s = None for ts in timestamps: try: @@ -4315,21 +4373,13 @@ class MessageMonitor: except Exception: pass if _newest_age_s is not None and _newest_age_s > _hard_cutoff_s: - logger.debug(f"[ocr] 过滤器1结果: 最新时间戳已过期>{_hard_cutoff_s}s({_newest_age_s:.0f}s),硬截止丢弃: {text[:80]!r}") + logger.debug(f"[ocr] 过滤器1结果: 最新时间戳已过期>{_hard_cutoff_s}s({_newest_age_s:.0f}s),严格报废丢弃: {text[:80]!r}") return False - if self._block_has_cjk_message(normalized): - logger.debug(f"[ocr] 过滤器1结果: 时间戳全过期但含中文消息内容(潜在新消息),放行交由下游去重: {text[:80]!r}") - return True - logger.debug(f"[ocr] 过滤器1结果: 最新可见时间戳已过期≥600s且无中文消息内容,丢弃: {text[:80]!r}") + # ⭐ 严格300s:有时间戳且全过期→直接丢弃,不再放行CJK内容 + logger.debug(f"[ocr] 过滤器1结果: 时间戳全过期(>300s)且无新鲜时间戳,丢弃: {text[:80]!r}") return False - # 最新时间戳在 300-600s 之间,且文本含明确新鲜特征 → 放行给 stage splitting - if self._looks_fresh_content(text): - logger.debug(f"[ocr] 过滤器1结果: 时间戳全过期但最新≤600s且含新鲜特征,放行: {text[:80]!r}") - return True - # ⭐ 修复:时间戳过期(>600s)时不再无条件放行 - # 原逻辑:只要块内有中文消息内容就放行,导致过期消息(如20:41的消息在20:59时仍被放行) - # 新逻辑:时间戳过期超过600s(10分钟)的消息必须丢弃,除非有明确新鲜特征 - logger.debug(f"[ocr] 过滤器1结果: 时间戳全过期且无新鲜特征,丢弃: {text[:80]!r}") + # ⭐ 严格300s:最新时间戳在300-600s之间也视为过期,直接丢弃 + logger.debug(f"[ocr] 过滤器1结果: 最新时间戳已过期(>300s),丢弃: {text[:80]!r}") return False # 未来时间戳容忍偏差(秒):同日内时间戳比系统时间晚超过此值 = 未到来,直接丢弃。