修复关键词打分器:时间比较逻辑 + 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:
+77
-31
@@ -187,20 +187,28 @@ class KeywordScorer:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def should_skip_message(self, text: str, platform: str,
|
def should_skip_message(self, text: str, platform: str,
|
||||||
|
ocr_time: Optional[str] = None,
|
||||||
threshold: int = 2) -> Tuple[bool, int, List[str]]:
|
threshold: int = 2) -> Tuple[bool, int, List[str]]:
|
||||||
"""判断是否应该跳过该消息(基于关键词得分 + 时间感知)
|
"""判断是否应该跳过该消息(基于关键词得分 + 时间感知)
|
||||||
|
|
||||||
⭐ Task #1558: 新逻辑
|
流程:
|
||||||
- 得分≤1 → 直接放行(新消息)
|
1. 得分≤1 → 直接放行(新消息)
|
||||||
- 得分≥2 → 检查最近一次打分时间:
|
2. 得分≥1 → 比较三个时间源:
|
||||||
* 最近打分在5分钟内 → 丢弃(同一用户重复)
|
- DB落库时间(历史最后一次处理时间)
|
||||||
* 最近打分超过5分钟 → 放行(不同用户的新提问)
|
- OCR识别时间(来自时间戳)
|
||||||
这样避免一刀切≥2分就丢弃,因为不同用户可能提出相同问题。
|
- 系统当前时间
|
||||||
|
3. 时间源选择:
|
||||||
|
- 若OCR时间与系统时间差 ≤ ±10秒 → 用OCR时间(OCR时间更准)
|
||||||
|
- 否则 → 用系统时间
|
||||||
|
4. 判断:
|
||||||
|
- 若当前时间 > DB落库时间 + 5分钟 → 放行(5分钟前已处理过)
|
||||||
|
- 否则 → 拦截(重复消息)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: 消息文本
|
text: 消息文本
|
||||||
platform: 平台标识
|
platform: 平台标识
|
||||||
threshold: 得分阈值,≥threshold 则检查时间
|
ocr_time: OCR识别到的时间戳(如'22:54:16'),可选
|
||||||
|
threshold: 得分阈值
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(是否跳过, 得分, 匹配关键词)
|
(是否跳过, 得分, 匹配关键词)
|
||||||
@@ -218,47 +226,85 @@ class KeywordScorer:
|
|||||||
if score <= 1:
|
if score <= 1:
|
||||||
return False, score, matched
|
return False, score, matched
|
||||||
|
|
||||||
# 得分≥2:检查最近一次同类消息的打分时间
|
# 得分≥1:时间比较逻辑
|
||||||
should_skip = self._should_skip_by_recent_score(platform, score, matched)
|
should_skip = self._should_skip_by_time(text, platform, score, matched, ocr_time)
|
||||||
|
|
||||||
if should_skip:
|
if should_skip:
|
||||||
logger.info(f"[keyword-scorer] 跳过消息(得分{score}≥{threshold},5分钟内): {text[:40]}... "
|
logger.info(f"[keyword-scorer] 跳过消息(得分{score},重复): {text[:40]}... "
|
||||||
f"匹配关键词: {matched[:5]}")
|
f"匹配关键词: {matched[:5]}")
|
||||||
else:
|
else:
|
||||||
logger.info(f"[keyword-scorer] 放行消息(得分{score}≥{threshold},超过5分钟或无记录): "
|
logger.info(f"[keyword-scorer] 放行消息(得分{score},时间超时): {text[:40]}... "
|
||||||
f"{text[:40]}... 匹配关键词: {matched[:5]}")
|
f"匹配关键词: {matched[:5]}")
|
||||||
|
|
||||||
return should_skip, score, matched
|
return should_skip, score, matched
|
||||||
|
|
||||||
def _should_skip_by_recent_score(self, platform: str, score: int, matched: List[str]) -> bool:
|
def _should_skip_by_time(self, text: str, platform: str, score: int, matched: List[str],
|
||||||
"""检查最近一次同类消息的打分时间,超过5分钟则放行"""
|
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)
|
conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
||||||
try:
|
try:
|
||||||
from datetime import timedelta
|
# 查询同hash的最新记录
|
||||||
cutoff_time = (datetime.now() - timedelta(seconds=300)).strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
|
|
||||||
# 查询最近300秒内得分≥1的消息(不限score阈值,只看时间)
|
|
||||||
cursor = conn.execute("""
|
cursor = conn.execute("""
|
||||||
SELECT local_time, score
|
SELECT local_time, score
|
||||||
FROM keyword_scores
|
FROM keyword_scores
|
||||||
WHERE platform = ?
|
WHERE platform = ?
|
||||||
AND score >= 1
|
AND message_hash = ?
|
||||||
AND matched_keywords != ''
|
|
||||||
AND local_time > ?
|
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
LIMIT 3
|
LIMIT 1
|
||||||
""", (platform, cutoff_time))
|
""", (platform, message_hash))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
recent = cursor.fetchall()
|
if not row:
|
||||||
if not recent:
|
# 无历史同hash记录 → 放行
|
||||||
# 无近期记录 → 放行(可能是新用户的相同提问)
|
logger.debug(f"[keyword-scorer] 无历史同hash记录,放行: hash={message_hash[:8]}")
|
||||||
logger.debug(f"[keyword-scorer] 无近期记录,放行: score={score}, matched={matched[:3]}")
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 有近期记录 → 丢弃(同一用户重复提问)
|
db_time_str = row[0]
|
||||||
logger.debug(f"[keyword-scorer] 发现近期记录,跳过: count={len(recent)}, "
|
db_time = datetime.strptime(db_time_str, "%Y-%m-%d %H:%M:%S")
|
||||||
f"最近时间={recent[0][0]}, score={recent[0][1]}")
|
logger.debug(f"[keyword-scorer] DB落库时间: {db_time_str}, score={row[1]}")
|
||||||
return True
|
|
||||||
|
# 确定当前时间(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:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -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}')
|
||||||
@@ -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
@@ -533,6 +533,8 @@ class MessageMonitor:
|
|||||||
self._new_msg_cooldown: float = 3.0 # 同平台 3 秒内不重复处理
|
self._new_msg_cooldown: float = 3.0 # 同平台 3 秒内不重复处理
|
||||||
# 发送失败消息冷却:msg_hash → 失败时间,冷却期内不再尝试回复(防止卡死)
|
# 发送失败消息冷却:msg_hash → 失败时间,冷却期内不再尝试回复(防止卡死)
|
||||||
self._failed_send_cooldown: dict = {} # platform → timestamp
|
self._failed_send_cooldown: dict = {} # platform → timestamp
|
||||||
|
# ⭐ 连续失败计数:同一消息连续3次发送失败则跳过本轮(用户可能已接管)
|
||||||
|
self._failed_consecutive_count: dict = {} # platform → int
|
||||||
|
|
||||||
# 跨平台前台冷却:某平台刚切过前台(回复后),对端平台等待此时间再截图
|
# 跨平台前台冷却:某平台刚切过前台(回复后),对端平台等待此时间再截图
|
||||||
self._foreground_cooldown_until: dict = {} # platform → float(time.time)
|
self._foreground_cooldown_until: dict = {} # platform → float(time.time)
|
||||||
@@ -8208,14 +8210,21 @@ class MessageMonitor:
|
|||||||
if len(ts) <= 3 and len(clean) == 0:
|
if len(ts) <= 3 and len(clean) == 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# ⭐ Task #1558: 关键词打分去重(替代OCR时间戳判断)
|
# ⭐ Task #1558 新逻辑: 关键词打分前置(所有消息必须先打分)
|
||||||
# 核心思路:与历史回复匹配,得分≥1视为已知回复,
|
# 流程:
|
||||||
# - 若5分钟内已有同类记录 → 跳过(同一用户重复)
|
# 1. 提取OCR时间戳(用于时间比较)
|
||||||
# - 若超过5分钟或无记录 → 放行(不同用户的新提问)
|
# 2. 调用关键词打分器 → ≤1分无条件放行,≥1分时间比较
|
||||||
# 得分≤1 直接放行(新消息)
|
# 3. 时间比较:OCR时间 vs 系统时间(差值≤10秒用OCR,否则用系统时间)
|
||||||
|
# 4. 判断:当前时间 > DB落库时间+5分钟 → 放行,否则拦截
|
||||||
|
ocr_time = None
|
||||||
if self._keyword_scorer:
|
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(
|
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:
|
if should_skip:
|
||||||
logger.debug(f"[{platform}] 关键词打分跳过(score={score}): {ts[:50]}... "
|
logger.debug(f"[{platform}] 关键词打分跳过(score={score}): {ts[:50]}... "
|
||||||
@@ -8225,6 +8234,8 @@ class MessageMonitor:
|
|||||||
elif score > 0:
|
elif score > 0:
|
||||||
logger.info(f"[{platform}] 关键词得分(score={score},新消息): {ts[:50]}... "
|
logger.info(f"[{platform}] 关键词得分(score={score},新消息): {ts[:50]}... "
|
||||||
f"匹配: {matched_kws[:5]}")
|
f"匹配: {matched_kws[:5]}")
|
||||||
|
else:
|
||||||
|
logger.debug(f"[{platform}] 关键词得分(score={score},新消息): {ts[:50]}")
|
||||||
|
|
||||||
seen.add(ts)
|
seen.add(ts)
|
||||||
new_items.append(ts)
|
new_items.append(ts)
|
||||||
|
|||||||
Reference in New Issue
Block a user