435 lines
15 KiB
Python
435 lines
15 KiB
Python
"""
|
|
意图:私聊图片询价。
|
|
|
|
本文件职责:收图 → 回「正在识别」→ 千问抄可见原文 → 拼口播 → 新开后复用文字询价。
|
|
禁止:千问填最终缺项;另写海运/空运/陆运图片专线;回调线程同步等千问;群图走本 Handler。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
from typing import Any, Callable, Optional
|
|
|
|
from agent.channel.queue import get_message_store
|
|
from agent.channel.wecom.models import InboundMessage
|
|
from agent.policy import inquiry_copy as copy
|
|
from agent.policy.image_wave import get_image_wave_book
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
VisionFn = Callable[..., dict[str, Any]]
|
|
DownloadFn = Callable[[str], tuple[bytes, str]]
|
|
PutFn = Callable[..., dict[str, Any]]
|
|
|
|
|
|
def drop_all_private_bookmarks(sender_id: str) -> None:
|
|
"""三线当前书签都丢掉;已出号仍能按工单号找回。"""
|
|
from agent.policy.air_text_flow import get_air_text_flow
|
|
from agent.policy.land_text_flow import get_land_text_flow
|
|
from agent.policy.sea_text_flow import get_sea_text_flow
|
|
|
|
get_air_text_flow().drop_current_bookmark(sender_id)
|
|
get_sea_text_flow().drop_current_bookmark(sender_id)
|
|
get_land_text_flow().drop_current_bookmark(sender_id)
|
|
|
|
|
|
def is_active_image_wave(sender_id: str) -> bool:
|
|
"""业务结果尚未发出的图片波次。"""
|
|
return get_image_wave_book().active(sender_id) is not None
|
|
|
|
|
|
def append_wave_text(sender_id: str, text: str) -> bool:
|
|
"""认图完成前的字并进本波,不走文字询价。"""
|
|
return get_image_wave_book().append_follow_text(sender_id, text)
|
|
|
|
|
|
def note_private_text(sender_id: str, text: str, *, now: Optional[float] = None) -> None:
|
|
"""文字询价记下最近一句,供 10 秒内发图带走。"""
|
|
get_image_wave_book().note_text(sender_id, text, now=now if now is not None else time.time())
|
|
|
|
|
|
def handle_image_inquiry(
|
|
message: InboundMessage,
|
|
*,
|
|
store=None,
|
|
vision_fn: Optional[VisionFn] = None,
|
|
download_fn: Optional[DownloadFn] = None,
|
|
put_fn: Optional[PutFn] = None,
|
|
process_now: bool = True,
|
|
) -> str:
|
|
"""
|
|
私聊一张图。inbox 线程只开波、回识别中;认图在 Worker 或单测 vision_fn。
|
|
|
|
须已有 sender_id。群消息不要调用本函数。
|
|
"""
|
|
if not message.has_sender():
|
|
logger.warning("image_inquiry 拒绝:无 sender_id")
|
|
return "reject_no_sender"
|
|
if (message.chat_type or "single") == "group":
|
|
logger.info("image_inquiry 忽略群图 chat=%s", message.chat_id)
|
|
return "ignore_group_image"
|
|
|
|
book = get_image_wave_book()
|
|
msg_store = store or get_message_store()
|
|
media_id = str((message.media or {}).get("media_id") or "")
|
|
filename = str((message.media or {}).get("filename") or "image.jpg")
|
|
wave, how = book.open_or_join(message.sender_id, media_id, now=time.time())
|
|
if how in {"opened", "new_after_done"}:
|
|
drop_all_private_bookmarks(message.sender_id)
|
|
msg_store.enqueue_outbound(
|
|
touser=message.sender_id,
|
|
content=copy.image_recognizing(),
|
|
dedupe_key=f"img-ack:{wave.wave_id}",
|
|
)
|
|
book.mark_ack(message.sender_id)
|
|
logger.info("image_inquiry 开波 how=%s wave=%s", how, wave.wave_id)
|
|
else:
|
|
logger.info(
|
|
"image_inquiry 并入 how=%s wave=%s pending=%s",
|
|
how,
|
|
wave.wave_id,
|
|
wave.pending,
|
|
)
|
|
|
|
if process_now and vision_fn is not None:
|
|
_recognize_one(
|
|
sender_id=message.sender_id,
|
|
media_id=media_id,
|
|
filename=filename,
|
|
vision_fn=vision_fn,
|
|
download_fn=download_fn,
|
|
put_fn=put_fn,
|
|
)
|
|
wave = book.active(message.sender_id) or wave
|
|
if wave.pending <= 0:
|
|
return finish_wave(message.sender_id, store=msg_store, seed_message=message)
|
|
return "recognizing"
|
|
|
|
if vision_fn is None:
|
|
try:
|
|
from agent.jobs.recognition import enqueue_recognition
|
|
|
|
enqueue_recognition(
|
|
kind="vision",
|
|
payload={
|
|
"sender_id": message.sender_id,
|
|
"media_id": media_id,
|
|
"filename": filename,
|
|
"message_id": message.message_id,
|
|
"wave_id": wave.wave_id,
|
|
},
|
|
idempotency_key=message.message_id or media_id,
|
|
)
|
|
except Exception:
|
|
logger.exception("image_inquiry 入识别队失败 sender=%s", message.sender_id)
|
|
_recognize_one(
|
|
sender_id=message.sender_id,
|
|
media_id=media_id,
|
|
filename=filename,
|
|
vision_fn=None,
|
|
download_fn=download_fn,
|
|
put_fn=put_fn,
|
|
)
|
|
return finish_wave(message.sender_id, store=msg_store, seed_message=message)
|
|
return "recognizing"
|
|
|
|
return "recognizing"
|
|
|
|
|
|
def flush_active_wave(
|
|
sender_id: str,
|
|
*,
|
|
store=None,
|
|
vision_fn: Optional[VisionFn] = None,
|
|
download_fn: Optional[DownloadFn] = None,
|
|
put_fn: Optional[PutFn] = None,
|
|
seed_message: Optional[InboundMessage] = None,
|
|
) -> str:
|
|
"""单测:两张图都 join 后再认,只出一次业务结果。"""
|
|
book = get_image_wave_book()
|
|
wave = book.active(sender_id)
|
|
if wave is None:
|
|
return "no_wave"
|
|
pending_ids = list(wave.media_ids[-wave.pending :]) if wave.pending else []
|
|
for mid in pending_ids:
|
|
_recognize_one(
|
|
sender_id=sender_id,
|
|
media_id=mid,
|
|
filename="",
|
|
vision_fn=vision_fn,
|
|
download_fn=download_fn,
|
|
put_fn=put_fn,
|
|
)
|
|
return finish_wave(sender_id, store=store, seed_message=seed_message)
|
|
|
|
|
|
def finish_queued_image(payload: dict[str, Any]) -> dict[str, Any]:
|
|
"""
|
|
Worker 识别槽:认完一张,pending 为 0 则出业务结果。
|
|
|
|
payload.__vision / __bytes 仅单测注入,生产不得依赖。
|
|
"""
|
|
sender_id = str(payload.get("sender_id") or "")
|
|
media_id = str(payload.get("media_id") or "")
|
|
filename = str(payload.get("filename") or "image.jpg")
|
|
vision_hook = payload.get("__vision")
|
|
raw = payload.get("__bytes")
|
|
|
|
def _vision(**_kwargs: Any) -> dict[str, Any]:
|
|
if isinstance(vision_hook, dict):
|
|
return dict(vision_hook)
|
|
from agent.config import get_settings
|
|
from agent.llm.mode_vision import invoke_vision
|
|
|
|
settings = get_settings()
|
|
return invoke_vision(
|
|
payload=_kwargs.get("payload") or {},
|
|
allow_network=bool(settings.llm_allow_network and settings.llm_data_usage_confirmed),
|
|
)
|
|
|
|
def _download(mid: str) -> tuple[bytes, str]:
|
|
if isinstance(raw, (bytes, bytearray)):
|
|
return bytes(raw), ""
|
|
from agent.channel.outbox.sender import WeComAppClient
|
|
from agent.config import get_settings
|
|
|
|
settings = get_settings()
|
|
client = WeComAppClient(
|
|
corp_id=settings.wecom_corp_id,
|
|
secret=settings.wecom_secret,
|
|
agent_id=int(settings.wecom_agent_id or "0") or 0,
|
|
)
|
|
return client.download_media(mid)
|
|
|
|
_recognize_one(
|
|
sender_id=sender_id,
|
|
media_id=media_id,
|
|
filename=filename,
|
|
vision_fn=_vision,
|
|
download_fn=_download,
|
|
put_fn=None,
|
|
)
|
|
book = get_image_wave_book()
|
|
wave = book.active(sender_id)
|
|
if wave is None:
|
|
logger.warning(
|
|
"image_inquiry Worker 认完但无活跃波次 sender=%s media=%s(多半 HTTP/Worker 账不一致)",
|
|
sender_id,
|
|
media_id,
|
|
)
|
|
return {"ok": False, "phase": "no_active_wave", "pending": 0}
|
|
if wave.pending > 0:
|
|
return {"ok": True, "phase": "recognizing", "pending": wave.pending}
|
|
phase = finish_wave(sender_id, seed_message=InboundMessage(
|
|
sender_id=sender_id,
|
|
message_id=str(payload.get("message_id") or media_id),
|
|
content="",
|
|
msg_type="image",
|
|
media={"media_id": media_id, "filename": filename},
|
|
))
|
|
return {"ok": True, "phase": phase}
|
|
|
|
|
|
def _recognize_one(
|
|
*,
|
|
sender_id: str,
|
|
media_id: str,
|
|
filename: str,
|
|
vision_fn: Optional[VisionFn],
|
|
download_fn: Optional[DownloadFn],
|
|
put_fn: Optional[PutFn],
|
|
) -> None:
|
|
"""下载、可选落对象存储、读图,写入波次。"""
|
|
import base64
|
|
|
|
from agent.llm.mode_vision import sniff_image_mime
|
|
|
|
book = get_image_wave_book()
|
|
err = ""
|
|
raw = b""
|
|
if download_fn is None:
|
|
raw, err = b"", "no_download"
|
|
else:
|
|
raw, err = download_fn(media_id)
|
|
if not raw:
|
|
logger.info(
|
|
"image_inquiry 无字节 sender=%s err=%s",
|
|
sender_id,
|
|
err or "media_empty",
|
|
)
|
|
book.add_vision(
|
|
sender_id,
|
|
"",
|
|
object_key="",
|
|
filename=filename,
|
|
error=err or "media_empty",
|
|
)
|
|
return
|
|
mime = sniff_image_mime(raw) or "image/jpeg"
|
|
object_key = ""
|
|
if put_fn is not None:
|
|
key = f"inquiry-image/{sender_id}/{media_id or filename}"
|
|
try:
|
|
stored = put_fn(key=key, data=raw, content_type=mime)
|
|
if stored.get("ok"):
|
|
object_key = str(stored.get("key") or key)
|
|
except Exception:
|
|
logger.exception("原图落存储失败 sender=%s", sender_id)
|
|
else:
|
|
object_key = f"inquiry-image/{sender_id}/{media_id or filename}"
|
|
try:
|
|
from agent.config import get_settings
|
|
from agent.storage.s3_client import S3ObjectStorage
|
|
|
|
S3ObjectStorage.from_settings(get_settings(), allow_network=False).put_bytes(
|
|
key=object_key, data=raw, content_type=mime
|
|
)
|
|
except Exception:
|
|
logger.info("原图 dry_run 存储跳过 sender=%s", sender_id)
|
|
|
|
vision: dict[str, Any] = {"ok": False, "text": "", "empty": True, "error": err or "no_vision"}
|
|
if vision_fn is not None:
|
|
b64 = base64.b64encode(raw).decode("ascii")
|
|
vision = vision_fn(payload={"image_b64": b64, "mime": mime, "media_id": media_id}) or vision
|
|
else:
|
|
from agent.config import get_settings
|
|
from agent.llm.mode_vision import invoke_vision
|
|
|
|
settings = get_settings()
|
|
vision = invoke_vision(
|
|
payload={"image_b64": base64.b64encode(raw).decode("ascii"), "mime": mime},
|
|
allow_network=bool(settings.llm_allow_network and settings.llm_data_usage_confirmed),
|
|
)
|
|
text = str(vision.get("text") or "")
|
|
if not vision.get("ok"):
|
|
err = str(vision.get("error") or err or "vision_failed")
|
|
logger.info(
|
|
"image_inquiry 千问 bytes=%s mime=%s ok=%s empty=%s text_len=%s err=%s",
|
|
len(raw),
|
|
mime,
|
|
bool(vision.get("ok")),
|
|
bool(vision.get("empty")),
|
|
len(text.strip()),
|
|
err or "-",
|
|
)
|
|
book.add_vision(
|
|
sender_id,
|
|
text,
|
|
object_key=object_key,
|
|
filename=filename,
|
|
error=err,
|
|
)
|
|
|
|
|
|
def finish_wave(
|
|
sender_id: str,
|
|
*,
|
|
store=None,
|
|
seed_message: Optional[InboundMessage] = None,
|
|
) -> str:
|
|
"""
|
|
本波图都认完:拼口播、当文字询价、必要时把说明和补问合成一条。
|
|
"""
|
|
from agent.handlers.text_inquiry import handle_text_inquiry
|
|
from agent.policy.air_text_flow import get_air_text_flow
|
|
from agent.policy.land_text_flow import get_land_text_flow
|
|
from agent.policy.sea_text_flow import get_sea_text_flow
|
|
|
|
book = get_image_wave_book()
|
|
wave = book.active(sender_id)
|
|
oral = book.compose_oral(sender_id)
|
|
reason = ""
|
|
if not oral.strip():
|
|
reason = copy.image_tech_fail() if (wave and wave.errors) else copy.IMAGE_UNREADABLE
|
|
|
|
msg_store = store or get_message_store()
|
|
prefix_used = {"v": False}
|
|
|
|
class _StoreProxy:
|
|
def enqueue_outbound(self, **kwargs):
|
|
body = str(kwargs.get("content") or "")
|
|
extra = dict(kwargs.get("payload") or {})
|
|
if reason and not prefix_used["v"] and body:
|
|
body = copy.image_fail_then_clarify(reason, body)
|
|
kwargs["content"] = body
|
|
prefix_used["v"] = True
|
|
return msg_store.enqueue_outbound(
|
|
touser=kwargs.get("touser") or sender_id,
|
|
content=body,
|
|
dedupe_key=str(kwargs.get("dedupe_key") or ""),
|
|
payload=extra,
|
|
)
|
|
|
|
inbound = InboundMessage(
|
|
sender_id=sender_id,
|
|
message_id=(seed_message.message_id if seed_message else f"img:{sender_id}:{int(time.time())}"),
|
|
content=oral,
|
|
msg_type="text",
|
|
media=dict(seed_message.media) if seed_message else {},
|
|
raw=dict(seed_message.raw) if seed_message else {},
|
|
)
|
|
phase = handle_text_inquiry(inbound, store=_StoreProxy())
|
|
if reason and not prefix_used["v"]:
|
|
msg_store.enqueue_outbound(
|
|
touser=sender_id,
|
|
content=copy.image_fail_then_clarify(reason, copy.ask_transport()),
|
|
dedupe_key=f"img-empty:{inbound.message_id}",
|
|
)
|
|
phase = "need_mode"
|
|
book.mark_business_done(sender_id)
|
|
book.forget_text(sender_id)
|
|
sess = (
|
|
get_sea_text_flow().session_of(sender_id)
|
|
or get_air_text_flow().session_of(sender_id)
|
|
or get_land_text_flow().session_of(sender_id)
|
|
)
|
|
wo = (sess.work_order_no if sess else "") or ""
|
|
if (
|
|
not wo
|
|
and sess
|
|
and wave
|
|
and (sess.business_line or "").upper() == "SEA"
|
|
and wave.object_keys
|
|
):
|
|
# 海运先出核对卡,工单要等确定。图片键先留在书签上。
|
|
files = []
|
|
for i, key in enumerate(wave.object_keys):
|
|
name = wave.filenames[i] if i < len(wave.filenames) else "image.jpg"
|
|
if not key:
|
|
continue
|
|
files.append(
|
|
{
|
|
"file_name": name or "image.jpg",
|
|
"object_key": key,
|
|
"content_type": "image/jpeg",
|
|
}
|
|
)
|
|
for engine in (get_sea_text_flow(), get_air_text_flow(), get_land_text_flow()):
|
|
hit = engine.session_of(sender_id)
|
|
if hit is sess:
|
|
engine.remember_pending_files(sender_id, files)
|
|
break
|
|
if wo and wave:
|
|
ledger = None
|
|
for engine in (get_sea_text_flow(), get_air_text_flow(), get_land_text_flow()):
|
|
hit = engine.session_of(sender_id)
|
|
if hit and hit.work_order_no == wo:
|
|
ledger = engine.ledger
|
|
break
|
|
if ledger is not None:
|
|
for i, key in enumerate(wave.object_keys):
|
|
name = wave.filenames[i] if i < len(wave.filenames) else "image.jpg"
|
|
try:
|
|
ledger.attach_file(
|
|
work_order_no=wo,
|
|
file_name=name or "image.jpg",
|
|
object_key=key,
|
|
content_type="image/jpeg",
|
|
sender_id=sender_id,
|
|
)
|
|
except Exception:
|
|
logger.exception("挂图片附件失败 wo=%s", wo)
|
|
logger.info("image_inquiry 波次完成 phase=%s oral_len=%s", phase, len(oral))
|
|
return phase
|