From 7c77e45e03f685fc856756d42fba17471148dd55 Mon Sep 17 00:00:00 2001 From: jillion <439912596@qq.com> Date: Sat, 3 Oct 2026 22:39:54 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B0=83=E4=BB=B7=E5=90=8E=E7=9A=84=E6=96=B0?= =?UTF-8?q?=E4=BB=B7=E6=A0=BC=E5=86=99=E5=85=A5=E6=8A=A5=E4=BB=B7=E5=8D=95?= =?UTF-8?q?=EF=BC=9B=E5=A4=9A=E6=AE=B5=E6=AE=B5=E5=89=8D=E7=9A=84=E8=B4=A7?= =?UTF-8?q?=E7=89=A9=E5=B8=A6=E5=88=B0=E5=90=84=E6=AE=B5=EF=BC=8C=E5=8D=8F?= =?UTF-8?q?=E5=90=8C=E7=BE=A4=E4=B8=8D=E5=86=8D=E6=8A=8A=E4=BD=93=E7=A7=AF?= =?UTF-8?q?=E9=87=8C=E7=9A=84=E6=98=9F=E5=8F=B7=E5=90=83=E6=8E=89=E3=80=82?= =?UTF-8?q?=E6=B5=B7=E8=BF=90=E5=AD=97=E6=AE=B5=E9=BD=90=E4=BA=86=E5=85=88?= =?UTF-8?q?=E6=A0=B8=E5=AF=B9=E5=86=8D=E6=9F=A5=E4=BB=B7=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- aibot-bridge/group_message_frame.cjs | 13 +- .../tests/group_message_frame.test.cjs | 15 ++ inquiry-agent/agent/channel/aibot/reply.py | 23 +- .../agent/handlers/air_group_collab.py | 18 +- .../agent/handlers/attachment_inquiry.py | 26 ++ inquiry-agent/agent/handlers/image_inquiry.py | 25 ++ inquiry-agent/agent/ledger/http_ledger.py | 3 + inquiry-agent/agent/llm/extract_text.py | 3 +- inquiry-agent/agent/policy/air_group_ops.py | 26 ++ inquiry-agent/agent/policy/air_text_flow.py | 63 +++++ inquiry-agent/agent/policy/inquiry_copy.py | 54 ++-- inquiry-agent/agent/policy/land_text_flow.py | 2 +- inquiry-agent/agent/policy/multi_segments.py | 120 ++++++++- inquiry-agent/agent/policy/sea_group_ops.py | 4 +- inquiry-agent/agent/policy/sea_text_flow.py | 123 ++++++++- inquiry-agent/agent/routing/deepseek_a.py | 2 +- inquiry-agent/agent/schema/field_validate.py | 7 + inquiry-agent/agent/schema/quote_adjust.py | 241 ++++++++++++++++++ inquiry-agent/agent/schema/sea_options.py | 45 +++- inquiry-agent/agent/schema/tms_air_query.py | 13 + inquiry-agent/tests/test_aibot_reply.py | 10 +- inquiry-agent/tests/test_air_group_collab.py | 64 +++++ .../tests/test_attachment_inquiry.py | 24 ++ inquiry-agent/tests/test_continue_ticket.py | 52 ++++ inquiry-agent/tests/test_extract_oral.py | 39 +++ inquiry-agent/tests/test_image_inquiry.py | 20 +- inquiry-agent/tests/test_land_dispatch.py | 13 +- .../tests/test_land_route_options.py | 38 +++ inquiry-agent/tests/test_multi_segments.py | 23 ++ inquiry-agent/tests/test_quote_adjust.py | 98 +++++++ inquiry-agent/tests/test_sea_options.py | 26 ++ inquiry-agent/tests/test_sea_text_inquiry.py | 226 ++++++++++++++-- 32 files changed, 1384 insertions(+), 75 deletions(-) diff --git a/aibot-bridge/group_message_frame.cjs b/aibot-bridge/group_message_frame.cjs index ea8bd3a..146ead8 100644 --- a/aibot-bridge/group_message_frame.cjs +++ b/aibot-bridge/group_message_frame.cjs @@ -15,8 +15,17 @@ function pickReplyReqId({ fromCaller, fromChat, lastAny }) { return String(lastAny || "").trim(); } +// 含星号的尺寸或箱量。已在行内代码里的整段跳过,避免套两层。 +const STAR_TOKEN = /`[^`]*`|([A-Za-z0-9.]+(?:\s*\*\s*[A-Za-z0-9.]+)+)/g; + +function showLiteralAsterisks(text) { + // 企微 markdown 会吃掉星号:1.1*2.1*2.1 显示成 1.12.12.1。 + // 放进行内代码后,群里仍是星号。智能体若已经套过,这里不再套第二层。 + return String(text || "").replace(STAR_TOKEN, (full, token) => (token ? "`" + token + "`" : full)); +} + function buildGroupMessageFrame({ chatId, content, callbackReqId, reqId }) { - const text = String(content || ""); + const text = showLiteralAsterisks(String(content || "")); const markdown = { content: text }; if (callbackReqId) { return { @@ -42,4 +51,4 @@ function buildGroupMessageFrame({ chatId, content, callbackReqId, reqId }) { }; } -module.exports = { buildGroupMessageFrame, pickReplyReqId }; +module.exports = { buildGroupMessageFrame, pickReplyReqId, showLiteralAsterisks }; diff --git a/aibot-bridge/tests/group_message_frame.test.cjs b/aibot-bridge/tests/group_message_frame.test.cjs index 435ccb5..5f1cf44 100644 --- a/aibot-bridge/tests/group_message_frame.test.cjs +++ b/aibot-bridge/tests/group_message_frame.test.cjs @@ -24,6 +24,21 @@ test("group reply never uses stream", () => { assert.equal(proactive.body.markdown.feedback, undefined); }); +test("dimension asterisks stay visible in group markdown", () => { + const frame = buildGroupMessageFrame({ + chatId: "wr_1", + content: "体积(CBM)(必填):1.1*2.1*2.1", + callbackReqId: "", + }); + assert.equal(frame.body.markdown.content, "体积(CBM)(必填):`1.1*2.1*2.1`"); + const again = buildGroupMessageFrame({ + chatId: "wr_1", + content: frame.body.markdown.content, + callbackReqId: "", + }); + assert.equal(again.body.markdown.content, "体积(CBM)(必填):`1.1*2.1*2.1`"); +}); + test("reply req id falls back to last inbound when chat map misses", () => { assert.equal(pickReplyReqId({ fromCaller: "cb_1", fromChat: "old", lastAny: "any" }), "cb_1"); assert.equal(pickReplyReqId({ fromCaller: "", fromChat: "chat_cb", lastAny: "any" }), "chat_cb"); diff --git a/inquiry-agent/agent/channel/aibot/reply.py b/inquiry-agent/agent/channel/aibot/reply.py index 311b84b..51e33f2 100644 --- a/inquiry-agent/agent/channel/aibot/reply.py +++ b/inquiry-agent/agent/channel/aibot/reply.py @@ -10,6 +10,7 @@ from __future__ import annotations import logging +import re from typing import Any import httpx @@ -18,6 +19,26 @@ from agent.config import get_settings logger = logging.getLogger(__name__) +# 含星号的尺寸或箱量,如 1.1*2.1*2.1、40HQ*1。已在行内代码里的不再套。 +_CODE_OR_STAR = re.compile(r"`[^`]*`|([A-Za-z0-9.]+(?:\s*\*\s*[A-Za-z0-9.]+)+)") + + +def protect_markdown_asterisks(text: str) -> str: + """ + 协同群正文走企微 markdown。星号会被当成强调,1.1*2.1*2.1 会显示成 1.12.12.1。 + + 把含星号的一段放进行内代码,群里看到的仍是星号,不会变成灰底以外的别的字。 + 已经套过代码的不再套第二层。不改账上的体积原文,只改这一次出站文字。 + """ + + def _wrap(match: re.Match[str]) -> str: + token = match.group(1) + if not token: + return match.group(0) + return f"`{token}`" + + return _CODE_OR_STAR.sub(_wrap, text or "") + class AibotReplyClient: """ @@ -41,7 +62,7 @@ class AibotReplyClient: mention_userids: list[str] | None = None, ) -> dict[str, Any]: _ = mention_userids - text = str(content or "").strip() + text = protect_markdown_asterisks(str(content or "").strip()) self.sent.append({"chat_id": chat_id, "content": text, "kind": "text"}) if not text: return {"ok": False, "error": "empty"} diff --git a/inquiry-agent/agent/handlers/air_group_collab.py b/inquiry-agent/agent/handlers/air_group_collab.py index 0487e0d..2c51985 100644 --- a/inquiry-agent/agent/handlers/air_group_collab.py +++ b/inquiry-agent/agent/handlers/air_group_collab.py @@ -184,7 +184,8 @@ def handle_air_group( return "handoff_ignore" from agent.policy.system_exception import reply_paused_ticket, ticket_is_paused - if ticket_is_paused(ticket): + # 锁舱失败留下的暂停只拦还要打 TMS 的操作。成交、未成交和补原因不调用 TMS。 + if ticket_is_paused(ticket) and not _deal_ignores_tms_pause(ticket, intent): reply_paused_ticket( ticket=ticket, reply=lambda text: send_group_text(group_client_of(engine), chat_id, text), @@ -281,6 +282,21 @@ def _handle_cabin( ) +def _deal_ignores_tms_pause(ticket: object, intent: str) -> bool: + """ + 成交跟进不因 TMS 暂停而改口。 + + 上午锁舱失败会把工单标成暂停。之后群里确认未成交、或补未成交原因, + 不能再回「TMS接口异常」。锁舱、释放仍走各自的重试。 + """ + if intent in {INTENT_DEAL, INTENT_LOST, INTENT_NEGOTIATE}: + return True + view = ticket_view(ticket) + status = str(view.get("status") or "").strip() + reason = str(view.get("lost_reason") or "").strip() + return status == "未成交" and not reason + + def _handle_bound( engine, message: InboundMessage, diff --git a/inquiry-agent/agent/handlers/attachment_inquiry.py b/inquiry-agent/agent/handlers/attachment_inquiry.py index 1073f69..6b4f56e 100644 --- a/inquiry-agent/agent/handlers/attachment_inquiry.py +++ b/inquiry-agent/agent/handlers/attachment_inquiry.py @@ -452,6 +452,32 @@ def finish_attach_wave( or get_land_text_flow().session_of(sender_id) ) wo = (sess.work_order_no if sess else "") or "" + if ( + not wo + and sess + and wave + and (sess.business_line or "").upper() == "SEA" + ): + # 海运先出核对卡,工单要等确定。附件键先留在书签上。 + files = [] + for row in wave.materials: + key = row.object_key + if not key: + continue + name = row.filename or ("image.jpg" if row.kind == "image" else "attachment.bin") + ctype = "image/jpeg" if row.kind == "image" else "application/octet-stream" + files.append( + { + "file_name": name, + "object_key": key, + "content_type": ctype, + } + ) + for engine in (get_sea_text_flow(), get_air_text_flow(), get_land_text_flow()): + hit = engine.session_of(sender_id) + if hit is sess: + engine.remember_pending_files(sender_id, files) + break if wo and wave: ledger = None for engine in (get_sea_text_flow(), get_air_text_flow(), get_land_text_flow()): diff --git a/inquiry-agent/agent/handlers/image_inquiry.py b/inquiry-agent/agent/handlers/image_inquiry.py index a01933b..f3fe231 100644 --- a/inquiry-agent/agent/handlers/image_inquiry.py +++ b/inquiry-agent/agent/handlers/image_inquiry.py @@ -385,6 +385,31 @@ def finish_wave( or get_land_text_flow().session_of(sender_id) ) wo = (sess.work_order_no if sess else "") or "" + if ( + not wo + and sess + and wave + and (sess.business_line or "").upper() == "SEA" + and wave.object_keys + ): + # 海运先出核对卡,工单要等确定。图片键先留在书签上。 + files = [] + for i, key in enumerate(wave.object_keys): + name = wave.filenames[i] if i < len(wave.filenames) else "image.jpg" + if not key: + continue + files.append( + { + "file_name": name or "image.jpg", + "object_key": key, + "content_type": "image/jpeg", + } + ) + for engine in (get_sea_text_flow(), get_air_text_flow(), get_land_text_flow()): + hit = engine.session_of(sender_id) + if hit is sess: + engine.remember_pending_files(sender_id, files) + break if wo and wave: ledger = None for engine in (get_sea_text_flow(), get_air_text_flow(), get_land_text_flow()): diff --git a/inquiry-agent/agent/ledger/http_ledger.py b/inquiry-agent/agent/ledger/http_ledger.py index 4cfb155..ac7a745 100644 --- a/inquiry-agent/agent/ledger/http_ledger.py +++ b/inquiry-agent/agent/ledger/http_ledger.py @@ -216,7 +216,10 @@ class HttpLedger: 失败(无关键词且无默认)原样带回销售话术。Java 不转 PDF。 """ from agent.quote_templates.match import FAIL_MESSAGE + from agent.schema.quote_adjust import stamp_prices_for_sheet + # 出单前把当前费用写入线路表、海运分段、空运舱位。否则报价单仍是旧价。 + quote = stamp_prices_for_sheet(dict(quote or {})) # 填表+回文件可能超过默认 10s,单独放宽,仍禁止无限等。 prev_timeout = self._client.timeout_seconds self._client.timeout_seconds = max(float(prev_timeout or 10), 45.0) diff --git a/inquiry-agent/agent/llm/extract_text.py b/inquiry-agent/agent/llm/extract_text.py index 5f86967..35cd85f 100644 --- a/inquiry-agent/agent/llm/extract_text.py +++ b/inquiry-agent/agent/llm/extract_text.py @@ -352,7 +352,8 @@ def extract_inquiry_snapshot( merged = dict(b_payload.get("facts") or {}) merged.update(facts) facts = merged - mode = mode or str(b_payload.get("business_line") or "") + # 运输方式只认原话里的空运/海运/陆运、1/2/3、飞。 + # 模型常把「郑州-吉隆坡」猜成空运,不能拿来默认。 source = "deepseek_b" facts = harvest_oral_measures(text, normalize_facts(facts)) facts = _merge_spoken_collab(text, facts) diff --git a/inquiry-agent/agent/policy/air_group_ops.py b/inquiry-agent/agent/policy/air_group_ops.py index 64d98d4..d98f907 100644 --- a/inquiry-agent/agent/policy/air_group_ops.py +++ b/inquiry-agent/agent/policy/air_group_ops.py @@ -116,6 +116,7 @@ def activate_ticket( 把本群当前工单切到指定空运未结案单。 返回 (工单, phase)。失败时工单为 None。 + 航线已经用附件或文字落过价时,再发工单号只回到成交跟进,不再发空白模板。 """ book = flow.ledger no = (work_order_no or "").strip() @@ -156,6 +157,12 @@ def activate_ticket( return None, "handoff_ignore" # 先落默认协同,再决定要不要发摘要。发文件切单不说话,工单上仍要有含电/含磁。 _seed_air_activate_collab(flow, ticket) + if speak and _air_already_quoted_for_deal(ticket): + view = ticket_view(ticket) + view["collab_chat_id"] = chat_id + emit_group_deal(flow=flow, ticket=view, quote=dict(view.get("quote") or {})) + _watch_archive_room(chat_id, no) + return ticket, "activated_deal" if speak: send_air_brief_and_sheet(flow=flow, ticket=ticket, chat_id=chat_id) _watch_archive_room(chat_id, no) @@ -165,6 +172,25 @@ def activate_ticket( return ticket, "activated" +def _air_already_quoted_for_deal(ticket: object) -> bool: + """ + 航线报价已经落账,再激活应回成交跟进。 + + 附件报价会把来源标成「航线附件报价」,但报价编号仍可能留着 TMS。 + 只看 source 会误当成还没报价。还只有 TMS 标准价时仍发模板。 + """ + status = ticket_status(ticket) + if status == "协商中": + return True + if status != "已报价": + return False + quote = dict(ticket_view(ticket).get("quote") or {}) + label = str(quote.get("source_label") or quote.get("sourceLabel") or "") + if "航线" in label or "产品" in label: + return True + return copy.quote_is_sales_adjust(quote) + + def _collab_slot_empty(val: object) -> bool: """协同项没写,或只写了横杠。横杠是摘要占位,不能当成已经填过。""" return str(val or "").strip() in {"", "-", "—", "-"} diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py index 4be1587..87113c8 100644 --- a/inquiry-agent/agent/policy/air_text_flow.py +++ b/inquiry-agent/agent/policy/air_text_flow.py @@ -64,6 +64,8 @@ class FlowSession: handoff_offer: str = "" handoff_committed: bool = False virtual_work_order_no: str = "" + # 核对卡还没有工单号时,图片/附件先记在这里,确定建单后再挂到主账。 + pending_files: tuple[dict[str, str], ...] = () def is_waiting_mode_session(sess) -> bool: @@ -139,6 +141,15 @@ def _session_from_payload(raw: dict[str, Any]) -> FlowSession: handoff_offer=str(raw.get("handoff_offer") or ""), handoff_committed=bool(raw.get("handoff_committed")), virtual_work_order_no=str(raw.get("virtual_work_order_no") or ""), + pending_files=tuple( + { + "file_name": str(item.get("file_name") or ""), + "object_key": str(item.get("object_key") or ""), + "content_type": str(item.get("content_type") or ""), + } + for item in (raw.get("pending_files") or []) + if isinstance(item, dict) and str(item.get("object_key") or "").strip() + ), ) @@ -174,6 +185,29 @@ class AirTextInquiryFlow: """空运群 BOT 出站;单测注入假客户端。""" return self._group_client + def remember_pending_files(self, sender_id: str, files: list[dict[str, str]]) -> None: + """ + 核对卡阶段还没有工单号,先把图片/附件键记在当前书签上。 + + 确定建单后再挂主账。副作用:覆盖该书签的 pending_files 并持久化。 + """ + sess = self.session_of(sender_id) + if sess is None: + return + cleaned = tuple( + { + "file_name": str(item.get("file_name") or ""), + "object_key": str(item.get("object_key") or ""), + "content_type": str(item.get("content_type") or ""), + } + for item in files + if str(item.get("object_key") or "").strip() + ) + if not cleaned: + return + sess.pending_files = cleaned + self._save(sess) + def session_of(self, sender_id: str) -> Optional[FlowSession]: sid = (sender_id or "").strip() with self._lock: @@ -417,6 +451,8 @@ class AirTextInquiryFlow: from agent.policy.handoff_human import inherit_pre_create_ticket, note_clarify_round if sess is not None: + if not new_sess.pending_files: + new_sess.pending_files = tuple(getattr(sess, "pending_files", ()) or ()) new_sess.clarify_missing_sig = sess.clarify_missing_sig new_sess.clarify_retry = sess.clarify_retry new_sess.handoff_offer = sess.handoff_offer @@ -550,6 +586,7 @@ class AirTextInquiryFlow: 按工单号回到当时停点并重出对应卡片。 先用进程内该书签;没有再读主账推断节点。不重跑 TMS、不新建工单。 + 正在等未成交原因时,原话记成原因,不再把「请补充未成交原因」重发一遍。 """ no = (work_order_no or "").strip().upper() if not no: @@ -566,6 +603,9 @@ class AirTextInquiryFlow: closed = self._close_spoken_deal(keep, text, reply) if closed: return closed + taken = self._resume_lost_reason(keep, text, reply) + if taken: + return taken return self._replay_wait(keep, reply, source="session") getter = getattr(self._ledger, "get_for_agent", None) if not callable(getter): @@ -584,6 +624,9 @@ class AirTextInquiryFlow: closed = self._close_spoken_deal(sess, text, reply) if closed: return closed + taken = self._resume_lost_reason(sess, text, reply) + if taken: + return taken return self._replay_wait(sess, reply, source="ledger") def _session_from_ledger(self, *, sender_id: str, view: dict[str, Any]) -> FlowSession: @@ -652,6 +695,26 @@ class AirTextInquiryFlow: logger.info("resume 报价文件已出,回到成交跟进 wo=%s was=%s", sess.work_order_no, sess.phase) self._promote_wait_deal(sess) + def _resume_lost_reason(self, sess: FlowSession, text: str, reply: ReplyFn) -> str: + """ + 等未成交原因时,续办带来的原话就是原因。 + + 短句常被判成继续工单。继续工单若只重发提示,原因不会入账。 + 原话只有工单号时仍重发提示,不把工单号当成原因。 + """ + if (sess.phase or "") != "wait_lost_reason": + return "" + body = (text or "").strip() + if not body: + return "" + named = copy.extract_work_order_no(body) + leftover = body + if named: + leftover = body.replace(named, "", 1).strip(" ,,。.;;") + if not leftover: + return "" + return self._capture_lost_reason(sess, leftover, reply) + def _replay_wait(self, sess: FlowSession, reply: ReplyFn, *, source: str) -> str: """按书签重出当时节点,不改六态。""" logger.info( diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index 0ef37b5..92fa222 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -51,6 +51,9 @@ _AIR_OPTIONAL_CLARIFY = tuple( ) AIR_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。" AIR_CONFIRM_EXPIRED = "刚才的空运核对已过期,请把空运需求再发一次,出核对卡后再回复确定。" +# 海运私聊与空运同一句。核对卡没有工单号,回复确定后才建单查价。 +SEA_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。" +SEA_CONFIRM_EXPIRED = "刚才的海运核对已过期,请把海运需求再发一次,出核对卡后再回复确定。" # 海运补问:与合同 10 项一致。报价日期默认当天,不进待补充。 _SEA_EXAMPLES = { @@ -478,6 +481,26 @@ def air_confirm_card(facts: dict[str, str] | None) -> str: return "\n".join(lines) +def sea_confirm_card(facts: dict[str, str] | None) -> str: + """ + 海运确定查价前的核对卡。没有工单号。 + + 销售侧必填都列出(货物数量对应内部键货量)。报价日期没说则用已经填上的当天。 + 协同字段不在这张卡上,不挡首次查价。 + """ + src = dict(facts or {}) + lines = ["运输方式(必填):海运"] + for key, label in SEA_REQUIRED_FIELDS: + if key == "货量": + val = (src.get("货量") or src.get("货物数量") or "").strip() + else: + val = (src.get(key) or "").strip() + lines.append(f"{label}(必填):{val}") + lines.append("") + lines.append(SEA_CONFIRM_TAIL) + return "\n".join(lines) + + def ask_clarify( *, facts: dict[str, str] | None = None, @@ -1032,34 +1055,28 @@ def quote_area_fee_text( return "\n".join(fee_lines) -# 企微引用条过长会被客户端裁成省略号。海运常有 6~8 项费用,必须走文本明细。 -_QUOTE_AREA_SAFE_CHARS = 80 - - def quote_area_safe_text( quote: dict[str, Any] | None, business_line: str = "", transport_mode: str = "", ) -> str: """ - 卡片引用条:空运有费用行就只留总价,禁止再塞费用。 + 价格明细卡的引用条只留两行:费用合计、共几项费用。 - 三项短费用也会被企微裁成「人工费:CNY 123...」。完整明细走文本。 - 海运/陆运仍按字数:短的原样,超长同样只留总价。 + 空运、海运、陆运都这样。完整路段和逐项费用在上一条文字里。 + 引用条再塞路段名或费用明细,企微会裁成半句。 """ src = dict(quote or {}) header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip() total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy) n = len(quote_fee_lines(src)) + if _is_land_surface(quote=src, business_line=business_line, transport_mode=transport_mode): + from agent.schema.land_route_options import land_leg_fee_blocks + + block_n = sum(len(fees) for _title, fees, _eta in land_leg_fee_blocks(src)) + if block_n: + n = block_n label = _total_label(src, business_line, transport_mode) - line = (business_line or "").upper() - mode = (transport_mode or "").strip() - is_air = line == "AIR" or mode == "空运" - if is_air and n: - return f"{label}:{total}\n共{n}项费用,详见上一条明细" - full = quote_area_fee_text(quote, business_line=business_line, transport_mode=transport_mode) - if len(full) <= _QUOTE_AREA_SAFE_CHARS: - return full if n: return f"{label}:{total}\n共{n}项费用,详见上一条明细" return f"{label}:{total}" @@ -1094,8 +1111,13 @@ def sea_quote_detail_text( if route: lines.append(route) if line == "LAND": + from agent.schema.land_route_options import land_leg_fee_blocks + + # 只有一段时,费用前的「始发 → 目的」已经是这条线路。 + # 再写横杠全程(上海-谅山…)就是同一条路出现两次。多段仍保留全程,方便看中转。 + blocks = land_leg_fee_blocks(src) land_line = str(src.get("route_line") or "").strip() - if land_line: + if land_line and len(blocks) != 1: lines.append(land_line) lines.append(quote_area_fee_text(src, business_line=business_line or "SEA")) # 空运、陆运的价格明细正文都要带有效期。海运仍放在按钮卡副标题,避免和原口径打架。 diff --git a/inquiry-agent/agent/policy/land_text_flow.py b/inquiry-agent/agent/policy/land_text_flow.py index aa4af1c..3dd7ac0 100644 --- a/inquiry-agent/agent/policy/land_text_flow.py +++ b/inquiry-agent/agent/policy/land_text_flow.py @@ -4,7 +4,7 @@ 本文件职责:三项选项各自必填(不联动)→ 按类型补货物 → 无工单号核对卡 →(确定后)建单查 TMS。 查价后:1 条完整线路直接出价格明细;多于 1 条发入口卡,H5 选定后再出该条明细。 查价后半段复用海运拉群/报价单/成交,拉人按线路类别,禁止陆运岗全员。 -禁止:字段齐了就建单;直连 TMS;把中转段当成另一条线路;把海运「出卡即查价」改掉。 +禁止:字段齐了就建单;直连 TMS;把中转段当成另一条线路。海运私聊同样是核对后确定再查价。 调用:handler 在 Worker/inbox 线程;禁止回调线程同步跑。 """ diff --git a/inquiry-agent/agent/policy/multi_segments.py b/inquiry-agent/agent/policy/multi_segments.py index 774c484..ff2e93c 100644 --- a/inquiry-agent/agent/policy/multi_segments.py +++ b/inquiry-agent/agent/policy/multi_segments.py @@ -27,13 +27,36 @@ from agent.schema.land_options import ( ) from agent.schema.sea_options import harvest_sea_class -# 行首或「段N:」后面的运输方式。排除「空运费」,避免费用词被当成新的一段。 -_MODE_RE = re.compile(r"(?:段\s*(\d+)\s*[::]\s*)?(空运|海运|陆运)(?!费)") -# 已经写了「段1:陆运」时,段内再写「运输方式:海运」不能再切一段。 -_EXPLICIT_SEG = re.compile(r"段\s*(\d+)\s*[::]\s*(空运|海运|陆运)(?!费)") +# 「段1:」或「第一段」。第组是阿拉伯数字,第二组是「一」或单个数字。运输方式在第三组。 +_SEG_PREFIX = ( + r"(?:段\s*(\d+)\s*[::]\s*|第\s*([一二三四五六七八九]|\d+)\s*段\s*[::,,]?\s*)" +) +# 行首或段标后面的运输方式。排除「空运费」,避免费用词被当成新的一段。 +_MODE_RE = re.compile(rf"(?:{_SEG_PREFIX})?(空运|海运|陆运)(?!费)") +# 已经写了「段1:陆运」或「第一段陆运」时,段内再写「运输方式:海运」不能再切一段。 +_EXPLICIT_SEG = re.compile(rf"{_SEG_PREFIX}(空运|海运|陆运)(?!费)") # 单段标签行没覆盖的陆运销售叫法。内部键仍交给字段校验去归一。 _EXTRA_LABEL = re.compile(r"^(始发站|货物品名|运输类型|线路类别)\s*[::]\s*(.+)$") -_SEG_MARK = re.compile(r"段\s*(\d+)") +_SEG_MARK = re.compile(r"第\s*([一二三四五六七八九]|\d+)\s*段|段\s*(\d+)") +_CN_INDEX = { + "一": 1, + "二": 2, + "三": 3, + "四": 4, + "五": 5, + "六": 6, + "七": 7, + "八": 8, + "九": 9, +} +# 段标前面写一次、两段都要的货物。地名不在这里,避免把北京写进空运起运地。 +_SHARED_CARGO_KEYS = ("品名", "件数", "毛重", "体积", "包装方式") +_SHARED_NOISE = re.compile( + r"\d+(?:\.\d+)?(?:\s*[*xX×]\s*\d+(?:\.\d+)?){1,2}" + r"|\d+(?:\.\d+)?\s*(?:kgs|kg|公斤|千克|cbm|立方米|立方|m3|m³|方|件|pcs)\b" + r"|纸箱|木箱|托盘|卡板|散货", + re.I, +) _MODE_BY_WORD = {"空运": "AIR", "海运": "SEA", "陆运": "LAND"} _WORD_BY_MODE = {"AIR": "空运", "SEA": "海运", "LAND": "陆运"} @@ -85,7 +108,8 @@ def split_segments(text: str) -> SplitOutcome: 表里再写「运输方式:海运」仍是单段,不能报同一方式两段。 没写段标时,报价单反复出现「海运」也当单段;只有「陆运+海运」才切多段。 真的两段都是海运(段1:海运 + 段2:海运)才 same_mode,不建单。 - 每段正文只收「字段名:值」,不猜没写标签的港口。 + 「第一段陆运」和「段1:陆运」是同一种段标。 + 段标前面单独写的品名、件数、重量、体积,每一段都收下。不猜没写标签的港口。 """ raw = text or "" explicit = list(_EXPLICIT_SEG.finditer(raw)) @@ -93,31 +117,91 @@ def split_segments(text: str) -> SplitOutcome: hits = explicit if explicit else list(_MODE_RE.finditer(raw)) if len(hits) < 2: return SplitOutcome() - modes = [_MODE_BY_WORD[hit.group(2)] for hit in hits] + modes = [_MODE_BY_WORD[hit.group(3)] for hit in hits] unique = set(modes) if len(modes) != len(unique): # 没标出两段时,同一种方式写两次仍是单段(海运报价单标题+运输方式)。 if len(explicit) < 2 and len(unique) == 1: return SplitOutcome() return SplitOutcome(same_mode=True) + shared = _shared_cargo_facts(raw[: hits[0].start()]) + lead = raw[: hits[0].start()].strip() segments: list[Segment] = [] for i, hit in enumerate(hits): end = hits[i + 1].start() if i + 1 < len(hits) else len(raw) body = raw[hit.end() : end] - explicit = hit.group(1) - index = int(explicit) if explicit else i + 1 + mode = modes[i] + # 抽取要看得到段前那句货物,否则「茶叶,11kg」写在第一段前面会被丢掉。 + source = f"{lead}\n{body}".strip() if lead else body segments.append( Segment( - index=index, - mode=modes[i], - facts=_facts_from_body(body, modes[i]), - source=body, + index=_segment_index(hit, i + 1), + mode=mode, + facts=_fill_blank(_facts_from_body(body, mode), shared), + source=source, ) ) segments.sort(key=lambda seg: seg.index) return SplitOutcome(ok=True, segments=segments) +def _segment_index(hit: re.Match[str], fallback: int) -> int: + """段标上的序号。没有段标就按出现顺序,从 1 起。""" + token = hit.group(1) or hit.group(2) or "" + if token in _CN_INDEX: + return _CN_INDEX[token] + if token.isdigit(): + return int(token) + return fallback + + +def _shared_cargo_facts(header: str) -> dict[str, str]: + """ + 段标前面写一次的货物。 + + 件数、重量、体积、包装用口语机读。尺寸 1.1*2.1*2.2 保持原文,不乘成立方。 + 去掉这些之后只剩一个词,就当品名。带「到」的地名不当品名。 + 只返回货物键,不把起运地抄到每一段。 + """ + raw = (header or "").strip() + if not raw: + return {} + measured = harvest_oral_measures(raw, {}) + name = _cargo_name_before_segments(raw) + if name and not str(measured.get("品名") or "").strip(): + measured["品名"] = name + return { + key: str(measured.get(key) or "").strip() + for key in _SHARED_CARGO_KEYS + if str(measured.get(key) or "").strip() + } + + +def _cargo_name_before_segments(raw: str) -> str: + """段前货物句里,去掉件数重量体积包装后剩下的那一个品名。""" + cleaned = _SHARED_NOISE.sub(" ", raw or "") + parts: list[str] = [] + for part in re.split(r"[,,、;;\n]+", cleaned): + token = part.strip(" ::。.") + if not token or re.fullmatch(r"[\d.\s*xX×]+", token): + continue + if any(word in token for word in ("到", "飞", "空运", "海运", "陆运", "段")): + continue + parts.append(token) + if len(parts) == 1 and len(parts[0]) <= 30: + return parts[0] + return "" + + +def _fill_blank(base: dict[str, str], extra: dict[str, str]) -> dict[str, str]: + """只补还空着的键。段内已经写了的品名、港口不被段前那句盖掉。""" + out = dict(base) + for key, val in extra.items(): + if str(val or "").strip() and not str(out.get(key) or "").strip(): + out[key] = str(val).strip() + return out + + def _segment_required_check(seg: Segment) -> tuple[dict[str, Any], dict[str, str]]: """ 一段是否齐了询价必填。陆运还要三项选项,模板码站点当没填。 @@ -513,6 +597,14 @@ def _facts_from_body(body: str, mode: str) -> dict[str, str]: return facts +def _mark_index(mark: re.Match[str]) -> int: + """「段1」或「第一段」的序号。调用前已经匹配到段标。""" + token = mark.group(1) or mark.group(2) or "" + if token in _CN_INDEX: + return _CN_INDEX[token] + return int(token) + + def _split_by_segment_mark(text: str) -> dict[int, dict[str, str]]: """ 补充消息里「段1」「段2」后面的内容。 @@ -533,5 +625,5 @@ def _split_by_segment_mark(text: str) -> dict[int, dict[str, str]]: facts = harvest_sea_class(body, facts) facts = harvest_oral_measures(body, facts) if facts: - out[int(mark.group(1))] = facts + out[_mark_index(mark)] = facts return out diff --git a/inquiry-agent/agent/policy/sea_group_ops.py b/inquiry-agent/agent/policy/sea_group_ops.py index 75a8754..80ff673 100644 --- a/inquiry-agent/agent/policy/sea_group_ops.py +++ b/inquiry-agent/agent/policy/sea_group_ops.py @@ -1608,7 +1608,9 @@ def merge_quote_fees(base: dict[str, Any] | None, overlay: dict[str, Any] | None out["total"] = total if currency: out["currency"] = currency - return out + from agent.schema.quote_adjust import stamp_prices_for_sheet + + return stamp_prices_for_sheet(out) def _save_product_quote( diff --git a/inquiry-agent/agent/policy/sea_text_flow.py b/inquiry-agent/agent/policy/sea_text_flow.py index 49f0109..94e9004 100644 --- a/inquiry-agent/agent/policy/sea_text_flow.py +++ b/inquiry-agent/agent/policy/sea_text_flow.py @@ -1,7 +1,7 @@ """ 海运文字询价确定性流程。 -本文件职责:海运七项补问 → 建单出确认卡 → 主账查 TMS → 有价/无价卡 → 跳过协同或拉群。 +本文件职责:海运补问 → 无工单号核对卡 → 回复确定后建单查 TMS → 有价/无价卡 → 跳过协同或拉群。 空运 Owner 仍在 air_text_flow,禁止把海运再堆回去。 调用:handler 在 Worker/inbox 线程;禁止回调线程同步跑。 禁止:直连 TMS;本地拆箱型;用户文字冒充群报价版本。 @@ -108,6 +108,21 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): adjusted = try_private_adjust(self, sess, text, reply) if adjusted: return adjusted + from agent.schema.land_options import looks_like_confirm + + if sess and sess.phase == "wait_confirm" and looks_like_confirm(text): + # 确定查价不再抽字段,避免「确定」被模型改成空 facts。 + logger.info("sea.confirm sender=%s", sender_id) + return self._confirm_or_hold( + sender_id=sender_id, + facts=dict(sess.facts), + text=sess.immutable_text or text, + reply=reply, + sess=sess, + ) + if not sess and looks_like_confirm(text): + reply(copy.SEA_CONFIRM_EXPIRED) + return "confirm_expired" # 上一单已出号:新一轮从本句重收,禁止把旧贸易条款/分类带进下一张工单。 sess = self._detach_ticketed_for_new_inquiry(sess) @@ -132,7 +147,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): return llm_phase mode = (snap.get("business_line") or "").upper() facts = dict(snap.get("facts") or {}) - if sess and sess.phase in {"clarify", "need_mode"}: + if sess and sess.phase in {"clarify", "need_mode", "wait_confirm"}: from agent.llm.extract_text import detect_transport_mode from agent.schema.field_validate import harvest_oral_measures @@ -205,7 +220,8 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): # 整句只回「港到港」是贸易条款举例,不要改写成运输分类。 class_source = "" if short_sea_trade_reply(source) == "港到港" else source facts = harvest_sea_class(class_source, facts) - # 贸易条款标签行原样优先;整句回复门到门/港到港要收下;没写这两个词时模型冒充的门到门丢掉。 + # 贸易条款标签行原样优先;整句回复或长句里写了门到门/港到港都要收下。 + # 原话没出现这两个词时,模型冒充的门到门、港到港继续丢掉。 facts = apply_spoken_sea_trade_reply(source, facts) spoken = harvest_spoken_trade_term(source) if spoken.get("贸易条款") and not str(facts.get("贸易条款") or "").strip(): @@ -243,13 +259,109 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): ), ) - return self._create_and_quote( + return self._hold_for_confirm( sender_id=sender_id, facts=facts, + text=sess.immutable_text if sess and sess.immutable_text else text, + reply=reply, + sess=sess, + ) + + def _hold_for_confirm( + self, + *, + sender_id: str, + facts: dict[str, str], + text: str, + reply: ReplyFn, + sess: Optional[FlowSession], + ) -> str: + """必填已齐:出海运核对卡,等确定再建单。核对卡没有工单号。""" + reply(copy.sea_confirm_card(facts)) + pending = tuple(getattr(sess, "pending_files", ()) or ()) if sess else () + self._save( + FlowSession( + sender_id=sender_id, + thread_id=sender_id, + phase="wait_confirm", + business_line="SEA", + facts=facts, + immutable_text=text, + wait_version=(sess.wait_version + 1) if sess else 1, + pending_files=pending, + ) + ) + return "wait_confirm" + + def _confirm_or_hold( + self, + *, + sender_id: str, + facts: dict[str, str], + text: str, + reply: ReplyFn, + sess: FlowSession, + ) -> str: + """确定意图:字段仍齐则建单查价。缺了就回到补问,不建单。""" + check = validate_required_fields(facts=facts, business_line="SEA") + tms_ready = assemble_sea_query(check["facts"]) + ready_facts = dict(tms_ready.get("facts") or facts) + if not check["ok"] or not tms_ready.get("ok"): + keys = list(check["missing"] or []) + for key in tms_ready.get("missing") or []: + if key not in keys: + keys.append(key) + new_sess = FlowSession( + sender_id=sender_id, + thread_id=sender_id, + phase="clarify", + business_line="SEA", + facts=ready_facts, + immutable_text=sess.immutable_text or text, + wait_version=sess.wait_version + 1, + ) + return self._clarify_or_handoff( + sess=sess, + keys=keys, + new_sess=new_sess, + reply=reply, + clarify_text=copy.ask_clarify( + facts=new_sess.facts, + missing_keys=keys, + transport_mode="海运", + ), + ) + return self._create_and_quote( + sender_id=sender_id, + facts=dict(tms_ready["facts"]), text=text, reply=reply, ) + def _flush_pending_files(self, sender_id: str, work_order_no: str) -> None: + """确定建单后,把核对前收下的图片/附件挂到这张工单。挂完即清,避免重试重复挂。""" + sess = self.session_of(sender_id) + files = tuple(getattr(sess, "pending_files", ()) or ()) if sess else () + if not files or not (work_order_no or "").strip(): + return + for row in files: + key = str(row.get("object_key") or "").strip() + if not key: + continue + try: + self._ledger.attach_file( + work_order_no=work_order_no, + file_name=str(row.get("file_name") or "") or "attachment.bin", + object_key=key, + content_type=str(row.get("content_type") or "") or "application/octet-stream", + sender_id=sender_id, + ) + except Exception: + logger.exception("海运核对后挂附件失败 wo=%s", work_order_no) + if sess is not None: + sess.pending_files = () + self._save(sess) + def _create_and_quote( self, *, @@ -266,6 +378,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): old = self.session_of(sender_id) if old: void_virtual_no(old) + pending = tuple(getattr(old, "pending_files", ()) or ()) if old else () self._save( FlowSession( sender_id=sender_id, @@ -274,6 +387,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): business_line="SEA", facts=facts, immutable_text=text, + pending_files=pending, ) ) created = self._ledger.create_ticket( @@ -286,6 +400,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): if not no: reply("建单失败,请稍后把刚才那条再发一次。海运字段已经记下。") return "ledger_fail" + self._flush_pending_files(sender_id, no) first, history_wo = self._resolve_similar(created, facts=facts, current_no=no) out_id = reply( copy.inquiry_card( diff --git a/inquiry-agent/agent/routing/deepseek_a.py b/inquiry-agent/agent/routing/deepseek_a.py index e1d45ae..e3d1903 100644 --- a/inquiry-agent/agent/routing/deepseek_a.py +++ b/inquiry-agent/agent/routing/deepseek_a.py @@ -103,7 +103,7 @@ _SYSTEM = ( "【新询价】intent=ordinary_text_inquiry,action_type=ORDINARY_TEXT_INQUIRY。" "原话在描述新的运输需求(港口/机场、货物、件数重量体积、空运海运陆运等)," "即使偶尔拿旧单号当参考,也走新询价,work_order_no 留空。" - "陆运核对卡之后回复确定、确认、查价、可以查了,仍是新询价,禁止标闲聊。" + "陆运或海运核对卡之后回复确定、确认、查价、可以查了,仍是新询价,禁止标闲聊。" "机器人正在补问缺项时,销售只回件数、品名、重量、体积等短答(如 111、3件),仍是新询价续填,禁止标闲聊。" "【调价】intent=quote_adjust,action_type=QUOTE_ADJUST。" "inbound_facts 里 session_quoted=true 时,销售用自然语言改当前已有报价:" diff --git a/inquiry-agent/agent/schema/field_validate.py b/inquiry-agent/agent/schema/field_validate.py index 884fd4a..cfd6731 100644 --- a/inquiry-agent/agent/schema/field_validate.py +++ b/inquiry-agent/agent/schema/field_validate.py @@ -387,6 +387,13 @@ def harvest_oral_measures(text: str, facts: dict[str, str] | None = None) -> dic hit = _RE_VOLUME_FANG.search(raw) or _RE_VOLUME_WORD.search(raw) if hit: merged["体积"] = hit.group(1) + else: + # 尺寸式不换算成立方,原文留给报价单;TMS 出站再乘。 + from agent.schema.tms_air_query import dimension_volume_text + + dim = dimension_volume_text(raw) + if dim: + merged["体积"] = dim pack = _harvest_packaging(raw) if pack: merged["包装方式"] = pack diff --git a/inquiry-agent/agent/schema/quote_adjust.py b/inquiry-agent/agent/schema/quote_adjust.py index 557d4dd..a376f51 100644 --- a/inquiry-agent/agent/schema/quote_adjust.py +++ b/inquiry-agent/agent/schema/quote_adjust.py @@ -659,6 +659,246 @@ def _mark_sales_adjust(quote: dict[str, Any]) -> None: quote["source_label"] = "销售调价" +def _sheet_follows_adjusted_fees(quote: dict[str, Any]) -> bool: + """ + 产品、航线、销售改过的价,报价单必须跟着这份走。 + + 航线只是确认沿用 TMS 时不改写舱位明细,避免把附加费之和当成总价。 + """ + label = str(quote.get("source_label") or quote.get("sourceLabel") or "").replace(" ", "") + if "确认TMS" in label: + return False + code = str(quote.get("source") or "").strip().upper() + if code == "SALES_ADJUST" or "销售调价" in label: + return True + if code == "PRODUCT": + return True + return any(word in label for word in ("产品", "航线", "手工")) + + +def _json_struct(value: Any) -> tuple[Any, bool]: + """字符串 JSON 先解开。返回 (对象, 原来是不是字符串)。""" + if isinstance(value, str) and value.strip()[:1] in {"{", "["}: + try: + return json.loads(value), True + except json.JSONDecodeError: + return value, False + return value, False + + +def _put_struct(quote: dict[str, Any], key: str, value: Any, was_str: bool) -> None: + quote[key] = json.dumps(value, ensure_ascii=False) if was_str else value + + +def _fee_title(item: dict[str, Any]) -> str: + return str( + item.get("name") or item.get("feeName") or item.get("chargeName") or item.get("label") or "" + ).strip() + + +def _write_price(item: dict[str, Any], plain: str) -> None: + """同一笔钱写进报价单会读的金额字段。单价有值也一起改,否则模板仍按旧单价重算。""" + if not plain: + return + for key in ( + "amount", + "costPrice", + "cost_price", + "salePrice", + "sale_price", + "unitPrice", + "unit_price", + "value", + "price", + ): + if item.get(key) not in (None, ""): + item[key] = plain + item["amount"] = plain + if item.get("costPrice") not in (None, "") or item.get("cost_price") not in (None, "") or "costPrice" in item: + item["costPrice"] = plain + + +def _match_fee_index(name: str, fees: list[dict[str, Any]], used: set[int]) -> int: + return next( + ( + i + for i, fee in enumerate(fees) + if i not in used and fee_name_same(name, _fee_title(fee)) + ), + -1, + ) + + +def _stamp_fee_items(quote: dict[str, Any], fees: list[dict[str, Any]]) -> None: + raw, was_str = _json_struct(quote.get("feeItems")) + items = [dict(x) for x in raw if isinstance(x, dict)] if isinstance(raw, list) else [] + if not items: + items = [ + { + "name": _fee_title(fee), + "feeName": _fee_title(fee), + "amount": _plain_amount(str(fee.get("amount") or "")), + "currency": fee.get("currency") or quote.get("currency") or "", + } + for fee in fees + if _fee_title(fee) + ] + else: + used: set[int] = set() + for item in items: + idx = _match_fee_index(_fee_title(item), fees, used) + if idx < 0: + continue + used.add(idx) + _write_price(item, _plain_amount(str(fees[idx].get("amount") or ""))) + if not item.get("name"): + item["name"] = _fee_title(fees[idx]) + if not item.get("feeName"): + item["feeName"] = _fee_title(fees[idx]) + if items: + _put_struct(quote, "feeItems", items, was_str) + + +def _stamp_one_route(row: dict[str, Any], fees: list[dict[str, Any]]) -> None: + """一段线路:运输费写金额,提货/送货写到对应列。""" + if len(fees) == 1: + _write_price(row, _plain_amount(str(fees[0].get("amount") or ""))) + return + transport = [fee for fee in fees if any(word in _fee_title(fee) for word in ("运输", "运费", "陆运"))] + if len(transport) == 1: + _write_price(row, _plain_amount(str(transport[0].get("amount") or ""))) + for fee in fees: + title = _fee_title(fee) + plain = _plain_amount(str(fee.get("amount") or "")) + if not plain: + continue + if "提货" in title: + row["pickup_fee"] = plain + row["pickupFee"] = plain + elif "送货" in title or "派送" in title: + row["delivery_fee"] = plain + row["deliveryFee"] = plain + + +def _stamp_route_list(rows: list[Any], fees: list[dict[str, Any]]) -> None: + legs = [row for row in rows if isinstance(row, dict)] + if not legs or not fees: + return + if len(legs) == len(fees): + for leg, fee in zip(legs, fees): + _write_price(leg, _plain_amount(str(fee.get("amount") or ""))) + return + if len(legs) == 1: + _stamp_one_route(legs[0], fees) + + +def _stamp_route_copies(quote: dict[str, Any], fees: list[dict[str, Any]]) -> None: + """陆运报价单优先读 route_rows,不读已经改过的 fee_lines。""" + for key in ("route_rows", "landSegments", "land_segments"): + raw, was_str = _json_struct(quote.get(key)) + if not isinstance(raw, list) or not raw: + continue + copied = [dict(x) if isinstance(x, dict) else x for x in raw] + _stamp_route_list(copied, fees) + _put_struct(quote, key, copied, was_str) + for key in ("selected_land_route", "selectedLandRoute"): + raw, was_str = _json_struct(quote.get(key)) + if not isinstance(raw, dict): + continue + selected = dict(raw) + legs = [dict(x) if isinstance(x, dict) else x for x in list(selected.get("legs") or [])] + if not legs: + continue + _stamp_route_list(legs, fees) + selected["legs"] = legs + _put_struct(quote, key, selected, was_str) + + +def _stamp_buckets(quote: dict[str, Any], fees: list[dict[str, Any]]) -> None: + """海运报价单按始发港/海运/目的港三段读,不能只改扁平费用行。""" + for key, _label in _SEA_BUCKETS: + items = _bucket_items(quote, key) + if not items: + continue + used: set[int] = set() + changed = False + for item in items: + idx = _match_fee_index(_item_fee_name(item), fees, used) + if idx < 0: + continue + used.add(idx) + plain = _plain_amount(str(fees[idx].get("amount") or "")) + if not plain: + continue + _write_price(item, plain) + changed = True + if changed: + _store_bucket(quote, key, items) + + +def _stamp_air_copies(quote: dict[str, Any], fees: list[dict[str, Any]]) -> None: + """空运报价单读舱位 departureCharges 和 salePrice,不读改过的 fee_lines。""" + total_plain = _plain_amount(str(quote.get("total") or "")) + for key in ("airOptions", "air_options", "selected_air_option", "selectedAirOption"): + raw, was_str = _json_struct(quote.get(key)) + single = isinstance(raw, dict) + options = [dict(raw)] if single else [dict(x) if isinstance(x, dict) else x for x in raw] if isinstance(raw, list) else [] + if not options: + continue + for opt in options: + if not isinstance(opt, dict): + continue + charge_key = "departureCharges" if "departureCharges" in opt or "departure_charges" not in opt else "departure_charges" + if charge_key not in opt and "departureCharges" not in opt and "departure_charges" not in opt: + charge_key = "" + if charge_key: + charges, charges_str = _json_struct(opt.get(charge_key)) + if isinstance(charges, list) and charges: + used: set[int] = set() + copied = [dict(x) if isinstance(x, dict) else x for x in charges] + for charge in copied: + if not isinstance(charge, dict): + continue + idx = _match_fee_index(_fee_title(charge), fees, used) + if idx < 0: + continue + used.add(idx) + _write_price(charge, _plain_amount(str(fees[idx].get("amount") or ""))) + opt[charge_key] = json.dumps(copied, ensure_ascii=False) if charges_str else copied + if total_plain: + if opt.get("salePrice") not in (None, ""): + opt["salePrice"] = total_plain + if opt.get("sale_price") not in (None, ""): + opt["sale_price"] = total_plain + if single: + _put_struct(quote, key, options[0], was_str) + else: + _put_struct(quote, key, options, was_str) + + +def stamp_prices_for_sheet(quote: dict[str, Any] | None) -> dict[str, Any]: + """ + 把当前生效费用写进报价单实际读取的每一份副本。 + + 销售调价、产品报价、航线报价都会改 fee_lines,但陆运单读 route_rows, + 海运单读三段费用,空运单读舱位 departureCharges。这些副本不跟着改, + 报价单上就还是 TMS 原价。TMS 原价和「确认沿用 TMS」不改写这些副本。 + """ + src = dict(quote or {}) + if not _sheet_follows_adjusted_fees(src): + return src + fees = [dict(row) for row in quote_fee_rows(src)] + if not fees: + return src + src["fee_lines"] = fees + src["fee_rows"] = fees + _stamp_fee_items(src, fees) + _stamp_route_copies(src, fees) + _stamp_buckets(src, fees) + _stamp_air_copies(src, fees) + return src + + def compute_profit(*, x: Optional[float], baseline: str) -> Optional[float]: """ 只在销售调价后算一个利润点。 @@ -975,6 +1215,7 @@ def apply_adjust( if tms_sale and "tms_baseline_sale" not in out: out["tms_baseline_sale"] = tms_sale _mark_sales_adjust(out) + out = stamp_prices_for_sheet(out) logger.info( "quote_adjust applied=%s unmatched=%s hint=%s profit=%s", [x.get("name") for x in applied], diff --git a/inquiry-agent/agent/schema/sea_options.py b/inquiry-agent/agent/schema/sea_options.py index e1fce96..d9638a4 100644 --- a/inquiry-agent/agent/schema/sea_options.py +++ b/inquiry-agent/agent/schema/sea_options.py @@ -144,7 +144,7 @@ def harvest_sea_trade_terms(text: str, facts: dict[str, str] | None = None) -> d return merged -# 补问举例让销售直接回这两个词。整句只有其中一个才记成贸易条款,长句里的港到港仍给运输分类。 +# 补问举例让销售直接回这两个词。整句只有其中一个,或长句里写了其中一个,都记成贸易条款。 _SHORT_SEA_TRADE_REPLY = re.compile( r"^\s*(门到门|港到港|door\s*to\s*door|door-to-door|d2d)\s*[.。!!??]?\s*$", re.IGNORECASE, @@ -196,17 +196,54 @@ def drop_invented_door_trade_terms(text: str, facts: dict[str, str] | None = Non return merged +def _spoken_door_in_text(text: str) -> str: + """ + 长句里写了「门到门」或「港到港」就是贸易条款。 + + 举例行「(如:门到门、港到港)」先丢掉,不能当成已填。 + 原话没有这两个词时返回空,禁止把模型猜的条款写回去。 + 同一句里两个词都有时,取靠后的那个。 + 「Port to Port」仍是运输分类,不在这里改写成贸易条款。 + 「广州港到巴生港」不含「港到港」这三个连续字,不能误收。 + """ + cleaned = _without_clarify_examples(text or "") + found: list[tuple[int, str]] = [] + for word in ("门到门", "港到港"): + idx = cleaned.rfind(word) + if idx >= 0: + found.append((idx, word)) + door = re.search(r"door\s*[- ]?to\s*[- ]?door", cleaned, re.I) + if door: + found.append((door.start(), "门到门")) + if not found: + return "" + found.sort(key=lambda item: item[0]) + return found[-1][1] + + def apply_spoken_sea_trade_reply(text: str, facts: dict[str, str] | None = None) -> dict[str, str]: """ - 先丢掉模型冒充的门到门,再收下销售整句回复的门到门或港到港。 + 先丢掉模型冒充的门到门,再按原话收回贸易条款。 - 测服 2026-10-03:补问举例是门到门、港到港,销售回「门到门」仍被当成没填,并提示参数提取不全。 - 原话没出现这两个词时,旧草稿里的门到门继续丢掉。返回新 dict。 + 整句只回「门到门」或「港到港」要收下。长句里写了其中一个(例如先报货、再回「2」选海运) + 也是贸易条款,不能因为原话没有「贸易条款」四个字就丢掉。 + 原话没出现「门到门」时,旧草稿里的门到门继续丢掉(WO202609200013)。 + 原话没出现「港到港」时,模型填的港到港同样丢掉。 + 销售已写「贸易条款:…」标签时以标签为准,不用句中的词覆盖。返回新 dict。 """ merged = drop_invented_door_trade_terms(text, facts) + if _labeled_value(text or "", _LABELED_TRADE_TERMS): + return merged spoken = short_sea_trade_reply(text) if spoken: merged["贸易条款"] = spoken + return merged + door = _spoken_door_in_text(text) + if door: + merged["贸易条款"] = door + return merged + if str(merged.get("贸易条款") or "").strip() == "港到港": + merged.pop("贸易条款", None) return merged diff --git a/inquiry-agent/agent/schema/tms_air_query.py b/inquiry-agent/agent/schema/tms_air_query.py index 22a75af..d21adfd 100644 --- a/inquiry-agent/agent/schema/tms_air_query.py +++ b/inquiry-agent/agent/schema/tms_air_query.py @@ -89,6 +89,19 @@ def _first_number(raw: str) -> str: return hit.group(1) if hit else "" +def dimension_volume_text(raw: str) -> str: + """ + 原话里的尺寸式体积,保持销售写法,不在这里相乘。 + + 「1.1*2.1*2.2」「1.1x1.2x3」都算体积原文。出站数字仍交给 volume_cbm_number。 + 箱型「40HQ*1」中间有字母,不算尺寸。没有尺寸返回空串。 + """ + hit = _DIM_PRODUCT.search(raw or "") + if not hit: + return "" + return hit.group(0).strip() + + def volume_cbm_number(raw: str) -> str: """ 体积出站用数字:尺寸式先相乘;否则取第一个数。 diff --git a/inquiry-agent/tests/test_aibot_reply.py b/inquiry-agent/tests/test_aibot_reply.py index 5bdf1b8..2be8877 100644 --- a/inquiry-agent/tests/test_aibot_reply.py +++ b/inquiry-agent/tests/test_aibot_reply.py @@ -14,7 +14,7 @@ if ROOT not in sys.path: sys.path.insert(0, ROOT) from agent.channel.aibot import normalize_aibot_payload -from agent.channel.aibot.reply import AibotReplyClient +from agent.channel.aibot.reply import AibotReplyClient, protect_markdown_asterisks class AibotInboundNormalizeTests(unittest.TestCase): @@ -51,6 +51,14 @@ class AibotInboundNormalizeTests(unittest.TestCase): class AibotReplyTests(unittest.TestCase): + def test_group_markdown_keeps_dimension_asterisks(self) -> None: + raw = "体积(CBM)(必填):1.1*2.1*2.1\n箱型箱量:40HQ*1" + shown = protect_markdown_asterisks(raw) + self.assertEqual( + shown, + "体积(CBM)(必填):`1.1*2.1*2.1`\n箱型箱量:`40HQ*1`", + ) + self.assertEqual(protect_markdown_asterisks(shown), shown) def test_empty_content_skips(self) -> None: client = AibotReplyClient("") out = client.send_group(chat_id="wr_1", content=" ") diff --git a/inquiry-agent/tests/test_air_group_collab.py b/inquiry-agent/tests/test_air_group_collab.py index 1c2c0e9..6625c9e 100644 --- a/inquiry-agent/tests/test_air_group_collab.py +++ b/inquiry-agent/tests/test_air_group_collab.py @@ -1091,6 +1091,31 @@ class AirGroupCollabTests(unittest.TestCase): self.assertTrue(any(f"工单{wo}的1个附件已保存到后台工单" in t for t in self._texts())) self.assertTrue(any("报价单已发送,请确认成交状态" in t for t in self._texts())) + def test_reactivate_after_airline_file_is_deal_not_blank_sheet(self) -> None: + """航线附件已报价后,再发工单号回到成交跟进,不再要航线报价。""" + wo = self._create_air() + handle_air_group( + self._msg(sender="air1", text=wo, chat="wr_air_1", mid="af3b"), + flow=self.flow, + ledger=self.ledger, + ) + handle_air_group( + self._file(sender="air1", filename="航线报价.pdf", chat="wr_air_1", mid="af4b"), + flow=self.flow, + ledger=self.ledger, + ) + before = len(self.groups.sent) + phase = handle_air_group( + self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="af4c"), + flow=self.flow, + ledger=self.ledger, + ) + self.assertEqual(phase, "activated_deal") + added = self.groups.sent[before:] + texts = "\n".join(x.get("content") or "" for x in added) + self.assertIn("请确认成交状态", texts) + self.assertFalse(any(x.get("kind") == "file" for x in added)) + def test_image_rejected(self) -> None: wo = self._create_air() handle_air_group( @@ -1376,6 +1401,45 @@ class AirGroupCollabTests(unittest.TestCase): self.assertIn("锁舱失败的原因:该航班日期不存在可锁定的配载记录", joined) self.assertNotIn("TMS接口异常,IT运维排查中", joined) + def test_paused_after_lock_still_records_lost_reason(self) -> None: + """锁舱失败标了暂停后,未成交和原因仍要入账,不能回 TMS 异常。""" + wo = self._create_air() + handle_air_group( + self._msg(sender="sales1", text=wo, chat="wr_air_paused_lost", mid="pl0"), + flow=self.flow, + ledger=self.ledger, + injected_intent="air_activate", + ) + self.ledger.mark_system_exception( + work_order_no=wo, + system_exception="TMS", + system_exception_reason="空运锁舱接口调用失败", + step="tms_lock", + payload={}, + ) + self.groups.sent.clear() + phase = handle_air_group( + self._msg(sender="sales1", text="未成交", chat="wr_air_paused_lost", mid="pl1"), + flow=self.flow, + ledger=self.ledger, + injected_intent="deal_lost", + ) + self.assertEqual(phase, "wait_lost_reason") + self.assertNotIn("TMS接口异常", "\n".join(self._texts())) + self.groups.sent.clear() + phase = handle_air_group( + self._msg(sender="sales1", text="客户不需要了", chat="wr_air_paused_lost", mid="pl2"), + flow=self.flow, + ledger=self.ledger, + injected_intent="other", + ) + self.assertEqual(phase, "done") + joined = "\n".join(self._texts()) + self.assertIn("已记下未成交原因:客户不需要了", joined) + self.assertNotIn("TMS接口异常", joined) + ticket = self.ledger.get_ticket(work_order_no=wo) + self.assertEqual(ticket.lost_reason, "客户不需要了") + def test_release_fail_tells_operator_and_tms_reason(self) -> None: """释放失败同理:操作人、原因、已通知IT,不要系统异常兜底。""" wo = self._create_air() diff --git a/inquiry-agent/tests/test_attachment_inquiry.py b/inquiry-agent/tests/test_attachment_inquiry.py index e46c83c..804299d 100644 --- a/inquiry-agent/tests/test_attachment_inquiry.py +++ b/inquiry-agent/tests/test_attachment_inquiry.py @@ -213,6 +213,15 @@ class AttachmentInquiryTests(unittest.TestCase): self.assertIn(copy.ATTACH_RECOGNIZING, self._bodies()) sess = get_sea_text_flow().session_of("s1") self.assertIsNotNone(sess) + self.assertEqual(sess.phase, "wait_confirm") + self.assertFalse((sess.work_order_no or "").strip()) + self.assertTrue(sess.pending_files) + self.assertIn(copy.SEA_CONFIRM_TAIL, self._bodies()[-1]) + handle_text_inquiry( + InboundMessage(sender_id="s1", message_id="sea-ok", content="确定"), + store=self.store, + ) + sess = get_sea_text_flow().session_of("s1") 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) @@ -267,6 +276,13 @@ class AttachmentInquiryTests(unittest.TestCase): 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.phase == "wait_confirm") + self.assertIn(copy.SEA_CONFIRM_TAIL, self._bodies()[-1]) + handle_text_inquiry( + InboundMessage(sender_id="s1", message_id="sea-ok", content="确定"), + store=self.store, + ) + 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: @@ -276,6 +292,10 @@ class AttachmentInquiryTests(unittest.TestCase): injected_facts=COMPLETE_SEA, injected_mode="SEA", ) + handle_text_inquiry( + InboundMessage(sender_id="s1", message_id="old-ok", content="确定"), + store=self.store, + ) wo_old = get_sea_text_flow().session_of("s1").work_order_no handle_attachment_inquiry( InboundMessage(sender_id="s1", message_id="m2", content="我有个excel要询价"), @@ -292,6 +312,10 @@ class AttachmentInquiryTests(unittest.TestCase): "empty": False, }, ) + handle_text_inquiry( + InboundMessage(sender_id="s1", message_id="new-ok", content="确定"), + store=self.store, + ) 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)) diff --git a/inquiry-agent/tests/test_continue_ticket.py b/inquiry-agent/tests/test_continue_ticket.py index 7525f9a..6d529f2 100644 --- a/inquiry-agent/tests/test_continue_ticket.py +++ b/inquiry-agent/tests/test_continue_ticket.py @@ -265,6 +265,58 @@ class ContinueTicketTests(unittest.TestCase): self.assertTrue(any("已记录为「已成交」" in x for x in replies)) self.assertIn(wo, "\n".join(replies)) + def test_resume_waiting_lost_reason_saves_sentence(self) -> None: + """等未成交原因时继续工单,下一句要写入原因,不能再要一遍。""" + ledger = MemoryLedger() + created = ledger.create_ticket( + sender_id="u7", business_line="AIR", facts=COMPLETE_AIR + ) + wo = created["work_order_no"] + ledger.upsert_quote( + work_order_no=wo, + quote={"total": "USD 1", "eta": "-", "valid_until": "-"}, + to_status="已报价", + ) + ledger.render_quote( + work_order_no=wo, + business_line="AIR", + facts=COMPLETE_AIR, + quote={"total": "USD 1"}, + ) + flow = AirTextInquiryFlow(ledger) + replies: list[str] = [] + + def reply(text: str, extra=None) -> None: + replies.append(text) + _ = extra + + self.assertEqual( + flow.resume_historical(sender_id="u7", work_order_no=wo, reply=reply), + "wait_deal", + ) + self.assertEqual( + flow.on_button(sender_id="u7", action=copy.BTN_LOST, reply=reply), + "wait_lost_reason", + ) + replies.clear() + phase = flow.resume_historical( + sender_id="u7", work_order_no=wo, reply=reply, text="" + ) + self.assertEqual(phase, "wait_lost_reason") + self.assertTrue(any("请补充未成交原因" in x for x in replies)) + replies.clear() + phase = flow.resume_historical( + sender_id="u7", + work_order_no=wo, + reply=reply, + text="客户不需要了", + ) + self.assertEqual(phase, "done") + self.assertTrue(any("已记下未成交原因:客户不需要了" in x for x in replies)) + self.assertFalse(any("请补充未成交原因" in x for x in replies)) + view = ledger.get_for_agent(work_order_no=wo, sender_id="u7") or {} + self.assertEqual(view.get("lost_reason"), "客户不需要了") + def test_resume_missing_wo_asks(self) -> None: flow = AirTextInquiryFlow(MemoryLedger()) replies: list[str] = [] diff --git a/inquiry-agent/tests/test_extract_oral.py b/inquiry-agent/tests/test_extract_oral.py index 2c7b69e..afb982e 100644 --- a/inquiry-agent/tests/test_extract_oral.py +++ b/inquiry-agent/tests/test_extract_oral.py @@ -59,6 +59,37 @@ class OralExtractTests(unittest.TestCase): out = invoke_shell(payload={"a": 1}) self.assertEqual(out.get("error"), "extract_fields_shell_not_implemented") + def test_city_pair_without_mode_does_not_default_air(self) -> None: + """没写运输方式时,模型猜的空运不能当成已选。港口和日期仍留下。""" + text = "查一下10月9日郑州-吉隆坡的报价" + guessed = parse_extract_payload( + { + "transport_mode": "AIR", + "fields": { + "起运港": "郑州", + "目的港": "吉隆坡", + "报价日期": "2026-10-09", + }, + } + ) + snap = extract_inquiry_snapshot(text, b_result=guessed) + self.assertEqual(snap["business_line"], "") + self.assertEqual(snap["facts"].get("起运港"), "郑州") + self.assertEqual(snap["facts"].get("目的港"), "吉隆坡") + self.assertEqual(snap["facts"].get("报价日期"), "2026-10-09") + flow = AirTextInquiryFlow(MemoryLedger()) + replies: list[str] = [] + phase = flow.on_text( + sender_id="u-mode", + text=text, + reply=lambda body, extra=None: replies.append(body), + b_result=guessed, + allow_b=False, + ) + self.assertEqual(phase, "need_mode") + self.assertTrue(any("告知我询价的运输方式" in x for x in replies)) + self.assertFalse(any(x.startswith("运输方式:空运") or "运输方式:空运" in x for x in replies)) + def test_oral_snapshot_uses_b_not_regex(self) -> None: snap = extract_inquiry_snapshot(ORAL, b_result=parse_extract_payload(B_JSON)) self.assertEqual(snap["business_line"], "AIR") @@ -304,6 +335,14 @@ class OralExtractTests(unittest.TestCase): self.assertIn("重量(KG):1.5KG", shown) self.assertNotIn("1.5KGS", shown) + def test_dimension_text_is_volume_without_multiplying(self) -> None: + from agent.schema.field_validate import harvest_oral_measures + + facts = harvest_oral_measures("茶叶,1.1*2.1*2.2,11kg,21件", {}) + self.assertEqual(facts["体积"], "1.1*2.1*2.2") + self.assertEqual(facts["毛重"], "11kg") + self.assertEqual(facts["件数"], "21件") + def test_oral_units_fill_weight_volume_without_b(self) -> None: snap = extract_inquiry_snapshot( "珠海到克拉克空运,茶叶 10件 100KGS 2CBM 托盘" diff --git a/inquiry-agent/tests/test_image_inquiry.py b/inquiry-agent/tests/test_image_inquiry.py index bc350be..7bc30a7 100644 --- a/inquiry-agent/tests/test_image_inquiry.py +++ b/inquiry-agent/tests/test_image_inquiry.py @@ -134,6 +134,15 @@ class ImageInquiryFlowTests(unittest.TestCase): sea = get_sea_text_flow() sess = sea.session_of("s1") self.assertIsNotNone(sess) + self.assertEqual(sess.phase, "wait_confirm") + self.assertFalse((sess.work_order_no or "").strip()) + self.assertTrue(sess.pending_files) + self.assertIn(copy.SEA_CONFIRM_TAIL, self._bodies()[-1]) + handle_text_inquiry( + InboundMessage(sender_id="s1", message_id="sea-ok", content="确定"), + store=self.store, + ) + sess = sea.session_of("s1") self.assertTrue((sess.work_order_no or "").strip()) ticket = sea.ledger.get_ticket(work_order_no=sess.work_order_no) self.assertTrue(ticket.attachments) @@ -163,7 +172,8 @@ class ImageInquiryFlowTests(unittest.TestCase): 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) + self.assertTrue(sess and sess.phase == "wait_confirm") + self.assertFalse((sess.work_order_no or "").strip()) def test_precede_text_joins_new_ticket(self) -> None: handle_text_inquiry( @@ -207,6 +217,10 @@ class ImageInquiryFlowTests(unittest.TestCase): injected_facts=COMPLETE_SEA, injected_mode="SEA", ) + handle_text_inquiry( + InboundMessage(sender_id="s1", message_id="old-ok", content="确定"), + store=self.store, + ) wo_old = get_sea_text_flow().session_of("s1").work_order_no self.assertTrue(wo_old) handle_image_inquiry( @@ -215,6 +229,10 @@ class ImageInquiryFlowTests(unittest.TestCase): vision_fn=_vision(SEA_ORAL), download_fn=lambda _mid: (b"x", ""), ) + handle_text_inquiry( + InboundMessage(sender_id="s1", message_id="new-ok", content="确定"), + store=self.store, + ) wo_new = get_sea_text_flow().session_of("s1").work_order_no self.assertTrue(wo_new) self.assertNotEqual(wo_old, wo_new) diff --git a/inquiry-agent/tests/test_land_dispatch.py b/inquiry-agent/tests/test_land_dispatch.py index c6f7c7d..96394c2 100644 --- a/inquiry-agent/tests/test_land_dispatch.py +++ b/inquiry-agent/tests/test_land_dispatch.py @@ -257,7 +257,10 @@ class LandDispatchTests(unittest.TestCase): last = list(self.store._outbox.values())[-1].content self.assertIn(SYS_EXC_TMS_FALLBACK, last) - def test_sea_still_creates_immediately(self) -> None: + def test_sea_holds_confirm_before_quote(self) -> None: + """海运私聊字段齐了先出核对卡,回复确定后才查价。""" + from agent.policy import inquiry_copy as copy + msg = InboundMessage(sender_id="s-sea", message_id="m-sea-keep", content="海运询价") phase = handle_text_inquiry( msg, @@ -265,6 +268,14 @@ class LandDispatchTests(unittest.TestCase): injected_mode="SEA", store=self.store, ) + self.assertEqual(phase, "wait_confirm") + held = "\n".join(item.content for item in self.store._outbox.values()) + self.assertIn(copy.SEA_CONFIRM_TAIL, held) + self.assertNotIn("工单号", held) + phase = handle_text_inquiry( + InboundMessage(sender_id="s-sea", message_id="m-sea-ok", content="确定"), + store=self.store, + ) self.assertEqual(phase, "wait_collab") def _land_to_quoted(self, sender_id: str) -> None: diff --git a/inquiry-agent/tests/test_land_route_options.py b/inquiry-agent/tests/test_land_route_options.py index 9da2053..2a42847 100644 --- a/inquiry-agent/tests/test_land_route_options.py +++ b/inquiry-agent/tests/test_land_route_options.py @@ -243,6 +243,44 @@ class LandRouteOptionsTests(unittest.TestCase): valid = text.find("报价有效期:", text.find("费用合计:")) self.assertGreater(valid, text.find("费用合计:")) + def test_one_leg_detail_does_not_repeat_route_and_card_is_summary(self) -> None: + """一段陆运:目的站名里已有横杠,明细只写始发→目的;卡片引用条只留合计和项数。""" + from agent.policy import inquiry_copy as copy + + dest = "谅山Lạng Sơn-春疆货场Huu Nghi" + quote = { + "source": "TMS", + "source_label": "TMS标准报价", + "currency": "CNY", + "valid_until": "2026-10-31", + "landSegments": [ + { + "segmentSequence": 1, + "routeLegSequence": 1, + "origin": "上海", + "destination": dest, + "costPrice": 7500, + "currency": "CNY", + "transitDays": "N+2", + } + ], + "fee_lines": [{"name": "运输费", "amount": "7500", "currency": "CNY"}], + } + projected = project_selected_route(quote, group_land_routes(quote)[0]) + text = copy.sea_quote_detail_text( + work_order_no="WO202610030035", + quote=projected, + business_line="LAND", + ) + self.assertNotIn(f"上海-{dest}", text) + self.assertIn(f"上海 → {dest}", text) + self.assertEqual(text.count(dest), 1) + self.assertIn("运输费:CNY 7500", text) + card = copy.quote_area_safe_text(projected, business_line="LAND") + self.assertEqual(card, "费用合计:CNY 7500\n共1项费用,详见上一条明细") + self.assertNotIn("运输费", card) + self.assertNotIn(dest, card) + def test_land_detail_and_group_use_selected_route_valid_until(self) -> None: """选定广西这条后:私聊明细和群 TMS 段同一套正文,不摊开没选的线路。""" from agent.policy import inquiry_copy as copy diff --git a/inquiry-agent/tests/test_multi_segments.py b/inquiry-agent/tests/test_multi_segments.py index fb066ae..fcf8fa7 100644 --- a/inquiry-agent/tests/test_multi_segments.py +++ b/inquiry-agent/tests/test_multi_segments.py @@ -133,6 +133,29 @@ class SplitTests(unittest.TestCase): self.assertTrue(outcome.ok) self.assertEqual([seg.mode for seg in outcome.segments], ["LAND", "SEA"]) + def test_cargo_before_ordinal_segments_fills_both(self) -> None: + """段前写一次的品名、尺寸、重量、件数,陆运和空运都要带上。""" + oral = ( + "茶叶,1.1*2.1*2.2,11kg,21件\n" + "第一段陆运,北京到上海\n" + "第二段空运,上海到北宁" + ) + outcome = split_segments(oral) + self.assertTrue(outcome.ok) + self.assertEqual([seg.index for seg in outcome.segments], [1, 2]) + self.assertEqual([seg.mode for seg in outcome.segments], ["LAND", "AIR"]) + land, air = outcome.segments + self.assertEqual(land.facts.get("品名"), "茶叶") + self.assertEqual(air.facts.get("品名"), "茶叶") + self.assertEqual(air.facts.get("件数"), "21件") + self.assertEqual(air.facts.get("毛重"), "11kg") + self.assertEqual(air.facts.get("体积"), "1.1*2.1*2.2") + _, _, gaps = refresh_segments(outcome.segments) + self.assertNotIn("品名(必填):\n(如:茶叶)", gaps) + self.assertNotIn("重量(KG)(必填):\n(如:", gaps) + self.assertNotIn("体积(CBM)(必填):\n(如:", gaps) + self.assertNotIn("件数(必填):\n(如:", gaps) + def test_group_brief_has_no_blank_under_mentions(self) -> None: """拉群摘要 @人后直接接段1,中间不要空行。""" segs = split_segments(SEA_AIR).segments diff --git a/inquiry-agent/tests/test_quote_adjust.py b/inquiry-agent/tests/test_quote_adjust.py index c074638..18e53e3 100644 --- a/inquiry-agent/tests/test_quote_adjust.py +++ b/inquiry-agent/tests/test_quote_adjust.py @@ -5,6 +5,7 @@ from __future__ import annotations +import json import os import sys import unittest @@ -618,5 +619,102 @@ class QuoteAdjustTests(unittest.TestCase): self.assertIn("费用合计:CNY 100.00", text.split("销售调价:", 1)[1]) +class SheetPriceStampTests(unittest.TestCase): + """报价单读的是线路表和舱位明细,不是只读 fee_lines。""" + + def test_sales_adjust_writes_land_route_copies(self) -> None: + src = { + "source": "TMS", + "source_label": "TMS标准报价", + "currency": "CNY", + "total": "CNY 16500", + "fee_lines": [{"name": "运输费", "amount": "16500.00", "currency": "CNY"}], + "feeItems": [{"feeName": "运输费", "amount": 16500.0, "currency": "CNY"}], + "landSegments": [ + {"origin": "上海", "destination": "谅山", "costPrice": 16500.0, "currency": "CNY"} + ], + "route_rows": [ + { + "origin": "上海", + "destination": "谅山", + "costPrice": "16500.00", + "amount": "16500.00", + } + ], + "selected_land_route": json.dumps( + {"legs": [{"origin": "上海", "destination": "谅山", "costPrice": 16500.0}]}, + ensure_ascii=False, + ), + } + got = apply_adjust( + src, + extract_adjust("运输费改为28150", fee_names=["运输费"], allow_model=False), + business_line="LAND", + tms_cost="16500", + tms_sale="16500", + ) + self.assertTrue(got.ok) + self.assertIn("28150", str(got.quote["route_rows"][0]["amount"])) + self.assertIn("28150", str(got.quote["route_rows"][0]["costPrice"])) + self.assertIn("28150", str(got.quote["landSegments"][0]["costPrice"])) + selected = json.loads(got.quote["selected_land_route"]) + self.assertIn("28150", str(selected["legs"][0]["costPrice"])) + self.assertIn("28150", str(got.quote["feeItems"][0]["amount"])) + + def test_product_and_airline_sheet_copies_follow_new_fee(self) -> None: + from agent.policy.sea_group_ops import merge_quote_fees + from agent.schema.quote_adjust import stamp_prices_for_sheet + + base = { + "source": "TMS", + "source_label": "TMS标准报价", + "currency": "CNY", + "total": "16500", + "fee_lines": [{"name": "运输费", "amount": "16500", "currency": "CNY"}], + "route_rows": [{"costPrice": "16500", "amount": "16500"}], + "oceanCostItems": [{"name": "海运费", "amount": "100", "currency": "USD"}], + } + merged = merge_quote_fees( + base, + {"fee_lines": [{"name": "运输费", "amount": "18000"}], "source_label": "产品报价"}, + ) + self.assertIn("18000", str(merged["route_rows"][0]["amount"])) + self.assertEqual(merged["source_label"], "产品报价") + + air = stamp_prices_for_sheet( + { + "source_label": "航线报价", + "total": "900", + "currency": "CNY", + "fee_lines": [{"name": "空运费", "amount": "900", "currency": "CNY"}], + "airOptions": [ + { + "salePrice": "7918.25", + "departureCharges": [ + {"chargeName": "空运费", "unitPrice": "1000", "amount": "1000"} + ], + } + ], + } + ) + self.assertEqual(air["airOptions"][0]["salePrice"], "900") + self.assertIn("900", str(air["airOptions"][0]["departureCharges"][0]["amount"])) + kept = stamp_prices_for_sheet( + { + "source_label": "航线确认TMS", + "total": "200", + "fee_lines": [{"name": "增点费", "amount": "200"}], + "airOptions": [ + { + "salePrice": "7918.25", + "departureCharges": [{"chargeName": "增点费", "unitPrice": "100", "amount": "100"}], + } + ], + } + ) + self.assertEqual(kept["airOptions"][0]["salePrice"], "7918.25") + self.assertEqual(kept["airOptions"][0]["departureCharges"][0]["amount"], "100") + + if __name__ == "__main__": unittest.main() diff --git a/inquiry-agent/tests/test_sea_options.py b/inquiry-agent/tests/test_sea_options.py index 9e54dc0..150396e 100644 --- a/inquiry-agent/tests/test_sea_options.py +++ b/inquiry-agent/tests/test_sea_options.py @@ -71,6 +71,32 @@ class SeaOptionsTest(unittest.TestCase): example = apply_spoken_sea_trade_reply("(如:门到门、港到港)", {}) self.assertNotIn("贸易条款", example) + def test_door_inside_long_sentence_is_trade_term(self): + """先报货再选海运:长句里的门到门是贸易条款,港到港仍是运输分类。""" + from agent.schema.sea_options import apply_spoken_sea_trade_reply + + text = "广州港到巴生港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件,Port to Port,门到门" + got = apply_spoken_sea_trade_reply(text, {"运输分类": "Port to Port"}) + self.assertEqual(got.get("贸易条款"), "门到门") + self.assertEqual(got.get("运输分类"), "Port to Port") + absent = apply_spoken_sea_trade_reply( + "广州港到巴生港,机器设备,40HQ*1,整柜,Port to Port", + {"贸易条款": "门到门", "运输分类": "Port to Port"}, + ) + self.assertNotIn("贸易条款", absent) + self.assertEqual(absent.get("运输分类"), "Port to Port") + port_term = apply_spoken_sea_trade_reply( + "广州港到巴生港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件,Port to Port,港到港", + {"运输分类": "Port to Port"}, + ) + self.assertEqual(port_term.get("贸易条款"), "港到港") + self.assertEqual(port_term.get("运输分类"), "Port to Port") + invented = apply_spoken_sea_trade_reply( + "广州港到巴生港,机器设备,40HQ*1,整柜,Port to Port", + {"贸易条款": "港到港"}, + ) + self.assertNotIn("贸易条款", invented) + def test_wo012_trade_port_class_other(self): """条款写 Port to Port、分类写其它:二者各归各栏,禁止互盖。""" text = ( diff --git a/inquiry-agent/tests/test_sea_text_inquiry.py b/inquiry-agent/tests/test_sea_text_inquiry.py index ad82aca..e56366a 100644 --- a/inquiry-agent/tests/test_sea_text_inquiry.py +++ b/inquiry-agent/tests/test_sea_text_inquiry.py @@ -46,6 +46,14 @@ COMPLETE_SEA = { } +def drive_sea(flow, **kwargs): + """字段齐时先出核对卡;要看到查价结果的旧用例再自动回一句确定。""" + phase = flow.on_text(**kwargs) + if phase == "wait_confirm": + phase = flow.on_text(sender_id=kwargs["sender_id"], text="确定", reply=kwargs["reply"]) + return phase + + class SeaCollabContractTests(unittest.TestCase): def test_collab_no_longer_asks_trade_terms(self): self.assertNotIn("贸易条款", SEA_COLLAB_FIELDS) @@ -127,6 +135,80 @@ class SeaTextInquiryTests(unittest.TestCase): self.replies.append(text) self.extras.append(dict(extra) if extra else {}) + def test_complete_fields_hold_confirm_before_tms(self) -> None: + """字段齐了只出核对卡,不建单、不查价。""" + phase = self.flow.on_text( + sender_id="s-hold", + text="海运完整", + reply=self._reply, + injected_facts=COMPLETE_SEA, + injected_mode="SEA", + ) + self.assertEqual(phase, "wait_confirm") + card = self.replies[-1] + self.assertIn(copy.SEA_CONFIRM_TAIL, card) + self.assertIn("运输方式(必填):海运", card) + self.assertIn("货物数量(必填):20吨", card) + self.assertIn("贸易条款(必填):FOB", card) + self.assertNotIn("工单号", card) + self.assertNotIn("正在查询TMS报价", card) + sess = self.flow.session_of("s-hold") + self.assertEqual(sess.phase, "wait_confirm") + self.assertFalse((sess.work_order_no or "").strip()) + self.assertEqual(self.ledger._tickets, {}) + + def test_confirm_phrases_then_query_tms(self) -> None: + """确定、确认、查价、可以查了 都建单并查价。""" + for word in ("确定", "确认", "查价", "可以查了"): + with self.subTest(word=word): + self.replies.clear() + sender = f"s-go-{word}" + held = self.flow.on_text( + sender_id=sender, + text="海运完整", + reply=self._reply, + injected_facts=COMPLETE_SEA, + injected_mode="SEA", + ) + self.assertEqual(held, "wait_confirm") + self.replies.clear() + phase = self.flow.on_text(sender_id=sender, text=word, reply=self._reply) + self.assertEqual(phase, "wait_collab") + joined = "\n".join(self.replies) + self.assertIn("正在查询TMS报价", joined) + self.assertIn("工单号:", joined) + sess = self.flow.session_of(sender) + self.assertTrue((sess.work_order_no or "").startswith("WO")) + + def test_resend_while_waiting_merges_and_does_not_quote(self) -> None: + """核对卡出来后改一处,并进原字段再出卡,仍不建单。""" + phase = self.flow.on_text( + sender_id="s-fix", + text="海运完整", + reply=self._reply, + injected_facts=COMPLETE_SEA, + injected_mode="SEA", + ) + self.assertEqual(phase, "wait_confirm") + self.replies.clear() + fixed = dict(COMPLETE_SEA) + fixed["目的港"] = "纽约" + phase = self.flow.on_text( + sender_id="s-fix", + text="目的港改成纽约", + reply=self._reply, + injected_facts=fixed, + injected_mode="SEA", + ) + self.assertEqual(phase, "wait_confirm") + self.assertIn("目的港(必填):纽约", self.replies[-1]) + self.assertIn(copy.SEA_CONFIRM_TAIL, self.replies[-1]) + sess = self.flow.session_of("s-fix") + self.assertEqual(sess.facts.get("目的港"), "纽约") + self.assertEqual(sess.facts.get("起运港"), "上海") + self.assertFalse((sess.work_order_no or "").strip()) + self.assertEqual(self.ledger._tickets, {}) + def test_sea_pieces_fill_cargo_qty(self) -> None: facts = { "起运港": "上海", @@ -287,7 +369,7 @@ class SeaTextInquiryTests(unittest.TestCase): def test_incomplete_without_trade_terms_does_not_create_ticket(self) -> None: facts = dict(COMPLETE_SEA) facts.pop("贸易条款") - phase = self.flow.on_text( + phase = drive_sea(self.flow, sender_id="s-miss", text="海运缺条款", reply=self._reply, @@ -318,7 +400,7 @@ class SeaTextInquiryTests(unittest.TestCase): facts=leftover, ) ) - phase = self.flow.on_text( + phase = drive_sea(self.flow, sender_id="s-door", text="海运、纽约港到巴生西港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件", reply=self._reply, @@ -335,7 +417,7 @@ class SeaTextInquiryTests(unittest.TestCase): """补问后只回门到门,要记成贸易条款,不能回参数提取不全。""" first = dict(COMPLETE_SEA) first.pop("贸易条款") - phase = self.flow.on_text( + phase = drive_sea(self.flow, sender_id="s-door-reply", text="先缺条款", reply=self._reply, @@ -344,7 +426,7 @@ class SeaTextInquiryTests(unittest.TestCase): ) self.assertEqual(phase, "clarify") self.replies.clear() - phase = self.flow.on_text( + phase = drive_sea(self.flow, sender_id="s-door-reply", text="门到门", reply=self._reply, @@ -354,10 +436,92 @@ class SeaTextInquiryTests(unittest.TestCase): self.assertIn("贸易条款:门到门", joined) self.assertNotIn("参数提取不全", joined) + def test_door_in_sentence_kept_after_choosing_sea(self) -> None: + """没写海运、句中有门到门,回「2」后贸易条款要带上,不能再追问。""" + from agent.channel.queue import MemoryMessageStore + 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 get_sea_text_flow + + reset_sea_text_flow_for_test() + reset_land_text_flow_for_test() + reset_air_text_flow_for_test() + store = MemoryMessageStore() + text = "广州港到巴生港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件,Port to Port,门到门" + facts = { + "起运港": "广州港", + "目的港": "巴生港", + "品名": "机器设备", + "货量": "15件", + "整柜或拼柜": "整柜", + "箱型箱量": "40HQ*1", + "运输分类": "Port to Port", + } + phase = handle_text_inquiry( + InboundMessage(sender_id="s-door-mode", message_id="m-door-1", content=text), + injected_facts=facts, + store=store, + ) + self.assertEqual(phase, "need_mode") + phase = handle_text_inquiry( + InboundMessage(sender_id="s-door-mode", message_id="m-door-2", content="2"), + store=store, + ) + body = "\n".join(item.content for item in store._outbox.values()) + sess = get_sea_text_flow().session_of("s-door-mode") + self.assertEqual(sess.facts.get("贸易条款"), "门到门") + self.assertEqual(sess.facts.get("运输分类"), "Port to Port") + self.assertIn("贸易条款(必填):门到门", body) + self.assertIn(copy.SEA_CONFIRM_TAIL, body) + self.assertEqual(phase, "wait_confirm") + self.assertNotIn("1.贸易条款(必填)", body) + self.assertNotEqual(phase, "need_mode") + + def test_port_to_port_phrase_kept_after_choosing_sea(self) -> None: + """没写海运、句中有港到港,回「2」后贸易条款是港到港,运输分类仍是 Port to Port。""" + from agent.channel.queue import MemoryMessageStore + 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 get_sea_text_flow + + reset_sea_text_flow_for_test() + reset_land_text_flow_for_test() + reset_air_text_flow_for_test() + store = MemoryMessageStore() + text = "广州港到巴生港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件,Port to Port,港到港" + facts = { + "起运港": "广州港", + "目的港": "巴生港", + "品名": "机器设备", + "货量": "15件", + "整柜或拼柜": "整柜", + "箱型箱量": "40HQ*1", + "运输分类": "Port to Port", + } + phase = handle_text_inquiry( + InboundMessage(sender_id="s-port-mode", message_id="m-port-1", content=text), + injected_facts=facts, + store=store, + ) + self.assertEqual(phase, "need_mode") + phase = handle_text_inquiry( + InboundMessage(sender_id="s-port-mode", message_id="m-port-2", content="2"), + store=store, + ) + body = "\n".join(item.content for item in store._outbox.values()) + sess = get_sea_text_flow().session_of("s-port-mode") + self.assertEqual(sess.facts.get("贸易条款"), "港到港") + self.assertEqual(sess.facts.get("运输分类"), "Port to Port") + self.assertIn("贸易条款(必填):港到港", body) + self.assertIn(copy.SEA_CONFIRM_TAIL, body) + self.assertEqual(phase, "wait_confirm") + self.assertNotIn("1.贸易条款(必填)", body) + self.assertNotEqual(phase, "need_mode") + def test_harvest_class_from_reply_then_quotes(self) -> None: first = dict(COMPLETE_SEA) first.pop("运输分类") - phase = self.flow.on_text( + phase = drive_sea(self.flow, sender_id="s-class", text="先缺分类", reply=self._reply, @@ -366,7 +530,7 @@ class SeaTextInquiryTests(unittest.TestCase): ) self.assertEqual(phase, "clarify") self.replies.clear() - phase = self.flow.on_text( + phase = drive_sea(self.flow, sender_id="s-class", text="Port to Port", reply=self._reply, @@ -378,7 +542,7 @@ class SeaTextInquiryTests(unittest.TestCase): self.assertIn("贸易条款:FOB", joined) def test_complete_sea_inquiry_card_uses_ten_fields(self) -> None: - phase = self.flow.on_text( + phase = drive_sea(self.flow, sender_id="s1", text="海运完整", reply=self._reply, @@ -404,7 +568,7 @@ class SeaTextInquiryTests(unittest.TestCase): def test_new_round_after_ticket_does_not_reuse_old_facts(self) -> None: """上一单已出号,新一轮不能带上旧贸易条款。""" - first = self.flow.on_text( + first = drive_sea(self.flow, sender_id="s-round", text="海运第一轮", reply=self._reply, @@ -424,7 +588,7 @@ class SeaTextInquiryTests(unittest.TestCase): "箱型箱量": "40HQ*1", "运输分类": "Port to Port", } - phase = self.flow.on_text( + phase = drive_sea(self.flow, sender_id="s-round", text="海运、纽约港到巴生西港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件,Port to Port", reply=self._reply, @@ -457,7 +621,7 @@ class SeaTextInquiryTests(unittest.TestCase): flow=self.flow, store=store, ) - self.assertEqual(phase, "wait_collab") + self.assertEqual(phase, "wait_confirm") self.assertNotEqual(phase, "sea_deferred") @@ -569,9 +733,19 @@ class SeaTmsCardTests(unittest.TestCase): self.assertNotIn("时效", text) self.assertNotIn("报价有效期", text) card_text = copy.quote_area_safe_text(long_quote, business_line="SEA") - self.assertIn("费用合计:CNY 2287.65", card_text) - self.assertNotIn("海运费:USD 100.00\n燃油附加费", card_text) - self.assertLessEqual(len(card_text), 80) + self.assertEqual( + card_text, + "费用合计:CNY 2287.65\n共7项费用,详见上一条明细", + ) + short_quote = { + "total": "100", + "currency": "CNY", + "fee_lines": [{"name": "海运费", "amount": "100", "currency": "CNY"}], + } + self.assertEqual( + copy.quote_area_safe_text(short_quote, business_line="SEA"), + "费用合计:CNY 100\n共1项费用,详见上一条明细", + ) payload = copy.sea_tms_hit_wecom_payload( work_order_no="WO202609150030", quote=long_quote ) @@ -614,7 +788,7 @@ class SeaTmsCardTests(unittest.TestCase): self.assertIn("card", steps) def test_sea_hit_card_has_price_and_two_buttons(self) -> None: - phase = self.flow.on_text( + phase = drive_sea(self.flow, sender_id="s1", text="海运有价", reply=self._reply, @@ -639,7 +813,7 @@ class SeaTmsCardTests(unittest.TestCase): def test_sea_miss_one_card_with_pull_button(self) -> None: facts = dict(COMPLETE_SEA) facts["目的港"] = "无价港" - phase = self.flow.on_text( + phase = drive_sea(self.flow, sender_id="s1", text="海运无价", reply=self._reply, @@ -664,7 +838,7 @@ class SeaTmsCardTests(unittest.TestCase): def test_sea_tech_fail_is_not_miss(self) -> None: facts = dict(COMPLETE_SEA) facts["tms_force"] = "tech" - phase = self.flow.on_text( + phase = drive_sea(self.flow, sender_id="s1", text="海运技术失败", reply=self._reply, @@ -708,7 +882,7 @@ class SeaSkipAndPullTests(unittest.TestCase): return "" def _quote_first(self, sender: str = "s1") -> None: - self.flow.on_text( + drive_sea(self.flow, sender_id=sender, text="海运有价", reply=self._reply, @@ -820,7 +994,7 @@ class SeaSkipAndPullTests(unittest.TestCase): ) facts = dict(COMPLETE_SEA) facts["是否含电"] = "否" - self.flow.on_text( + drive_sea(self.flow, sender_id="s1", text="海运不含电", reply=self._reply, @@ -857,7 +1031,7 @@ class SeaSkipAndPullTests(unittest.TestCase): ) with patch("agent.policy.sea_text_flow._default_group_client", return_value=live): flow = SeaTextInquiryFlow(self.ledger, group_client=None) - flow.on_text( + drive_sea(flow, sender_id="s9", text="海运有价", reply=self._reply, @@ -964,7 +1138,7 @@ class SeaSkipAndPullTests(unittest.TestCase): ) facts = dict(COMPLETE_SEA) facts["目的港"] = "无价港" - self.flow.on_text( + drive_sea(self.flow, sender_id="s1", text="海运无价", reply=self._reply, @@ -1018,7 +1192,7 @@ class SeaGroupCollabFieldTests(unittest.TestCase): } ] ) - self.flow.on_text( + drive_sea(self.flow, sender_id="sales1", text="海运有价", reply=self._reply, @@ -1074,7 +1248,7 @@ class SeaGroupCollabFieldTests(unittest.TestCase): } ] ) - self.flow.on_text( + drive_sea(self.flow, sender_id="sales1", text="海运有价", reply=self._reply, @@ -1258,7 +1432,7 @@ class SeaContinueTests(unittest.TestCase): from agent.handlers.continue_thread import handle_continue_thread from agent.policy.air_text_flow import AirTextInquiryFlow - self.flow.on_text( + drive_sea(self.flow, sender_id="c1", text="海运有价", reply=self._reply, @@ -1358,7 +1532,7 @@ class SeaContinueTests(unittest.TestCase): from agent.handlers.continue_thread import handle_continue_thread from agent.policy.air_text_flow import AirTextInquiryFlow - self.flow.on_text( + drive_sea(self.flow, sender_id="c3", text="海运有价", reply=self._reply, @@ -1590,7 +1764,7 @@ class SeaGroupOpsTests(unittest.TestCase): self.replies.append(text) def _open_group(self) -> tuple[str, str]: - self.flow.on_text( + drive_sea(self.flow, sender_id="sales1", text="海运有价", reply=self._reply, @@ -2175,7 +2349,7 @@ class SeaGroupOpsTests(unittest.TestCase): """TMS 无价时产品文字报价:摘要 TMS 仍是暂无,人工报价两行齐全。""" facts = dict(COMPLETE_SEA) facts["目的港"] = "无价港" - self.flow.on_text( + drive_sea(self.flow, sender_id="sales1", text="海运无价", reply=self._reply,