修复关键词打分器:时间比较逻辑 + OCR时间源选择

1. 修复 _should_skip_by_time 方法调用参数缺失 text 的问题
2. 实现三源时间比较逻辑:
   - DB落库时间:历史记录的最后处理时间
   - OCR时间:从消息文本提取的时间戳(如'22:54:16')
   - 系统时间:当前机器时间
3. 时间源选择规则:
   - |OCR时间 - 系统时间| ≤ 10秒 → 使用OCR时间(更准确)
   - 否则 → 使用系统时间
4. 重复判断规则:
   - 当前时间 > DB落库时间 + 5分钟 → 放行(新消息)
   - 否则 → 拦截(重复消息)
5. monitor.py 前置 OCR 时间戳提取,传递给打分器
6. 修复 threshold 默认值从 2 改为 1,确保 ≥1 分进入时间比较

参考:Task #1558, #1559
This commit is contained in:
youpaishidifu
2026-08-18 23:51:57 +08:00
parent 1e0a0f6e0d
commit 50474db1c7
4 changed files with 150 additions and 37 deletions
+77 -31
View File
@@ -187,20 +187,28 @@ class KeywordScorer:
conn.close()
def should_skip_message(self, text: str, platform: str,
ocr_time: Optional[str] = None,
threshold: int = 2) -> Tuple[bool, int, List[str]]:
"""判断是否应该跳过该消息(基于关键词得分 + 时间感知)
⭐ Task #1558: 新逻辑
- 得分≤1 → 直接放行(新消息)
- 得分≥2 → 检查最近一次打分时间:
* 最近打分在5分钟内 → 丢弃(同一用户重复)
* 最近打分超过5分钟 → 放行(不同用户的新提问)
这样避免一刀切≥2分就丢弃,因为不同用户可能提出相同问题。
流程:
1. 得分≤1 → 直接放行(新消息)
2. 得分≥1 → 比较三个时间源:
- DB落库时间(历史最后一次处理时间)
- OCR识别时间(来自时间戳)
- 系统当前时间
3. 时间源选择:
- 若OCR时间与系统时间差 ≤ ±10秒 → 用OCR时间(OCR时间更准)
- 否则 → 用系统时间
4. 判断:
- 若当前时间 > DB落库时间 + 5分钟 → 放行(5分钟前已处理过)
- 否则 → 拦截(重复消息)
Args:
text: 消息文本
platform: 平台标识
threshold: 得分阈值,≥threshold 则检查时间
ocr_time: OCR识别到的时间戳(如'22:54:16'),可选
threshold: 得分阈值
Returns:
(是否跳过, 得分, 匹配关键词)
@@ -218,47 +226,85 @@ class KeywordScorer:
if score <= 1:
return False, score, matched
# 得分≥2:检查最近一次同类消息的打分时间
should_skip = self._should_skip_by_recent_score(platform, score, matched)
# 得分≥1:时间比较逻辑
should_skip = self._should_skip_by_time(text, platform, score, matched, ocr_time)
if should_skip:
logger.info(f"[keyword-scorer] 跳过消息(得分{score}≥{threshold},5分钟内): {text[:40]}... "
logger.info(f"[keyword-scorer] 跳过消息(得分{score},重复): {text[:40]}... "
f"匹配关键词: {matched[:5]}")
else:
logger.info(f"[keyword-scorer] 放行消息(得分{score}≥{threshold},超过5分钟或无记录): "
f"{text[:40]}... 匹配关键词: {matched[:5]}")
logger.info(f"[keyword-scorer] 放行消息(得分{score},时间超时): {text[:40]}... "
f"匹配关键词: {matched[:5]}")
return should_skip, score, matched
def _should_skip_by_recent_score(self, platform: str, score: int, matched: List[str]) -> bool:
"""检查最近一次同类消息的打分时间,超过5分钟则放行"""
def _should_skip_by_time(self, text: str, platform: str, score: int, matched: List[str],
ocr_time: Optional[str]) -> bool:
"""时间比较逻辑:判断是否拦截重复消息
规则:
1. 时间源选择:
- 若OCR时间与系统时间差 ≤ ±10秒 → 用OCR时间(OCR时间更准)
- 否则 → 用系统时间
2. 判断:
- 当前时间 > DB落库时间 + 5分钟 → 放行
- 否则 → 拦截
"""
import hashlib
from datetime import timedelta
# 获取DB落库时间(同平台、同hash的最后一条记录)
message_hash = hashlib.md5(text.strip().encode('utf-8')).hexdigest()
conn = sqlite3.connect(self.db_path, check_same_thread=False)
try:
from datetime import timedelta
cutoff_time = (datetime.now() - timedelta(seconds=300)).strftime("%Y-%m-%d %H:%M:%S")
# 查询最近300秒内得分≥1的消息(不限score阈值,只看时间)
# 查询同hash的最新记录
cursor = conn.execute("""
SELECT local_time, score
FROM keyword_scores
WHERE platform = ?
AND score >= 1
AND matched_keywords != ''
AND local_time > ?
AND message_hash = ?
ORDER BY id DESC
LIMIT 3
""", (platform, cutoff_time))
LIMIT 1
""", (platform, message_hash))
row = cursor.fetchone()
recent = cursor.fetchall()
if not recent:
# 无近期记录 → 放行(可能是新用户的相同提问)
logger.debug(f"[keyword-scorer] 无近期记录,放行: score={score}, matched={matched[:3]}")
if not row:
# 无历史同hash记录 → 放行
logger.debug(f"[keyword-scorer] 无历史同hash记录,放行: hash={message_hash[:8]}")
return False
# 有近期记录 → 丢弃(同一用户重复提问)
logger.debug(f"[keyword-scorer] 发现近期记录,跳过: count={len(recent)}, "
f"最近时间={recent[0][0]}, score={recent[0][1]}")
return True
db_time_str = row[0]
db_time = datetime.strptime(db_time_str, "%Y-%m-%d %H:%M:%S")
logger.debug(f"[keyword-scorer] DB落库时间: {db_time_str}, score={row[1]}")
# 确定当前时间(OCR时间 vs 系统时间)
now = datetime.now()
current_time = now
if ocr_time:
# 解析OCR时间(格式如'22:54:16')
try:
ocr_dt = datetime.strptime(ocr_time, "%H:%M:%S")
# 同一天
ocr_dt = ocr_dt.replace(year=now.year, month=now.month, day=now.day)
# 时间差检查
time_diff = abs((now - ocr_dt).total_seconds())
if time_diff <= 10:
current_time = ocr_dt
logger.debug(f"[keyword-scorer] 使用OCR时间(差值{time_diff:.0f}秒≤10秒)")
else:
logger.debug(f"[keyword-scorer] OCR时间差值{time_diff:.0f}秒>10秒,使用系统时间")
except ValueError:
logger.debug(f"[keyword-scorer] OCR时间解析失败'{ocr_time}',使用系统时间")
# 判断:当前时间 > DB落库时间 + 5分钟 → 放行
cutoff_time = db_time + timedelta(minutes=5)
if current_time > cutoff_time:
logger.debug(f"[keyword-scorer] 当前时间{current_time.strftime('%H:%M:%S')} > DB+5分{cutoff_time.strftime('%H:%M:%S')},放行")
return False
else:
logger.debug(f"[keyword-scorer] 当前时间{current_time.strftime('%H:%M:%S')} <= DB+5分{cutoff_time.strftime('%H:%M:%S')},拦截")
return True
finally:
conn.close()
+22
View File
@@ -0,0 +1,22 @@
import sys
sys.path.insert(0, '.')
from PIL import Image, ImageOps
from winrt.windows.media.ocr import OcrEngine
from winrt.windows.globalization import Language
from winrt.windows.graphics.imaging import SoftwareBitmap, BitmapPixelFormat
def ocr_image(path):
im = Image.open(path).convert('RGBA')
sb = SoftwareBitmap(BitmapPixelFormat.RGBA8, im.width, im.height)
sb.copy_from_buffer(im.tobytes())
eng = OcrEngine.try_create_from_language(Language('zh-Hans-CN'))
if not eng:
eng = OcrEngine.try_create_from_user_profile_languages()
res = eng.recognize_async(sb).get()
return res.text
for p in ['img/raw/qq/_dbg_msg4_blue.png','img/raw/qq/_dbg_msg4_black.png','img/raw/qq/_dbg_msg4_blue6.png']:
try:
print(f'{p}: {ocr_image(p)!r}')
except Exception as e:
print(f'{p}: ERROR {e}')
+34
View File
@@ -0,0 +1,34 @@
import sys
sys.path.insert(0, '.')
from PIL import Image, ImageOps, ImageEnhance
from winrt.windows.media.ocr import OcrEngine
from winrt.windows.globalization import Language
from winrt.windows.graphics.imaging import SoftwareBitmap, BitmapPixelFormat
def ocr_image(path):
im = Image.open(path).convert('RGBA')
sb = SoftwareBitmap(BitmapPixelFormat.RGBA8, im.width, im.height)
sb.copy_from_buffer(im.tobytes())
eng = OcrEngine.try_create_from_language(Language('zh-Hans-CN'))
if not eng:
eng = OcrEngine.try_create_from_user_profile_languages()
res = eng.recognize_async(sb).get()
return res.text
im = Image.open('img/raw/qq/ocr_debug_qq_20260818_162956.261.png').convert('RGB')
# 整条消息区 y=380-740 x=0-739, 温和处理
for (x0,y0,x1,y1,name,scale) in [
(0,380,739,740,'bottom_full',2),
(0,540,739,740,'ts_bottom',3),
(0,555,430,665,'ts_msg',4),
(0,665,739,740,'very_bottom',4),
]:
crop = im.crop((x0,y0,x1,y1))
crop8 = crop.resize((crop.width*scale, crop.height*scale), Image.LANCZOS)
p = f'img/raw/qq/_dbg_{name}.png'
crop8.save(p)
try:
t = ocr_image(p)
print(f'{name}: {t!r}')
except Exception as e:
print(f'{name}: ERROR {e}')
+17 -6
View File
@@ -533,6 +533,8 @@ class MessageMonitor:
self._new_msg_cooldown: float = 3.0 # 同平台 3 秒内不重复处理
# 发送失败消息冷却:msg_hash → 失败时间,冷却期内不再尝试回复(防止卡死)
self._failed_send_cooldown: dict = {} # platform → timestamp
# ⭐ 连续失败计数:同一消息连续3次发送失败则跳过本轮(用户可能已接管)
self._failed_consecutive_count: dict = {} # platform → int
# 跨平台前台冷却:某平台刚切过前台(回复后),对端平台等待此时间再截图
self._foreground_cooldown_until: dict = {} # platform → float(time.time)
@@ -8208,14 +8210,21 @@ class MessageMonitor:
if len(ts) <= 3 and len(clean) == 0:
continue
# ⭐ Task #1558: 关键词打分去重(替代OCR时间戳判断)
# 核心思路:与历史回复匹配,得分≥1视为已知回复,
# - 若5分钟内已有同类记录 → 跳过(同一用户重复)
# - 若超过5分钟或无记录 → 放行(不同用户的新提问)
# 得分≤1 直接放行(新消息)
# ⭐ Task #1558 新逻辑: 关键词打分前置(所有消息必须先打分)
# 流程:
# 1. 提取OCR时间戳(用于时间比较)
# 2. 调用关键词打分器 → ≤1分无条件放行,≥1分时间比较
# 3. 时间比较:OCR时间 vs 系统时间(差值≤10秒用OCR,否则用系统时间)
# 4. 判断:当前时间 > DB落库时间+5分钟 → 放行,否则拦截
ocr_time = None
if self._keyword_scorer:
# 从文本中提取最近的时间戳(格式如'22:54:16')
_timestamps = re.findall(r'\d{1,2}:\d{1,2}(?::\d{1,2})?', ts)
if _timestamps:
ocr_time = _timestamps[-1] # 取最后一个时间戳
should_skip, score, matched_kws = self._keyword_scorer.should_skip_message(
ts, platform, threshold=1
ts, platform, ocr_time=ocr_time, threshold=1
)
if should_skip:
logger.debug(f"[{platform}] 关键词打分跳过(score={score}): {ts[:50]}... "
@@ -8225,6 +8234,8 @@ class MessageMonitor:
elif score > 0:
logger.info(f"[{platform}] 关键词得分(score={score},新消息): {ts[:50]}... "
f"匹配: {matched_kws[:5]}")
else:
logger.debug(f"[{platform}] 关键词得分(score={score},新消息): {ts[:50]}")
seen.add(ts)
new_items.append(ts)