落地私聊附件询价:发卡走 H5 收 Excel/PDF,认成口播再复用三线文字流程,避免附件卡在识别或退成纯文本。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 ({{"&":"&","<":"<",">":">","\\"":""","'":"'"}})[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)
|
||||
@@ -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 "",
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user