Files
inquiry_robot/inquiry-agent/tests/test_attachment_inquiry.py
T

445 lines
17 KiB
Python

"""
私聊附件询价:发卡、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.multi_text_flow import get_multi_text_flow, reset_multi_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
贸易条款:FOB
运输分类:Port to Port
"""
LAND_ORAL = """运输类型:国内运输整车
线路类别:国内长途/零担
运输分类:普件(东南亚、中港常用线路/常规车型)
始发站:东莞
目的地:上海
货物品名:普货
重量:1000
体积:10
"""
# 测服陆运报价单抽出约 997 字、整理后只剩 48 字。
_SEA_SHEET_RAW = (
"起运港:上海\n目的港:洛杉矶\n品名:普货\n整柜或拼柜:整柜\n"
"箱型箱量:1x40HQ\n贸易条款:FOB\n货物数量:20吨\n运输分类:Port to Port\n"
) * 16
_SEA_SHEET_TINY = "货物数量:100\n运输分类:其他\n报价日期:2026-09-23\n备注:报价单整理"
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_multi_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_tiny_organize_keeps_extracted_text(self) -> None:
"""报价单被整理成一个字时,改用抽出的原文,否则多段开单看不到附件内容。"""
from agent.handlers.attachment_inquiry import prefer_extracted_oral
raw = "起运港\t纽约港\n目的港\t巴生西港\n品名\t机器设备\n" * 8
kept = "运输方式:海运\n起运港:纽约港\n目的港:巴生西港\n品名:机器设备\n箱型箱量:40HQ*1"
self.assertEqual(prefer_extracted_oral(raw, "。"), raw.strip())
self.assertEqual(prefer_extracted_oral(raw, kept), kept)
self.assertEqual(prefer_extracted_oral("", ""), "")
self.assertGreaterEqual(len(_SEA_SHEET_RAW), 200)
self.assertLess(len(_SEA_SHEET_TINY), 80)
self.assertEqual(prefer_extracted_oral(_SEA_SHEET_RAW, _SEA_SHEET_TINY), _SEA_SHEET_RAW.strip())
def test_leftover_multi_plus_shrunk_sheet_still_keeps_sea_ports(self) -> None:
"""
旧多段补问草稿 + 千问把报价单压成几十个字时,
新附件必须当新询价,并把单元格原文里的起运港/目的港留下。
"""
leftover = get_multi_text_flow()
leftover.on_text(
sender_id="s1",
text="段1:陆运\n段2:海运\n货物数量:100\n运输分类:其他",
reply=lambda t, extra=None: None,
)
self.assertEqual((leftover.session_of("s1").phase or ""), "clarify")
handle_attachment_inquiry(
InboundMessage(sender_id="s1", message_id="m-attach", content="附件询价"),
store=self.store,
)
self.assertIsNone(get_multi_text_flow().session_of("s1"))
def _extract_two(name: str, _raw: bytes):
if "陆运" in name:
return {"ok": True, "text": LAND_ORAL, "empty": False, "error": ""}
return {"ok": True, "text": _SEA_SHEET_RAW, "empty": False, "error": ""}
def _shrink_qianwen(**kwargs):
raw = str((kwargs.get("payload") or {}).get("raw_text") or "")
if "起运港" in raw:
return {"ok": True, "text": _SEA_SHEET_TINY, "empty": False, "error": ""}
return {"ok": True, "text": "运输类型:国内运输整车", "empty": False, "error": ""}
accept_h5_uploads(
sender_id="s1",
files=[("WO陆运报价单.xlsx", b"land"), ("手工报价海运.xlsx", b"sea")],
store=self.store,
extract_fn=_extract_two,
attach_text_fn=_shrink_qianwen,
)
bodies = self._bodies()
joined = "\n".join(bodies)
self.assertNotIn("起运港:(如:上海)", joined)
sess = get_multi_text_flow().session_of("s1")
self.assertIsNotNone(sess)
segs = (sess.quote or {}).get("multi_segments") or []
sea = next((row for row in segs if str(row.get("mode") or "") == "SEA"), None)
self.assertIsNotNone(sea)
sea_facts = dict((sea or {}).get("facts") or {})
self.assertEqual(str(sea_facts.get("起运港") or ""), "上海")
self.assertEqual(str(sea_facts.get("目的港") or ""), "洛杉矶")
self.assertEqual(str(sea_facts.get("品名") or ""), "普货")
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_text_inquiry_before_upload_gets_reply(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=SEA_ORAL),
RouteDecision(intent="ordinary_text_inquiry"),
)
self.assertEqual(name, "ordinary_text_inquiry")
self.assertIsNone(get_attach_wave_book().active("s1"))
self.assertIsNotNone(get_sea_text_flow().session_of("s1"))
def test_follow_text_while_recognizing_not_new_ticket(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")],
store=self.store,
process_now=False,
)
name = dispatch_inbound(
InboundMessage(sender_id="s1", message_id="m2", content="只要整柜"),
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()