diff --git a/inquiry-agent/agent/channel/h5/__init__.py b/inquiry-agent/agent/channel/h5/__init__.py
index d62bf81..e51b512 100644
--- a/inquiry-agent/agent/channel/h5/__init__.py
+++ b/inquiry-agent/agent/channel/h5/__init__.py
@@ -10,14 +10,20 @@ from __future__ import annotations
import logging
from typing import Any, Optional
-from fastapi import APIRouter, HTTPException, Query
+from fastapi import APIRouter, File, HTTPException, Query, UploadFile
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, Field
from agent.channel.clarification import validate_token
from agent.channel.h5.copy_page import expired_copy_page, render_copy_page
from agent.channel.h5.copy_store import lookup_copy_token
+from agent.channel.h5.upload_page import (
+ expired_upload_page,
+ render_upload_message,
+ render_upload_page,
+)
from agent.handlers.card_action import handle_card_action
+from agent.policy import inquiry_copy as copy
logger = logging.getLogger(__name__)
@@ -67,6 +73,45 @@ def h5_jsapi_signature(
raise HTTPException(status_code=503, detail="jsapi_unavailable") from exc
+@router.get("/upload/{token}", response_class=HTMLResponse)
+def h5_upload_open(token: str) -> HTMLResponse:
+ """
+ 打开附件上传页。身份只认 token;过期给 410,不吐内部栈。
+ """
+ from agent.handlers.attachment_inquiry import lookup_upload_sender
+
+ if lookup_upload_sender(token) is None:
+ return HTMLResponse(expired_upload_page(), status_code=410)
+ return HTMLResponse(render_upload_page())
+
+
+@router.post("/upload/{token}", response_class=HTMLResponse)
+async def h5_upload_submit(
+ token: str,
+ files: Optional[list[UploadFile]] = File(default=None),
+) -> HTMLResponse:
+ """
+ 接收多个 Excel/PDF。请求线程只验 token、读字节、入队,不等千问。
+ """
+ from agent.handlers.attachment_inquiry import accept_h5_uploads, lookup_upload_sender
+
+ sender_id = lookup_upload_sender(token)
+ if sender_id is None:
+ return HTMLResponse(expired_upload_page(), status_code=410)
+ incoming: list[tuple[str, bytes]] = []
+ for item in files or []:
+ name = item.filename or ""
+ raw = await item.read()
+ incoming.append((name, raw))
+ phase, hint = accept_h5_uploads(sender_id=sender_id, files=incoming, process_now=False)
+ logger.info("h5.upload phase=%s sender=%s n=%s", phase, sender_id, len(incoming))
+ if phase == "expired":
+ return HTMLResponse(expired_upload_page(), status_code=410)
+ if phase in {"reject_type", "need_file"}:
+ return HTMLResponse(render_upload_message("无法上传", hint), status_code=400)
+ return HTMLResponse(render_upload_message("上传成功", copy.ATTACH_H5_OK))
+
+
@router.get("/{token}")
def h5_open(token: str) -> dict[str, Any]:
"""
diff --git a/inquiry-agent/agent/channel/h5/copy_store.py b/inquiry-agent/agent/channel/h5/copy_store.py
index aee2801..946edda 100644
--- a/inquiry-agent/agent/channel/h5/copy_store.py
+++ b/inquiry-agent/agent/channel/h5/copy_store.py
@@ -46,6 +46,23 @@ def issue_copy_token(work_order_no: str, *, ttl_seconds: int = COPY_TTL_SECONDS)
return token
+def inquiry_form_base() -> str:
+ """H5 公网根路径(含 /inquiry-form),不含 userid。"""
+ return _form_base()
+
+
+def build_upload_url(token: str) -> str:
+ """
+ 拼附件上传页:{inquiry-form}/upload/{token}。
+
+ token 为空则返回空串,调用方不要出跳转按钮。
+ """
+ raw = (token or "").strip()
+ if not raw:
+ return ""
+ return f"{_form_base()}/upload/{raw}"
+
+
def lookup_copy_token(token: str) -> Optional[str]:
"""校验 token 未过期;返回工单号或 None。不消费,可重复打开。"""
raw = (token or "").strip()
diff --git a/inquiry-agent/agent/channel/h5/upload_page.py b/inquiry-agent/agent/channel/h5/upload_page.py
new file mode 100644
index 0000000..cee6508
--- /dev/null
+++ b/inquiry-agent/agent/channel/h5/upload_page.py
@@ -0,0 +1,319 @@
+"""
+附件询价 H5 上传页。
+
+本文件职责:渲染选文件页与过期/成功/拒收提示。URL 不含 userid。
+样式按企微内嵌、手机竖屏:卡片、虚线选区、已选文件列表;不改上传规则。
+"""
+
+from __future__ import annotations
+
+from html import escape
+import json
+
+from agent.policy import inquiry_copy as copy
+
+# 三页共用:浅底、白卡片、企微蓝主按钮。禁止在页面里写 userid。
+_PAGE_CSS = """
+:root {
+ --bg: #eef3f9;
+ --card: #ffffff;
+ --text: #17233d;
+ --muted: #5b6b7c;
+ --line: #c5d4ea;
+ --brand: #1677ff;
+ --brand-press: #0f62d9;
+ --danger: #d83931;
+ --ok: #0f8a5f;
+ --shadow: 0 10px 28px rgba(23, 51, 91, 0.10);
+}
+* { box-sizing: border-box; }
+html, body { margin: 0; min-height: 100%; }
+body {
+ font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ -webkit-font-smoothing: antialiased;
+}
+.wrap {
+ max-width: 480px;
+ margin: 0 auto;
+ padding: 20px 16px calc(24px + env(safe-area-inset-bottom));
+}
+.card {
+ background: var(--card);
+ border-radius: 16px;
+ padding: 22px 20px 24px;
+ box-shadow: var(--shadow);
+}
+.hero {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ margin-bottom: 12px;
+}
+.badge {
+ width: 44px;
+ height: 44px;
+ border-radius: 12px;
+ background: #e8f1ff;
+ color: var(--brand);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+.badge svg { width: 22px; height: 22px; }
+h1 { font-size: 20px; line-height: 1.3; margin: 0 0 4px; font-weight: 600; }
+.lead { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
+.hint { margin: 12px 0 0; color: var(--muted); font-size: 13px; line-height: 1.7; }
+.pills { display: flex; gap: 8px; margin: 14px 0 0; }
+.pill {
+ font-size: 12px;
+ color: #2b5aa8;
+ background: #eef4ff;
+ border-radius: 999px;
+ padding: 4px 10px;
+}
+.drop {
+ position: relative;
+ display: block;
+ margin-top: 18px;
+ padding: 22px 16px;
+ border: 1.5px dashed var(--line);
+ border-radius: 12px;
+ background: #f7faff;
+ text-align: center;
+ cursor: pointer;
+ -webkit-tap-highlight-color: transparent;
+}
+.drop:focus-within { border-color: var(--brand); box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.16); }
+.drop input {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ opacity: 0;
+ width: 100%;
+ height: 100%;
+ cursor: pointer;
+}
+.drop-title { display: block; font-size: 16px; font-weight: 600; }
+.drop-sub { display: block; margin-top: 6px; font-size: 13px; color: var(--muted); }
+.picked { list-style: none; margin: 14px 0 0; padding: 0; }
+.picked li {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 12px;
+ margin-top: 8px;
+ background: #f6f8fb;
+ border-radius: 10px;
+ font-size: 14px;
+}
+.idx {
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ background: #e8f1ff;
+ color: var(--brand);
+ font-size: 12px;
+ font-weight: 700;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+.name { word-break: break-all; }
+button[type="submit"], .btn {
+ width: 100%;
+ min-height: 48px;
+ margin-top: 20px;
+ border: 0;
+ border-radius: 10px;
+ background: var(--brand);
+ color: #fff;
+ font-size: 16px;
+ font-weight: 700;
+ cursor: pointer;
+ touch-action: manipulation;
+}
+button[type="submit"]:active, .btn:active { background: var(--brand-press); }
+button[disabled] { background: #94a3b8; cursor: not-allowed; }
+.err {
+ min-height: 1.4em;
+ margin: 12px 0 0;
+ color: var(--danger);
+ font-size: 14px;
+ line-height: 1.5;
+}
+.rules { margin: 16px 0 0; padding-left: 18px; color: var(--muted); font-size: 13px; line-height: 1.8; }
+.tone-ok .badge { background: #e7f7ef; color: var(--ok); }
+.tone-warn .badge { background: #fdecea; color: var(--danger); }
+.tone-expired .badge { background: #eef1f5; color: var(--muted); }
+.body { margin: 8px 0 0; font-size: 15px; line-height: 1.7; }
+.back { margin: 10px 0 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
+@media (prefers-reduced-motion: reduce) {
+ * { transition: none !important; }
+}
+"""
+
+_ICON_FILE = (
+ ''
+)
+_ICON_OK = (
+ ''
+)
+_ICON_WARN = (
+ ''
+)
+_ICON_EXPIRED = (
+ ''
+)
+
+
+def _shell(*, title: str, body: str, tone: str = "ok") -> str:
+ """成功 / 拒绝 / 过期共用卡片壳,避免三页三种简陋白底。"""
+ icons = {"ok": _ICON_OK, "warn": _ICON_WARN, "expired": _ICON_EXPIRED}
+ icon = icons.get(tone, _ICON_WARN)
+ extra = '
请回企微查看识别结果。
' if tone == "ok" else ""
+ return f"""
+
+
+
+
+ {title}
+
+
+
+
+
+
+"""
+
+
+def render_upload_page() -> str:
+ """上传页:一次可选多个 Excel/PDF;图片请发回企微。"""
+ reject = json.dumps(copy.ATTACH_H5_REJECT_TYPE, ensure_ascii=False)
+ need = json.dumps(copy.ATTACH_H5_NEED_FILE, ensure_ascii=False)
+ return f"""
+
+
+
+
+ 上传询价附件
+
+
+
+
+
+
+
{_ICON_FILE}
+
+
上传询价附件
+
这里只传 Excel、PDF。图片请直接发到当前企微会话。
+
+
+
ExcelPDF
+
一次可选多个文件,顺序按你选中的顺序。提交后请回企微查看识别结果。
+
+
+
+ - 只接受 .xls / .xlsx / .pdf
+ - 图片请发到当前企微会话,不要在本页上传
+
+
+
+
+
+
+"""
+
+
+def render_upload_message(title: str, body: str, *, expired: bool = False) -> str:
+ """成功 / 拒绝 / 过期页。"""
+ tone = "expired" if expired else ("ok" if title == "上传成功" else "warn")
+ return _shell(title=escape(title), body=escape(body), tone=tone)
+
+
+def expired_upload_page() -> str:
+ return render_upload_message("链接已过期", copy.ATTACH_H5_EXPIRED, expired=True)
diff --git a/inquiry-agent/agent/handlers/__init__.py b/inquiry-agent/agent/handlers/__init__.py
index 7e5bd3d..b519f32 100644
--- a/inquiry-agent/agent/handlers/__init__.py
+++ b/inquiry-agent/agent/handlers/__init__.py
@@ -2,15 +2,17 @@
Handler 包:一类意图 / 一类卡片一个模块。
禁止:单文件处理全部卡片;禁止把 Schema 校验复制粘贴进每个 handler(放 schema/policy)。
-当前:文字询价空运/海运/陆运闭环 + 私聊图片询价 + 海运应用群协同 + 空运 BOT 群文字/附件 + echo 联调残留。
+当前:文字询价空运/海运/陆运闭环 + 私聊图片询价 + 私聊附件询价 + 海运应用群协同 + 空运 BOT 群文字/附件 + echo 联调残留。
"""
from agent.handlers.echo_text import handle_echo_text
from agent.handlers.image_inquiry import handle_image_inquiry
+from agent.handlers.attachment_inquiry import handle_attachment_inquiry
from agent.handlers.text_inquiry import handle_text_inquiry, handle_text_other
__all__ = [
"handle_echo_text",
+ "handle_attachment_inquiry",
"handle_image_inquiry",
"handle_text_inquiry",
"handle_text_other",
diff --git a/inquiry-agent/agent/handlers/attachment_inquiry.py b/inquiry-agent/agent/handlers/attachment_inquiry.py
new file mode 100644
index 0000000..e265fa4
--- /dev/null
+++ b/inquiry-agent/agent/handlers/attachment_inquiry.py
@@ -0,0 +1,661 @@
+"""
+意图:私聊附件询价。
+
+本文件职责:发卡等材料 → 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 "",
+ )
diff --git a/inquiry-agent/agent/jobs/recognition.py b/inquiry-agent/agent/jobs/recognition.py
index 2047daa..bd4a64f 100644
--- a/inquiry-agent/agent/jobs/recognition.py
+++ b/inquiry-agent/agent/jobs/recognition.py
@@ -1,7 +1,7 @@
"""
识别任务壳:入队 Redis Stream,Worker HTTP 槽认领后跑占位处理。
-本文件职责:识别入队;Worker 槽消费。kind=vision 跑私聊图片波次,其它 kind 仍占位。
+本文件职责:识别入队;Worker 槽消费。kind=vision 跑私聊图片波次,kind=attachment 跑附件材料,其它 kind 仍占位。
禁止:回调线程同步等识别完成;禁止本模块写主账六态。
"""
@@ -74,6 +74,10 @@ def process_recognition(data: dict[str, Any]) -> dict[str, Any]:
from agent.handlers.image_inquiry import finish_queued_image
return finish_queued_image(payload)
+ if kind == "attachment":
+ from agent.handlers.attachment_inquiry import finish_queued_attachment
+
+ return finish_queued_attachment(payload)
return process_recognition_stub(data)
diff --git a/inquiry-agent/agent/llm/mode_attachment_text.py b/inquiry-agent/agent/llm/mode_attachment_text.py
index 0ff7e12..47fc5ec 100644
--- a/inquiry-agent/agent/llm/mode_attachment_text.py
+++ b/inquiry-agent/agent/llm/mode_attachment_text.py
@@ -1,30 +1,106 @@
"""
-千问:附件文本 mode 壳。
+千问:整理 Excel/PDF 抽出的原文。
+
+本文件职责:把抽文本结果交给 qwen-plus,只整理看得见的字,不填询价字段。
+属于 llm 包。单测注入 chat_fn。禁止算最终缺项、禁止发明港口/箱型。
"""
from __future__ import annotations
-from typing import Any
+import logging
+from typing import Any, Optional
from agent.llm.modes import LlmMode
+logger = logging.getLogger(__name__)
+
MODE = LlmMode.ATTACHMENT_TEXT
+ATTACH_SYSTEM = (
+ "下面是从 Excel 或 PDF 抽出来的原文。请整理成一段可读的询价口播:"
+ "只保留材料里已经出现的文字和数字,原样保留港口、机场三字码、箱型、重量。"
+ "看不清或没出现的不要猜,不要补询价缺项,不要发明港口、重量或日期。"
+ "材料完全没有询价信息时输出空。"
+)
+
def mode_meta() -> dict[str, Any]:
return {
"mode": MODE.value,
"provider": "qwen",
"purpose": "attachment_text",
- "implemented": False,
+ "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;否则把 raw_text 当整理结果。
+ """
+ data = dict(payload or {})
+ if "assistant_text" in data:
+ return _pack_text(str(data.get("assistant_text") or ""), stub=True)
+ if "raw_text" in data:
+ return _pack_text(str(data.get("raw_text") or ""), stub=True)
return {
"ok": False,
"stub": True,
"mode": MODE.value,
+ "text": "",
+ "empty": True,
"error": "attachment_text_shell_not_implemented",
- "echo_keys": sorted(payload.keys()),
+ "echo_keys": sorted(data.keys()),
}
+
+
+def invoke_attachment_text(
+ *,
+ payload: dict[str, Any],
+ chat_fn: Optional[Any] = None,
+ allow_network: bool = False,
+) -> dict[str, Any]:
+ """
+ 整理一份抽出原文。chat_fn 单测注入;无网走 invoke_shell。
+ """
+ data = dict(payload or {})
+ raw = str(data.get("raw_text") or "")
+ if chat_fn is None and not allow_network:
+ return invoke_shell(payload=data)
+ if not raw.strip():
+ return _pack_text("", stub=chat_fn is None)
+ if chat_fn is None:
+ from agent.llm.http_client import LlmHttpClient
+
+ client = LlmHttpClient.from_settings()
+ chat_fn = client.chat
+ messages = [
+ {"role": "system", "content": ATTACH_SYSTEM},
+ {"role": "user", "content": raw.strip()},
+ ]
+ result = chat_fn(mode=MODE, messages=messages)
+ if not getattr(result, "ok", False):
+ err = str(getattr(result, "error", "") or "attachment_text_failed")
+ logger.info("attachment_text 失败 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
+ return packed
diff --git a/inquiry-agent/agent/policy/attachment_extract.py b/inquiry-agent/agent/policy/attachment_extract.py
new file mode 100644
index 0000000..8e247f4
--- /dev/null
+++ b/inquiry-agent/agent/policy/attachment_extract.py
@@ -0,0 +1,110 @@
+"""
+Excel / PDF 抽出纯文本。
+
+本文件职责:把允许的附件字节变成可见原文,供千问整理、再走文字询价。
+属于 policy 包。禁止在这里猜港口/箱型/缺项;禁止写六态。
+识别槽与 H5 都只调用本模块的纯函数。
+"""
+
+from __future__ import annotations
+
+import io
+import logging
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+_ALLOWED_EXT = {".xls", ".xlsx", ".pdf"}
+_REJECT_HINT = "only_xlsx_xls_pdf"
+
+
+def attachment_ext(filename: str) -> str:
+ """小写后缀,含点。无后缀返回空。"""
+ name = (filename or "").strip().rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
+ if "." not in name:
+ return ""
+ return "." + name.rsplit(".", 1)[-1].lower()
+
+
+def allowed_attachment(filename: str) -> bool:
+ """H5 只收 xls / xlsx / pdf。"""
+ return attachment_ext(filename) in _ALLOWED_EXT
+
+
+def extract_attachment_text(filename: str, raw: bytes) -> dict[str, Any]:
+ """
+ 抽出单元格或 PDF 可见字。
+
+ 返回 ok/text/error/empty。失败时 text 为空,由上层走「没看清 / 暂时没认出」。
+ """
+ ext = attachment_ext(filename)
+ if ext not in _ALLOWED_EXT:
+ return {"ok": False, "text": "", "empty": True, "error": _REJECT_HINT}
+ blob = raw or b""
+ if not blob:
+ return {"ok": False, "text": "", "empty": True, "error": "empty_bytes"}
+ try:
+ if ext == ".pdf":
+ text = _pdf_text(blob)
+ elif ext == ".xlsx":
+ text = _xlsx_text(blob)
+ else:
+ text = _xls_text(blob)
+ except Exception as exc: # noqa: BLE001
+ logger.exception("抽附件文本失败 name=%s", filename)
+ return {"ok": False, "text": "", "empty": True, "error": f"extract_fail:{type(exc).__name__}"}
+ body = (text or "").strip()
+ return {"ok": True, "text": body, "empty": not bool(body), "error": ""}
+
+
+def _xlsx_text(raw: bytes) -> str:
+ from openpyxl import load_workbook
+
+ wb = load_workbook(io.BytesIO(raw), read_only=True, data_only=True)
+ parts: list[str] = []
+ try:
+ for sheet in wb.worksheets:
+ title = str(sheet.title or "").strip()
+ if title:
+ parts.append(f"# {title}")
+ for row in sheet.iter_rows(values_only=True):
+ cells = [str(c).strip() for c in row if c is not None and str(c).strip()]
+ if cells:
+ parts.append("\t".join(cells))
+ finally:
+ wb.close()
+ return "\n".join(parts)
+
+
+def _xls_text(raw: bytes) -> str:
+ try:
+ import xlrd
+ except ImportError:
+ return ""
+ book = xlrd.open_workbook(file_contents=raw)
+ parts: list[str] = []
+ for sheet in book.sheets():
+ title = str(sheet.name or "").strip()
+ if title:
+ parts.append(f"# {title}")
+ for r in range(sheet.nrows):
+ cells = [
+ str(sheet.cell_value(r, c)).strip()
+ for c in range(sheet.ncols)
+ if str(sheet.cell_value(r, c)).strip()
+ ]
+ if cells:
+ parts.append("\t".join(cells))
+ return "\n".join(parts)
+
+
+def _pdf_text(raw: bytes) -> str:
+ from pypdf import PdfReader
+
+ reader = PdfReader(io.BytesIO(raw))
+ parts: list[str] = []
+ for page in reader.pages:
+ piece = page.extract_text() or ""
+ if piece.strip():
+ parts.append(piece.strip())
+ return "\n".join(parts)
diff --git a/inquiry-agent/agent/policy/attachment_wave.py b/inquiry-agent/agent/policy/attachment_wave.py
new file mode 100644
index 0000000..481719d
--- /dev/null
+++ b/inquiry-agent/agent/policy/attachment_wave.py
@@ -0,0 +1,413 @@
+"""
+私聊附件等材料波次账。
+
+本文件职责:记住「这一票」Excel/PDF、企微图和开口/期间的字,供附件询价拼口播。
+属于 policy 包。生产波次落 Redis(HTTP 发卡/收 H5、Worker 识别共用);单测可纯内存。
+禁止:调模型、建单、写六态;禁止一把大锁串全部销售。
+
+未开始认时 30 分钟过期。一旦发图或 H5 提交,只看业务结果出没出。
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import threading
+import uuid
+from dataclasses import asdict, dataclass, field
+from typing import Any, Optional
+
+from agent.policy.image_wave import WaveKV
+
+# 规格:上传链接与未开始认的等待同一时效。
+WAIT_TTL_SECONDS = 30 * 60
+# 已开始认的波次短协调:超时后当新开,不当六态。
+_WAVE_TTL_SEC = 2 * 3600
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class AttachMaterial:
+ """一份按到达/上传顺序记下的材料。text 在识别完成后才填。"""
+
+ kind: str
+ filename: str = ""
+ media_id: str = ""
+ text: str = ""
+ object_key: str = ""
+ error: str = ""
+ done: bool = False
+
+
+@dataclass
+class AttachWave:
+ """
+ 一张新询价对应的附件等材料波次。
+
+ started:已发图或已 H5 提交。pending 用 materials 里未 done 的份数。
+ """
+
+ sender_id: str
+ wave_id: str
+ token: str = ""
+ opened_at: float = 0.0
+ started: bool = False
+ ack_sent: bool = False
+ ack_kind: str = ""
+ business_done: bool = False
+ precede_text: str = ""
+ follow_text: str = ""
+ materials: list[AttachMaterial] = field(default_factory=list)
+
+
+class AttachWaveBook:
+ """
+ 附件波次账。按 sender 一把锁,不同销售互不等。
+
+ 生产必须挂 Redis:发卡在 HTTP、识别在 Worker,两边不能各记一份内存。
+ """
+
+ def __init__(self, kv: Optional[WaveKV] = None) -> None:
+ self._lock = threading.Lock()
+ self._kv = kv
+ self._waves: dict[str, AttachWave] = {}
+
+ def _hydrate(self, sender_id: str) -> None:
+ sid = (sender_id or "").strip()
+ if not sid or self._kv is None:
+ return
+ try:
+ raw = self._kv.get_json("wave", sid)
+ if raw:
+ self._waves[sid] = _wave_from_dict(raw)
+ else:
+ self._waves.pop(sid, None)
+ except Exception:
+ logger.exception("读附件波次失败 sender=%s", sid)
+
+ def _persist(self, sender_id: str) -> None:
+ sid = (sender_id or "").strip()
+ if not sid or self._kv is None:
+ return
+ try:
+ wave = self._waves.get(sid)
+ if wave is None:
+ self._kv.delete("wave", sid)
+ else:
+ self._kv.set_json("wave", sid, _wave_to_dict(wave))
+ except Exception:
+ logger.exception("写附件波次失败 sender=%s", sid)
+
+ def pending(self, wave: Optional[AttachWave]) -> int:
+ """还没认完的材料份数。"""
+ if wave is None:
+ return 0
+ return sum(1 for m in wave.materials if not m.done)
+
+ def open_wait(
+ self,
+ sender_id: str,
+ *,
+ precede_text: str,
+ token: str,
+ now: float,
+ ) -> AttachWave:
+ """
+ 听成附件询价:开等材料票。覆盖上一票附件波次。
+
+ 副作用:写账。不发送企微、不建单。
+ """
+ sid = (sender_id or "").strip()
+ wave = AttachWave(
+ sender_id=sid,
+ wave_id=uuid.uuid4().hex,
+ token=(token or "").strip(),
+ opened_at=float(now),
+ precede_text=(precede_text or "").strip(),
+ )
+ with self._lock:
+ self._waves[sid] = wave
+ self._persist(sid)
+ return wave
+
+ def active(self, sender_id: str, now: Optional[float] = None) -> Optional[AttachWave]:
+ """
+ 未出业务结果的当前票。
+
+ 尚未开始认且超过 30 分钟:视为过期,不当活跃。
+ """
+ sid = (sender_id or "").strip()
+ stamp = float(now) if now is not None else 0.0
+ with self._lock:
+ self._hydrate(sid)
+ wave = self._waves.get(sid)
+ if wave is None or wave.business_done:
+ return None
+ if not wave.started:
+ base = stamp if now is not None else wave.opened_at
+ if now is not None and (base - wave.opened_at) > WAIT_TTL_SECONDS:
+ return None
+ return wave
+
+ def join_image(
+ self,
+ sender_id: str,
+ media_id: str,
+ *,
+ filename: str,
+ now: float,
+ ) -> tuple[Optional[AttachWave], str]:
+ """
+ 等材料期间发来的图并入。无活跃票返回 inactive。
+
+ 副作用:materials 追加、started=True。
+ """
+ sid = (sender_id or "").strip()
+ wave = self.active(sid, now=now)
+ if wave is None:
+ return None, "inactive"
+ with self._lock:
+ self._hydrate(sid)
+ wave = self._waves.get(sid)
+ if wave is None or wave.business_done:
+ return None, "inactive"
+ wave.started = True
+ wave.materials.append(
+ AttachMaterial(
+ kind="image",
+ filename=(filename or "").strip() or "image.jpg",
+ media_id=(media_id or "").strip(),
+ )
+ )
+ self._persist(sid)
+ return wave, "joined"
+
+ def add_files(
+ self,
+ sender_id: str,
+ filenames: list[str],
+ *,
+ now: float,
+ ) -> Optional[AttachWave]:
+ """H5 一次提交的多个文件按选中顺序追加。"""
+ sid = (sender_id or "").strip()
+ names = [str(x).strip() for x in (filenames or []) if str(x).strip()]
+ if not names:
+ return self.active(sid, now=now)
+ with self._lock:
+ self._hydrate(sid)
+ wave = self._waves.get(sid)
+ if wave is None or wave.business_done:
+ return None
+ if not wave.started and (float(now) - wave.opened_at) > WAIT_TTL_SECONDS:
+ return None
+ wave.started = True
+ for name in names:
+ wave.materials.append(AttachMaterial(kind="file", filename=name))
+ self._persist(sid)
+ return wave
+
+ def complete_material(
+ self,
+ sender_id: str,
+ index: int,
+ *,
+ text: str = "",
+ object_key: str = "",
+ error: str = "",
+ ) -> Optional[AttachWave]:
+ """
+ 第 index 份材料认完。按上传顺序填原文,不按完成先后重排。
+
+ 调用:Worker 识别槽。不得在本方法出发业务回复。
+ """
+ sid = (sender_id or "").strip()
+ with self._lock:
+ self._hydrate(sid)
+ wave = self._waves.get(sid)
+ if wave is None:
+ return None
+ if index < 0 or index >= len(wave.materials):
+ logger.warning("附件材料下标越界 sender=%s idx=%s", sid, index)
+ return wave
+ row = wave.materials[index]
+ row.text = (text or "").strip()
+ if object_key:
+ row.object_key = object_key
+ if error:
+ row.error = error
+ row.done = True
+ self._persist(sid)
+ return wave
+
+ def append_follow_text(self, sender_id: str, text: str) -> bool:
+ """业务结果发出前的字并进本票。发出后或无票返回 False。"""
+ body = (text or "").strip()
+ sid = (sender_id or "").strip()
+ if not sid or not body:
+ return False
+ with self._lock:
+ self._hydrate(sid)
+ wave = self._waves.get(sid)
+ if wave is None or wave.business_done:
+ return False
+ if wave.follow_text:
+ wave.follow_text = f"{wave.follow_text}\n{body}"
+ else:
+ wave.follow_text = body
+ self._persist(sid)
+ return True
+
+ def mark_ack(self, sender_id: str, kind: str) -> None:
+ """已经回过「正在识别」,同一次不得再 ack。"""
+ sid = (sender_id or "").strip()
+ with self._lock:
+ self._hydrate(sid)
+ wave = self._waves.get(sid)
+ if wave is not None:
+ wave.ack_sent = True
+ wave.ack_kind = (kind or "").strip()
+ self._persist(sid)
+
+ def compose_oral(self, sender_id: str) -> str:
+ """开口字 + 按材料顺序的原文 + 后到的字。"""
+ sid = (sender_id or "").strip()
+ with self._lock:
+ self._hydrate(sid)
+ wave = self._waves.get(sid)
+ if wave is None:
+ return ""
+ parts: list[str] = []
+ if wave.precede_text.strip():
+ parts.append(wave.precede_text.strip())
+ for row in wave.materials:
+ if row.text.strip():
+ parts.append(row.text.strip())
+ if wave.follow_text.strip():
+ parts.append(wave.follow_text.strip())
+ return "\n".join(parts)
+
+ def mark_business_done(self, sender_id: str) -> None:
+ """业务补问/卡片已发出。之后旧 H5 与并入图都必须拒绝。"""
+ sid = (sender_id or "").strip()
+ with self._lock:
+ self._hydrate(sid)
+ wave = self._waves.get(sid)
+ if wave is not None:
+ wave.business_done = True
+ self._persist(sid)
+
+ def has_errors(self, sender_id: str) -> bool:
+ """本票是否有下载/识别技术失败(用于选没看清还是暂时没认出)。"""
+ sid = (sender_id or "").strip()
+ with self._lock:
+ self._hydrate(sid)
+ wave = self._waves.get(sid)
+ if wave is None:
+ return False
+ return any(bool(m.error) for m in wave.materials)
+
+
+def _wave_to_dict(wave: AttachWave) -> dict[str, Any]:
+ data = asdict(wave)
+ return data
+
+
+def _wave_from_dict(raw: dict[str, Any]) -> AttachWave:
+ materials = []
+ for item in raw.get("materials") or []:
+ if not isinstance(item, dict):
+ continue
+ materials.append(
+ AttachMaterial(
+ kind=str(item.get("kind") or "file"),
+ filename=str(item.get("filename") or ""),
+ media_id=str(item.get("media_id") or ""),
+ text=str(item.get("text") or ""),
+ object_key=str(item.get("object_key") or ""),
+ error=str(item.get("error") or ""),
+ done=bool(item.get("done")),
+ )
+ )
+ return AttachWave(
+ sender_id=str(raw.get("sender_id") or ""),
+ wave_id=str(raw.get("wave_id") or uuid.uuid4().hex),
+ token=str(raw.get("token") or ""),
+ opened_at=float(raw.get("opened_at") or 0.0),
+ started=bool(raw.get("started")),
+ ack_sent=bool(raw.get("ack_sent")),
+ ack_kind=str(raw.get("ack_kind") or ""),
+ business_done=bool(raw.get("business_done")),
+ precede_text=str(raw.get("precede_text") or ""),
+ follow_text=str(raw.get("follow_text") or ""),
+ materials=materials,
+ )
+
+
+class RedisAttachKV:
+ """附件波次落到本前缀 Redis。HTTP 与 Worker 必须读同一把键。"""
+
+ def __init__(self, client: Any) -> None:
+ self._client = client
+
+ def get_json(self, kind: str, sender_id: str) -> Optional[dict[str, Any]]:
+ from agent.redis_coord.keys import ATTACH_WAVE
+
+ _ = kind
+ raw = self._client.raw.get(self._client.key(ATTACH_WAVE, sender_id))
+ if not raw:
+ return None
+ if isinstance(raw, bytes):
+ raw = raw.decode("utf-8")
+ data = json.loads(raw)
+ return dict(data) if isinstance(data, dict) else None
+
+ def set_json(self, kind: str, sender_id: str, data: dict[str, Any]) -> None:
+ from agent.redis_coord.keys import ATTACH_WAVE
+
+ _ = kind
+ self._client.raw.set(
+ self._client.key(ATTACH_WAVE, sender_id),
+ json.dumps(data, ensure_ascii=False),
+ ex=_WAVE_TTL_SEC,
+ )
+
+ def delete(self, kind: str, sender_id: str) -> None:
+ from agent.redis_coord.keys import ATTACH_WAVE
+
+ _ = kind
+ self._client.raw.delete(self._client.key(ATTACH_WAVE, sender_id))
+
+
+def _production_kv() -> Optional[WaveKV]:
+ try:
+ from agent.redis_coord.runtime import get_redis_runtime
+
+ rt = get_redis_runtime()
+ if rt is None or rt.client is None:
+ return None
+ return RedisAttachKV(rt.client)
+ except Exception:
+ logger.exception("附件波次 Redis 不可用,本进程退回内存账")
+ return None
+
+
+_BOOK: Optional[AttachWaveBook] = None
+_BOOK_GUARD = threading.Lock()
+
+
+def get_attach_wave_book() -> AttachWaveBook:
+ """进程内一份句柄;生产内容在 Redis。"""
+ global _BOOK
+ with _BOOK_GUARD:
+ if _BOOK is None:
+ _BOOK = AttachWaveBook(kv=_production_kv())
+ return _BOOK
+
+
+def reset_attach_wave_book_for_test(kv: Optional[WaveKV] = None) -> AttachWaveBook:
+ """单测重置。传入同一 kv 可模拟 HTTP/Worker 两份句柄。"""
+ global _BOOK
+ with _BOOK_GUARD:
+ _BOOK = AttachWaveBook(kv=kv)
+ return _BOOK
diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py
index 615d72b..6e62153 100644
--- a/inquiry-agent/agent/policy/inquiry_copy.py
+++ b/inquiry-agent/agent/policy/inquiry_copy.py
@@ -104,6 +104,42 @@ ASK_TRANSPORT = "请补充运输方式:海运 / 空运 / 陆运。"
IMAGE_RECOGNIZING = "正在识别图片"
IMAGE_UNREADABLE = "图片没看清询价信息,请换一张更清楚的,或直接打字。"
IMAGE_TECH_FAIL = "图片暂时没认出来,请再发一张或直接打字。"
+# 私聊附件询价:H5 或发图开始认;看不清/失败与补问拼成一条。
+ATTACH_RECOGNIZING = "正在识别附件"
+ATTACH_UNREADABLE = "附件没看清询价信息,请换一份更清楚的 Excel / PDF(或把图片发到会话),或直接打字。"
+ATTACH_TECH_FAIL = "附件暂时没认出来,请再传一次或直接打字。"
+ATTACH_H5_REJECT_TYPE = "只接受 Excel、PDF;图片请发到当前企微会话。"
+ATTACH_H5_NEED_FILE = "请选择要上传的 Excel 或 PDF。"
+ATTACH_H5_EXPIRED = "已过期,请回企微重新说附件询价。"
+ATTACH_H5_OK = "已收到,请回企微查看识别结果。"
+
+
+def attachment_upload_card(url: str) -> dict[str, Any]:
+ """
+ 附件上传入口模板卡(text_notice 跳转 H5)。
+
+ 必须跟现网同一卡型:企微 news_notice 强制要 card_image.url,缺了会 42044,
+ 出站再回退成一句「上传询价附件」,销售侧就看不到卡片。
+ 文案对齐规格:Excel/PDF 走 H5,图片发当前会话。url 必须已含 token、不含 userid。
+ """
+ jump = (url or "").strip()
+ fallback = "https://ai.ytd-scm.com/inquiry-form"
+ action_url = jump or fallback
+ card: dict[str, Any] = {
+ "card_type": "text_notice",
+ "source": {"desc": "附件询价", "desc_color": 0},
+ "main_title": {
+ "title": "上传询价附件",
+ "desc": "附件入口支持 Excel、PDF;图片请直接在企微聊天窗口发送",
+ },
+ "sub_title_text": (
+ "Excel、PDF 请点击下方入口上传;图片请直接在当前企微会话发送。"
+ "字段完整时自动进入建单与查价;缺失字段会在企微中补问"
+ ),
+ "jump_list": [{"type": 1, "title": "上传询价附件", "url": action_url}],
+ "card_action": {"type": 1, "url": action_url},
+ }
+ return {"msgtype": "template_card", "template_card": card}
def image_fail_then_clarify(reason: str, clarify: str) -> str:
diff --git a/inquiry-agent/agent/redis_coord/keys.py b/inquiry-agent/agent/redis_coord/keys.py
index 26a4816..8cd9b25 100644
--- a/inquiry-agent/agent/redis_coord/keys.py
+++ b/inquiry-agent/agent/redis_coord/keys.py
@@ -62,6 +62,10 @@ FLOW_BOOKMARK = "flow_bookmark"
# 私聊图片波次:HTTP 与 Worker 必须共用,不当六态
IMAGE_WAVE = "image_wave"
IMAGE_WAVE_TEXT = "image_wave_text"
+# 私聊附件等材料波次:HTTP 发卡/H5 与 Worker 识别共用,不当六态
+ATTACH_WAVE = "attach_wave"
+# 附件字节短交接:HTTP 收文件、Worker 识别读,TTL 与等待票一致,不当六态
+ATTACH_BLOB = "attach_blob"
# 空运群同一句:BOT 与存档补听去重,不当六态
AIR_INBOUND_MSG = "air_inbound_msg"
AIR_INBOUND_TEXT = "air_inbound_text"
diff --git a/inquiry-agent/agent/routing/dispatch.py b/inquiry-agent/agent/routing/dispatch.py
index a00821c..b37bcec 100644
--- a/inquiry-agent/agent/routing/dispatch.py
+++ b/inquiry-agent/agent/routing/dispatch.py
@@ -15,6 +15,7 @@ from agent.handlers import abnormal, card_action, close_deal, continue_thread, n
from agent.handlers.echo_text import handle_echo_text
from agent.handlers.group_collab import handle_group_collab
from agent.handlers.image_inquiry import handle_image_inquiry
+from agent.handlers.attachment_inquiry import handle_attachment_inquiry
from agent.handlers.text_inquiry import handle_text_inquiry, handle_text_other, is_land_confirm_action
from agent.routing.decision import RouteDecision
@@ -57,6 +58,11 @@ def _wrap_new(message: InboundMessage) -> None:
new_inquiry.handle_new_inquiry(message)
+def _wrap_attachment(message: InboundMessage) -> None:
+ """私聊附件询价:发卡等材料,认完当口播新开,复用文字流程。"""
+ handle_attachment_inquiry(message)
+
+
def _wrap_image(message: InboundMessage) -> None:
"""私聊图片询价:认图后当口播新开,复用文字流程。"""
handle_image_inquiry(message)
@@ -117,7 +123,7 @@ HANDLER_REGISTRY: dict[str, HandlerFn] = {
"echo_text": _wrap_echo,
"ordinary_text_inquiry": _wrap_text_inquiry,
"ordinary_text_other": _wrap_text_other,
- "attachment_inquiry": _wrap_new,
+ "attachment_inquiry": _wrap_attachment,
"image_inquiry": _wrap_image,
"multi_segment_transport": _wrap_new,
"tms_read_only_quote": _wrap_continue,
@@ -211,11 +217,24 @@ def dispatch_inbound(message: InboundMessage, decision: RouteDecision) -> str:
kind = (message.msg_type or "text").lower()
if kind == "image":
+ from agent.handlers.attachment_inquiry import is_active_attachment_wait, join_attachment_image
+
+ if is_active_attachment_wait(message.sender_id):
+ join_attachment_image(message)
+ return "attachment_inquiry"
handle_image_inquiry(message)
return "image_inquiry"
if kind in {"text", ""}:
+ from agent.handlers.attachment_inquiry import append_attach_text, is_active_attachment_wait
from agent.handlers.image_inquiry import append_wave_text, is_active_image_wave
+ if is_active_attachment_wait(message.sender_id) and (message.content or "").strip():
+ if decision.intent == "attachment_inquiry":
+ handle_attachment_inquiry(message)
+ return "attachment_inquiry"
+ append_attach_text(message.sender_id, message.content or "")
+ logger.info("dispatch 附件波次收字 sender=%s", message.sender_id)
+ return "attach_wave_follow_text"
if is_active_image_wave(message.sender_id) and (message.content or "").strip():
append_wave_text(message.sender_id, message.content or "")
logger.info("dispatch 图片波次收字 sender=%s", message.sender_id)
diff --git a/inquiry-agent/requirements.txt b/inquiry-agent/requirements.txt
index 3a48ddd..f799b89 100644
--- a/inquiry-agent/requirements.txt
+++ b/inquiry-agent/requirements.txt
@@ -16,3 +16,7 @@ psycopg-pool>=3.2,<4.0
redis>=5.0,<6.0
# 报价模板填表(按 mappingJson 写 xlsx)
openpyxl>=3.1,<4.0
+# 附件询价:PDF 抽文本;旧版 .xls;H5 multipart 上传
+pypdf>=4.0,<6.0
+xlrd>=1.2,<3.0
+python-multipart>=0.0.9,<1.0
diff --git a/inquiry-agent/tests/test_attachment_inquiry.py b/inquiry-agent/tests/test_attachment_inquiry.py
new file mode 100644
index 0000000..76870b3
--- /dev/null
+++ b/inquiry-agent/tests/test_attachment_inquiry.py
@@ -0,0 +1,339 @@
+"""
+私聊附件询价:发卡、H5/图并入、口播复用文字流程。
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+import unittest
+
+ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
+if ROOT not in sys.path:
+ sys.path.insert(0, ROOT)
+
+os.environ.setdefault("YTD_ENV", "test")
+os.environ.setdefault("REDIS_BACKEND", "memory")
+os.environ.setdefault("CHECKPOINT_BACKEND", "memory")
+os.environ.setdefault("MESSAGE_STORE_BACKEND", "memory")
+os.environ.setdefault("LLM_DATA_USAGE_CONFIRMED", "false")
+os.environ.setdefault("LLM_ALLOW_NETWORK", "false")
+os.environ.setdefault("LEDGER_BACKEND", "memory")
+
+from agent.channel.queue import MemoryMessageStore
+from agent.channel.wecom.models import InboundMessage
+from agent.handlers import attachment_inquiry as attach_mod
+from agent.handlers.attachment_inquiry import (
+ accept_h5_uploads,
+ finish_queued_attachment,
+ handle_attachment_inquiry,
+ join_attachment_image,
+)
+from agent.handlers.text_inquiry import handle_text_inquiry
+from agent.policy import inquiry_copy as copy
+from agent.policy.air_text_flow import reset_air_text_flow_for_test
+from agent.policy.attachment_wave import get_attach_wave_book, reset_attach_wave_book_for_test
+from agent.policy.image_wave import reset_image_wave_book_for_test
+from agent.policy.land_text_flow import reset_land_text_flow_for_test
+from agent.policy.sea_text_flow import get_sea_text_flow, reset_sea_text_flow_for_test
+from agent.routing.decision import RouteDecision
+from agent.routing.dispatch import dispatch_inbound
+from tests.test_sea_text_inquiry import COMPLETE_SEA
+
+SEA_ORAL = """海运
+起运港:上海
+目的港:洛杉矶
+品名:普货
+货量:20吨
+整柜或拼柜:整柜
+箱型箱量:1x40HQ
+"""
+
+
+def _extract(text: str):
+ def _fn(_name: str, _raw: bytes):
+ body = (text or "").strip()
+ return {"ok": True, "text": body, "empty": not bool(body), "error": ""}
+
+ return _fn
+
+
+def _vision(text: str, *, ok: bool = True, error: str = ""):
+ def _fn(**_kwargs):
+ body = (text or "").strip()
+ return {"ok": ok, "text": body, "empty": not bool(body), "error": error}
+
+ return _fn
+
+
+def _img(sender: str, mid: str) -> InboundMessage:
+ return InboundMessage(
+ sender_id=sender,
+ message_id=mid,
+ content="",
+ msg_type="image",
+ media={"media_id": mid, "filename": f"{mid}.jpg"},
+ )
+
+
+class AttachmentInquiryTests(unittest.TestCase):
+ def setUp(self) -> None:
+ reset_sea_text_flow_for_test()
+ reset_land_text_flow_for_test()
+ reset_air_text_flow_for_test()
+ reset_attach_wave_book_for_test()
+ reset_image_wave_book_for_test()
+ self.store = MemoryMessageStore()
+
+ def _bodies(self) -> list[str]:
+ return [i.content for i in self.store._outbox.values()]
+
+ def _extras(self) -> list[dict]:
+ return [i.payload for i in self.store._outbox.values()]
+
+ def test_intent_sends_upload_card_not_ticket(self) -> None:
+ phase = handle_attachment_inquiry(
+ InboundMessage(sender_id="s1", message_id="m1", content="帮我传个Excel询价,海运上海到纽约"),
+ store=self.store,
+ )
+ self.assertEqual(phase, "wait_upload")
+ self.assertIsNone(get_sea_text_flow().session_of("s1"))
+ extras = self._extras()
+ self.assertTrue(extras)
+ card = extras[0].get("template_card") or {}
+ self.assertEqual(card.get("card_type"), "text_notice")
+ self.assertEqual(card.get("main_title", {}).get("title"), "上传询价附件")
+ self.assertIn("上传询价附件", (card.get("jump_list") or [{}])[0].get("title") or "")
+ url = (card.get("card_action") or {}).get("url") or ""
+ self.assertIn("/inquiry-form/upload/", url)
+
+ def test_xlsx_complete_creates_sea_ticket(self) -> None:
+ handle_attachment_inquiry(
+ InboundMessage(sender_id="s1", message_id="m1", content="附件询价"),
+ store=self.store,
+ )
+ phase, hint = accept_h5_uploads(
+ sender_id="s1",
+ files=[("sea.xlsx", b"fake")],
+ store=self.store,
+ extract_fn=_extract(SEA_ORAL),
+ attach_text_fn=lambda **k: {
+ "ok": True,
+ "text": str((k.get("payload") or {}).get("raw_text") or ""),
+ "empty": False,
+ "error": "",
+ },
+ put_fn=lambda **kw: {"ok": True, "key": kw.get("key") or "k"},
+ )
+ self.assertEqual(hint, copy.ATTACH_H5_OK)
+ self.assertIn(copy.ATTACH_RECOGNIZING, self._bodies())
+ sess = get_sea_text_flow().session_of("s1")
+ self.assertIsNotNone(sess)
+ self.assertTrue((sess.work_order_no or "").strip())
+ ticket = get_sea_text_flow().ledger.get_ticket(work_order_no=sess.work_order_no)
+ self.assertTrue(ticket.attachments)
+
+ def test_empty_file_asks_mode(self) -> None:
+ handle_attachment_inquiry(
+ InboundMessage(sender_id="s1", message_id="m1", content="附件询价"),
+ store=self.store,
+ )
+ accept_h5_uploads(
+ sender_id="s1",
+ files=[("empty.xlsx", b"x")],
+ store=self.store,
+ extract_fn=_extract(""),
+ attach_text_fn=lambda **_k: {"ok": True, "text": "", "empty": True, "error": ""},
+ )
+ bodies = self._bodies()
+ self.assertTrue(any("没看清" in b for b in bodies))
+ self.assertTrue(any("海运" in b and "空运" in b for b in bodies))
+
+ def test_image_during_wait_joins_same_ticket(self) -> None:
+ handle_attachment_inquiry(
+ InboundMessage(sender_id="s1", message_id="m1", content="附件询价"),
+ store=self.store,
+ )
+ join_attachment_image(
+ _img("s1", "mid-1"),
+ store=self.store,
+ vision_fn=_vision("截图"),
+ download_fn=lambda _m: (b"x", ""),
+ process_now=False,
+ )
+ accept_h5_uploads(
+ sender_id="s1",
+ files=[("sea.xlsx", b"x")],
+ store=self.store,
+ extract_fn=_extract(SEA_ORAL),
+ attach_text_fn=lambda **k: {
+ "ok": True,
+ "text": str((k.get("payload") or {}).get("raw_text") or ""),
+ "empty": False,
+ },
+ process_now=True,
+ )
+ from agent.handlers.attachment_inquiry import finish_attach_wave
+ from agent.policy.attachment_wave import get_attach_wave_book
+
+ book = get_attach_wave_book()
+ book.complete_material("s1", 0, text="截图")
+ finish_attach_wave("s1", store=self.store)
+ acks = [c for c in self._bodies() if c in {copy.IMAGE_RECOGNIZING, copy.ATTACH_RECOGNIZING}]
+ self.assertEqual(len(acks), 1)
+ self.assertEqual(acks[0], copy.IMAGE_RECOGNIZING)
+ sess = get_sea_text_flow().session_of("s1")
+ self.assertTrue(sess and sess.work_order_no)
+
+ def test_mid_clarify_then_attach_is_new_ticket(self) -> None:
+ handle_text_inquiry(
+ InboundMessage(sender_id="s1", message_id="old", content="海运出号"),
+ store=self.store,
+ injected_facts=COMPLETE_SEA,
+ injected_mode="SEA",
+ )
+ wo_old = get_sea_text_flow().session_of("s1").work_order_no
+ handle_attachment_inquiry(
+ InboundMessage(sender_id="s1", message_id="m2", content="我有个excel要询价"),
+ store=self.store,
+ )
+ accept_h5_uploads(
+ sender_id="s1",
+ files=[("sea.xlsx", b"x")],
+ store=self.store,
+ extract_fn=_extract(SEA_ORAL),
+ attach_text_fn=lambda **k: {
+ "ok": True,
+ "text": str((k.get("payload") or {}).get("raw_text") or ""),
+ "empty": False,
+ },
+ )
+ wo_new = get_sea_text_flow().session_of("s1").work_order_no
+ self.assertNotEqual(wo_old, wo_new)
+ self.assertIsNotNone(get_sea_text_flow().ledger.get_ticket(work_order_no=wo_old))
+
+ def test_reject_png_on_h5(self) -> None:
+ handle_attachment_inquiry(
+ InboundMessage(sender_id="s1", message_id="m1", content="附件询价"),
+ store=self.store,
+ )
+ phase, hint = accept_h5_uploads(
+ sender_id="s1",
+ files=[("shot.png", b"\x89PNG")],
+ store=self.store,
+ )
+ self.assertEqual(phase, "reject_type")
+ self.assertIn("Excel", hint)
+ self.assertIsNone(get_sea_text_flow().session_of("s1"))
+
+ def test_dispatch_image_joins_wait_not_new_image_inquiry(self) -> None:
+ handle_attachment_inquiry(
+ InboundMessage(sender_id="s1", message_id="m1", content="附件询价"),
+ store=self.store,
+ )
+ name = dispatch_inbound(
+ _img("s1", "mid-x"),
+ RouteDecision(intent="image_inquiry"),
+ )
+ self.assertEqual(name, "attachment_inquiry")
+
+ def test_follow_text_not_create_ticket_before_upload(self) -> None:
+ handle_attachment_inquiry(
+ InboundMessage(sender_id="s1", message_id="m1", content="附件询价"),
+ store=self.store,
+ )
+ name = dispatch_inbound(
+ InboundMessage(sender_id="s1", message_id="m2", content="海运上海到纽约 2个40尺"),
+ RouteDecision(intent="ordinary_text_inquiry"),
+ )
+ self.assertEqual(name, "attach_wave_follow_text")
+ self.assertIsNone(get_sea_text_flow().session_of("s1"))
+
+ def test_after_business_old_h5_rejected(self) -> None:
+ handle_attachment_inquiry(
+ InboundMessage(sender_id="s1", message_id="m1", content="附件询价"),
+ store=self.store,
+ )
+ accept_h5_uploads(
+ sender_id="s1",
+ files=[("sea.xlsx", b"x")],
+ store=self.store,
+ extract_fn=_extract(SEA_ORAL),
+ attach_text_fn=lambda **k: {
+ "ok": True,
+ "text": str((k.get("payload") or {}).get("raw_text") or ""),
+ "empty": False,
+ },
+ )
+ phase, _hint = accept_h5_uploads(
+ sender_id="s1",
+ files=[("again.xlsx", b"x")],
+ store=self.store,
+ extract_fn=_extract(SEA_ORAL),
+ )
+ self.assertEqual(phase, "expired")
+
+ def test_worker_reads_bytes_after_http_stash_cleared(self) -> None:
+ handle_attachment_inquiry(
+ InboundMessage(sender_id="s1", message_id="m1", content="附件询价"),
+ store=self.store,
+ )
+ accept_h5_uploads(
+ sender_id="s1",
+ files=[("sea.xlsx", b"fake-xlsx-bytes")],
+ store=self.store,
+ process_now=False,
+ )
+ attach_mod._STASH.clear()
+ wave = get_attach_wave_book().active("s1")
+ self.assertIsNotNone(wave)
+ key = f"inquiry-attach/s1/{wave.wave_id}/0/sea.xlsx"
+ finish_queued_attachment(
+ {
+ "sender_id": "s1",
+ "index": 0,
+ "role": "file",
+ "filename": "sea.xlsx",
+ "object_key": key,
+ "wave_id": wave.wave_id,
+ "__extract": _extract(SEA_ORAL),
+ "__attach_text": lambda **k: {
+ "ok": True,
+ "text": str((k.get("payload") or {}).get("raw_text") or ""),
+ "empty": False,
+ "error": "",
+ },
+ }
+ )
+ self.assertIsNotNone(get_sea_text_flow().session_of("s1"))
+
+ def test_second_unreadable_wave_still_replies(self) -> None:
+ def _empty(_name: str, _raw: bytes):
+ return {"ok": True, "text": "", "empty": True, "error": ""}
+
+ handle_attachment_inquiry(
+ InboundMessage(sender_id="s1", message_id="m1", content="附件询价"),
+ store=self.store,
+ )
+ accept_h5_uploads(
+ sender_id="s1",
+ files=[("blank.xlsx", b"xx")],
+ store=self.store,
+ extract_fn=_empty,
+ )
+ handle_attachment_inquiry(
+ InboundMessage(sender_id="s1", message_id="m2", content="附件询价"),
+ store=self.store,
+ )
+ accept_h5_uploads(
+ sender_id="s1",
+ files=[("blank2.xlsx", b"yy")],
+ store=self.store,
+ extract_fn=_empty,
+ )
+ fails = [b for b in self._bodies() if "没看清" in b or "没认出来" in b]
+ self.assertGreaterEqual(len(fails), 2, fails)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/inquiry-agent/tests/test_attachment_upload_h5.py b/inquiry-agent/tests/test_attachment_upload_h5.py
new file mode 100644
index 0000000..8d1e8cf
--- /dev/null
+++ b/inquiry-agent/tests/test_attachment_upload_h5.py
@@ -0,0 +1,96 @@
+"""
+附件上传 H5:token 不含账号,过期 410,拒收图片。
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+import unittest
+
+ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
+if ROOT not in sys.path:
+ sys.path.insert(0, ROOT)
+
+os.environ.setdefault("YTD_ENV", "test")
+os.environ.setdefault("REDIS_BACKEND", "memory")
+os.environ.setdefault("CHECKPOINT_BACKEND", "memory")
+os.environ.setdefault("MESSAGE_STORE_BACKEND", "memory")
+os.environ.setdefault("LEDGER_BACKEND", "memory")
+
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from agent.channel.h5 import router as h5_router
+from agent.channel.h5.copy_store import build_upload_url
+from agent.channel.wecom.models import InboundMessage
+from agent.handlers.attachment_inquiry import handle_attachment_inquiry, lookup_upload_sender
+from agent.policy.attachment_wave import reset_attach_wave_book_for_test
+from agent.policy.image_wave import reset_image_wave_book_for_test
+from agent.policy.air_text_flow import reset_air_text_flow_for_test
+from agent.policy.land_text_flow import reset_land_text_flow_for_test
+from agent.policy.sea_text_flow import reset_sea_text_flow_for_test
+
+
+class AttachmentUploadH5Tests(unittest.TestCase):
+ def setUp(self) -> None:
+ reset_attach_wave_book_for_test()
+ reset_image_wave_book_for_test()
+ reset_sea_text_flow_for_test()
+ reset_air_text_flow_for_test()
+ reset_land_text_flow_for_test()
+ app = FastAPI()
+ app.include_router(h5_router)
+ self.client = TestClient(app)
+
+ def _open_card(self) -> str:
+ handle_attachment_inquiry(
+ InboundMessage(sender_id="s1", message_id="m1", content="附件询价")
+ )
+ from agent.policy.attachment_wave import get_attach_wave_book
+
+ wave = get_attach_wave_book().active("s1")
+ self.assertIsNotNone(wave)
+ url = build_upload_url(wave.token)
+ self.assertIn("/upload/", url)
+ return wave.token
+
+ def test_open_page_ok_and_expired(self) -> None:
+ token = self._open_card()
+ self.assertEqual(lookup_upload_sender(token), "s1")
+ hit = self.client.get(f"/inquiry-form/upload/{token}")
+ self.assertEqual(hit.status_code, 200)
+ self.assertIn("Excel", hit.text)
+ self.assertNotIn("s1", hit.text)
+ miss = self.client.get("/inquiry-form/upload/not-a-real-token")
+ self.assertEqual(miss.status_code, 410)
+ self.assertIn("过期", miss.text)
+
+ def test_upload_page_uses_card_chrome(self) -> None:
+ from agent.channel.h5.upload_page import expired_upload_page, render_upload_message, render_upload_page
+
+ page = render_upload_page()
+ self.assertIn('class="card"', page)
+ self.assertIn('class="drop"', page)
+ self.assertIn('id="picked"', page)
+ self.assertIn("一次可选多个", page)
+ self.assertIn("Excel", page)
+ self.assertNotIn("s1", page)
+ ok = render_upload_message("上传成功", "已收到,请回企微查看识别结果。")
+ dead = expired_upload_page()
+ self.assertIn('class="card"', ok)
+ self.assertIn('class="card"', dead)
+ self.assertIn("过期", dead)
+
+ def test_post_png_rejected(self) -> None:
+ token = self._open_card()
+ hit = self.client.post(
+ f"/inquiry-form/upload/{token}",
+ files=[("files", ("shot.png", b"\x89PNG", "image/png"))],
+ )
+ self.assertEqual(hit.status_code, 400)
+ self.assertIn("Excel", hit.text)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/inquiry-agent/tests/test_attachment_wave.py b/inquiry-agent/tests/test_attachment_wave.py
new file mode 100644
index 0000000..b1f8380
--- /dev/null
+++ b/inquiry-agent/tests/test_attachment_wave.py
@@ -0,0 +1,116 @@
+"""
+私聊附件等材料波次:发卡等待、发图/H5 并入、30 分钟时效。
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import sys
+import unittest
+
+ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
+if ROOT not in sys.path:
+ sys.path.insert(0, ROOT)
+
+from agent.policy.attachment_wave import (
+ WAIT_TTL_SECONDS,
+ AttachWaveBook,
+ reset_attach_wave_book_for_test,
+)
+
+
+class AttachWaveTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self.book = reset_attach_wave_book_for_test()
+
+ def test_wait_ttl_is_30_minutes(self) -> None:
+ self.assertEqual(WAIT_TTL_SECONDS, 1800)
+
+ def test_open_wait_is_active_without_files(self) -> None:
+ wave = self.book.open_wait("s1", precede_text="海运上海到纽约", token="tok1", now=100.0)
+ self.assertTrue(wave.wave_id)
+ self.assertEqual(wave.precede_text, "海运上海到纽约")
+ self.assertFalse(wave.started)
+ self.assertIsNotNone(self.book.active("s1", now=100.0))
+
+ def test_wait_expires_after_30_minutes_if_not_started(self) -> None:
+ self.book.open_wait("s1", precede_text="海运", token="tok1", now=100.0)
+ self.assertIsNone(self.book.active("s1", now=100.0 + 1800.1))
+
+ def test_follow_text_while_waiting(self) -> None:
+ self.book.open_wait("s1", precede_text="", token="tok1", now=1.0)
+ self.assertTrue(self.book.append_follow_text("s1", "只要整柜"))
+ self.assertFalse(self.book.append_follow_text("s1", ""))
+
+ def test_join_image_starts_recognition(self) -> None:
+ self.book.open_wait("s1", precede_text="海运", token="tok1", now=1.0)
+ wave, how = self.book.join_image("s1", "mid-a", filename="a.jpg", now=2.0)
+ self.assertEqual(how, "joined")
+ self.assertTrue(wave.started)
+ self.assertEqual(self.book.pending(wave), 1)
+
+ def test_add_files_keeps_upload_order(self) -> None:
+ self.book.open_wait("s1", precede_text="", token="tok1", now=1.0)
+ wave = self.book.add_files("s1", ["sea.xlsx", "air.pdf"], now=2.0)
+ self.assertTrue(wave.started)
+ self.assertEqual([m.filename for m in wave.materials], ["sea.xlsx", "air.pdf"])
+ self.assertEqual(self.book.pending(wave), 2)
+
+ def test_compose_oral_uses_material_order(self) -> None:
+ self.book.open_wait("s1", precede_text="开口字", token="tok1", now=1.0)
+ self.book.add_files("s1", ["a.xlsx", "b.pdf"], now=2.0)
+ self.book.append_follow_text("s1", "只要40尺")
+ wave = self.book.active("s1", now=2.0)
+ self.assertIsNotNone(wave)
+ self.book.complete_material("s1", 1, text="空运 上海到纽约")
+ self.book.complete_material("s1", 0, text="海运 宁波到汉堡")
+ oral = self.book.compose_oral("s1")
+ self.assertEqual(oral, "开口字\n海运 宁波到汉堡\n空运 上海到纽约\n只要40尺")
+
+ def test_after_business_done_not_active(self) -> None:
+ self.book.open_wait("s1", precede_text="", token="tok1", now=1.0)
+ self.book.add_files("s1", ["a.xlsx"], now=2.0)
+ self.book.complete_material("s1", 0, text="海运")
+ self.book.mark_business_done("s1")
+ self.assertIsNone(self.book.active("s1", now=3.0))
+ self.assertFalse(self.book.append_follow_text("s1", "补一句"))
+
+ def test_image_after_business_rejected(self) -> None:
+ self.book.open_wait("s1", precede_text="", token="tok1", now=1.0)
+ self.book.mark_business_done("s1")
+ wave, how = self.book.join_image("s1", "mid-b", filename="b.jpg", now=2.0)
+ self.assertEqual(how, "inactive")
+ self.assertIsNone(wave)
+
+ def test_http_and_worker_share_kv(self) -> None:
+ kv = _MemoryWaveKV()
+ http = reset_attach_wave_book_for_test(kv=kv)
+ worker = AttachWaveBook(kv=kv)
+ http.open_wait("s1", precede_text="海运", token="tok", now=1.0)
+ self.assertIsNotNone(worker.active("s1", now=1.0))
+ worker.add_files("s1", ["a.xlsx"], now=2.0)
+ worker.complete_material("s1", 0, text="宁波到汉堡")
+ worker.mark_business_done("s1")
+ self.assertIsNone(http.active("s1", now=3.0))
+
+
+class _MemoryWaveKV:
+ """单测假 Redis:两份账本共用同一字典。"""
+
+ def __init__(self) -> None:
+ self._d: dict[str, dict] = {}
+
+ def get_json(self, kind: str, sender_id: str):
+ row = self._d.get(f"{kind}:{sender_id}")
+ return json.loads(json.dumps(row)) if row is not None else None
+
+ def set_json(self, kind: str, sender_id: str, data: dict) -> None:
+ self._d[f"{kind}:{sender_id}"] = json.loads(json.dumps(data))
+
+ def delete(self, kind: str, sender_id: str) -> None:
+ self._d.pop(f"{kind}:{sender_id}", None)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/inquiry-agent/tests/test_extract_attachment.py b/inquiry-agent/tests/test_extract_attachment.py
new file mode 100644
index 0000000..51e641e
--- /dev/null
+++ b/inquiry-agent/tests/test_extract_attachment.py
@@ -0,0 +1,61 @@
+"""
+Excel / PDF 抽纯文本:不猜字段。
+"""
+
+from __future__ import annotations
+
+import io
+import os
+import sys
+import unittest
+
+ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
+if ROOT not in sys.path:
+ sys.path.insert(0, ROOT)
+
+from agent.policy.attachment_extract import allowed_attachment, extract_attachment_text
+
+
+def _xlsx_bytes() -> bytes:
+ from openpyxl import Workbook
+
+ wb = Workbook()
+ ws = wb.active
+ ws.title = "询价"
+ ws["A1"] = "海运"
+ ws["B1"] = "上海到洛杉矶"
+ ws["A2"] = "1x40HQ"
+ buf = io.BytesIO()
+ wb.save(buf)
+ return buf.getvalue()
+
+
+class ExtractAttachmentTests(unittest.TestCase):
+ def test_allow_only_excel_pdf(self) -> None:
+ self.assertTrue(allowed_attachment("a.XLSX"))
+ self.assertTrue(allowed_attachment("b.xls"))
+ self.assertTrue(allowed_attachment("c.pdf"))
+ self.assertFalse(allowed_attachment("d.png"))
+ self.assertFalse(allowed_attachment("e.docx"))
+ self.assertFalse(allowed_attachment("f.zip"))
+
+ def test_reject_image_bytes(self) -> None:
+ got = extract_attachment_text("shot.png", b"\x89PNG")
+ self.assertFalse(got["ok"])
+ self.assertEqual(got["error"], "only_xlsx_xls_pdf")
+
+ def test_xlsx_cells_become_text(self) -> None:
+ got = extract_attachment_text("sea.xlsx", _xlsx_bytes())
+ self.assertTrue(got["ok"])
+ self.assertIn("海运", got["text"])
+ self.assertIn("上海到洛杉矶", got["text"])
+ self.assertIn("1x40HQ", got["text"])
+
+ def test_empty_bytes_fail(self) -> None:
+ got = extract_attachment_text("a.xlsx", b"")
+ self.assertFalse(got["ok"])
+ self.assertTrue(got["empty"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/inquiry-agent/tests/test_land_copy.py b/inquiry-agent/tests/test_land_copy.py
index 4dd207d..443b39d 100644
--- a/inquiry-agent/tests/test_land_copy.py
+++ b/inquiry-agent/tests/test_land_copy.py
@@ -320,6 +320,26 @@ class LandCopyTest(unittest.TestCase):
self.assertTrue(merged.startswith(copy.IMAGE_UNREADABLE))
self.assertIn(copy.ASK_TRANSPORT, merged)
+ def test_attachment_copy_and_card(self):
+ self.assertEqual(copy.ATTACH_RECOGNIZING, "正在识别附件")
+ self.assertIn("没看清", copy.ATTACH_UNREADABLE)
+ self.assertIn("暂时没认出来", copy.ATTACH_TECH_FAIL)
+ payload = copy.attachment_upload_card("https://ai.ytd-scm.com/inquiry-form/upload/tok")
+ card = payload["template_card"]
+ self.assertEqual(payload["msgtype"], "template_card")
+ # 现网上传卡是 text_notice。news_notice 必须带 card_image,企微会 42044 后退成纯文本。
+ self.assertEqual(card["card_type"], "text_notice")
+ self.assertNotIn("card_image", card)
+ self.assertEqual(card["source"]["desc"], "附件询价")
+ self.assertEqual(card["main_title"]["title"], "上传询价附件")
+ self.assertIn("Excel", card["main_title"]["desc"])
+ self.assertIn("图片请直接在企微聊天窗口发送", card["main_title"]["desc"])
+ self.assertIn("图片请直接在当前企微会话发送", card["sub_title_text"])
+ self.assertEqual(card["jump_list"][0]["title"], "上传询价附件")
+ self.assertIn("/upload/tok", card["card_action"]["url"])
+ self.assertEqual(card["card_action"]["type"], 1)
+ self.assertNotIn("userid", card["card_action"]["url"])
+
if __name__ == "__main__":
unittest.main()
diff --git a/inquiry-agent/tests/test_mode_attachment_text.py b/inquiry-agent/tests/test_mode_attachment_text.py
new file mode 100644
index 0000000..02ae1c2
--- /dev/null
+++ b/inquiry-agent/tests/test_mode_attachment_text.py
@@ -0,0 +1,70 @@
+"""
+千问附件文本:只整理抽出原文,不算缺项。
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+import unittest
+
+ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
+if ROOT not in sys.path:
+ sys.path.insert(0, ROOT)
+
+from agent.llm.mode_attachment_text import invoke_attachment_text, invoke_shell
+from agent.llm.modes import LlmMode
+
+
+class ModeAttachmentTextTests(unittest.TestCase):
+ def test_shell_uses_raw_text(self) -> None:
+ got = invoke_shell(payload={"raw_text": "海运 宁波到汉堡 1x40HQ"})
+ self.assertTrue(got["ok"])
+ self.assertIn("宁波到汉堡", got["text"])
+ self.assertFalse(got["empty"])
+
+ def test_blank_is_empty(self) -> None:
+ got = invoke_shell(payload={"assistant_text": " "})
+ self.assertTrue(got["ok"])
+ self.assertTrue(got["empty"])
+
+ def test_injected_chat_gets_raw_only(self) -> None:
+ seen: dict = {}
+
+ class _R:
+ ok = True
+ content = "海运 上海到洛杉矶"
+ error = ""
+
+ def chat(**kwargs):
+ seen.update(kwargs)
+ return _R()
+
+ got = invoke_attachment_text(
+ payload={"raw_text": "# 询价\n海运\t上海到洛杉矶"},
+ chat_fn=chat,
+ allow_network=False,
+ )
+ self.assertTrue(got["ok"])
+ self.assertEqual(got["text"], "海运 上海到洛杉矶")
+ self.assertEqual(seen["mode"], LlmMode.ATTACHMENT_TEXT)
+ sys_msg = seen["messages"][0]["content"]
+ self.assertIn("不要猜", sys_msg)
+ self.assertNotIn("缺项", seen["messages"][1]["content"] or "x")
+
+ def test_chat_fail(self) -> None:
+ class _R:
+ ok = False
+ content = ""
+ error = "timeout"
+
+ got = invoke_attachment_text(
+ payload={"raw_text": "海运"},
+ chat_fn=lambda **_k: _R(),
+ )
+ self.assertFalse(got["ok"])
+ self.assertEqual(got["error"], "timeout")
+
+
+if __name__ == "__main__":
+ unittest.main()