落地私聊图片询价:识图当口播复用三线文字流程,图上航线三字码保持原样,避免被改写成中文地名。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-18 15:51:55 +08:00
co-authored by Cursor
parent 982b3a2317
commit 8219dce84b
30 changed files with 2153 additions and 45 deletions
@@ -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",
+64 -1
View File
@@ -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(
+272
View File
@@ -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()
+103
View File
@@ -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()
+59
View File
@@ -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()
+51 -1
View File
@@ -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):
+102
View File
@@ -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()