fix: #1448 相关性短查询放行 + #1449 严格300s报废

#1448 auto_reply: _has_content_relevance() 不再对≤2token短查询无条件放行
- 短查询必须整个query作为子串出现在文档中才放行
- 修复下载→前置依赖误匹配(无任何共同token/token交集)

#1449 monitor: 严格300s报废策略
- stage splitting: 继承时间戳>300s一律丢弃,移除新鲜内容/提问豁免
- 区块级: 硬截止从1800s→300s,移除CJK内容/新鲜特征bypass
- 修复16:07消息在21:08仍被处理的问题
This commit is contained in:
youpaishidifu
2026-08-07 22:42:14 +08:00
parent 5c4a17043a
commit 7fc737d80f
2 changed files with 165 additions and 105 deletions
+12 -2
View File
@@ -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):
+136 -86
View File
@@ -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}")
# ⭐ 严格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
# ⭐ Task #1440: 继承时间戳超过5分钟(300s)的消息直接丢弃
# 修复:12:09的消息能通过13:49的过滤器(差100分钟)
# 根因:无独立时间戳的消息被放行交由下游去重,但下游去重不够严格
logger.debug(f"[ocr] stage[{i}] 继承时间戳{last_ts}较老(>5min)且消息无独立时间戳,丢弃: {buf.strip()[:60]!r}")
buf = ''
# —— 过滤器层(始终运行,绝不静默吞噬;每层放行/打回都记录)——
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,43 +3008,35 @@ 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 = ''
# else: 时间戳新鲜(<5min),直接保留
# —— 过滤器层(始终运行,绝不静默吞噬;每层放行/打回都记录)——
if buf.strip():
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}")
# EasyOCR 常把横向相邻的两条消息(用户消息+群昵称/UI文字)
# 检测为同一行,导致"兑换码导入错误时如何处置?游戏专用小筑憩-草一"
# 这种合并文本。尝试在中文句尾标点处拆分为多条。
@@ -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
# 未来时间戳容忍偏差(秒):同日内时间戳比系统时间晚超过此值 = 未到来,直接丢弃。