落地私聊图片询价:识图当口播复用三线文字流程,图上航线三字码保持原样,避免被改写成中文地名。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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,14 @@ 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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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,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()
|
||||
Reference in New Issue
Block a user