""" 私聊附件询价:发卡、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()