落地私聊附件询价:发卡走 H5 收 Excel/PDF,认成口播再复用三线文字流程,避免附件卡在识别或退成纯文本。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-18 17:25:38 +08:00
co-authored by Cursor
parent 8219dce84b
commit 65ef75f1ef
19 changed files with 2420 additions and 8 deletions
+46 -1
View File
@@ -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]:
"""
@@ -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()
@@ -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 = (
'<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">'
'<path d="M7 3h7l5 5v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z" stroke="currentColor" stroke-width="1.7"/>'
'<path d="M14 3v5h5" stroke="currentColor" stroke-width="1.7"/>'
'</svg>'
)
_ICON_OK = (
'<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">'
'<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.7"/>'
'<path d="M8 12.5l2.5 2.5L16 9.5" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>'
'</svg>'
)
_ICON_WARN = (
'<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">'
'<path d="M12 4l9 16H3L12 4z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/>'
'<path d="M12 10v5M12 17.5h.01" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>'
'</svg>'
)
_ICON_EXPIRED = (
'<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">'
'<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.7"/>'
'<path d="M12 7v6l4 2" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>'
'</svg>'
)
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 = '<p class="back">请回企微查看识别结果。</p>' if tone == "ok" else ""
return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{title}</title>
<style>{_PAGE_CSS}</style>
</head>
<body>
<div class="wrap tone-{tone}">
<div class="card">
<div class="hero">
<div class="badge">{icon}</div>
<div>
<h1>{title}</h1>
</div>
</div>
<p class="body">{body}</p>
{extra}
</div>
</div>
</body>
</html>
"""
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"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>上传询价附件</title>
<style>{_PAGE_CSS}</style>
</head>
<body>
<div class="wrap">
<div class="card">
<div class="hero">
<div class="badge">{_ICON_FILE}</div>
<div>
<h1>上传询价附件</h1>
<p class="lead">这里只传 Excel、PDF。图片请直接发到当前企微会话。</p>
</div>
</div>
<div class="pills"><span class="pill">Excel</span><span class="pill">PDF</span></div>
<p class="hint">一次可选多个文件,顺序按你选中的顺序。提交后请回企微查看识别结果。</p>
<form id="f" method="post" enctype="multipart/form-data">
<label class="drop" for="files">
<input id="files" name="files" type="file" multiple
accept=".xls,.xlsx,.pdf,application/pdf,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"/>
<span class="drop-title">选择 Excel 或 PDF</span>
<span class="drop-sub">点这里选文件,可一次多选</span>
</label>
<ul class="picked" id="picked" hidden></ul>
<button type="submit" id="go">上传</button>
</form>
<p class="err" id="err" role="alert"></p>
<ul class="rules">
<li>只接受 .xls / .xlsx / .pdf</li>
<li>图片请发到当前企微会话,不要在本页上传</li>
</ul>
</div>
</div>
<script>
var REJECT = {reject};
var NEED = {need};
var input = document.getElementById("files");
var picked = document.getElementById("picked");
var err = document.getElementById("err");
var go = document.getElementById("go");
function esc(s) {{
return String(s).replace(/[&<>"']/g, function (c) {{
return ({{"&":"&amp;","<":"&lt;",">":"&gt;","\\"":"&quot;","'":"&#39;"}})[c];
}});
}}
function renderPicked() {{
var files = input.files || [];
err.textContent = "";
if (!files.length) {{
picked.innerHTML = "";
picked.hidden = true;
return;
}}
var html = "";
for (var i = 0; i < files.length; i++) {{
html += '<li><span class="idx">' + (i + 1) + '</span><span class="name">' + esc(files[i].name) + '</span></li>';
}}
picked.innerHTML = html;
picked.hidden = false;
}}
input.addEventListener("change", renderPicked);
document.getElementById("f").addEventListener("submit", function (ev) {{
var files = input.files;
err.textContent = "";
if (!files || !files.length) {{
ev.preventDefault();
err.textContent = NEED;
return;
}}
var okExt = /\\.(xlsx|xls|pdf)$/i;
for (var i = 0; i < files.length; i++) {{
if (!okExt.test(files[i].name)) {{
ev.preventDefault();
err.textContent = REJECT;
return;
}}
}}
go.disabled = true;
go.textContent = "正在上传…";
}});
</script>
</body>
</html>
"""
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)
+3 -1
View File
@@ -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",
@@ -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 "",
)
+5 -1
View File
@@ -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)
@@ -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
@@ -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)
@@ -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
@@ -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:
+4
View File
@@ -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"
+20 -1
View File
@@ -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)
+4
View File
@@ -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
@@ -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()
@@ -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()
+116
View File
@@ -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()
@@ -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()
+20
View File
@@ -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()
@@ -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()