落地私聊附件询价:发卡走 H5 收 Excel/PDF,认成口播再复用三线文字流程,避免附件卡在识别或退成纯文本。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
"""
|
||||
私聊附件询价:发卡、H5/图并入、口播复用文字流程。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
os.environ.setdefault("YTD_ENV", "test")
|
||||
os.environ.setdefault("REDIS_BACKEND", "memory")
|
||||
os.environ.setdefault("CHECKPOINT_BACKEND", "memory")
|
||||
os.environ.setdefault("MESSAGE_STORE_BACKEND", "memory")
|
||||
os.environ.setdefault("LLM_DATA_USAGE_CONFIRMED", "false")
|
||||
os.environ.setdefault("LLM_ALLOW_NETWORK", "false")
|
||||
os.environ.setdefault("LEDGER_BACKEND", "memory")
|
||||
|
||||
from agent.channel.queue import MemoryMessageStore
|
||||
from agent.channel.wecom.models import InboundMessage
|
||||
from agent.handlers import attachment_inquiry as attach_mod
|
||||
from agent.handlers.attachment_inquiry import (
|
||||
accept_h5_uploads,
|
||||
finish_queued_attachment,
|
||||
handle_attachment_inquiry,
|
||||
join_attachment_image,
|
||||
)
|
||||
from agent.handlers.text_inquiry import handle_text_inquiry
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.air_text_flow import reset_air_text_flow_for_test
|
||||
from agent.policy.attachment_wave import get_attach_wave_book, reset_attach_wave_book_for_test
|
||||
from agent.policy.image_wave import reset_image_wave_book_for_test
|
||||
from agent.policy.land_text_flow import reset_land_text_flow_for_test
|
||||
from agent.policy.sea_text_flow import get_sea_text_flow, reset_sea_text_flow_for_test
|
||||
from agent.routing.decision import RouteDecision
|
||||
from agent.routing.dispatch import dispatch_inbound
|
||||
from tests.test_sea_text_inquiry import COMPLETE_SEA
|
||||
|
||||
SEA_ORAL = """海运
|
||||
起运港:上海
|
||||
目的港:洛杉矶
|
||||
品名:普货
|
||||
货量:20吨
|
||||
整柜或拼柜:整柜
|
||||
箱型箱量:1x40HQ
|
||||
"""
|
||||
|
||||
|
||||
def _extract(text: str):
|
||||
def _fn(_name: str, _raw: bytes):
|
||||
body = (text or "").strip()
|
||||
return {"ok": True, "text": body, "empty": not bool(body), "error": ""}
|
||||
|
||||
return _fn
|
||||
|
||||
|
||||
def _vision(text: str, *, ok: bool = True, error: str = ""):
|
||||
def _fn(**_kwargs):
|
||||
body = (text or "").strip()
|
||||
return {"ok": ok, "text": body, "empty": not bool(body), "error": error}
|
||||
|
||||
return _fn
|
||||
|
||||
|
||||
def _img(sender: str, mid: str) -> InboundMessage:
|
||||
return InboundMessage(
|
||||
sender_id=sender,
|
||||
message_id=mid,
|
||||
content="",
|
||||
msg_type="image",
|
||||
media={"media_id": mid, "filename": f"{mid}.jpg"},
|
||||
)
|
||||
|
||||
|
||||
class AttachmentInquiryTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
reset_sea_text_flow_for_test()
|
||||
reset_land_text_flow_for_test()
|
||||
reset_air_text_flow_for_test()
|
||||
reset_attach_wave_book_for_test()
|
||||
reset_image_wave_book_for_test()
|
||||
self.store = MemoryMessageStore()
|
||||
|
||||
def _bodies(self) -> list[str]:
|
||||
return [i.content for i in self.store._outbox.values()]
|
||||
|
||||
def _extras(self) -> list[dict]:
|
||||
return [i.payload for i in self.store._outbox.values()]
|
||||
|
||||
def test_intent_sends_upload_card_not_ticket(self) -> None:
|
||||
phase = handle_attachment_inquiry(
|
||||
InboundMessage(sender_id="s1", message_id="m1", content="帮我传个Excel询价,海运上海到纽约"),
|
||||
store=self.store,
|
||||
)
|
||||
self.assertEqual(phase, "wait_upload")
|
||||
self.assertIsNone(get_sea_text_flow().session_of("s1"))
|
||||
extras = self._extras()
|
||||
self.assertTrue(extras)
|
||||
card = extras[0].get("template_card") or {}
|
||||
self.assertEqual(card.get("card_type"), "text_notice")
|
||||
self.assertEqual(card.get("main_title", {}).get("title"), "上传询价附件")
|
||||
self.assertIn("上传询价附件", (card.get("jump_list") or [{}])[0].get("title") or "")
|
||||
url = (card.get("card_action") or {}).get("url") or ""
|
||||
self.assertIn("/inquiry-form/upload/", url)
|
||||
|
||||
def test_xlsx_complete_creates_sea_ticket(self) -> None:
|
||||
handle_attachment_inquiry(
|
||||
InboundMessage(sender_id="s1", message_id="m1", content="附件询价"),
|
||||
store=self.store,
|
||||
)
|
||||
phase, hint = accept_h5_uploads(
|
||||
sender_id="s1",
|
||||
files=[("sea.xlsx", b"fake")],
|
||||
store=self.store,
|
||||
extract_fn=_extract(SEA_ORAL),
|
||||
attach_text_fn=lambda **k: {
|
||||
"ok": True,
|
||||
"text": str((k.get("payload") or {}).get("raw_text") or ""),
|
||||
"empty": False,
|
||||
"error": "",
|
||||
},
|
||||
put_fn=lambda **kw: {"ok": True, "key": kw.get("key") or "k"},
|
||||
)
|
||||
self.assertEqual(hint, copy.ATTACH_H5_OK)
|
||||
self.assertIn(copy.ATTACH_RECOGNIZING, self._bodies())
|
||||
sess = get_sea_text_flow().session_of("s1")
|
||||
self.assertIsNotNone(sess)
|
||||
self.assertTrue((sess.work_order_no or "").strip())
|
||||
ticket = get_sea_text_flow().ledger.get_ticket(work_order_no=sess.work_order_no)
|
||||
self.assertTrue(ticket.attachments)
|
||||
|
||||
def test_empty_file_asks_mode(self) -> None:
|
||||
handle_attachment_inquiry(
|
||||
InboundMessage(sender_id="s1", message_id="m1", content="附件询价"),
|
||||
store=self.store,
|
||||
)
|
||||
accept_h5_uploads(
|
||||
sender_id="s1",
|
||||
files=[("empty.xlsx", b"x")],
|
||||
store=self.store,
|
||||
extract_fn=_extract(""),
|
||||
attach_text_fn=lambda **_k: {"ok": True, "text": "", "empty": True, "error": ""},
|
||||
)
|
||||
bodies = self._bodies()
|
||||
self.assertTrue(any("没看清" in b for b in bodies))
|
||||
self.assertTrue(any("海运" in b and "空运" in b for b in bodies))
|
||||
|
||||
def test_image_during_wait_joins_same_ticket(self) -> None:
|
||||
handle_attachment_inquiry(
|
||||
InboundMessage(sender_id="s1", message_id="m1", content="附件询价"),
|
||||
store=self.store,
|
||||
)
|
||||
join_attachment_image(
|
||||
_img("s1", "mid-1"),
|
||||
store=self.store,
|
||||
vision_fn=_vision("截图"),
|
||||
download_fn=lambda _m: (b"x", ""),
|
||||
process_now=False,
|
||||
)
|
||||
accept_h5_uploads(
|
||||
sender_id="s1",
|
||||
files=[("sea.xlsx", b"x")],
|
||||
store=self.store,
|
||||
extract_fn=_extract(SEA_ORAL),
|
||||
attach_text_fn=lambda **k: {
|
||||
"ok": True,
|
||||
"text": str((k.get("payload") or {}).get("raw_text") or ""),
|
||||
"empty": False,
|
||||
},
|
||||
process_now=True,
|
||||
)
|
||||
from agent.handlers.attachment_inquiry import finish_attach_wave
|
||||
from agent.policy.attachment_wave import get_attach_wave_book
|
||||
|
||||
book = get_attach_wave_book()
|
||||
book.complete_material("s1", 0, text="截图")
|
||||
finish_attach_wave("s1", store=self.store)
|
||||
acks = [c for c in self._bodies() if c in {copy.IMAGE_RECOGNIZING, copy.ATTACH_RECOGNIZING}]
|
||||
self.assertEqual(len(acks), 1)
|
||||
self.assertEqual(acks[0], copy.IMAGE_RECOGNIZING)
|
||||
sess = get_sea_text_flow().session_of("s1")
|
||||
self.assertTrue(sess and sess.work_order_no)
|
||||
|
||||
def test_mid_clarify_then_attach_is_new_ticket(self) -> None:
|
||||
handle_text_inquiry(
|
||||
InboundMessage(sender_id="s1", message_id="old", content="海运出号"),
|
||||
store=self.store,
|
||||
injected_facts=COMPLETE_SEA,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
wo_old = get_sea_text_flow().session_of("s1").work_order_no
|
||||
handle_attachment_inquiry(
|
||||
InboundMessage(sender_id="s1", message_id="m2", content="我有个excel要询价"),
|
||||
store=self.store,
|
||||
)
|
||||
accept_h5_uploads(
|
||||
sender_id="s1",
|
||||
files=[("sea.xlsx", b"x")],
|
||||
store=self.store,
|
||||
extract_fn=_extract(SEA_ORAL),
|
||||
attach_text_fn=lambda **k: {
|
||||
"ok": True,
|
||||
"text": str((k.get("payload") or {}).get("raw_text") or ""),
|
||||
"empty": False,
|
||||
},
|
||||
)
|
||||
wo_new = get_sea_text_flow().session_of("s1").work_order_no
|
||||
self.assertNotEqual(wo_old, wo_new)
|
||||
self.assertIsNotNone(get_sea_text_flow().ledger.get_ticket(work_order_no=wo_old))
|
||||
|
||||
def test_reject_png_on_h5(self) -> None:
|
||||
handle_attachment_inquiry(
|
||||
InboundMessage(sender_id="s1", message_id="m1", content="附件询价"),
|
||||
store=self.store,
|
||||
)
|
||||
phase, hint = accept_h5_uploads(
|
||||
sender_id="s1",
|
||||
files=[("shot.png", b"\x89PNG")],
|
||||
store=self.store,
|
||||
)
|
||||
self.assertEqual(phase, "reject_type")
|
||||
self.assertIn("Excel", hint)
|
||||
self.assertIsNone(get_sea_text_flow().session_of("s1"))
|
||||
|
||||
def test_dispatch_image_joins_wait_not_new_image_inquiry(self) -> None:
|
||||
handle_attachment_inquiry(
|
||||
InboundMessage(sender_id="s1", message_id="m1", content="附件询价"),
|
||||
store=self.store,
|
||||
)
|
||||
name = dispatch_inbound(
|
||||
_img("s1", "mid-x"),
|
||||
RouteDecision(intent="image_inquiry"),
|
||||
)
|
||||
self.assertEqual(name, "attachment_inquiry")
|
||||
|
||||
def test_follow_text_not_create_ticket_before_upload(self) -> None:
|
||||
handle_attachment_inquiry(
|
||||
InboundMessage(sender_id="s1", message_id="m1", content="附件询价"),
|
||||
store=self.store,
|
||||
)
|
||||
name = dispatch_inbound(
|
||||
InboundMessage(sender_id="s1", message_id="m2", content="海运上海到纽约 2个40尺"),
|
||||
RouteDecision(intent="ordinary_text_inquiry"),
|
||||
)
|
||||
self.assertEqual(name, "attach_wave_follow_text")
|
||||
self.assertIsNone(get_sea_text_flow().session_of("s1"))
|
||||
|
||||
def test_after_business_old_h5_rejected(self) -> None:
|
||||
handle_attachment_inquiry(
|
||||
InboundMessage(sender_id="s1", message_id="m1", content="附件询价"),
|
||||
store=self.store,
|
||||
)
|
||||
accept_h5_uploads(
|
||||
sender_id="s1",
|
||||
files=[("sea.xlsx", b"x")],
|
||||
store=self.store,
|
||||
extract_fn=_extract(SEA_ORAL),
|
||||
attach_text_fn=lambda **k: {
|
||||
"ok": True,
|
||||
"text": str((k.get("payload") or {}).get("raw_text") or ""),
|
||||
"empty": False,
|
||||
},
|
||||
)
|
||||
phase, _hint = accept_h5_uploads(
|
||||
sender_id="s1",
|
||||
files=[("again.xlsx", b"x")],
|
||||
store=self.store,
|
||||
extract_fn=_extract(SEA_ORAL),
|
||||
)
|
||||
self.assertEqual(phase, "expired")
|
||||
|
||||
def test_worker_reads_bytes_after_http_stash_cleared(self) -> None:
|
||||
handle_attachment_inquiry(
|
||||
InboundMessage(sender_id="s1", message_id="m1", content="附件询价"),
|
||||
store=self.store,
|
||||
)
|
||||
accept_h5_uploads(
|
||||
sender_id="s1",
|
||||
files=[("sea.xlsx", b"fake-xlsx-bytes")],
|
||||
store=self.store,
|
||||
process_now=False,
|
||||
)
|
||||
attach_mod._STASH.clear()
|
||||
wave = get_attach_wave_book().active("s1")
|
||||
self.assertIsNotNone(wave)
|
||||
key = f"inquiry-attach/s1/{wave.wave_id}/0/sea.xlsx"
|
||||
finish_queued_attachment(
|
||||
{
|
||||
"sender_id": "s1",
|
||||
"index": 0,
|
||||
"role": "file",
|
||||
"filename": "sea.xlsx",
|
||||
"object_key": key,
|
||||
"wave_id": wave.wave_id,
|
||||
"__extract": _extract(SEA_ORAL),
|
||||
"__attach_text": lambda **k: {
|
||||
"ok": True,
|
||||
"text": str((k.get("payload") or {}).get("raw_text") or ""),
|
||||
"empty": False,
|
||||
"error": "",
|
||||
},
|
||||
}
|
||||
)
|
||||
self.assertIsNotNone(get_sea_text_flow().session_of("s1"))
|
||||
|
||||
def test_second_unreadable_wave_still_replies(self) -> None:
|
||||
def _empty(_name: str, _raw: bytes):
|
||||
return {"ok": True, "text": "", "empty": True, "error": ""}
|
||||
|
||||
handle_attachment_inquiry(
|
||||
InboundMessage(sender_id="s1", message_id="m1", content="附件询价"),
|
||||
store=self.store,
|
||||
)
|
||||
accept_h5_uploads(
|
||||
sender_id="s1",
|
||||
files=[("blank.xlsx", b"xx")],
|
||||
store=self.store,
|
||||
extract_fn=_empty,
|
||||
)
|
||||
handle_attachment_inquiry(
|
||||
InboundMessage(sender_id="s1", message_id="m2", content="附件询价"),
|
||||
store=self.store,
|
||||
)
|
||||
accept_h5_uploads(
|
||||
sender_id="s1",
|
||||
files=[("blank2.xlsx", b"yy")],
|
||||
store=self.store,
|
||||
extract_fn=_empty,
|
||||
)
|
||||
fails = [b for b in self._bodies() if "没看清" in b or "没认出来" in b]
|
||||
self.assertGreaterEqual(len(fails), 2, fails)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
附件上传 H5:token 不含账号,过期 410,拒收图片。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
os.environ.setdefault("YTD_ENV", "test")
|
||||
os.environ.setdefault("REDIS_BACKEND", "memory")
|
||||
os.environ.setdefault("CHECKPOINT_BACKEND", "memory")
|
||||
os.environ.setdefault("MESSAGE_STORE_BACKEND", "memory")
|
||||
os.environ.setdefault("LEDGER_BACKEND", "memory")
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from agent.channel.h5 import router as h5_router
|
||||
from agent.channel.h5.copy_store import build_upload_url
|
||||
from agent.channel.wecom.models import InboundMessage
|
||||
from agent.handlers.attachment_inquiry import handle_attachment_inquiry, lookup_upload_sender
|
||||
from agent.policy.attachment_wave import reset_attach_wave_book_for_test
|
||||
from agent.policy.image_wave import reset_image_wave_book_for_test
|
||||
from agent.policy.air_text_flow import reset_air_text_flow_for_test
|
||||
from agent.policy.land_text_flow import reset_land_text_flow_for_test
|
||||
from agent.policy.sea_text_flow import reset_sea_text_flow_for_test
|
||||
|
||||
|
||||
class AttachmentUploadH5Tests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
reset_attach_wave_book_for_test()
|
||||
reset_image_wave_book_for_test()
|
||||
reset_sea_text_flow_for_test()
|
||||
reset_air_text_flow_for_test()
|
||||
reset_land_text_flow_for_test()
|
||||
app = FastAPI()
|
||||
app.include_router(h5_router)
|
||||
self.client = TestClient(app)
|
||||
|
||||
def _open_card(self) -> str:
|
||||
handle_attachment_inquiry(
|
||||
InboundMessage(sender_id="s1", message_id="m1", content="附件询价")
|
||||
)
|
||||
from agent.policy.attachment_wave import get_attach_wave_book
|
||||
|
||||
wave = get_attach_wave_book().active("s1")
|
||||
self.assertIsNotNone(wave)
|
||||
url = build_upload_url(wave.token)
|
||||
self.assertIn("/upload/", url)
|
||||
return wave.token
|
||||
|
||||
def test_open_page_ok_and_expired(self) -> None:
|
||||
token = self._open_card()
|
||||
self.assertEqual(lookup_upload_sender(token), "s1")
|
||||
hit = self.client.get(f"/inquiry-form/upload/{token}")
|
||||
self.assertEqual(hit.status_code, 200)
|
||||
self.assertIn("Excel", hit.text)
|
||||
self.assertNotIn("s1", hit.text)
|
||||
miss = self.client.get("/inquiry-form/upload/not-a-real-token")
|
||||
self.assertEqual(miss.status_code, 410)
|
||||
self.assertIn("过期", miss.text)
|
||||
|
||||
def test_upload_page_uses_card_chrome(self) -> None:
|
||||
from agent.channel.h5.upload_page import expired_upload_page, render_upload_message, render_upload_page
|
||||
|
||||
page = render_upload_page()
|
||||
self.assertIn('class="card"', page)
|
||||
self.assertIn('class="drop"', page)
|
||||
self.assertIn('id="picked"', page)
|
||||
self.assertIn("一次可选多个", page)
|
||||
self.assertIn("Excel", page)
|
||||
self.assertNotIn("s1", page)
|
||||
ok = render_upload_message("上传成功", "已收到,请回企微查看识别结果。")
|
||||
dead = expired_upload_page()
|
||||
self.assertIn('class="card"', ok)
|
||||
self.assertIn('class="card"', dead)
|
||||
self.assertIn("过期", dead)
|
||||
|
||||
def test_post_png_rejected(self) -> None:
|
||||
token = self._open_card()
|
||||
hit = self.client.post(
|
||||
f"/inquiry-form/upload/{token}",
|
||||
files=[("files", ("shot.png", b"\x89PNG", "image/png"))],
|
||||
)
|
||||
self.assertEqual(hit.status_code, 400)
|
||||
self.assertIn("Excel", hit.text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
私聊附件等材料波次:发卡等待、发图/H5 并入、30 分钟时效。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
from agent.policy.attachment_wave import (
|
||||
WAIT_TTL_SECONDS,
|
||||
AttachWaveBook,
|
||||
reset_attach_wave_book_for_test,
|
||||
)
|
||||
|
||||
|
||||
class AttachWaveTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.book = reset_attach_wave_book_for_test()
|
||||
|
||||
def test_wait_ttl_is_30_minutes(self) -> None:
|
||||
self.assertEqual(WAIT_TTL_SECONDS, 1800)
|
||||
|
||||
def test_open_wait_is_active_without_files(self) -> None:
|
||||
wave = self.book.open_wait("s1", precede_text="海运上海到纽约", token="tok1", now=100.0)
|
||||
self.assertTrue(wave.wave_id)
|
||||
self.assertEqual(wave.precede_text, "海运上海到纽约")
|
||||
self.assertFalse(wave.started)
|
||||
self.assertIsNotNone(self.book.active("s1", now=100.0))
|
||||
|
||||
def test_wait_expires_after_30_minutes_if_not_started(self) -> None:
|
||||
self.book.open_wait("s1", precede_text="海运", token="tok1", now=100.0)
|
||||
self.assertIsNone(self.book.active("s1", now=100.0 + 1800.1))
|
||||
|
||||
def test_follow_text_while_waiting(self) -> None:
|
||||
self.book.open_wait("s1", precede_text="", token="tok1", now=1.0)
|
||||
self.assertTrue(self.book.append_follow_text("s1", "只要整柜"))
|
||||
self.assertFalse(self.book.append_follow_text("s1", ""))
|
||||
|
||||
def test_join_image_starts_recognition(self) -> None:
|
||||
self.book.open_wait("s1", precede_text="海运", token="tok1", now=1.0)
|
||||
wave, how = self.book.join_image("s1", "mid-a", filename="a.jpg", now=2.0)
|
||||
self.assertEqual(how, "joined")
|
||||
self.assertTrue(wave.started)
|
||||
self.assertEqual(self.book.pending(wave), 1)
|
||||
|
||||
def test_add_files_keeps_upload_order(self) -> None:
|
||||
self.book.open_wait("s1", precede_text="", token="tok1", now=1.0)
|
||||
wave = self.book.add_files("s1", ["sea.xlsx", "air.pdf"], now=2.0)
|
||||
self.assertTrue(wave.started)
|
||||
self.assertEqual([m.filename for m in wave.materials], ["sea.xlsx", "air.pdf"])
|
||||
self.assertEqual(self.book.pending(wave), 2)
|
||||
|
||||
def test_compose_oral_uses_material_order(self) -> None:
|
||||
self.book.open_wait("s1", precede_text="开口字", token="tok1", now=1.0)
|
||||
self.book.add_files("s1", ["a.xlsx", "b.pdf"], now=2.0)
|
||||
self.book.append_follow_text("s1", "只要40尺")
|
||||
wave = self.book.active("s1", now=2.0)
|
||||
self.assertIsNotNone(wave)
|
||||
self.book.complete_material("s1", 1, text="空运 上海到纽约")
|
||||
self.book.complete_material("s1", 0, text="海运 宁波到汉堡")
|
||||
oral = self.book.compose_oral("s1")
|
||||
self.assertEqual(oral, "开口字\n海运 宁波到汉堡\n空运 上海到纽约\n只要40尺")
|
||||
|
||||
def test_after_business_done_not_active(self) -> None:
|
||||
self.book.open_wait("s1", precede_text="", token="tok1", now=1.0)
|
||||
self.book.add_files("s1", ["a.xlsx"], now=2.0)
|
||||
self.book.complete_material("s1", 0, text="海运")
|
||||
self.book.mark_business_done("s1")
|
||||
self.assertIsNone(self.book.active("s1", now=3.0))
|
||||
self.assertFalse(self.book.append_follow_text("s1", "补一句"))
|
||||
|
||||
def test_image_after_business_rejected(self) -> None:
|
||||
self.book.open_wait("s1", precede_text="", token="tok1", now=1.0)
|
||||
self.book.mark_business_done("s1")
|
||||
wave, how = self.book.join_image("s1", "mid-b", filename="b.jpg", now=2.0)
|
||||
self.assertEqual(how, "inactive")
|
||||
self.assertIsNone(wave)
|
||||
|
||||
def test_http_and_worker_share_kv(self) -> None:
|
||||
kv = _MemoryWaveKV()
|
||||
http = reset_attach_wave_book_for_test(kv=kv)
|
||||
worker = AttachWaveBook(kv=kv)
|
||||
http.open_wait("s1", precede_text="海运", token="tok", now=1.0)
|
||||
self.assertIsNotNone(worker.active("s1", now=1.0))
|
||||
worker.add_files("s1", ["a.xlsx"], now=2.0)
|
||||
worker.complete_material("s1", 0, text="宁波到汉堡")
|
||||
worker.mark_business_done("s1")
|
||||
self.assertIsNone(http.active("s1", now=3.0))
|
||||
|
||||
|
||||
class _MemoryWaveKV:
|
||||
"""单测假 Redis:两份账本共用同一字典。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._d: dict[str, dict] = {}
|
||||
|
||||
def get_json(self, kind: str, sender_id: str):
|
||||
row = self._d.get(f"{kind}:{sender_id}")
|
||||
return json.loads(json.dumps(row)) if row is not None else None
|
||||
|
||||
def set_json(self, kind: str, sender_id: str, data: dict) -> None:
|
||||
self._d[f"{kind}:{sender_id}"] = json.loads(json.dumps(data))
|
||||
|
||||
def delete(self, kind: str, sender_id: str) -> None:
|
||||
self._d.pop(f"{kind}:{sender_id}", None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
Excel / PDF 抽纯文本:不猜字段。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
from agent.policy.attachment_extract import allowed_attachment, extract_attachment_text
|
||||
|
||||
|
||||
def _xlsx_bytes() -> bytes:
|
||||
from openpyxl import Workbook
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "询价"
|
||||
ws["A1"] = "海运"
|
||||
ws["B1"] = "上海到洛杉矶"
|
||||
ws["A2"] = "1x40HQ"
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
class ExtractAttachmentTests(unittest.TestCase):
|
||||
def test_allow_only_excel_pdf(self) -> None:
|
||||
self.assertTrue(allowed_attachment("a.XLSX"))
|
||||
self.assertTrue(allowed_attachment("b.xls"))
|
||||
self.assertTrue(allowed_attachment("c.pdf"))
|
||||
self.assertFalse(allowed_attachment("d.png"))
|
||||
self.assertFalse(allowed_attachment("e.docx"))
|
||||
self.assertFalse(allowed_attachment("f.zip"))
|
||||
|
||||
def test_reject_image_bytes(self) -> None:
|
||||
got = extract_attachment_text("shot.png", b"\x89PNG")
|
||||
self.assertFalse(got["ok"])
|
||||
self.assertEqual(got["error"], "only_xlsx_xls_pdf")
|
||||
|
||||
def test_xlsx_cells_become_text(self) -> None:
|
||||
got = extract_attachment_text("sea.xlsx", _xlsx_bytes())
|
||||
self.assertTrue(got["ok"])
|
||||
self.assertIn("海运", got["text"])
|
||||
self.assertIn("上海到洛杉矶", got["text"])
|
||||
self.assertIn("1x40HQ", got["text"])
|
||||
|
||||
def test_empty_bytes_fail(self) -> None:
|
||||
got = extract_attachment_text("a.xlsx", b"")
|
||||
self.assertFalse(got["ok"])
|
||||
self.assertTrue(got["empty"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -320,6 +320,26 @@ class LandCopyTest(unittest.TestCase):
|
||||
self.assertTrue(merged.startswith(copy.IMAGE_UNREADABLE))
|
||||
self.assertIn(copy.ASK_TRANSPORT, merged)
|
||||
|
||||
def test_attachment_copy_and_card(self):
|
||||
self.assertEqual(copy.ATTACH_RECOGNIZING, "正在识别附件")
|
||||
self.assertIn("没看清", copy.ATTACH_UNREADABLE)
|
||||
self.assertIn("暂时没认出来", copy.ATTACH_TECH_FAIL)
|
||||
payload = copy.attachment_upload_card("https://ai.ytd-scm.com/inquiry-form/upload/tok")
|
||||
card = payload["template_card"]
|
||||
self.assertEqual(payload["msgtype"], "template_card")
|
||||
# 现网上传卡是 text_notice。news_notice 必须带 card_image,企微会 42044 后退成纯文本。
|
||||
self.assertEqual(card["card_type"], "text_notice")
|
||||
self.assertNotIn("card_image", card)
|
||||
self.assertEqual(card["source"]["desc"], "附件询价")
|
||||
self.assertEqual(card["main_title"]["title"], "上传询价附件")
|
||||
self.assertIn("Excel", card["main_title"]["desc"])
|
||||
self.assertIn("图片请直接在企微聊天窗口发送", card["main_title"]["desc"])
|
||||
self.assertIn("图片请直接在当前企微会话发送", card["sub_title_text"])
|
||||
self.assertEqual(card["jump_list"][0]["title"], "上传询价附件")
|
||||
self.assertIn("/upload/tok", card["card_action"]["url"])
|
||||
self.assertEqual(card["card_action"]["type"], 1)
|
||||
self.assertNotIn("userid", card["card_action"]["url"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
千问附件文本:只整理抽出原文,不算缺项。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
from agent.llm.mode_attachment_text import invoke_attachment_text, invoke_shell
|
||||
from agent.llm.modes import LlmMode
|
||||
|
||||
|
||||
class ModeAttachmentTextTests(unittest.TestCase):
|
||||
def test_shell_uses_raw_text(self) -> None:
|
||||
got = invoke_shell(payload={"raw_text": "海运 宁波到汉堡 1x40HQ"})
|
||||
self.assertTrue(got["ok"])
|
||||
self.assertIn("宁波到汉堡", got["text"])
|
||||
self.assertFalse(got["empty"])
|
||||
|
||||
def test_blank_is_empty(self) -> None:
|
||||
got = invoke_shell(payload={"assistant_text": " "})
|
||||
self.assertTrue(got["ok"])
|
||||
self.assertTrue(got["empty"])
|
||||
|
||||
def test_injected_chat_gets_raw_only(self) -> None:
|
||||
seen: dict = {}
|
||||
|
||||
class _R:
|
||||
ok = True
|
||||
content = "海运 上海到洛杉矶"
|
||||
error = ""
|
||||
|
||||
def chat(**kwargs):
|
||||
seen.update(kwargs)
|
||||
return _R()
|
||||
|
||||
got = invoke_attachment_text(
|
||||
payload={"raw_text": "# 询价\n海运\t上海到洛杉矶"},
|
||||
chat_fn=chat,
|
||||
allow_network=False,
|
||||
)
|
||||
self.assertTrue(got["ok"])
|
||||
self.assertEqual(got["text"], "海运 上海到洛杉矶")
|
||||
self.assertEqual(seen["mode"], LlmMode.ATTACHMENT_TEXT)
|
||||
sys_msg = seen["messages"][0]["content"]
|
||||
self.assertIn("不要猜", sys_msg)
|
||||
self.assertNotIn("缺项", seen["messages"][1]["content"] or "x")
|
||||
|
||||
def test_chat_fail(self) -> None:
|
||||
class _R:
|
||||
ok = False
|
||||
content = ""
|
||||
error = "timeout"
|
||||
|
||||
got = invoke_attachment_text(
|
||||
payload={"raw_text": "海运"},
|
||||
chat_fn=lambda **_k: _R(),
|
||||
)
|
||||
self.assertFalse(got["ok"])
|
||||
self.assertEqual(got["error"], "timeout")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user