Compare commits

...
42 changed files with 4566 additions and 51 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)
@@ -628,6 +628,42 @@ class WeComAppClient:
self._token_expire_at = time.time() + expires_in
return token
def download_media(self, media_id: str) -> tuple[bytes, str]:
"""
下载企微临时素材(cgi-bin/media/get)。
返回 (字节, 错误码)。失败时字节为空。禁止把 token 写入日志。
调用:Worker 识别槽,禁止回调线程同步下载。
"""
mid = (media_id or "").strip()
if not mid:
return b"", "media_empty"
try:
token = self._get_token()
except Exception:
logger.exception("下载素材取 token 失败")
return b"", "token_failed"
url = f"{self._api_base}/cgi-bin/media/get"
try:
with httpx.Client(timeout=self._timeout) as client:
resp = client.get(url, params={"access_token": token, "media_id": mid})
ctype = (resp.headers.get("content-type") or "").lower()
raw = resp.content or b""
if "application/json" in ctype or raw[:1] in (b"{", b"["):
try:
data = resp.json()
except Exception:
data = {}
if isinstance(data, dict) and int(data.get("errcode") or 0) != 0:
return b"", str(data.get("errmsg") or "media_get_fail")
return b"", "media_json_body"
if len(raw) < 32:
return b"", "media_too_small"
return raw, ""
except Exception:
logger.exception("下载素材失败")
return b"", "media_download_failed"
# 企微侧明确不可重试的错误(IP 白名单、非法 userid 等)
PERMANENT_SEND_ERRORS = frozenset(
+5
View File
@@ -75,6 +75,11 @@ class ChannelRuntime:
from agent.routing.decision import RouteDecision
decision = RouteDecision(intent="ordinary_text_other")
elif (message.msg_type or "text").lower() == "image":
from agent.routing.decision import RouteDecision
# 私聊图片不进 DeepSeek A(正文为空会被判闲聊)。
decision = RouteDecision(intent="image_inquiry", confidence=1.0)
else:
decision = self._router.route_inbound(
text=message.content or "",
+10 -2
View File
@@ -2,10 +2,18 @@
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_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 "",
)
@@ -0,0 +1,409 @@
"""
意图:私聊图片询价。
本文件职责:收图 → 回「正在识别」→ 千问抄可见原文 → 拼口播 → 新开后复用文字询价。
禁止:千问填最终缺项;另写海运/空运/陆运图片专线;回调线程同步等千问;群图走本 Handler。
"""
from __future__ import annotations
import logging
import time
from typing import Any, Callable, Optional
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.image_wave import get_image_wave_book
logger = logging.getLogger(__name__)
VisionFn = Callable[..., dict[str, Any]]
DownloadFn = Callable[[str], tuple[bytes, str]]
PutFn = Callable[..., dict[str, Any]]
def drop_all_private_bookmarks(sender_id: str) -> None:
"""三线当前书签都丢掉;已出号仍能按工单号找回。"""
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
get_air_text_flow().drop_current_bookmark(sender_id)
get_sea_text_flow().drop_current_bookmark(sender_id)
get_land_text_flow().drop_current_bookmark(sender_id)
def is_active_image_wave(sender_id: str) -> bool:
"""业务结果尚未发出的图片波次。"""
return get_image_wave_book().active(sender_id) is not None
def append_wave_text(sender_id: str, text: str) -> bool:
"""认图完成前的字并进本波,不走文字询价。"""
return get_image_wave_book().append_follow_text(sender_id, text)
def note_private_text(sender_id: str, text: str, *, now: Optional[float] = None) -> None:
"""文字询价记下最近一句,供 10 秒内发图带走。"""
get_image_wave_book().note_text(sender_id, text, now=now if now is not None else time.time())
def handle_image_inquiry(
message: InboundMessage,
*,
store=None,
vision_fn: Optional[VisionFn] = None,
download_fn: Optional[DownloadFn] = None,
put_fn: Optional[PutFn] = None,
process_now: bool = True,
) -> str:
"""
私聊一张图。inbox 线程只开波、回识别中;认图在 Worker 或单测 vision_fn。
须已有 sender_id。群消息不要调用本函数。
"""
if not message.has_sender():
logger.warning("image_inquiry 拒绝:无 sender_id")
return "reject_no_sender"
if (message.chat_type or "single") == "group":
logger.info("image_inquiry 忽略群图 chat=%s", message.chat_id)
return "ignore_group_image"
book = get_image_wave_book()
msg_store = store or get_message_store()
media_id = str((message.media or {}).get("media_id") or "")
filename = str((message.media or {}).get("filename") or "image.jpg")
wave, how = book.open_or_join(message.sender_id, media_id, now=time.time())
if how in {"opened", "new_after_done"}:
drop_all_private_bookmarks(message.sender_id)
msg_store.enqueue_outbound(
touser=message.sender_id,
content=copy.IMAGE_RECOGNIZING,
dedupe_key=f"img-ack:{wave.wave_id}",
)
book.mark_ack(message.sender_id)
logger.info("image_inquiry 开波 how=%s wave=%s", how, wave.wave_id)
else:
logger.info(
"image_inquiry 并入 how=%s wave=%s pending=%s",
how,
wave.wave_id,
wave.pending,
)
if process_now and vision_fn is not None:
_recognize_one(
sender_id=message.sender_id,
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 wave.pending <= 0:
return finish_wave(message.sender_id, store=msg_store, seed_message=message)
return "recognizing"
if vision_fn is None:
try:
from agent.jobs.recognition import enqueue_recognition
enqueue_recognition(
kind="vision",
payload={
"sender_id": message.sender_id,
"media_id": media_id,
"filename": filename,
"message_id": message.message_id,
"wave_id": wave.wave_id,
},
idempotency_key=message.message_id or media_id,
)
except Exception:
logger.exception("image_inquiry 入识别队失败 sender=%s", message.sender_id)
_recognize_one(
sender_id=message.sender_id,
media_id=media_id,
filename=filename,
vision_fn=None,
download_fn=download_fn,
put_fn=put_fn,
)
return finish_wave(message.sender_id, store=msg_store, seed_message=message)
return "recognizing"
return "recognizing"
def flush_active_wave(
sender_id: str,
*,
store=None,
vision_fn: Optional[VisionFn] = None,
download_fn: Optional[DownloadFn] = None,
put_fn: Optional[PutFn] = None,
seed_message: Optional[InboundMessage] = None,
) -> str:
"""单测:两张图都 join 后再认,只出一次业务结果。"""
book = get_image_wave_book()
wave = book.active(sender_id)
if wave is None:
return "no_wave"
pending_ids = list(wave.media_ids[-wave.pending :]) if wave.pending else []
for mid in pending_ids:
_recognize_one(
sender_id=sender_id,
media_id=mid,
filename="",
vision_fn=vision_fn,
download_fn=download_fn,
put_fn=put_fn,
)
return finish_wave(sender_id, store=store, seed_message=seed_message)
def finish_queued_image(payload: dict[str, Any]) -> dict[str, Any]:
"""
Worker 识别槽:认完一张,pending 为 0 则出业务结果。
payload.__vision / __bytes 仅单测注入,生产不得依赖。
"""
sender_id = str(payload.get("sender_id") or "")
media_id = str(payload.get("media_id") or "")
filename = str(payload.get("filename") or "image.jpg")
vision_hook = payload.get("__vision")
raw = payload.get("__bytes")
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, (bytes, bytearray)):
return bytes(raw), ""
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_one(
sender_id=sender_id,
media_id=media_id,
filename=filename,
vision_fn=_vision,
download_fn=_download,
put_fn=None,
)
book = get_image_wave_book()
wave = book.active(sender_id)
if wave is None:
logger.warning(
"image_inquiry Worker 认完但无活跃波次 sender=%s media=%s(多半 HTTP/Worker 账不一致)",
sender_id,
media_id,
)
return {"ok": False, "phase": "no_active_wave", "pending": 0}
if wave.pending > 0:
return {"ok": True, "phase": "recognizing", "pending": wave.pending}
phase = finish_wave(sender_id, seed_message=InboundMessage(
sender_id=sender_id,
message_id=str(payload.get("message_id") or media_id),
content="",
msg_type="image",
media={"media_id": media_id, "filename": filename},
))
return {"ok": True, "phase": phase}
def _recognize_one(
*,
sender_id: str,
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_image_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:
logger.info(
"image_inquiry 无字节 sender=%s err=%s",
sender_id,
err or "media_empty",
)
book.add_vision(
sender_id,
"",
object_key="",
filename=filename,
error=err or "media_empty",
)
return
mime = sniff_image_mime(raw) or "image/jpeg"
object_key = ""
if put_fn is not None:
key = f"inquiry-image/{sender_id}/{media_id or filename}"
try:
stored = put_fn(key=key, data=raw, content_type=mime)
if stored.get("ok"):
object_key = str(stored.get("key") or key)
except Exception:
logger.exception("原图落存储失败 sender=%s", sender_id)
else:
object_key = f"inquiry-image/{sender_id}/{media_id or filename}"
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=mime
)
except Exception:
logger.info("原图 dry_run 存储跳过 sender=%s", sender_id)
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")
logger.info(
"image_inquiry 千问 bytes=%s mime=%s ok=%s empty=%s text_len=%s err=%s",
len(raw),
mime,
bool(vision.get("ok")),
bool(vision.get("empty")),
len(text.strip()),
err or "-",
)
book.add_vision(
sender_id,
text,
object_key=object_key,
filename=filename,
error=err,
)
def finish_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_image_wave_book()
wave = book.active(sender_id)
oral = book.compose_oral(sender_id)
reason = ""
if not oral.strip():
reason = copy.IMAGE_TECH_FAIL if (wave and wave.errors) else copy.IMAGE_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 else f"img:{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"img-empty:{inbound.message_id}",
)
phase = "need_mode"
book.mark_business_done(sender_id)
book.forget_text(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 i, key in enumerate(wave.object_keys):
name = wave.filenames[i] if i < len(wave.filenames) else "image.jpg"
try:
ledger.attach_file(
work_order_no=wo,
file_name=name or "image.jpg",
object_key=key,
content_type="image/jpeg",
sender_id=sender_id,
)
except Exception:
logger.exception("挂图片附件失败 wo=%s", wo)
logger.info("image_inquiry 波次完成 phase=%s oral_len=%s", phase, len(oral))
return phase
+22 -2
View File
@@ -21,11 +21,23 @@ from agent.policy.sea_text_flow import get_sea_text_flow
logger = logging.getLogger(__name__)
def _front_bookmark(sess) -> bool:
"""补问/核对手稿优先于已出票旧书签。空的 need_mode 不算(那是丢会话后的误伤)。"""
if sess is None:
return False
phase = (sess.phase or "").strip()
line = (sess.business_line or "").upper()
if phase == "need_mode" and not line:
return False
return phase in {"clarify", "need_mode", "need_land_options", "wait_confirm"}
def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = ""):
"""
按当前书签或本句运输方式选空运/海运/陆运流程。
陆运核对卡上的确定优先于旧海运书签,避免「确定」被海运/空运吃掉。
图片识图在 Worker 写下的补问书签,HTTP 必须从 Redis 读到,否则「托盘」会变成再问运输方式。
"""
from agent.schema.land_options import looks_like_confirm
@@ -38,10 +50,15 @@ def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = ""):
if retry:
return land
sea = get_sea_text_flow()
air = get_air_text_flow()
sea_sess = sea.session_of(sender_id)
land_sess = land.session_of(sender_id)
air_sess = air.session_of(sender_id)
for sess, flow in ((land_sess, land), (air_sess, air), (sea_sess, sea)):
if _front_bookmark(sess):
return flow
if sea_sess and (sea_sess.business_line or "").upper() == "SEA":
return sea
land_sess = land.session_of(sender_id)
if land_sess and (land_sess.business_line or "").upper() == "LAND":
return land
mode = (injected_mode or detect_transport_mode(text) or "").upper()
@@ -49,7 +66,7 @@ def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = ""):
return sea
if mode == "LAND":
return land
return get_air_text_flow()
return air
def is_pending_land_confirm(message: InboundMessage) -> bool:
@@ -126,6 +143,9 @@ def handle_text_inquiry(
if not message.has_sender():
logger.warning("text_inquiry 拒绝:无 sender_id")
return "reject_no_sender"
from agent.handlers.image_inquiry import note_private_text
note_private_text(message.sender_id, message.content or "")
settings = get_settings()
msg_store = store or get_message_store()
engine = flow or _pick_text_flow(
+1
View File
@@ -84,6 +84,7 @@ def main() -> None:
level=logging.INFO,
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
)
logging.getLogger("httpx").setLevel(logging.WARNING)
settings = get_settings()
# test 环境默认允许本机注入,便于并存期联调
if settings.ytd_env == "test" and not settings.dev_inject_enabled:
+17 -3
View File
@@ -1,7 +1,9 @@
"""
识别任务:入队 Redis Stream,Worker HTTP 槽认领后处理。
本文件职责:enqueue;process_recognition 按 kind 分支(含报价模板 AI 映射)。
本文件职责:enqueue;process_recognition 按 kind 分支。
kind=vision 跑私聊图片波次,kind=attachment 跑附件材料,
kind=quote_template_parse 跑报价模板 AI 映射;其它 kind 仍占位。
禁止:回调线程同步等识别完成;禁止本模块写主账六态。
"""
@@ -41,7 +43,7 @@ def enqueue_recognition(
"""
识别任务入队(快回)。
kind 例:attachment_text / vision / quote_template_parse。
kind 例:attachment / vision / quote_template_parse。
"""
rt = redis or get_redis_runtime()
if idempotency_key:
@@ -67,6 +69,7 @@ def process_recognition_stub(data: dict[str, Any]) -> dict[str, Any]:
识别占位:回显 kind,不读附件、不调模型。
兼容旧调用名;正式路径请用 process_recognition。
禁止再调本函数以免与 process_recognition 互相递归。
"""
return process_recognition(data, allow_network=False)
@@ -75,12 +78,23 @@ def process_recognition(data: dict[str, Any], *, allow_network: bool = True) ->
"""
Worker 槽:按 kind 处理识别/映射任务。
vision:私聊图片波次收口。
attachment:H5 附件材料收口。
quote_template_parse:调千问 sheet_map,再 PUT 主账 parseResult。
禁止在本函数写六态。
"""
kind = str(data.get("kind") or "unknown")
payload = data.get("payload") or {}
payload = data.get("payload") if isinstance(data.get("payload"), dict) else {}
if not isinstance(payload, dict):
payload = {}
if kind == "vision":
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)
if kind == "quote_template_parse":
return _process_quote_template_parse(payload, allow_network=allow_network)
logger.info(
+38 -4
View File
@@ -24,13 +24,18 @@ _LABEL_LINE = re.compile(
r"^(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型/数量|车型数量|"
r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
r"客户名称|货值|是否为危险品)\s*[::]\s*(.+)$"
r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸)\s*[::]\s*(.+)$"
)
_LABEL_INLINE = re.compile(
r"(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型/数量|车型数量|"
r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
r"客户名称|货值|是否为危险品)\s*[::]\s*(\S+)"
r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸)\s*[::]\s*(\S+)"
)
# 陆运核对卡非必填:销售可能写「通关口岸皇岗」或照抄「通关口岸(非必填):皇岗」。长词优先。
_OPTIONAL_INQUIRY_LABELS = (
"通关口岸(非必填)",
"通关口岸",
)
# 群里协同标签,冒号可省略:「客户名称张三」「货值 10万」。长词优先。
_COLLAB_LABELS = (
@@ -122,6 +127,7 @@ def extract_inquiry_snapshot(
mode = (injected_mode or detect_transport_mode(text) or "").upper()
facts = harvest_oral_measures(text, normalize_facts(dict(injected_facts)))
facts = _merge_spoken_collab(text, facts)
facts = _merge_optional_inquiry_labels(text, facts)
subtype = str(facts.get("运输类型") or "").strip()
return {
"business_line": mode,
@@ -147,6 +153,7 @@ def extract_inquiry_snapshot(
source = "deepseek_b"
facts = harvest_oral_measures(text, normalize_facts(facts))
facts = _merge_spoken_collab(text, facts)
facts = _merge_optional_inquiry_labels(text, facts)
land_subtype = str((b_payload or {}).get("land_subtype") or facts.get("运输类型") or "").strip()
if land_subtype and not str(facts.get("运输类型") or "").strip():
facts["运输类型"] = land_subtype
@@ -168,9 +175,36 @@ def harvest_collab_labels(text: str) -> dict[str, str]:
「客户名称张三 包装方式编织袋 货值 10万」切成三项。
只说「纸箱」没有标签则不收。值取到下一个标签之前。
"""
return _harvest_labels(text, _COLLAB_LABELS)
def harvest_inquiry_optional_labels(text: str) -> dict[str, str]:
"""
陆运询价非必填标签:通关口岸。
核对卡写「通关口岸(非必填):」,销售可能照抄或只写「通关口岸皇岗」。
有无冒号都收。不猜口岸名。
"""
return _harvest_labels(text, _OPTIONAL_INQUIRY_LABELS)
def _merge_optional_inquiry_labels(text: str, facts: dict[str, str]) -> dict[str, str]:
"""把原话里的通关口岸写进询价事实。有值才覆盖。"""
from agent.schema.field_validate import normalize_facts
merged = dict(facts or {})
extra = normalize_facts(harvest_inquiry_optional_labels(text))
for key, val in extra.items():
if val:
merged[key] = val
return merged
def _harvest_labels(text: str, labels: tuple[str, ...]) -> dict[str, str]:
"""按标签切原话。同一起点只留最长标签。"""
raw = text or ""
hits: list[tuple[int, str]] = []
for label in _COLLAB_LABELS:
for label in labels:
start = 0
while True:
pos = raw.find(label, start)
@@ -181,7 +215,7 @@ def harvest_collab_labels(text: str) -> dict[str, str]:
if not hits:
return {}
hits.sort()
# 同一起点只留最长标签,避免 HS 抢 HS编码。
# 同一起点只留最长标签,避免 HS 抢 HS编码、通关口岸抢「通关口岸(非必填)」。
kept: list[tuple[int, str]] = []
for pos, label in hits:
if kept and kept[-1][0] == pos and len(label) <= len(kept[-1][1]):
+41 -2
View File
@@ -19,6 +19,42 @@ from agent.llm.modes import LlmMode
logger = logging.getLogger(__name__)
def message_plain_text(msg: dict[str, Any]) -> str:
"""
把 assistant message 收成纯文本。
千问视觉的 content 经常是 [{text: ...}] 数组,不是字符串;
思考模式打开时正文可能只在 reasoning_content。两者都漏会让识图变「没看清」。
"""
if not isinstance(msg, dict):
return ""
text = _content_to_text(msg.get("content"))
if text:
return text
reason = msg.get("reasoning_content")
if isinstance(reason, str) and reason.strip():
return reason.strip()
return ""
def _content_to_text(content: Any) -> str:
"""字符串或千问多模态数组 → 纯文本。"""
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, str) and item.strip():
parts.append(item.strip())
elif isinstance(item, dict):
piece = item.get("text") if "text" in item else item.get("content")
got = _content_to_text(piece)
if got:
parts.append(got)
return "\n".join(parts).strip()
return ""
@dataclass
class LlmHttpResult:
ok: bool
@@ -72,7 +108,7 @@ class LlmHttpClient:
mode_val = mode.value if isinstance(mode, LlmMode) else str(mode)
if provider == "qwen":
if mode_val == LlmMode.VISION.value:
return self.settings.qwen_vl_model or "qwen-vl-plus"
return self.settings.qwen_vl_model or "qwen3-vl-plus"
return self.settings.qwen_text_model or "qwen-plus"
return self.settings.deepseek_model or "deepseek-chat"
@@ -115,6 +151,9 @@ class LlmHttpClient:
"messages": messages,
"temperature": temperature,
}
if provider == "qwen":
# 识图只要可见原文。思考开着时 content 常为空,销售侧就会「认不到」。
body["enable_thinking"] = False
# 报价模板 mappingJson 体积大,显式抬高上限,降低半截 JSON
mode_val = mode.value if isinstance(mode, LlmMode) else str(mode)
if mode_val == LlmMode.SHEET_MAP.value:
@@ -151,7 +190,7 @@ class LlmHttpClient:
tool_calls: list[dict[str, Any]] = []
if choices:
msg = (choices[0].get("message") or {})
content = str(msg.get("content") or "")
content = message_plain_text(msg if isinstance(msg, dict) else {})
raw_calls = msg.get("tool_calls") or []
if isinstance(raw_calls, list):
tool_calls = [c for c in raw_calls if isinstance(c, dict)]
@@ -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
@@ -33,6 +33,7 @@ _FIELD_KEYS = (
"箱型箱量",
"车型/数量",
"车型数量",
"通关口岸",
)
@@ -127,7 +128,10 @@ def build_extract_messages(text: str) -> list[dict[str, str]]:
"10件/3pcs→空运件数;海运同样数字+件也要写入货量;100KGS/180kg/1.5KG/100公斤→毛重;2CBM/4.2cbm/2.5立方/2方→体积;"
"托盘/散货/卡板/纸箱/木箱→包装方式(销售说什么记什么,散货也要抽)。"
"9月15日/9月15号/2026-09-15→报价日期,规范成 yyyy-MM-dd,未写年用今年。"
"陆运「通关口岸:皇岗」「通关口岸皇岗」「过皇岗」有说就写入通关口岸,没说留空,禁止编造口岸。"
"「从南京飞吉隆坡」起运港=南京、目的港=吉隆坡;「北京飞吉隆坡」起运港=北京。"
"原话是机场三字码或航线就原样记,禁止翻译成城市名:"
"SZX-CGK / SZX/CGK / SZX到CGK → 起运港=SZX、目的港=CGK,不要写成深圳、雅加达。"
"换城市必须覆盖旧值,不要沿用上一句的港口。"
"运输方式仅在原话能判断时填 AIR/SEA/LAND,否则 UNKNOWN。"
),
+140 -4
View File
@@ -1,30 +1,166 @@
"""
千问视觉:读图 mode 壳。
千问视觉:只读图上可见原文。
本文件职责:把图片交给 qwen3-vl-plus,抄出看得见的文字。
属于 llm 包。依赖:LlmHttpClient(生产);单测注入 chat_fn。
禁止:填询价字段 JSON、算最终缺项、发明港口/重量/日期;禁止在回调线程长等(由 Worker 槽调用)。
"""
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.VISION
VISION_SYSTEM = (
"只读出图片上能看见的文字和数字,原样抄写,一行不漏。"
"英文、机场三字码、航线(如 SZX-CGK、SZX/CGK)必须保持原样,禁止翻译成中文城市名。"
"看不清的字不要猜。不要补询价缺项,不要发明港口、重量或日期。"
"只有整张图完全没有文字时才输出空;只要看得见字就必须抄出来。"
)
def sniff_image_mime(raw: bytes) -> str:
"""
按文件头认图片类型。企微截图经常是 PNG,不能一律当 JPEG 送给千问。
"""
blob = raw or b""
if len(blob) < 12:
return ""
if blob.startswith(b"\x89PNG\r\n\x1a\n"):
return "image/png"
if blob.startswith(b"\xff\xd8\xff"):
return "image/jpeg"
if blob[:4] == b"RIFF" and blob[8:12] == b"WEBP":
return "image/webp"
if blob.startswith(b"GIF87a") or blob.startswith(b"GIF89a"):
return "image/gif"
if blob[4:8] == b"ftyp":
return "image/heic"
if blob.startswith(b"BM"):
return "image/bmp"
return ""
def mode_meta() -> dict[str, Any]:
return {
"mode": MODE.value,
"provider": "qwen",
"purpose": "vision",
"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 当作图上可见原文。
没有该键时保持失败码,避免假装已经读图。
"""
if "assistant_text" in (payload or {}):
packed = _pack_text(str(payload.get("assistant_text") or ""), stub=True)
return packed
return {
"ok": False,
"stub": True,
"mode": MODE.value,
"text": "",
"empty": True,
"error": "vision_shell_not_implemented",
"echo_keys": sorted(payload.keys()),
"echo_keys": sorted((payload or {}).keys()),
}
def _vision_messages(*, image_b64: str, mime: str, image_url: str) -> list[dict[str, Any]]:
"""拼兼容 OpenAI 的图文消息。优先 data URL,其次 http 图链。"""
url = (image_url or "").strip()
if not url and image_b64:
kind = (mime or "image/jpeg").strip() or "image/jpeg"
url = f"data:{kind};base64,{image_b64}"
user_content: list[dict[str, Any]] = []
if url:
user_content.append({"type": "image_url", "image_url": {"url": url}})
user_content.append({"type": "text", "text": "请只抄写图上可见文字。"})
return [
{"role": "system", "content": VISION_SYSTEM},
{"role": "user", "content": user_content},
]
def invoke_vision(
*,
payload: dict[str, Any],
chat_fn: Optional[Any] = None,
allow_network: bool = False,
) -> dict[str, Any]:
"""
读一张图,返回可见原文。
chat_fn:单测注入,签名同 LlmHttpClient.chat。
allow_network=False 且无 chat_fn:走 invoke_shell,不打真网。
"""
data = dict(payload or {})
image_b64 = str(data.get("image_b64") or "")
image_url = str(data.get("image_url") or "")
mime = str(data.get("mime") or "").strip() or "image/jpeg"
if chat_fn is None and not allow_network:
return invoke_shell(payload=data)
if not image_b64 and not image_url:
logger.info("vision 拒绝:没有图")
return {
"ok": False,
"stub": False,
"mode": MODE.value,
"text": "",
"empty": True,
"error": "no_image",
}
if chat_fn is None:
from agent.llm.http_client import LlmHttpClient
client = LlmHttpClient.from_settings()
chat_fn = client.chat
messages = _vision_messages(
image_b64=image_b64,
mime=mime,
image_url=image_url,
)
result = chat_fn(mode=MODE, messages=messages)
if not getattr(result, "ok", False):
err = str(getattr(result, "error", "") or "vision_failed")
logger.info("vision 失败 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
logger.info(
"vision 读完 ok=%s empty=%s text_len=%s mime=%s",
True,
packed.get("empty"),
len(str(packed.get("text") or "")),
mime,
)
return packed
+100 -8
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
import logging
import threading
import uuid
from dataclasses import dataclass, field
from dataclasses import asdict, dataclass, field
from typing import Any, Callable, Optional
from agent.ledger.http_ledger import HttpLedger
@@ -55,6 +55,43 @@ class FlowSession:
pending_product_quote: dict[str, Any] = field(default_factory=dict)
def _session_payload(sess: FlowSession) -> dict[str, Any]:
"""书签转 JSON。tuple 改 list,方便 HTTP/Worker 共用。"""
data = asdict(sess)
data["allowed"] = list(sess.allowed or ())
data["invited_userids"] = list(sess.invited_userids or ())
data["invited_product_names"] = list(sess.invited_product_names or ())
data["facts"] = {str(k): str(v) for k, v in (sess.facts or {}).items()}
return data
def _session_from_payload(raw: dict[str, Any]) -> FlowSession:
"""Redis JSON → 书签。缺字段用默认,避免旧键打崩。"""
return FlowSession(
sender_id=str(raw.get("sender_id") or ""),
thread_id=str(raw.get("thread_id") or raw.get("sender_id") or ""),
wait_version=int(raw.get("wait_version") or 0),
phase=str(raw.get("phase") or "idle"),
business_line=str(raw.get("business_line") or ""),
facts={str(k): str(v) for k, v in dict(raw.get("facts") or {}).items()},
work_order_no=str(raw.get("work_order_no") or ""),
first_or_same=str(raw.get("first_or_same") or "first"),
history_work_order_no=str(raw.get("history_work_order_no") or ""),
quote=dict(raw.get("quote") or {}),
status=str(raw.get("status") or ""),
allowed=tuple(str(x) for x in (raw.get("allowed") or ())),
immutable_text=str(raw.get("immutable_text") or ""),
collab_chat_id=str(raw.get("collab_chat_id") or ""),
collab_facts={str(k): str(v) for k, v in dict(raw.get("collab_facts") or {}).items()},
invited_userids=tuple(str(x) for x in (raw.get("invited_userids") or ())),
invited_sales_name=str(raw.get("invited_sales_name") or ""),
invited_product_names=tuple(str(x) for x in (raw.get("invited_product_names") or ())),
deal_version=int(raw.get("deal_version") or 0),
collab_ended=bool(raw.get("collab_ended")),
pending_product_quote=dict(raw.get("pending_product_quote") or {}),
)
class AirTextInquiryFlow:
"""
空运文字询价 Owner。海运走 SeaTextInquiryFlow,陆运走 LandTextInquiryFlow。
@@ -63,12 +100,15 @@ class AirTextInquiryFlow:
价格卡按工单号另存一份当时会话:点哪张卡就生成哪张卡当时的报价单。
"""
def __init__(self, ledger: Any = None, group_client: Any = None) -> None:
bookmark_kind = "air"
def __init__(self, ledger: Any = None, group_client: Any = None, *, bookmark_store: Any = None) -> None:
self._ledger = ledger or build_ledger()
self._lock = threading.Lock()
self._by_sender: dict[str, FlowSession] = {}
self._by_ticket: dict[str, FlowSession] = {}
self._group_client = group_client
self._bookmark_store = bookmark_store
@property
def ledger(self) -> Any:
@@ -83,8 +123,16 @@ class AirTextInquiryFlow:
return self._group_client
def session_of(self, sender_id: str) -> Optional[FlowSession]:
sid = (sender_id or "").strip()
with self._lock:
return self._by_sender.get(sender_id)
hit = self._by_sender.get(sid)
if hit is not None:
return hit
loaded = self._load_bookmark(sid)
if loaded is None:
return None
self._save(loaded)
return loaded
def session_by_work_order(self, work_order_no: str) -> Optional[FlowSession]:
"""用工单号找回书签,不校验 sender。群成交卡校验用。"""
@@ -126,17 +174,14 @@ class AirTextInquiryFlow:
必须校验 sender_id,禁止用别人的工单号串会话。
"""
no = copy.work_order_from_card_meta(card_meta, text=text)
if not no:
return self.session_of(sender_id)
with self._lock:
if no:
hit = self._by_ticket.get(no)
if hit and hit.sender_id == sender_id:
return hit
elif not no:
return self._by_sender.get(sender_id)
if no:
# 卡上有工单号就只动这一单;进程刚重启时从主账收回,禁止落到别人/最新单。
return self._load_ticket_session(sender_id, no)
return None
def _save(self, sess: FlowSession) -> None:
with self._lock:
@@ -144,6 +189,53 @@ class AirTextInquiryFlow:
no = (sess.work_order_no or "").strip()
if no:
self._by_ticket[no] = sess
self._persist_bookmark(sess)
def drop_current_bookmark(self, sender_id: str) -> Optional[FlowSession]:
"""
发图新开:丢掉当前书签,已出号会话仍留在 _by_ticket。
未出号的补问/核对草稿从此不再被文字当「当前单」。
HTTP 与 Worker 必须同时丢掉 Redis 里那份,否则补「托盘」会问运输方式。
"""
sid = (sender_id or "").strip()
if not sid:
return None
with self._lock:
old = self._by_sender.pop(sid, None)
self._delete_bookmark(sid)
return old
def _persist_bookmark(self, sess: FlowSession) -> None:
from agent.redis_coord.flow_bookmark import save_flow_bookmark
save_flow_bookmark(
self.bookmark_kind,
sess.sender_id,
_session_payload(sess),
store=self._bookmark_store,
)
def _load_bookmark(self, sender_id: str) -> Optional[FlowSession]:
from agent.redis_coord.flow_bookmark import load_flow_bookmark
raw = load_flow_bookmark(
self.bookmark_kind,
sender_id,
store=self._bookmark_store,
)
if not raw:
return None
return _session_from_payload(raw)
def _delete_bookmark(self, sender_id: str) -> None:
from agent.redis_coord.flow_bookmark import delete_flow_bookmark
delete_flow_bookmark(
self.bookmark_kind,
sender_id,
store=self._bookmark_store,
)
def _load_ticket_session(self, sender_id: str, work_order_no: str) -> Optional[FlowSession]:
"""
@@ -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
+386
View File
@@ -0,0 +1,386 @@
"""
私聊图片波次账。
本文件职责:记住「这一波」图和紧挨的字,供图片询价拼口播。
属于 policy 包。生产波次内容落 Redis(HTTP 与 Worker 共用);单测可纯内存。
禁止:调模型、建单、写六态、在这里等几秒;禁止一把大锁串全部销售。
10 秒只约束发图前那一句。发图之后只看业务结果出没出。
"""
from __future__ import annotations
import json
import logging
import threading
import uuid
from dataclasses import asdict, dataclass, field
from typing import Any, Optional, Protocol
# 规格:发图前紧挨的一句字,间隔不超过 10 秒才带到新单。
PRECEDE_SECONDS = 10.0
# 波次短协调:超时后当新开,不当六态。
_WAVE_TTL_SEC = 2 * 3600
logger = logging.getLogger(__name__)
class WaveKV(Protocol):
"""HTTP 与 Worker 共用的短存。单测可注入内存假实现。"""
def get_json(self, kind: str, sender_id: str) -> Optional[dict[str, Any]]: ...
def set_json(self, kind: str, sender_id: str, data: dict[str, Any]) -> None: ...
def delete(self, kind: str, sender_id: str) -> None: ...
@dataclass
class LastPrivateText:
"""同一发送人最近一句私聊文字。"""
sender_id: str
text: str
ts: float
@dataclass
class ImageWave:
"""
一张新询价对应的图片波次。
pending:还在认的图张数;为 0 且未 business_done 时才能出业务结果。
"""
sender_id: str
wave_id: str
ack_sent: bool = False
business_done: bool = False
media_ids: list[str] = field(default_factory=list)
vision_texts: list[str] = field(default_factory=list)
pending: int = 0
precede_text: str = ""
follow_text: str = ""
object_keys: list[str] = field(default_factory=list)
filenames: list[str] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
class ImageWaveBook:
"""
图片波次账。按 sender 一把锁,不同销售互不等。
生产必须挂 Redis:收图在 HTTP 进程、识图在 Worker,两边不能各记一份内存。
单测不传 kv,仍走进程内字典。
"""
def __init__(self, kv: Optional[WaveKV] = None) -> None:
self._lock = threading.Lock()
self._kv = kv
self._last_text: dict[str, LastPrivateText] = {}
self._waves: dict[str, ImageWave] = {}
def _hydrate(self, sender_id: str) -> None:
"""从 Redis 覆盖本进程缓存。无 kv 时不做事。"""
sid = (sender_id or "").strip()
if not sid or self._kv is None:
return
try:
raw_wave = self._kv.get_json("wave", sid)
if raw_wave:
self._waves[sid] = _wave_from_dict(raw_wave)
else:
self._waves.pop(sid, None)
raw_text = self._kv.get_json("text", sid)
if raw_text and raw_text.get("text"):
self._last_text[sid] = LastPrivateText(
sender_id=sid,
text=str(raw_text.get("text") or ""),
ts=float(raw_text.get("ts") or 0.0),
)
else:
self._last_text.pop(sid, None)
except Exception:
logger.exception("读图片波次失败 sender=%s", sid)
def _persist(self, sender_id: str) -> None:
"""把当前 sender 的波次和前一句写回 Redis。"""
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, asdict(wave))
row = self._last_text.get(sid)
if row is None:
self._kv.delete("text", sid)
else:
self._kv.set_json("text", sid, {"text": row.text, "ts": row.ts})
except Exception:
logger.exception("写图片波次失败 sender=%s", sid)
def note_text(self, sender_id: str, text: str, now: float) -> None:
"""记下最近一句私聊文字,供 10 秒内发图带走。空句不记。"""
body = (text or "").strip()
sid = (sender_id or "").strip()
if not sid or not body:
return
with self._lock:
self._hydrate(sid)
self._last_text[sid] = LastPrivateText(sender_id=sid, text=body, ts=float(now))
self._persist(sid)
def forget_text(self, sender_id: str) -> None:
"""
图片口播不是销售刚打的字,认完后清掉,避免 10 秒内再发图把旧口播带走。
"""
sid = (sender_id or "").strip()
if not sid:
return
with self._lock:
self._hydrate(sid)
self._last_text.pop(sid, None)
self._persist(sid)
def precede_text(self, sender_id: str, now: float) -> str:
"""发图前那一句:超过 10 秒返回空。"""
sid = (sender_id or "").strip()
with self._lock:
self._hydrate(sid)
row = self._last_text.get(sid)
if row is None:
return ""
if float(now) - row.ts > PRECEDE_SECONDS:
return ""
return row.text
def active(self, sender_id: str) -> Optional[ImageWave]:
"""未出业务结果的当前波;没有则 None。"""
sid = (sender_id or "").strip()
with self._lock:
self._hydrate(sid)
wave = self._waves.get(sid)
if wave is None or wave.business_done:
return None
return wave
def open_or_join(
self, sender_id: str, media_id: str, *, now: float
) -> tuple[ImageWave, str]:
"""
第一张图开波;业务发出前的后续图并入;发出后再发图开新波。
返回 how:opened / joined / new_after_done。
副作用:pending +1。不发送企微。
"""
sid = (sender_id or "").strip()
mid = (media_id or "").strip()
precede = self.precede_text(sid, now)
with self._lock:
self._hydrate(sid)
cur = self._waves.get(sid)
if cur is not None and not cur.business_done:
if mid:
cur.media_ids.append(mid)
cur.pending += 1
self._persist(sid)
return cur, "joined"
how = "new_after_done" if cur is not None and cur.business_done else "opened"
wave = ImageWave(
sender_id=sid,
wave_id=uuid.uuid4().hex,
media_ids=[mid] if mid else [],
pending=1,
precede_text=precede,
)
self._waves[sid] = wave
self._persist(sid)
return wave, how
def mark_ack(self, sender_id: 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
self._persist(sid)
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 add_vision(
self,
sender_id: str,
text: str,
*,
object_key: str = "",
filename: str = "",
error: str = "",
) -> ImageWave:
"""
一张图认完。pending 减一,可见原文追加。
调用:Worker 识别槽。不得在本方法出发业务回复。
"""
sid = (sender_id or "").strip()
with self._lock:
self._hydrate(sid)
wave = self._waves.get(sid)
if wave is None:
wave = ImageWave(sender_id=sid, wave_id=uuid.uuid4().hex, pending=1)
self._waves[sid] = wave
body = (text or "").strip()
if body:
wave.vision_texts.append(body)
if object_key:
wave.object_keys.append(object_key)
if filename:
wave.filenames.append(filename)
if error:
wave.errors.append(error)
if wave.pending > 0:
wave.pending -= 1
self._persist(sid)
return wave
def compose_oral(self, sender_id: str) -> str:
"""precede + 各张可见原文 + 后到的字,换行拼接。"""
sid = (sender_id or "").strip()
with self._lock:
self._hydrate(sid)
wave = self._waves.get(sid)
if wave is None:
return ""
parts = []
if wave.precede_text.strip():
parts.append(wave.precede_text.strip())
for chunk in wave.vision_texts:
if chunk.strip():
parts.append(chunk.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:
"""业务补问/卡片已发出。之后再发图必须新开。"""
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
wave.pending = 0
self._persist(sid)
_BOOK: Optional[ImageWaveBook] = None
_BOOK_GUARD = threading.Lock()
def _wave_from_dict(raw: dict[str, Any]) -> ImageWave:
"""Redis JSON → 波次对象。缺字段用默认,避免旧键打崩。"""
return ImageWave(
sender_id=str(raw.get("sender_id") or ""),
wave_id=str(raw.get("wave_id") or uuid.uuid4().hex),
ack_sent=bool(raw.get("ack_sent")),
business_done=bool(raw.get("business_done")),
media_ids=[str(x) for x in (raw.get("media_ids") or [])],
vision_texts=[str(x) for x in (raw.get("vision_texts") or [])],
pending=int(raw.get("pending") or 0),
precede_text=str(raw.get("precede_text") or ""),
follow_text=str(raw.get("follow_text") or ""),
object_keys=[str(x) for x in (raw.get("object_keys") or [])],
filenames=[str(x) for x in (raw.get("filenames") or [])],
errors=[str(x) for x in (raw.get("errors") or [])],
)
class RedisWaveKV:
"""
把图片波次落到本前缀 Redis,TTL 2 小时。
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 IMAGE_WAVE, IMAGE_WAVE_TEXT
suffix = IMAGE_WAVE if kind == "wave" else IMAGE_WAVE_TEXT
raw = self._client.raw.get(self._client.key(suffix, 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 IMAGE_WAVE, IMAGE_WAVE_TEXT
suffix = IMAGE_WAVE if kind == "wave" else IMAGE_WAVE_TEXT
self._client.raw.set(
self._client.key(suffix, 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 IMAGE_WAVE, IMAGE_WAVE_TEXT
suffix = IMAGE_WAVE if kind == "wave" else IMAGE_WAVE_TEXT
self._client.raw.delete(self._client.key(suffix, sender_id))
def _production_kv() -> Optional[WaveKV]:
"""生产挂 Redis;连不上则退回内存并打日志。"""
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 RedisWaveKV(rt.client)
except Exception:
logger.exception("图片波次 Redis 不可用,本进程退回内存账(双进程会丢第二张图)")
return None
def get_image_wave_book() -> ImageWaveBook:
"""
进程内一份句柄;生产波次内容在 Redis,HTTP 与 Worker 才能对上。
"""
global _BOOK
with _BOOK_GUARD:
if _BOOK is None:
_BOOK = ImageWaveBook(kv=_production_kv())
return _BOOK
def reset_image_wave_book_for_test(kv: Optional[WaveKV] = None) -> ImageWaveBook:
"""单测重置。传入同一 kv 可模拟 HTTP/Worker 两份句柄。"""
global _BOOK
with _BOOK_GUARD:
_BOOK = ImageWaveBook(kv=kv)
return _BOOK
+90 -10
View File
@@ -100,6 +100,60 @@ _CONTINUE_HINT = (
ASK_TRANSPORT = "请补充运输方式:海运 / 空运 / 陆运。"
# 私聊图片询价:先回识别中;看不清/失败与补问拼成一条,销售侧不加 [test]。
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:
"""
看不清或技术失败时,说明 + 文字询价补问写在同一条回复。
禁止只回说明就把人丢在那里。两端空白去掉,中间空一行。
"""
head = (reason or "").strip()
body = (clarify or "").strip()
if head and body:
return f"{head}\n\n{body}"
return head or body
# 旧一句只问运输类型;销售要一次看到类型、线路、分类。残留调用也走完整清单。
ASK_LAND_SUBTYPE = _land_option_prompt(current={})
@@ -195,6 +249,24 @@ def land_group_pair_rejected() -> str:
return "拉群失败,请排查填写内容,并重新发起工单。"
def _land_optional_keys(land_type: str) -> tuple[str, ...]:
"""当前类型询价非必填内部键。国内没有通关口岸。"""
if land_type in _LAND_CUSTOMS_TYPES:
return ("通关口岸",)
return ()
def _land_field_line(key: str, value: str, *, required: bool, example: str = "") -> str:
"""销售侧一行:名称(必填/非必填):值或示例。"""
label = LAND_DISPLAY.get(key, key)
mark = "必填" if required else "非必填"
if value:
return f"{label}({mark}):{value}"
if example:
return f"{label}({mark}):(如:{example})"
return f"{label}({mark}):"
def ask_land_fields(
*,
facts: dict[str, str] | None = None,
@@ -204,30 +276,37 @@ def ask_land_fields(
"""
陆运货物缺项补问。不出核对卡、不出现确定句。
已识别和待补充都标明必填/非必填。跨境/中港空着的通关口岸也列进待补充。
报价日期不进待补充。
"""
src = dict(facts or {})
skip = {"报价日期", "运输类型", "线路类别", "运输分类"}
missing = [k for k in missing_keys if k and k not in skip]
land_type = (land_subtype or src.get("运输类型") or "").strip()
optional = _land_optional_keys(land_type)
lines = ["请确认并补充以下信息:", "运输方式:陆运"]
if land_type:
lines.append(f"运输类型:{land_type}")
for key in _land_cargo_keys(land_type, src):
val = (src.get(key) or "").strip()
if val:
lines.append(f"{LAND_DISPLAY.get(key, key)}:{val}")
lines.append(_land_field_line(key, val, required=True))
for key in optional:
val = (src.get(key) or "").strip()
if val:
lines.append(_land_field_line(key, val, required=False))
pending = list(missing)
for key in optional:
if not (src.get(key) or "").strip() and key not in pending:
pending.append(key)
if not pending:
return ""
lines.append("")
lines.append("待补充:")
if not missing:
return ""
for key in missing:
label = display_name(key, "LAND")
for key in pending:
required = key not in optional
example = _LAND_EXAMPLES.get(key, "")
if example:
lines.append(f"{label}:(如:{example})")
else:
lines.append(f"{label}:")
lines.append(_land_field_line(key, "", required=required, example=example))
return "\n".join(lines)
@@ -367,7 +446,8 @@ def inquiry_field_lines(
"""
询价确认字段行(销售可见)。
起运/目的展示销售原文(南京/北京),三字码只给出站 TMS,不对销售展示。
起运/目的展示销售原文:说南京就南京,图上/原话是 SZX 就展示 SZX。
originCode 只给出站 TMS,不拿来改写成城市名再给销售看。
"""
origin = (facts.get("起运港") or facts.get("起运地") or "-").strip()
dest = (facts.get("目的港") or facts.get("目的地") or "-").strip()
@@ -41,6 +41,8 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
前半段与海运不同:核对通过前不建单。后半段套海运建群,匹配改走线路类别。
"""
bookmark_kind = "land"
def on_text(
self,
*,
@@ -40,6 +40,8 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
复用空运后半段(跳过协同 / 出方案卡 / Excel PDF / 成交),只改查价前与卡片、拉群。
"""
bookmark_kind = "sea"
def __init__(self, ledger: Any = None, group_client: Any = None) -> None:
super().__init__(ledger=ledger)
self._group_client = group_client
@@ -0,0 +1,133 @@
"""
询价当前书签短协调:HTTP 收字、Worker 识图后补问必须读同一份。
不当六态、不当 checkpoint。只存当前 sender 的补问/核对草稿。
禁止:写入现网 Redis 前缀;把已出号工单只放这里当主账。
"""
from __future__ import annotations
import json
import logging
from typing import Any, Optional, Protocol
from agent.redis_coord.keys import FLOW_BOOKMARK
from agent.redis_coord.runtime import get_redis_runtime
logger = logging.getLogger(__name__)
_TTL_SEC = 24 * 3600
class BookmarkStore(Protocol):
"""单测可注入;生产走 Redis。"""
def save(self, kind: str, sender_id: str, payload: dict[str, Any]) -> None: ...
def load(self, kind: str, sender_id: str) -> Optional[dict[str, Any]]: ...
def delete(self, kind: str, sender_id: str) -> None: ...
class DictBookmarkStore:
"""两份 Flow 实例共用同一字典,模拟 HTTP/Worker。"""
def __init__(self) -> None:
self._d: dict[str, dict[str, Any]] = {}
def save(self, kind: str, sender_id: str, payload: dict[str, Any]) -> None:
self._d[f"{kind}:{sender_id}"] = json.loads(json.dumps(payload))
def load(self, kind: str, sender_id: str) -> Optional[dict[str, Any]]:
row = self._d.get(f"{kind}:{sender_id}")
return json.loads(json.dumps(row)) if row is not None else None
def delete(self, kind: str, sender_id: str) -> None:
self._d.pop(f"{kind}:{sender_id}", None)
def _redis_ok() -> bool:
try:
return get_redis_runtime().client.backend == "redis"
except Exception:
return False
def save_flow_bookmark(
kind: str,
sender_id: str,
payload: dict[str, Any],
*,
store: Optional[BookmarkStore] = None,
) -> None:
"""写下当前书签。store 优先;否则仅真 Redis。"""
sid = (sender_id or "").strip()
name = (kind or "").strip() or "air"
if not sid or not isinstance(payload, dict):
return
if store is not None:
store.save(name, sid, payload)
return
if not _redis_ok():
return
try:
client = get_redis_runtime().client
client.raw.set(
client.key(FLOW_BOOKMARK, name, sid),
json.dumps(payload, ensure_ascii=False),
ex=_TTL_SEC,
)
except Exception:
logger.exception("写询价书签失败 kind=%s sender=%s", name, sid)
def load_flow_bookmark(
kind: str,
sender_id: str,
*,
store: Optional[BookmarkStore] = None,
) -> dict[str, Any]:
"""取出当前书签。没有则空。"""
sid = (sender_id or "").strip()
name = (kind or "").strip() or "air"
if not sid:
return {}
if store is not None:
return dict(store.load(name, sid) or {})
if not _redis_ok():
return {}
try:
client = get_redis_runtime().client
raw = client.raw.get(client.key(FLOW_BOOKMARK, name, sid))
if not raw:
return {}
if isinstance(raw, bytes):
raw = raw.decode("utf-8")
data = json.loads(raw)
return dict(data) if isinstance(data, dict) else {}
except Exception:
logger.exception("读书签失败 kind=%s sender=%s", name, sid)
return {}
def delete_flow_bookmark(
kind: str,
sender_id: str,
*,
store: Optional[BookmarkStore] = None,
) -> None:
"""发图新开或结束当前草稿时删掉。已出号会话不靠这把键。"""
sid = (sender_id or "").strip()
name = (kind or "").strip() or "air"
if not sid:
return
if store is not None:
store.delete(name, sid)
return
if not _redis_ok():
return
try:
client = get_redis_runtime().client
client.raw.delete(client.key(FLOW_BOOKMARK, name, sid))
except Exception:
logger.exception("删书签失败 kind=%s sender=%s", name, sid)
+9
View File
@@ -57,6 +57,15 @@ QUOTE_FILE_READY = "quote_file_ready"
PENDING_PRODUCT_QUOTE = "pending_product_quote"
# 陆运核对卡已出、等确定查价:短协调,不当六态、不当工单号
PENDING_LAND_CONFIRM = "pending_land_confirm"
# 空运/海运/陆运当前书签:HTTP 与 Worker 共用,不当六态
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"
+39 -2
View File
@@ -14,6 +14,8 @@ from agent.channel.wecom.models import InboundMessage
from agent.handlers import abnormal, card_action, close_deal, continue_thread, new_inquiry
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
@@ -56,6 +58,16 @@ 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)
def _wrap_continue(message: InboundMessage, *, thread_id: str = "") -> None:
continue_thread.handle_continue_thread(message, thread_id=thread_id)
@@ -111,8 +123,8 @@ 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,
"image_inquiry": _wrap_new,
"attachment_inquiry": _wrap_attachment,
"image_inquiry": _wrap_image,
"multi_segment_transport": _wrap_new,
"tms_read_only_quote": _wrap_continue,
"new_inquiry": _wrap_new,
@@ -203,6 +215,31 @@ def dispatch_inbound(message: InboundMessage, decision: RouteDecision) -> str:
HANDLER_REGISTRY["reject_no_sender"](message)
return "reject_no_sender"
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)
return "image_wave_follow_text"
fn = resolve_handler(decision.intent)
if fn is None:
logger.warning("dispatch 未知意图 intent=%s → noop", decision.intent)
@@ -189,6 +189,8 @@ _ALIASES = {
"客户名称": "客户名称",
"货值": "货值",
"是否为危险品": "是否为危险品",
"通关口岸": "通关口岸",
"通关口岸(非必填)": "通关口岸",
}
+43 -2
View File
@@ -47,6 +47,35 @@ _PACKS = {
}
_IATA = re.compile(r"^[A-Za-z]{3}$")
_IATA_PAIR = re.compile(
r"(?<![A-Za-z])([A-Za-z]{3})\s*[-–—/]\s*([A-Za-z]{3})(?![A-Za-z])"
)
# 原话里的 AAA-BBB 不一定是航线,货币/单位不能当成起运目的。
_IATA_PAIR_BLOCK = frozenset(
{
"USD",
"CNY",
"EUR",
"GBP",
"HKD",
"JPY",
"CBM",
"KGS",
"PCS",
"PKG",
"PDF",
"JPG",
"PNG",
"AND",
"THE",
"FOR",
"NOT",
"MAX",
"MIN",
"GMT",
"UTC",
}
)
_NUMBER = re.compile(r"(\d+(?:\.\d+)?)")
@@ -67,12 +96,24 @@ def resolve_airport_code(raw: str) -> str:
def harvest_named_air_route(text: str, facts: dict[str, str] | None = None) -> dict[str, str]:
"""
当前原话里的「从南京飞吉隆坡 / 北京飞吉隆坡」覆盖起运地、目的地。
当前原话里的航线覆盖起运地、目的地。
只认对照表里的地名,不猜没写过的港口。换城市必须丢掉旧三字码。
图上/原话写了 SZX-CGK 这类三字码对,必须保持英文,禁止改写成深圳/雅加达。
「从南京飞吉隆坡」仍按对照表收中文地名。换城市必须丢掉旧三字码。
"""
merged = dict(facts or {})
raw = text or ""
pair = _IATA_PAIR.search(raw)
if pair:
origin_code = pair.group(1).upper()
dest_code = pair.group(2).upper()
blocked = origin_code in _IATA_PAIR_BLOCK or dest_code in _IATA_PAIR_BLOCK
if origin_code != dest_code and not blocked:
merged["起运港"] = origin_code
merged["目的港"] = dest_code
merged.pop("originCode", None)
merged.pop("destinationCode", None)
return merged
names = sorted(_AIRPORTS.keys(), key=len, reverse=True)
for origin in names:
for dest in names:
+2
View File
@@ -30,6 +30,8 @@ def main() -> None:
level=logging.INFO,
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
)
# Worker 自己下素材,必须关掉 httpx INFO,否则 access_token 会进日志。
logging.getLogger("httpx").setLevel(logging.WARNING)
settings = get_settings()
graph_runtime = get_graph_runtime(settings)
redis_runtime = get_redis_runtime(settings)
+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
@@ -149,6 +149,33 @@ class AirTextInquiryTests(unittest.TestCase):
self.assertNotIn("请补充运输方式", joined)
self.assertEqual(self.flow.session_of("u_fly").business_line, "AIR")
def test_worker_clarify_http_pallet_keeps_air(self) -> None:
"""识图在 Worker 写下补问,HTTP 补「托盘」必须仍是空运,不能再问运输方式。"""
from agent.redis_coord.flow_bookmark import DictBookmarkStore
store = DictBookmarkStore()
worker = AirTextInquiryFlow(MemoryLedger(), bookmark_store=store)
http = AirTextInquiryFlow(MemoryLedger(), bookmark_store=store)
replies: list[str] = []
def reply(text: str, extra=None) -> None:
replies.append(text)
phase = worker.on_text(
sender_id="u_img",
text="空运 深圳到雅加达 发光二级管组件 11P 2400KG 20cbm",
reply=reply,
)
self.assertEqual(phase, "clarify")
replies.clear()
phase = http.on_text(sender_id="u_img", text="托盘", reply=reply)
self.assertNotEqual(phase, "need_mode")
self.assertNotIn("请补充运输方式", "\n".join(replies))
sess = http.session_of("u_img")
self.assertIsNotNone(sess)
self.assertEqual(sess.business_line, "AIR")
self.assertEqual(sess.facts.get("包装方式"), "托盘")
def test_missing_mode_asks_transport(self) -> None:
phase = self.flow.on_text(
sender_id="u1",
@@ -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()
+64 -1
View File
@@ -15,7 +15,7 @@ if ROOT not in sys.path:
os.environ.setdefault("LEDGER_BACKEND", "memory")
from agent.llm.extract_text import extract_inquiry_snapshot
from agent.llm.extract_text import extract_inquiry_snapshot, parse_labeled_facts
from agent.llm.mode_extract_fields import invoke_shell, parse_extract_payload
from agent.policy.air_text_flow import AirTextInquiryFlow
from agent.ledger.memory_ledger import MemoryLedger
@@ -65,6 +65,22 @@ class OralExtractTests(unittest.TestCase):
self.assertEqual(snap["business_line"], "AIR")
self.assertFalse(snap["facts"].get("起运港"))
def test_labeled_customs_port(self) -> None:
self.assertEqual(parse_labeled_facts("通关口岸:皇岗").get("通关口岸"), "皇岗")
snap = extract_inquiry_snapshot("通关口岸(非必填):皇岗")
self.assertEqual(snap["facts"].get("通关口岸"), "皇岗")
snap2 = extract_inquiry_snapshot("通关口岸皇岗")
self.assertEqual(snap2["facts"].get("通关口岸"), "皇岗")
def test_b_keeps_land_customs_port(self) -> None:
parsed = parse_extract_payload(
{
"transport_mode": "LAND",
"fields": {"起运港": "广州", "通关口岸": "皇岗"},
}
)
self.assertEqual(parsed["facts"].get("通关口岸"), "皇岗")
def test_assemble_air_query_zhuhai_clark(self) -> None:
from agent.schema.tms_air_query import assemble_air_query
@@ -134,6 +150,46 @@ class OralExtractTests(unittest.TestCase):
self.assertNotIn("起运地:NKG", shown)
self.assertNotIn("起运地:南京", shown)
def test_iata_pair_kept_not_rewritten_to_city(self) -> None:
from agent.schema.field_validate import harvest_oral_measures
from agent.schema.tms_air_query import assemble_air_query
from agent.policy import inquiry_copy as copy
oral = "空运 SZX-CGK 发光二级管组件 11P 2400KG 20cbm"
facts = harvest_oral_measures(
oral,
{"起运港": "深圳", "目的港": "雅加达", "品名": "发光二级管组件"},
)
self.assertEqual(facts["起运港"], "SZX")
self.assertEqual(facts["目的港"], "CGK")
assembled = assemble_air_query(
{
**facts,
"件数": "11P",
"毛重": "2400KG",
"体积": "20cbm",
"包装方式": "托盘",
"报价日期": "2026-09-18",
}
)
self.assertEqual(assembled["facts"]["originCode"], "SZX")
self.assertEqual(assembled["facts"]["destinationCode"], "CGK")
card = copy.inquiry_card(work_order_no="WO1", facts=assembled["facts"])
self.assertIn("起运地:SZX", card)
self.assertIn("目的地:CGK", card)
self.assertNotIn("深圳", card)
self.assertNotIn("雅加达", card)
def test_currency_pair_is_not_air_route(self) -> None:
from agent.schema.field_validate import harvest_oral_measures
facts = harvest_oral_measures(
"空运 货值 USD-CNY 茶叶",
{"起运港": "上海", "目的港": "洛杉矶"},
)
self.assertEqual(facts["起运港"], "上海")
self.assertEqual(facts["目的港"], "洛杉矶")
def test_oral_pack_and_quote_date_from_current_sentence(self) -> None:
from agent.schema.field_validate import harvest_oral_measures
@@ -225,6 +281,13 @@ class OralExtractTests(unittest.TestCase):
self.assertEqual(norm["毛重"], "180")
self.assertEqual(norm["体积"], "2.5")
def test_extract_prompt_keeps_iata(self) -> None:
from agent.llm.mode_extract_fields import build_extract_messages
blob = str(build_extract_messages("空运 SZX-CGK"))
self.assertIn("SZX-CGK", blob)
self.assertIn("禁止翻译成城市名", blob)
def test_chat_fn_fixture_drives_flow(self) -> None:
def chat_fn(**kwargs):
return SimpleNamespace(
+272
View File
@@ -0,0 +1,272 @@
"""
私聊图片询价:识图当口播、新开一票、复用文字流程。
"""
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.channel.outbox.sender import WeComAppClient
from agent.handlers.image_inquiry import (
flush_active_wave,
handle_image_inquiry,
)
from agent.handlers.text_inquiry import handle_text_inquiry
from agent.ledger.memory_ledger import MemoryLedger
from agent.policy import inquiry_copy as copy
from agent.policy.air_text_flow import AirTextInquiryFlow, FlowSession, reset_air_text_flow_for_test
from agent.policy.image_wave import reset_image_wave_book_for_test
from agent.policy.land_text_flow import get_land_text_flow, 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
"""
LAND_ORAL = """陆运
国内运输拼车
国内长途/零担
急件
起运港:广州
目的港:深圳
品名:衣服
体积:100CBM
毛重:50kg
"""
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, msgid: str = "") -> InboundMessage:
return InboundMessage(
sender_id=sender,
message_id=msgid or mid,
content="",
msg_type="image",
media={"media_id": mid, "filename": f"{mid}.jpg"},
)
class ImageBookmarkTests(unittest.TestCase):
def test_drop_keeps_ticket_session(self) -> None:
flow = AirTextInquiryFlow(MemoryLedger())
sess = FlowSession(
sender_id="s1", thread_id="s1", work_order_no="WO1", phase="quoted"
)
flow._save(sess)
dropped = flow.drop_current_bookmark("s1")
self.assertEqual(dropped.work_order_no, "WO1")
self.assertIsNone(flow.session_of("s1"))
self.assertIsNotNone(flow.session_by_work_order("WO1"))
def test_download_empty_media_id(self) -> None:
client = WeComAppClient(corp_id="c", secret="s", agent_id=1)
raw, err = client.download_media("")
self.assertEqual(raw, b"")
self.assertEqual(err, "media_empty")
class ImageInquiryFlowTests(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_image_wave_book_for_test()
self.store = MemoryMessageStore()
def _bodies(self) -> list[str]:
return [i.content for i in self.store._outbox.values()]
def test_only_image_asks_mode(self) -> None:
phase = handle_image_inquiry(
_img("s1", "mid-1"),
store=self.store,
vision_fn=_vision(""),
download_fn=lambda _mid: (b"x", ""),
)
bodies = self._bodies()
self.assertEqual(bodies[0], copy.IMAGE_RECOGNIZING)
self.assertIn("没看清", bodies[-1])
self.assertIn("海运", bodies[-1])
self.assertIn("空运", bodies[-1])
self.assertEqual(phase, "need_mode")
def test_sea_visible_creates_ticket(self) -> None:
handle_image_inquiry(
_img("s1", "mid-sea"),
store=self.store,
vision_fn=_vision(SEA_ORAL),
download_fn=lambda _mid: (b"x", ""),
put_fn=lambda **k: {"ok": True, "key": k.get("key") or "k1"},
)
sea = get_sea_text_flow()
sess = sea.session_of("s1")
self.assertIsNotNone(sess)
self.assertTrue((sess.work_order_no or "").strip())
ticket = sea.ledger.get_ticket(work_order_no=sess.work_order_no)
self.assertTrue(ticket.attachments)
def test_burst_two_images_one_ack(self) -> None:
handle_image_inquiry(
_img("s1", "mid-a", "m-a"),
store=self.store,
vision_fn=_vision(""),
download_fn=lambda _mid: (b"x", ""),
process_now=False,
)
handle_image_inquiry(
_img("s1", "mid-b", "m-b"),
store=self.store,
vision_fn=_vision(SEA_ORAL),
download_fn=lambda _mid: (b"x", ""),
process_now=False,
)
flush_active_wave(
"s1",
store=self.store,
vision_fn=_vision(SEA_ORAL),
download_fn=lambda _mid: (b"x", ""),
seed_message=_img("s1", "mid-b", "m-b"),
)
acks = [c for c in self._bodies() if c == copy.IMAGE_RECOGNIZING]
self.assertEqual(len(acks), 1)
sess = get_sea_text_flow().session_of("s1")
self.assertTrue(sess and sess.work_order_no)
def test_precede_text_joins_new_ticket(self) -> None:
handle_text_inquiry(
InboundMessage(
sender_id="s1",
message_id="txt1",
content="空运 上海到纽约 3件 180KGS 2CBM 托盘",
),
store=self.store,
)
handle_image_inquiry(
_img("s1", "mid-air"),
store=self.store,
vision_fn=_vision("包装方式:托盘"),
download_fn=lambda _mid: (b"x", ""),
)
from agent.policy.air_text_flow import get_air_text_flow
air = get_air_text_flow().session_of("s1")
self.assertIsNotNone(air)
self.assertEqual((air.business_line or "").upper(), "AIR")
def test_land_image_confirm_card_has_no_wo(self) -> None:
handle_image_inquiry(
_img("s1", "mid-land"),
store=self.store,
vision_fn=_vision(LAND_ORAL),
download_fn=lambda _mid: (b"x", ""),
)
last = self._bodies()[-1]
self.assertIn("请回复“确定”", last)
self.assertNotIn("WO", last)
sess = get_land_text_flow().session_of("s1")
self.assertEqual(sess.phase, "wait_confirm")
self.assertFalse((sess.work_order_no or "").strip())
def test_quoted_then_new_image_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
self.assertTrue(wo_old)
handle_image_inquiry(
_img("s1", "mid-new"),
store=self.store,
vision_fn=_vision(SEA_ORAL),
download_fn=lambda _mid: (b"x", ""),
)
wo_new = get_sea_text_flow().session_of("s1").work_order_no
self.assertTrue(wo_new)
self.assertNotEqual(wo_old, wo_new)
old = get_sea_text_flow().ledger.get_ticket(work_order_no=wo_old)
self.assertIsNotNone(old)
def test_group_image_not_this_handler(self) -> None:
msg = InboundMessage(
sender_id="s1",
message_id="g1",
content="",
msg_type="image",
chat_id="wr_group",
chat_type="group",
media={"media_id": "mid-g", "filename": "现场.jpg"},
)
name = dispatch_inbound(msg, RouteDecision(intent="ordinary_text_other"))
self.assertNotEqual(name, "image_inquiry")
def test_worker_kind_vision_finishes_wave(self) -> None:
from agent.handlers.image_inquiry import finish_queued_image
from agent.policy.image_wave import get_image_wave_book
book = reset_image_wave_book_for_test()
book.open_or_join("s1", "mid-1", now=1.0)
got = finish_queued_image(
{
"sender_id": "s1",
"media_id": "mid-1",
"message_id": "m1",
"__vision": {"ok": True, "text": "", "empty": True, "error": ""},
"__bytes": b"img",
}
)
self.assertTrue(got.get("ok"))
self.assertTrue(book._waves["s1"].business_done)
def test_png_download_sends_png_mime(self) -> None:
seen: dict = {}
png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 20
def _fn(**kwargs):
seen.update(kwargs.get("payload") or {})
return {"ok": True, "text": "起运地:深圳", "empty": False, "error": ""}
handle_image_inquiry(
_img("s1", "mid-png"),
store=self.store,
vision_fn=_fn,
download_fn=lambda _mid: (png, ""),
)
self.assertEqual(seen.get("mime"), "image/png")
self.assertTrue(seen.get("image_b64"))
if __name__ == "__main__":
unittest.main()
+103
View File
@@ -0,0 +1,103 @@
"""
私聊图片波次:前一句 10 秒、连发图、业务发出前收字。
"""
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.image_wave import (
ImageWaveBook,
PRECEDE_SECONDS,
reset_image_wave_book_for_test,
)
class ImageWaveTests(unittest.TestCase):
def setUp(self) -> None:
self.book = reset_image_wave_book_for_test()
def test_precede_window_is_ten_seconds(self) -> None:
self.assertEqual(PRECEDE_SECONDS, 10.0)
def test_precede_within_10s(self) -> None:
self.book.note_text("s1", "空运 上海到纽约", now=100.0)
self.assertEqual(self.book.precede_text("s1", now=109.9), "空运 上海到纽约")
def test_precede_after_10s_dropped(self) -> None:
self.book.note_text("s1", "空运 上海到纽约", now=100.0)
self.assertEqual(self.book.precede_text("s1", now=110.1), "")
def test_forget_text_drops_precede(self) -> None:
self.book.note_text("s1", "空运 上海到纽约", now=100.0)
self.book.forget_text("s1")
self.assertEqual(self.book.precede_text("s1", now=100.5), "")
def test_second_image_joins_before_business(self) -> None:
w1, how = self.book.open_or_join("s1", "mid-a", now=1.0)
self.assertEqual(how, "opened")
w2, how2 = self.book.open_or_join("s1", "mid-b", now=1.2)
self.assertEqual(how2, "joined")
self.assertEqual(w1.wave_id, w2.wave_id)
self.assertEqual(w2.media_ids, ["mid-a", "mid-b"])
def test_image_after_business_is_new_wave(self) -> None:
self.book.open_or_join("s1", "mid-a", now=1.0)
self.book.mark_business_done("s1")
w2, how = self.book.open_or_join("s1", "mid-b", now=2.0)
self.assertEqual(how, "new_after_done")
self.assertEqual(w2.media_ids, ["mid-b"])
def test_follow_text_before_business(self) -> None:
self.book.open_or_join("s1", "mid-a", now=1.0)
self.assertTrue(self.book.append_follow_text("s1", "海运"))
self.book.add_vision("s1", "宁波到汉堡")
oral = self.book.compose_oral("s1")
self.assertIn("海运", oral)
self.assertIn("宁波到汉堡", oral)
def test_follow_text_after_business_rejected(self) -> None:
self.book.open_or_join("s1", "mid-a", now=1.0)
self.book.mark_business_done("s1")
self.assertFalse(self.book.append_follow_text("s1", "海运"))
def test_http_and_worker_share_kv(self) -> None:
"""复现测服事故:HTTP 收图、Worker 认完必须看到同一份结束标记。"""
kv = _MemoryWaveKV()
http = reset_image_wave_book_for_test(kv=kv)
worker = ImageWaveBook(kv=kv)
http.open_or_join("s1", "mid-a", now=1.0)
self.assertIsNotNone(worker.active("s1"))
worker.add_vision("s1", "SZX-CGK 货交深圳")
worker.mark_business_done("s1")
self.assertIsNone(http.active("s1"))
_w2, how = http.open_or_join("s1", "mid-b", now=2.0)
self.assertEqual(how, "new_after_done")
class _MemoryWaveKV:
"""单测假 Redis:两份 ImageWaveBook 共用同一字典。"""
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()
+79
View File
@@ -106,6 +106,57 @@ class LandCopyTest(unittest.TestCase):
def test_no_staff_copy(self):
self.assertIn("陆运产品岗", copy.land_group_no_staff())
def test_ask_land_fields_marks_required_and_lists_optional(self):
text = copy.ask_land_fields(
facts={
"运输类型": "跨境集拼",
"线路类别": "东南亚",
"运输分类": "急件",
"起运港": "广州",
"目的港": "河内",
"品名": "衣服",
"毛重": "50kg",
},
missing_keys=["体积"],
land_subtype="跨境集拼",
)
self.assertIn("始发站(必填):广州", text)
self.assertIn("待补充:", text)
self.assertIn("体积(必填)", text)
self.assertIn("通关口岸(非必填)", text)
self.assertNotIn(copy.LAND_CONFIRM_TAIL, text)
def test_ask_land_fields_domestic_has_no_customs(self):
text = copy.ask_land_fields(
facts={
"运输类型": "国内运输拼车",
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"毛重": "50kg",
},
missing_keys=["体积"],
land_subtype="国内运输拼车",
)
self.assertIn("体积(必填)", text)
self.assertNotIn("通关口岸", text)
def test_ask_land_fields_filled_customs_not_pending(self):
text = copy.ask_land_fields(
facts={
"运输类型": "中港整车",
"起运港": "广州",
"目的港": "香港",
"品名": "衣服",
"通关口岸": "皇岗",
},
missing_keys=["起运港"],
land_subtype="中港整车",
)
self.assertIn("通关口岸(非必填):皇岗", text)
pending = text.split("待补充:", 1)[1]
self.assertNotIn("通关口岸", pending)
def test_inquiry_card_lists_land_fields_and_wo(self):
text = copy.inquiry_card(
work_order_no="WO202609170006",
@@ -261,6 +312,34 @@ class LandCopyTest(unittest.TestCase):
self.assertIn("客户名称", text)
self.assertNotIn("货好时间", text)
def test_image_copy(self):
self.assertEqual(copy.IMAGE_RECOGNIZING, "正在识别图片")
self.assertIn("没看清", copy.IMAGE_UNREADABLE)
self.assertIn("暂时没认出来", copy.IMAGE_TECH_FAIL)
merged = copy.image_fail_then_clarify(copy.IMAGE_UNREADABLE, copy.ASK_TRANSPORT)
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()
+51 -1
View File
@@ -122,7 +122,8 @@ class LandTextInquiryFrontTests(unittest.TestCase):
injected_mode="LAND",
)
self.assertEqual(phase, "clarify")
self.assertIn("车型/数量", self._joined())
self.assertIn("车型/数量(必填)", self._joined())
self.assertIn("通关口岸(非必填)", self._joined())
self.assertNotIn("\n数量:", self._joined())
self.assertNotIn(copy.LAND_CONFIRM_TAIL, self._joined())
self.assertEqual(self.ledger.create_calls, 0)
@@ -183,6 +184,53 @@ class LandTextInquiryFrontTests(unittest.TestCase):
self.assertEqual(phase, "wait_confirm")
self.assertIn("通关口岸(非必填):", self._joined())
def test_wait_confirm_can_fill_customs_port(self) -> None:
"""核对卡空着通关口岸时,再发「通关口岸:皇岗」必须写进下一张卡。"""
self.flow.on_text(
sender_id="u1",
text="跨境集拼 东南亚 急件",
reply=self._reply,
injected_facts={
"起运港": "广州",
"目的港": "河内",
"品名": "衣服",
"体积": "100CBM",
"毛重": "50kg",
},
injected_mode="LAND",
)
self.replies.clear()
phase = self.flow.on_text(
sender_id="u1",
text="通关口岸:皇岗",
reply=self._reply,
)
self.assertEqual(phase, "wait_confirm")
self.assertIn("通关口岸(非必填):皇岗", self._joined())
self.assertEqual(self.flow.session_of("u1").facts.get("通关口岸"), "皇岗")
def test_wait_confirm_fills_customs_from_card_label(self) -> None:
"""销售按卡片原样写「通关口岸(非必填):皇岗」也要收下。"""
self.flow.on_text(
sender_id="u1",
text="中港整车 中港/中亚/中欧 急件",
reply=self._reply,
injected_facts={
"起运港": "广州",
"目的港": "香港",
"品名": "衣服",
},
injected_mode="LAND",
)
self.replies.clear()
phase = self.flow.on_text(
sender_id="u1",
text="通关口岸(非必填):皇岗",
reply=self._reply,
)
self.assertEqual(phase, "wait_confirm")
self.assertEqual(self.flow.session_of("u1").facts.get("通关口岸"), "皇岗")
def test_missing_volume_no_confirm(self) -> None:
phase = self.flow.on_text(
sender_id="u1",
@@ -198,6 +246,8 @@ class LandTextInquiryFrontTests(unittest.TestCase):
)
self.assertEqual(phase, "clarify")
self.assertNotIn(copy.LAND_CONFIRM_TAIL, self._joined())
self.assertIn("体积(必填)", self._joined())
self.assertNotIn("通关口岸", self._joined())
class LandTextConfirmTests(unittest.TestCase):
@@ -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()
+102
View File
@@ -0,0 +1,102 @@
"""
千问视觉:只抄图上可见原文,不算缺项。
"""
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_vision import invoke_shell, invoke_vision
from agent.llm.modes import LlmMode
class ModeVisionTests(unittest.TestCase):
def test_shell_reads_assistant_text(self) -> None:
got = invoke_shell(payload={"assistant_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"])
self.assertEqual(got["text"], "")
def test_injected_chat_builds_image_message(self) -> None:
seen: dict = {}
class _R:
ok = True
content = "起运港上海"
error = ""
def chat(**kwargs):
seen.update(kwargs)
return _R()
got = invoke_vision(
payload={"image_b64": "abc", "mime": "image/jpeg"}, chat_fn=chat
)
self.assertEqual(got["text"], "起运港上海")
mode = seen.get("mode")
mode_val = mode.value if hasattr(mode, "value") else str(mode)
self.assertEqual(mode_val, LlmMode.VISION.value)
messages = seen.get("messages") or []
blob = str(messages)
self.assertIn("abc", blob)
self.assertIn("image", blob.lower())
def test_chat_fail(self) -> None:
class _R:
ok = False
content = ""
error = "http_timeout"
got = invoke_vision(
payload={"image_b64": "x"}, chat_fn=lambda **k: _R()
)
self.assertFalse(got["ok"])
self.assertEqual(got["error"], "http_timeout")
def test_sniff_png_not_jpeg(self) -> None:
from agent.llm.mode_vision import sniff_image_mime
png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
jpg = b"\xff\xd8\xff\xe0" + b"\x00" * 16
self.assertEqual(sniff_image_mime(png), "image/png")
self.assertEqual(sniff_image_mime(jpg), "image/jpeg")
self.assertEqual(sniff_image_mime(b"short"), "")
def test_network_without_image_fails(self) -> None:
got = invoke_vision(payload={}, chat_fn=lambda **k: None, allow_network=True)
self.assertFalse(got["ok"])
self.assertEqual(got["error"], "no_image")
def test_plain_text_from_list_and_reasoning(self) -> None:
from agent.llm.http_client import message_plain_text
self.assertEqual(
message_plain_text({"content": [{"type": "text", "text": "起运地深圳"}]}),
"起运地深圳",
)
self.assertEqual(
message_plain_text({"content": "", "reasoning_content": "目的地雅加达"}),
"目的地雅加达",
)
def test_vision_prompt_keeps_iata(self) -> None:
from agent.llm.mode_vision import VISION_SYSTEM
self.assertIn("SZX-CGK", VISION_SYSTEM)
self.assertIn("禁止翻译", VISION_SYSTEM)
if __name__ == "__main__":
unittest.main()