Files
inquiry_robot/inquiry-agent/agent/handlers/attachment_inquiry.py
T

677 lines
23 KiB
Python

"""
意图:私聊附件询价。
本文件职责:发卡等材料 → H5 收 Excel/PDF、企微收图 → 抽原文/认图 → 拼口播 → 新开后复用文字询价。
禁止:千问填最终缺项;另写海运/空运/陆运附件专线;回调/H5 线程同步等千问;群附件走本 Handler。
"""
from __future__ import annotations
import logging
import re
import threading
import time
from typing import Any, Callable, Optional
from agent.channel.clarification import issue_token, validate_token
from agent.channel.h5.copy_store import build_upload_url
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.attachment_extract import allowed_attachment, extract_attachment_text
from agent.policy.attachment_wave import WAIT_TTL_SECONDS, get_attach_wave_book
from agent.policy.image_wave import get_image_wave_book
logger = logging.getLogger(__name__)
ExtractFn = Callable[[str, bytes], dict[str, Any]]
AttachTextFn = Callable[..., dict[str, Any]]
VisionFn = Callable[..., dict[str, Any]]
DownloadFn = Callable[[str], tuple[bytes, str]]
PutFn = Callable[..., dict[str, Any]]
_STASH: dict[str, bytes] = {}
_STASH_LOCK = threading.Lock()
_PRECEDE_NOISE = (
r"附件询价",
r"上传询价附件",
r"上传附件",
r"帮我传个\s*excel\s*询价",
r"我有个\s*excel\s*要询价",
r"传个\s*(excel|pdf)",
r"传份\s*(excel|pdf)",
)
def extra_precede(text: str) -> str:
"""开口里去掉「要传附件」这类话,剩下的才带进口播。"""
body = (text or "").strip()
if not body:
return ""
cleaned = body
for pat in _PRECEDE_NOISE:
cleaned = re.sub(pat, " ", cleaned, flags=re.IGNORECASE)
return " ".join(cleaned.split())
def _stash_put(key: str, data: bytes) -> None:
"""同进程单测/回退:对象存储 dry_run 时仍能把字节交给识别。"""
if not key:
return
with _STASH_LOCK:
_STASH[key] = data or b""
def _stash_get(key: str) -> bytes:
with _STASH_LOCK:
return _STASH.get(key, b"")
def drop_all_private_bookmarks(sender_id: str) -> None:
"""三线当前书签都丢掉;已出号仍能按工单号找回。"""
from agent.handlers.image_inquiry import drop_all_private_bookmarks as _drop
_drop(sender_id)
def is_active_attachment_wait(sender_id: str, *, now: Optional[float] = None) -> bool:
"""业务结果尚未发出、链接也未过期的附件等待票。"""
return get_attach_wave_book().active(sender_id, now=now) is not None
def is_idle_attachment_wait(sender_id: str, *, now: Optional[float] = None) -> bool:
"""已发卡但还没传 Excel/PDF/图。此时销售打字应走文字询价。"""
wave = get_attach_wave_book().active(sender_id, now=now)
return wave is not None and not wave.started
def abandon_idle_attach_wait(sender_id: str) -> bool:
"""
作废未开始认的等待票。已开始认返回 False。
副作用:旧上传卡随后按过期拒绝。
"""
return get_attach_wave_book().abandon_if_idle(sender_id)
def append_attach_text(sender_id: str, text: str) -> bool:
"""认完之前的字并进本票,不走文字询价。"""
return get_attach_wave_book().append_follow_text(sender_id, text)
def handle_attachment_inquiry(
message: InboundMessage,
*,
store=None,
now: Optional[float] = None,
) -> str:
"""
听成要传附件:新开一票、发上传卡。不建单、不等 H5。
须已有 sender_id。群消息不要调用。
"""
if not message.has_sender():
logger.warning("attachment_inquiry 拒绝:无 sender_id")
return "reject_no_sender"
if (message.chat_type or "single") == "group":
logger.info("attachment_inquiry 忽略群 chat=%s", message.chat_id)
return "ignore_group_attachment"
stamp = time.time() if now is None else float(now)
img_book = get_image_wave_book()
if img_book.active(message.sender_id) is not None:
img_book.mark_business_done(message.sender_id)
drop_all_private_bookmarks(message.sender_id)
token = issue_token(
thread_id=f"attach:{message.sender_id}",
wait_version=0,
sender_id=message.sender_id,
allowed_actions=["upload_attachment"],
ttl_seconds=WAIT_TTL_SECONDS,
)
wave = get_attach_wave_book().open_wait(
message.sender_id,
precede_text=extra_precede(message.content or ""),
token=token,
now=stamp,
)
url = build_upload_url(token)
payload = copy.attachment_upload_card(url)
msg_store = store or get_message_store()
msg_store.enqueue_outbound(
touser=message.sender_id,
content="上传询价附件",
dedupe_key=f"attach-card:{wave.wave_id}",
payload=payload,
)
logger.info("attachment_inquiry 发卡 wave=%s sender=%s", wave.wave_id, message.sender_id)
return "wait_upload"
def join_attachment_image(
message: InboundMessage,
*,
store=None,
vision_fn: Optional[VisionFn] = None,
download_fn: Optional[DownloadFn] = None,
put_fn: Optional[PutFn] = None,
process_now: bool = True,
now: Optional[float] = None,
) -> str:
"""等材料期间的私聊图:并入本票,必要时 ack「正在识别图片」。"""
if not message.has_sender():
return "reject_no_sender"
stamp = time.time() if now is None else float(now)
book = get_attach_wave_book()
media_id = str((message.media or {}).get("media_id") or "")
filename = str((message.media or {}).get("filename") or "image.jpg")
wave, how = book.join_image(
message.sender_id, media_id, filename=filename, now=stamp
)
if how != "joined" or wave is None:
return "inactive"
msg_store = store or get_message_store()
index = len(wave.materials) - 1
if not wave.ack_sent:
msg_store.enqueue_outbound(
touser=message.sender_id,
content=copy.IMAGE_RECOGNIZING,
dedupe_key=f"attach-ack:{wave.wave_id}",
)
book.mark_ack(message.sender_id, "image")
if process_now and vision_fn is not None:
_recognize_image(
sender_id=message.sender_id,
index=index,
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 book.pending(wave) <= 0:
return finish_attach_wave(message.sender_id, store=msg_store, seed_message=message)
return "recognizing"
_enqueue_attach(
sender_id=message.sender_id,
index=index,
role="image",
filename=filename,
media_id=media_id,
message_id=message.message_id,
wave_id=wave.wave_id,
)
return "recognizing"
def accept_h5_uploads(
*,
sender_id: str,
files: list[tuple[str, bytes]],
store=None,
extract_fn: Optional[ExtractFn] = None,
attach_text_fn: Optional[AttachTextFn] = None,
put_fn: Optional[PutFn] = None,
process_now: bool = True,
now: Optional[float] = None,
) -> tuple[str, str]:
"""
H5 一次提交:校验后缀、按顺序入材料、开始认。
返回 (phase, hint)。hint 给网页看;phase 给调用方。
"""
sid = (sender_id or "").strip()
stamp = time.time() if now is None else float(now)
book = get_attach_wave_book()
wave = book.active(sid, now=stamp)
if wave is None:
return "expired", copy.ATTACH_H5_EXPIRED
incoming = [(str(n or "").strip(), raw or b"") for n, raw in files if str(n or "").strip()]
if not incoming:
return "need_file", copy.ATTACH_H5_NEED_FILE
for name, _raw in incoming:
if not allowed_attachment(name):
return "reject_type", copy.ATTACH_H5_REJECT_TYPE
start_index = len(wave.materials)
wave = book.add_files(sid, [n for n, _r in incoming], now=stamp)
if wave is None:
return "expired", copy.ATTACH_H5_EXPIRED
msg_store = store or get_message_store()
if not wave.ack_sent:
msg_store.enqueue_outbound(
touser=sid,
content=copy.ATTACH_RECOGNIZING,
dedupe_key=f"attach-ack:{wave.wave_id}",
)
book.mark_ack(sid, "file")
for offset, (name, raw) in enumerate(incoming):
index = start_index + offset
object_key = f"inquiry-attach/{sid}/{wave.wave_id}/{index}/{name}"
_store_bytes(object_key, raw, put_fn=put_fn)
if process_now:
_recognize_file(
sender_id=sid,
index=index,
filename=name,
raw=raw,
object_key=object_key,
extract_fn=extract_fn,
attach_text_fn=attach_text_fn,
)
else:
_enqueue_attach(
sender_id=sid,
index=index,
role="file",
filename=name,
object_key=object_key,
wave_id=wave.wave_id,
)
_stash_put(object_key, raw)
wave = book.active(sid, now=stamp)
if process_now and wave is not None and book.pending(wave) <= 0:
finish_attach_wave(sid, store=msg_store)
return "done", copy.ATTACH_H5_OK
return "recognizing", copy.ATTACH_H5_OK
def finish_queued_attachment(payload: dict[str, Any]) -> dict[str, Any]:
"""Worker 识别槽:认完一份材料,pending 为 0 则出业务结果。"""
sender_id = str(payload.get("sender_id") or "")
index = int(payload.get("index") or 0)
role = str(payload.get("role") or "file")
filename = str(payload.get("filename") or "")
if role == "image":
raw_hook = payload.get("__bytes")
vision_hook = payload.get("__vision")
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_hook, (bytes, bytearray)):
return bytes(raw_hook), ""
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_image(
sender_id=sender_id,
index=index,
media_id=str(payload.get("media_id") or ""),
filename=filename or "image.jpg",
vision_fn=_vision,
download_fn=_download,
put_fn=None,
)
else:
raw = payload.get("__bytes")
object_key = str(payload.get("object_key") or "")
blob = bytes(raw) if isinstance(raw, (bytes, bytearray)) else _load_bytes(object_key)
extract_hook = payload.get("__extract")
text_hook = payload.get("__attach_text")
_recognize_file(
sender_id=sender_id,
index=index,
filename=filename,
raw=blob,
object_key=object_key,
extract_fn=extract_hook if callable(extract_hook) else None,
attach_text_fn=text_hook if callable(text_hook) else None,
)
book = get_attach_wave_book()
wave = book.active(sender_id)
if wave is None:
return {"ok": False, "phase": "no_active_wave", "pending": 0}
if book.pending(wave) > 0:
return {"ok": True, "phase": "recognizing", "pending": book.pending(wave)}
phase = finish_attach_wave(
sender_id,
seed_message=InboundMessage(
sender_id=sender_id,
message_id=str(payload.get("message_id") or f"attach:{payload.get('wave_id') or sender_id}:{index}"),
content="",
msg_type="file",
),
)
return {"ok": True, "phase": phase}
def finish_attach_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_attach_wave_book()
wave = book.active(sender_id)
oral = book.compose_oral(sender_id)
reason = ""
if not oral.strip():
reason = copy.ATTACH_TECH_FAIL if book.has_errors(sender_id) else copy.ATTACH_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 and seed_message.message_id
else f"attach:{(wave.wave_id if wave else 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"attach-empty:{(wave.wave_id if wave else inbound.message_id)}",
)
phase = "need_mode"
book.mark_business_done(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 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 row in wave.materials:
key = row.object_key
if not key:
continue
name = row.filename or ("image.jpg" if row.kind == "image" else "attachment.bin")
ctype = "image/jpeg" if row.kind == "image" else "application/octet-stream"
try:
ledger.attach_file(
work_order_no=wo,
file_name=name,
object_key=key,
content_type=ctype,
sender_id=sender_id,
)
except Exception:
logger.exception("挂附件失败 wo=%s name=%s", wo, name)
logger.info("attachment_inquiry 波次完成 phase=%s oral_len=%s", phase, len(oral))
return phase
def lookup_upload_sender(token: str) -> Optional[str]:
"""
H5 打开/提交:token 有效且对应附件票仍活跃才返回 sender_id。
不消费 token,业务结果发出前可再传。
"""
rec = validate_token(token, action="upload_attachment", consume=False)
if rec is None:
return None
book = get_attach_wave_book()
wave = book.active(rec.sender_id)
if wave is None:
return None
if wave.token and wave.token != token:
return None
return rec.sender_id
def _enqueue_attach(**payload: Any) -> None:
try:
from agent.jobs.recognition import enqueue_recognition
enqueue_recognition(
kind="attachment",
payload=dict(payload),
idempotency_key=str(
payload.get("message_id")
or f"{payload.get('wave_id')}:{payload.get('index')}"
),
)
except ModuleNotFoundError:
logger.info("attachment 识别队跳过(本环境无 jobs 依赖) sender=%s", payload.get("sender_id"))
except Exception:
logger.exception("attachment 入识别队失败 sender=%s", payload.get("sender_id"))
def _blob_put(object_key: str, raw: bytes) -> None:
"""
把附件字节交给 Redis 短交接,供 Worker 识别槽读取。
HTTP 与 Worker 不是同一进程,内存 stash 跨不过去;对象存储 dry_run 也不落盘。
TTL 与等待票一致。失败只打日志,不在请求线程抛给销售。
"""
key = (object_key or "").strip()
if not key:
return
try:
import base64
from agent.redis_coord.keys import ATTACH_BLOB
from agent.redis_coord.runtime import get_redis_runtime
rt = get_redis_runtime()
packed = base64.b64encode(raw or b"").decode("ascii")
rt.client.raw.set(rt.client.key(ATTACH_BLOB, key), packed, ex=WAIT_TTL_SECONDS)
except Exception:
logger.exception("附件字节 Redis 交接失败 key=%s", key)
def _blob_get(object_key: str) -> bytes:
"""Worker 读 HTTP 放下的附件字节。没有则空。"""
key = (object_key or "").strip()
if not key:
return b""
try:
import base64
from agent.redis_coord.keys import ATTACH_BLOB
from agent.redis_coord.runtime import get_redis_runtime
rt = get_redis_runtime()
raw = rt.client.raw.get(rt.client.key(ATTACH_BLOB, key))
if not raw:
return b""
if isinstance(raw, bytes):
raw = raw.decode("ascii")
return base64.b64decode(str(raw))
except Exception:
logger.exception("读附件 Redis 交接失败 key=%s", key)
return b""
def _store_bytes(object_key: str, raw: bytes, *, put_fn: Optional[PutFn]) -> None:
_stash_put(object_key, raw)
_blob_put(object_key, raw)
if put_fn is not None:
try:
put_fn(key=object_key, data=raw, content_type="application/octet-stream")
except Exception:
logger.exception("附件落存储失败 key=%s", object_key)
return
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="application/octet-stream"
)
except Exception:
logger.info("附件对象存储跳过 key=%s", object_key)
def _load_bytes(object_key: str) -> bytes:
hit = _stash_get(object_key)
if hit:
return hit
blob = _blob_get(object_key)
if blob:
return blob
try:
from agent.config import get_settings
from agent.storage.s3_client import S3ObjectStorage
settings = get_settings()
got = S3ObjectStorage.from_settings(
settings,
allow_network=bool(settings.llm_allow_network),
).get_bytes(key=object_key)
data = bytes(got.get("data") or b"")
logger.info("读附件对象存储 key=%s bytes=%s", object_key, len(data))
return data
except Exception:
logger.exception("读附件字节失败 key=%s", object_key)
return b""
def _recognize_file(
*,
sender_id: str,
index: int,
filename: str,
raw: bytes,
object_key: str,
extract_fn: Optional[ExtractFn],
attach_text_fn: Optional[AttachTextFn],
) -> None:
book = get_attach_wave_book()
blob = raw or b""
logger.info(
"attachment 认文件 sender=%s idx=%s name=%s bytes=%s",
sender_id,
index,
filename,
len(blob),
)
if not blob:
book.complete_material(sender_id, index, object_key=object_key, error="empty_bytes")
return
extracted = (extract_fn or extract_attachment_text)(filename, blob)
raw_text = str(extracted.get("text") or "")
err = str(extracted.get("error") or "")
organized = raw_text
org_err = err
if raw_text.strip():
if attach_text_fn is not None:
packed = attach_text_fn(payload={"raw_text": raw_text}) or {}
else:
from agent.config import get_settings
from agent.llm.mode_attachment_text import invoke_attachment_text
settings = get_settings()
packed = invoke_attachment_text(
payload={"raw_text": raw_text},
allow_network=bool(settings.llm_allow_network and settings.llm_data_usage_confirmed),
)
if packed.get("ok") and str(packed.get("text") or "").strip():
organized = str(packed.get("text") or "")
elif not packed.get("ok"):
org_err = str(packed.get("error") or org_err or "attachment_text_failed")
book.complete_material(
sender_id,
index,
text=organized,
object_key=object_key,
error=org_err if not (organized or "").strip() else "",
)
def _recognize_image(
*,
sender_id: str,
index: int,
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_attach_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:
book.complete_material(sender_id, index, error=err or "media_empty")
return
mime = sniff_image_mime(raw) or "image/jpeg"
object_key = f"inquiry-attach/{sender_id}/{media_id or filename}"
_store_bytes(object_key, raw, put_fn=put_fn)
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")
book.complete_material(
sender_id,
index,
text=text,
object_key=object_key,
error=err if not text.strip() else "",
)