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