Files

167 lines
5.1 KiB
Python

"""
千问视觉:只读图上可见原文。
本文件职责:把图片交给 qwen3-vl-plus,抄出看得见的文字。
属于 llm 包。依赖:LlmHttpClient(生产);单测注入 chat_fn。
禁止:填询价字段 JSON、算最终缺项、发明港口/重量/日期;禁止在回调线程长等(由 Worker 槽调用)。
"""
from __future__ import annotations
import logging
from typing import Any, Optional
from agent.llm.modes import LlmMode
logger = logging.getLogger(__name__)
MODE = LlmMode.VISION
VISION_SYSTEM = (
"只读出图片上能看见的文字和数字,原样抄写,一行不漏。"
"英文、机场三字码、航线(如 SZX-CGK、SZX/CGK)必须保持原样,禁止翻译成中文城市名。"
"看不清的字不要猜。不要补询价缺项,不要发明港口、重量或日期。"
"只有整张图完全没有文字时才输出空;只要看得见字就必须抄出来。"
)
def sniff_image_mime(raw: bytes) -> str:
"""
按文件头认图片类型。企微截图经常是 PNG,不能一律当 JPEG 送给千问。
"""
blob = raw or b""
if len(blob) < 12:
return ""
if blob.startswith(b"\x89PNG\r\n\x1a\n"):
return "image/png"
if blob.startswith(b"\xff\xd8\xff"):
return "image/jpeg"
if blob[:4] == b"RIFF" and blob[8:12] == b"WEBP":
return "image/webp"
if blob.startswith(b"GIF87a") or blob.startswith(b"GIF89a"):
return "image/gif"
if blob[4:8] == b"ftyp":
return "image/heic"
if blob.startswith(b"BM"):
return "image/bmp"
return ""
def mode_meta() -> dict[str, Any]:
return {
"mode": MODE.value,
"provider": "qwen",
"purpose": "vision",
"implemented": True,
}
def _pack_text(text: str, *, stub: bool = False) -> dict[str, Any]:
body = (text or "").strip()
return {
"ok": True,
"stub": stub,
"mode": MODE.value,
"text": body,
"empty": not bool(body),
"error": "",
}
def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]:
"""
无网/单测入口:assistant_text 当作图上可见原文。
没有该键时保持失败码,避免假装已经读图。
"""
if "assistant_text" in (payload or {}):
packed = _pack_text(str(payload.get("assistant_text") or ""), stub=True)
return packed
return {
"ok": False,
"stub": True,
"mode": MODE.value,
"text": "",
"empty": True,
"error": "vision_shell_not_implemented",
"echo_keys": sorted((payload or {}).keys()),
}
def _vision_messages(*, image_b64: str, mime: str, image_url: str) -> list[dict[str, Any]]:
"""拼兼容 OpenAI 的图文消息。优先 data URL,其次 http 图链。"""
url = (image_url or "").strip()
if not url and image_b64:
kind = (mime or "image/jpeg").strip() or "image/jpeg"
url = f"data:{kind};base64,{image_b64}"
user_content: list[dict[str, Any]] = []
if url:
user_content.append({"type": "image_url", "image_url": {"url": url}})
user_content.append({"type": "text", "text": "请只抄写图上可见文字。"})
return [
{"role": "system", "content": VISION_SYSTEM},
{"role": "user", "content": user_content},
]
def invoke_vision(
*,
payload: dict[str, Any],
chat_fn: Optional[Any] = None,
allow_network: bool = False,
) -> dict[str, Any]:
"""
读一张图,返回可见原文。
chat_fn:单测注入,签名同 LlmHttpClient.chat。
allow_network=False 且无 chat_fn:走 invoke_shell,不打真网。
"""
data = dict(payload or {})
image_b64 = str(data.get("image_b64") or "")
image_url = str(data.get("image_url") or "")
mime = str(data.get("mime") or "").strip() or "image/jpeg"
if chat_fn is None and not allow_network:
return invoke_shell(payload=data)
if not image_b64 and not image_url:
logger.info("vision 拒绝:没有图")
return {
"ok": False,
"stub": False,
"mode": MODE.value,
"text": "",
"empty": True,
"error": "no_image",
}
if chat_fn is None:
from agent.llm.http_client import LlmHttpClient
client = LlmHttpClient.from_settings()
chat_fn = client.chat
messages = _vision_messages(
image_b64=image_b64,
mime=mime,
image_url=image_url,
)
result = chat_fn(mode=MODE, messages=messages)
if not getattr(result, "ok", False):
err = str(getattr(result, "error", "") or "vision_failed")
logger.info("vision 失败 err=%s", err)
return {
"ok": False,
"stub": False,
"mode": MODE.value,
"text": "",
"empty": True,
"error": err,
}
packed = _pack_text(str(getattr(result, "content", "") or ""))
packed["stub"] = False
logger.info(
"vision 读完 ok=%s empty=%s text_len=%s mime=%s",
True,
packed.get("empty"),
len(str(packed.get("text") or "")),
mime,
)
return packed