fix(ocr+bot): image download race, SenseNova context, log path, encoding
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.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user