From 299ddc1796d35ff74089f9585a0d5435d9753ca2 Mon Sep 17 00:00:00 2001 From: hmo Date: Mon, 20 Jul 2026 21:42:59 +0800 Subject: [PATCH] fix(ocr+bot): image download race, SenseNova context, log path, encoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root causes of the screenshot 404 incident: 1. RACE: client uploads image AND sends message concurrently; bot received the message before the upload finished writing, so its GET hit a 404 error page (<100B treated as failure). FIX: _download_image now retries 3x with 2s backoff. 2. Zhiwei mentioned tesseract/小果 because the failure text never told her the pipeline IS SenseNova. FIX: failure messages now name SenseNova explicitly and ask for resend. 3. log_xmpp never worked for the bot: sys.path used relative '../..' from a symlinked __file__ which resolved to '/' instead of MoFin root. This is why the '最近对话' panel never had bot chat data (only cron script entries). FIX: absolute path per red line #7. Verified: test message now lands in xmpp_messages.jsonl. 4. My PowerShell -replace corrupted the file encoding (UnicodeDecodeError crash loop on restart). Restored from git HEAD and re-applied edits with the edit tool. Lesson: never use PowerShell string replace on UTF-8 source files with Chinese content. 5. functional_health: new sense_ocr module (OCR config presence + SenseNova API TCP reachability), no token cost. --- deploy/bot/xmpp_agent_core.py | 58 ++++++++++++------- .../functional_health_check.py | 30 ++++++++++ scripts/test_bot_import.py | 14 +++++ scripts/test_msg_log.py | 8 +++ scripts/test_ocr_pipeline_live.py | 17 ++++++ 5 files changed, 107 insertions(+), 20 deletions(-) create mode 100644 scripts/test_bot_import.py create mode 100644 scripts/test_msg_log.py create mode 100644 scripts/test_ocr_pipeline_live.py diff --git a/deploy/bot/xmpp_agent_core.py b/deploy/bot/xmpp_agent_core.py index 0f4ae0f5..00e0137e 100644 --- a/deploy/bot/xmpp_agent_core.py +++ b/deploy/bot/xmpp_agent_core.py @@ -73,21 +73,32 @@ def _load_ocr_config(): return None, None, None -def _download_image(url, timeout=30): - """下载图片字节。失败返回 None。""" +def _download_image(url, timeout=30, retries=3): + """下载图片字节,带重试。失败返回 None。 + + 重试原因:客户端上传和发消息是并发的,bot 收到消息时上传可能还没写完, + 立即 GET 会 404(<100 字节错误页)。等 2 秒再试即可。 + """ import urllib.request import ssl - try: - ctx = ssl.create_default_context() - ctx.check_hostname = False - ctx.verify_mode = ssl.CERT_NONE - req = urllib.request.Request(url, headers={'User-Agent': 'curl/8.5.0'}) - with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: - data = resp.read() - return data if len(data) > 100 else None - except Exception as e: - log.error(f"图片下载失败 {url[:80]}: {e}") - return None + import time as _t + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + for attempt in range(1, retries + 1): + try: + req = urllib.request.Request(url, headers={'User-Agent': 'curl/8.5.0'}) + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + data = resp.read() + if len(data) > 100: + return data + log.warning(f"图片下载第{attempt}次: 响应过小({len(data)}B, 可能上传未完成),重试...") + except Exception as e: + log.warning(f"图片下载第{attempt}次失败: {str(e)[:100]}") + if attempt < retries: + _t.sleep(2) + log.error(f"图片下载最终失败({retries}次尝试): {url}") + return None def _ocr_image(img_data, prompt=None): @@ -132,11 +143,15 @@ def _process_image_message(url): """下载+OCR 一张截图,返回注入 LLM 的上下文文本。""" img = _download_image(url) if not img: - return "[老爸发来一张截图,但下载失败,无法识别]" + return (f"[老爸发来一张截图,bot 下载失败(HTTP错误),无法识别。" + f"图片URL: {url}\n" + f"注:bot 的识图管道是 SenseNova(商汤云OCR),不是小果也不是 tesseract。" + f"下载失败可能是 URL 被截断或 ejabberd 上传过期,请老爸重发一次试试。") ok, text = _ocr_image(img) if not ok: - return f"[老爸发来一张截图,OCR识别失败: {text}]" - return (f"[老爸发来一张截图,OCR识别内容如下]\n{text}\n" + return (f"[老爸发来一张截图,SenseNova OCR 识别失败: {text}。" + f"注:识图管道是 SenseNova,如持续失败请检查 OCR key 配额。") + return (f"[老爸发来一张截图,SenseNova OCR 识别内容如下]\n{text}\n" f"[截图内容结束] 请基于截图内容回应老爸。") # ── 全局队列 ── @@ -149,11 +164,14 @@ RECENT_SENT_MAX = 50 # ── XMPP 消息日志(dashboard 健康Tab「最近对话」数据源)── try: - sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..')) + sys.path.insert(0, '/home/hmo/MoFin') # 绝对路径(红线#7),相对解析会指到 / from xmpp_logger import log_xmpp as _log_xmpp -except Exception: + _LOG_XMPP_OK = True +except Exception as _e: + _LOG_XMPP_OK = False def _log_xmpp(*a, **kw): pass + print(f"[WARN] xmpp_logger import 失败,消息日志降级为 no-op: {_e}", file=sys.stderr) def _rs(p): @@ -289,12 +307,12 @@ class XmppAgent(slixmpp.ClientXMPP): oob_url = '' if oob_url and _is_image_url(oob_url): body = f"[IMAGE] {oob_url}" - log.info(f"📩 收到图片(OOB): from={msg['from']} url={oob_url[:80]}") + log.info(f"📩 收到图片(OOB): from={msg['from']} url={oob_url}") else: return # body 本身是上传 URL(部分客户端把 URL 直接放 body) elif body.startswith('http') and _is_image_url(body): - log.info(f"📩 收到图片(URL body): from={msg['from']} url={body[:80]}") + log.info(f"📩 收到图片(URL body): from={msg['from']} url={body}") body = f"[IMAGE] {body}" log.info(f"📩 收到: from={msg['from']} type={msg['type']} body={body[:60]}") _log_xmpp('in', sender_str := str(msg['from']), f"{AGENT_NAME}@yoin.fun", body) diff --git a/deploy/profile-scripts/functional_health_check.py b/deploy/profile-scripts/functional_health_check.py index e47315ed..ecbb7509 100644 --- a/deploy/profile-scripts/functional_health_check.py +++ b/deploy/profile-scripts/functional_health_check.py @@ -50,6 +50,9 @@ REGISTRY = [ {"module": "gateway_llm", "function": "LLM调用链可用(gateway agent.log)", "check": {"type": "agent_log", "max_age_min": 30, "when": "always"}, "repair": {"action": "llm_diagnose"}}, + {"module": "sense_ocr", "function": "识图服务(SenseNova OCR配置+API可达)", + "check": {"type": "ocr_health", "when": "always"}, + "repair": {"action": "llm_diagnose"}}, {"module": "xmpp_bot", "function": "XMPP消息收发(bot journal)", "check": {"type": "bot_activity", "when": "always"}, "repair": {"action": "llm_diagnose"}}, @@ -127,6 +130,31 @@ def check_bot_activity(chk, now): return "fail", f"检查失败: {e}" +def check_ocr_health(chk, now): + """识图服务健康:OCR 配置存在 + SenseNova API 可达(TCP 443)。 + 不发真实 OCR 请求(省钱),真实端到端验证走 K 测试。""" + import json as _json, socket + # 1. OCR 配置存在且含 key + cfg_path = '/home/hmo/.config/mofin/ocr_config.json' + if not os.path.exists(cfg_path): + return "fail", "OCR 配置文件不存在: /home/hmo/.config/mofin/ocr_config.json" + try: + cfg = _json.load(open(cfg_path)) + if not cfg.get('key') or not cfg.get('base_url'): + return "fail", "OCR 配置缺 key 或 base_url" + except Exception as e: + return "fail", f"OCR 配置解析失败: {str(e)[:60]}" + # 2. SenseNova API TCP 可达 + try: + host = cfg['base_url'].split('//')[1].split('/')[0].split(':')[0] + port = int(cfg['base_url'].split('//')[1].split('/')[0].split(':')[1]) if ':' in cfg['base_url'].split('//')[1].split('/')[0] else 443 + s = socket.create_connection((host, port), timeout=5) + s.close() + except Exception as e: + return "fail", f"SenseNova API 不可达 ({host}): {str(e)[:60]}" + return "ok", f"配置 OK + {host}:{port} 可达" + + def check_cron_engine(chk, now): """cron 引擎:最近 max_age_min 内是否有任何 job 运行过""" try: @@ -180,6 +208,8 @@ def main(): status, reason = check_agent_log(chk, now) elif t == "bot_activity": status, reason = check_bot_activity(chk, now) + elif t == "ocr_health": + status, reason = check_ocr_health(chk, now) elif t == "cron_engine": status, reason = check_cron_engine(chk, now) else: diff --git a/scripts/test_bot_import.py b/scripts/test_bot_import.py new file mode 100644 index 00000000..a04ae1ef --- /dev/null +++ b/scripts/test_bot_import.py @@ -0,0 +1,14 @@ +import sys, os +sys.path.insert(0, '/home/hmo/MoFin/deploy/bot') +sys.path.insert(0, '/home/hmo/MoFin/deploy/bot/../..') +try: + from xmpp_logger import log_xmpp + print('import OK from bot path') + import xmpp_logger + print('xmpp_logger file:', xmpp_logger.__file__) +except Exception as e: + print('import FAIL:', e) + +# also check the file exists +p = '/home/hmo/MoFin/xmpp_logger.py' +print('exists:', os.path.exists(p), 'size:', os.path.getsize(p) if os.path.exists(p) else 0) \ No newline at end of file diff --git a/scripts/test_msg_log.py b/scripts/test_msg_log.py new file mode 100644 index 00000000..baa6a82c --- /dev/null +++ b/scripts/test_msg_log.py @@ -0,0 +1,8 @@ +import json, urllib.request +payload = json.dumps({"to": "hmo@yoin.fun", "body": "[系统] 消息日志链路自检(请忽略此条)", "type": "chat"}).encode() +req = urllib.request.Request("http://127.0.0.1:5805/", data=payload, headers={"Content-Type": "application/json"}) +print(urllib.request.urlopen(req, timeout=5).read().decode()) +import time +time.sleep(2) +last = open('/home/hmo/MoFin/gateway/logs/xmpp_messages.jsonl').readlines()[-1] +print('jsonl last:', last[:200]) \ No newline at end of file diff --git a/scripts/test_ocr_pipeline_live.py b/scripts/test_ocr_pipeline_live.py new file mode 100644 index 00000000..9fd0ad79 --- /dev/null +++ b/scripts/test_ocr_pipeline_live.py @@ -0,0 +1,17 @@ +import sys +sys.argv = ['test', '--agent', 'zhiwei'] +src = open('/home/hmo/MoFin/deploy/bot/xmpp_agent_core.py').read() +cut = src.find('if __name__ ==') +if cut > 0: + src = src[:cut] +ns = {} +exec(compile(src, 'xmpp_agent_core.py', 'exec'), ns) + +url = 'https://upload.yoin.fun/upload/d22cef590582300cc5580c722a19280054bf0d6a/aAeuEbcXa1NNmU2b7c0Q0D75H0anzmGiB86SFxla/9a649bdc-eb7c-44b0-93eb-53ce64c446f7.png' +print('is_image_url:', ns['_is_image_url'](url)) +img = ns['_download_image'](url) +print('download:', len(img) if img else None, 'bytes') +if img: + ok, text = ns['_ocr_image'](img) + print('ocr ok:', ok) + print('ocr text:', text[:400]) \ No newline at end of file