""" 意图:私聊附件询价。 本文件职责:发卡等材料 → 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 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 "", )