Compare commits

..
36 changed files with 1997 additions and 96 deletions
+11 -2
View File
@@ -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 };
@@ -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");
+22 -1
View File
@@ -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"}
@@ -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,
@@ -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()):
@@ -91,6 +91,7 @@ def handle_continue_thread(
sender_id=message.sender_id,
work_order_no=wo,
reply=reply,
text=message.content or "",
)
logger.info(
"continue_thread phase=%s sender=%s wo=%s",
@@ -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()):
+108 -2
View File
@@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
def _front_bookmark(sess) -> bool:
"""补问/核对手稿优先于已出票旧书签。空的 need_mode 不算(那是丢会话后的误伤)。已转人工的票也不再当当前询价。"""
"""补问/核对手稿优先于已出票旧书签。还没定运输方式的草稿不算当前询价,避免抢走另一条已在补问的单。已转人工的票也不再当当前询价。"""
if sess is None:
return False
from agent.policy.handoff_human import is_handoff_frozen
@@ -36,17 +36,92 @@ def _front_bookmark(sess) -> bool:
return phase in {"clarify", "need_mode", "need_land_options", "wait_confirm"}
def _is_waiting_mode(sess) -> bool:
"""运输方式还没定、业务线空着。定方式前的几句询价字段都堆在这份草稿上。"""
if sess is None:
return False
from agent.policy.handoff_human import is_handoff_frozen
if is_handoff_frozen(sess):
return False
if (sess.phase or "").strip() != "need_mode":
return False
return not (sess.business_line or "").strip()
def _carry_waiting_inquiry_to_owner(*, owner: str, sender_id: str) -> None:
"""
销售点明空运/海运/陆运时,把定方式之前积累的询价字段整份交给该方式。
可能是连续几句,不只上一句。已在补问或核对的单不盖。不带旧工单号。
"""
kind = (owner or "").upper()
sid = (sender_id or "").strip()
if not sid or kind not in {"SEA", "AIR", "LAND"}:
return
flows = {
"AIR": get_air_text_flow(),
"SEA": get_sea_text_flow(),
"LAND": get_land_text_flow(),
}
owner_flow = flows[kind]
existing = owner_flow.session_of(sid)
if _front_bookmark(existing):
return
drafts = []
for flow in flows.values():
sess = flow.session_of(sid)
if _is_waiting_mode(sess):
drafts.append(sess)
if not drafts:
return
# 字段多的打底,其它草稿只补空缺,避免后写的空值盖掉已认出的港口。
drafts.sort(key=lambda item: len(item.facts or {}), reverse=True)
facts: dict[str, str] = {}
texts: list[str] = []
for sess in drafts:
for key, value in (sess.facts or {}).items():
text = str(value).strip()
if text and not str(facts.get(key) or "").strip():
facts[str(key)] = text
raw = (sess.immutable_text or "").strip()
if raw and raw not in texts:
texts.append(raw)
if not facts and not texts:
return
from agent.policy.air_text_flow import FlowSession
owner_flow._save(
FlowSession(
sender_id=sid,
thread_id=sid,
phase="need_mode",
business_line="",
facts=facts,
immutable_text="\n".join(texts),
)
)
logger.info(
"text_inquiry 未定方式的询价字段交给 %s keys=%s sender=%s",
kind,
",".join(sorted(facts)),
sid,
)
def _drop_other_mode_drafts(*, owner: str, sender_id: str) -> None:
"""
本句已点名空运/海运/陆运:丢掉其它方式未出号的补问/选项草稿。
本句已点名空运/海运/陆运:先带走「还没定方式」时积下的询价字段,再丢掉其它方式的补问/选项草稿。
测服复现:旧陆运「选项未齐」书签还在,海运补「贸易条款 / 运输分类」会被陆运抢走,
回「参数提取不全」并发送填写样例图。已出号会话留在工单号索引,不当当前草稿。
定方式前的 need_mode 要整份移交,不能直接删。
"""
kind = (owner or "").upper()
sid = (sender_id or "").strip()
if not sid or kind not in {"SEA", "AIR", "LAND", "MULTI"}:
return
_carry_waiting_inquiry_to_owner(owner=kind, sender_id=sid)
land = get_land_text_flow()
air = get_air_text_flow()
sea = get_sea_text_flow()
@@ -74,6 +149,24 @@ def _drop_other_mode_drafts(*, owner: str, sender_id: str) -> None:
clearer(sid)
def _flow_waiting_lost_reason(sender_id: str, sea, air, land):
"""谁在等未成交原因就交给谁。刚记下未成交的那条优先。"""
from agent.policy.handoff_human import is_handoff_frozen
waiting = []
for flow in (sea, air, land):
sess = flow.session_of(sender_id)
if sess is None or is_handoff_frozen(sess):
continue
if (sess.phase or "").strip() != "wait_lost_reason":
continue
waiting.append((int(getattr(sess, "wait_version", 0) or 0), flow))
if not waiting:
return None
waiting.sort(key=lambda item: item[0], reverse=True)
return waiting[0][1]
def should_resume_other_as_inquiry(message: InboundMessage) -> bool:
"""
A 把补问短答标成闲聊时,仍续填当前询价。
@@ -135,6 +228,10 @@ def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = "", prefe
return land
sea = get_sea_text_flow()
air = get_air_text_flow()
# 正在等未成交原因:下一句就是原因。调价、旧报价单、新的运输方式都不能把这句带走。
lost_flow = _flow_waiting_lost_reason(sender_id, sea, air, land)
if lost_flow is not None:
return lost_flow
spoken = (injected_mode or detect_transport_mode(text) or "").upper()
multi_kind = classify_multi_text(text) if not injected_mode else "single"
if multi_kind in {"multi", "same_mode"}:
@@ -183,6 +280,15 @@ def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = "", prefe
for sess, flow in ((sea_sess, sea), (air_sess, air), (land_sess, land)):
if _front_bookmark(sess):
return flow
# 还在问运输方式:后面几句询价字段继续叠在同一份草稿上,不要另开一条只留最后一句。
parked = [
(sess, flow)
for sess, flow in ((sea_sess, sea), (air_sess, air), (land_sess, land))
if _is_waiting_mode(sess)
]
if parked:
parked.sort(key=lambda item: len(item[0].facts or {}), reverse=True)
return parked[0][1]
if prefer_quoted:
quoted = quoted_adjust_flow(sender_id)
if quoted is not None:
@@ -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)
+8 -2
View File
@@ -275,10 +275,15 @@ def _merge_spoken_collab(text: str, facts: dict[str, str]) -> dict[str, str]:
不覆盖已有询价键。不拿去补问、不挡查价。
"""
from agent.schema.field_validate import harvest_oral_collab, normalize_facts
from agent.schema.field_validate import (
harvest_oral_collab,
harvest_spoken_trade_term,
normalize_facts,
)
merged = dict(facts or {})
extra = dict(harvest_oral_collab(text))
extra.update(harvest_spoken_trade_term(text))
extra.update(normalize_facts(harvest_collab_labels(text)))
for key, val in extra.items():
if val and not str(merged.get(key) or "").strip():
@@ -347,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)
@@ -34,6 +34,7 @@ _FIELD_KEYS = (
"车型/数量",
"车型数量",
"通关口岸",
"贸易条款",
)
@@ -131,6 +132,7 @@ def build_extract_messages(text: str) -> list[dict[str, str]]:
"询价日期、走货日期、询价时间、走货时间、发货时间、发货日期、出货日期、出货时间、提货时间、提货日期一律写入报价日期。"
"货好时间、提货地址不要写成报价日期。"
"陆运「通关口岸:皇岗」「通关口岸皇岗」「过皇岗」有说就写入通关口岸,没说留空,禁止编造口岸。"
"原话单独出现 DAP、DDP、CIF、CIP、FOB、EXW 时写入贸易条款;没出现留空,禁止编造。"
"「从南京飞吉隆坡」起运港=南京、目的港=吉隆坡;「北京飞吉隆坡」起运港=北京。"
"原话是机场三字码或航线就原样记,禁止翻译成城市名:"
"SZX-CGK / SZX/CGK / SZX到CGK → 起运港=SZX、目的港=CGK,不要写成深圳、雅加达。"
+40 -3
View File
@@ -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 {"", "-", "—", "-"}
@@ -912,6 +938,16 @@ def apply_air_lock(
return "lock_ok"
def _release_should_push_deal(ticket: object) -> bool:
"""
放舱成功后要不要再推成交跟进。
未成交、已关闭、已成交、转人工:成交结果已经定过,只回释放结果。
还在等销售确认时仍要推一张。
"""
return ticket_status(ticket) not in TERMINAL
def apply_air_release(
*,
flow: Any,
@@ -1009,9 +1045,10 @@ def apply_air_release(
if ticket_is_paused(ticket):
clear_system_exception(ledger=flow.ledger, work_order_no=no)
view = ticket_view(ticket)
view["collab_chat_id"] = chat_id
emit_group_deal(flow=flow, ticket=view, quote=dict(view.get("quote") or {}))
if _release_should_push_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 {}))
return "release_ok"
+146 -11
View File
@@ -64,6 +64,40 @@ 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:
"""还在问运输方式、业务线空着。这时抽字段要看前面几句,不只看点名的这一句。"""
if sess is None:
return False
if (getattr(sess, "phase", None) or "").strip() != "need_mode":
return False
return not (getattr(sess, "business_line", None) or "").strip()
def waiting_mode_source(sess, text: str) -> str:
"""未定方式时返回前面全部原话加本句;已经在补问则只返回本句,避免旧句盖掉销售刚改的值。"""
latest = text or ""
if not is_waiting_mode_session(sess):
return latest
return stack_user_text(getattr(sess, "immutable_text", "") or "", latest)
def stack_user_text(previous: str, latest: str) -> str:
"""
运输方式还没定时,把销售每一句原话接在一起。
不是只留上一句。同一句重复进来不追加。补问阶段不要用这个,避免把已定方式的旧句再拼回去改判。
"""
prev = (previous or "").strip()
cur = (latest or "").strip()
if not cur:
return prev
if not prev or cur == prev or prev.endswith("\n" + cur):
return cur if not prev else prev
return prev + "\n" + cur
def _session_payload(sess: FlowSession) -> dict[str, Any]:
@@ -107,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()
),
)
@@ -142,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:
@@ -385,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
@@ -512,11 +580,13 @@ class AirTextInquiryFlow:
sender_id: str,
work_order_no: str,
reply: ReplyFn,
text: str = "",
) -> str:
"""
按工单号回到当时停点并重出对应卡片。
先用进程内该书签;没有再读主账推断节点。不重跑 TMS、不新建工单。
正在等未成交原因时,原话记成原因,不再把「请补充未成交原因」重发一遍。
"""
no = (work_order_no or "").strip().upper()
if not no:
@@ -530,6 +600,12 @@ class AirTextInquiryFlow:
keep = None
if keep:
self._align_resume_phase(keep)
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):
@@ -545,6 +621,12 @@ class AirTextInquiryFlow:
sess = self._session_from_ledger(sender_id=sender_id, view=view)
self._align_resume_phase(sess)
self._save(sess)
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:
@@ -613,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(
@@ -659,8 +761,8 @@ class AirTextInquiryFlow:
reply("报价单正在生成,请稍候。")
return "wait_file"
if sess.phase == "wait_deal":
# 成交卡已经绑在报价文件那条出站上。这里再发一张独立卡,
# 会在文件还在上传时被另一条出站线程抢走,销售又先看到成交跟进。
# 成交卡已经绑在报价文件那条出站上。只续办、没说成交结果时不再另发一张卡。
# 原话写了已成交/未成交/协商中时,由 _close_spoken_deal 先入账,不会走到这里。
return "wait_deal"
if sess.phase == "wait_lost_reason":
reply(copy.deal_ack("未成交", sess.work_order_no))
@@ -774,7 +876,8 @@ class AirTextInquiryFlow:
merged = dict(sess.facts)
merged.update(facts)
facts = harvest_oral_measures(text, merged)
source = waiting_mode_source(sess, text)
facts = harvest_oral_measures(source, merged)
mode = mode or (sess.business_line or "").upper()
if not mode:
mode = detect_transport_mode(
@@ -790,7 +893,10 @@ class AirTextInquiryFlow:
phase="need_mode",
business_line="",
facts=facts,
immutable_text=sess.immutable_text if sess and sess.immutable_text else text,
immutable_text=stack_user_text(
sess.immutable_text if sess else "",
text,
),
)
)
return "need_mode"
@@ -831,6 +937,11 @@ class AirTextInquiryFlow:
reply(copy.ASK_TRANSPORT)
return "need_mode"
if is_waiting_mode_session(sess):
from agent.schema.field_validate import shape_facts_for_chosen_mode
# 刚选定空运:上文按空运字段收口,海运箱型不留在空运单上。
facts = shape_facts_for_chosen_mode(facts, "AIR")
check = validate_required_fields(facts=facts, business_line="AIR")
facts = dict(check["facts"])
tms_ready = assemble_air_query(facts)
@@ -1631,22 +1742,46 @@ class AirTextInquiryFlow:
card_meta: Optional[dict[str, Any]] = None,
) -> str:
"""
成交跟进阶段口播未成交;同一句可带原因。
成交跟进阶段口播已成交、未成交或协商中。未成交同一句可带原因。
点卡仍走 match_button。返回阶段名;不是未成交口播则空串。
点卡仍走 match_button。原话写了别的工单号则不关当前单。不是口播则空串。
"""
from agent.policy.deal_outcome import looks_like_lost_outcome
from agent.policy.deal_outcome import spoken_deal_status
if sess is None or not (text or "").strip():
return ""
allowed = sess.allowed or ()
if copy.BTN_LOST not in allowed and sess.phase not in {"wait_deal", "wait_file"}:
status = spoken_deal_status(text)
if not status:
return ""
if not looks_like_lost_outcome(text):
allowed = sess.allowed or ()
if sess.phase not in {"wait_deal", "wait_file"} and copy.BTN_LOST not in allowed and copy.BTN_DEAL not in allowed:
return ""
if sess.phase not in {"wait_deal", "wait_file"} and status != "未成交":
return ""
named = copy.extract_work_order_no(text)
if named and (sess.work_order_no or "").strip() and named.upper() != sess.work_order_no.strip().upper():
return ""
if sess.phase == "wait_file":
self._promote_wait_deal(sess)
return self._close(sess, reply, "未成交", card_meta=card_meta, source_text=text)
return self._close(sess, reply, status, card_meta=card_meta, source_text=text)
def _close_spoken_deal(self, sess: FlowSession, text: str, reply: ReplyFn) -> str:
"""
续办原话已经写了成交结果:直接入账,不要静默停在成交跟进。
只在报价文件已出、正在等成交时生效。没写结果仍按原样重放,避免多发一张卡。
"""
from agent.policy.deal_outcome import spoken_deal_status
status = spoken_deal_status(text)
if not status or sess.phase not in {"wait_deal", "wait_file"}:
return ""
named = copy.extract_work_order_no(text)
if named and named.upper() != (sess.work_order_no or "").strip().upper():
return ""
if sess.phase == "wait_file":
self._promote_wait_deal(sess)
return self._close(sess, reply, status, source_text=text)
def _close(
self,
@@ -79,6 +79,25 @@ def looks_like_lost_outcome(text: str) -> bool:
return any(token in blob for token in _LOST_MARKERS)
def spoken_deal_status(text: str) -> str:
"""
私聊口播成交结果。先认未成交,再认已成交、协商中。
「WO202609240006已成交」整句就是要关单,不是再打开成交卡。
问句(已成交吗)不算。对不上返回空串。
"""
blob = (text or "").replace(" ", "")
if not blob or blob[-1] in _QUESTION_TAILS:
return ""
if looks_like_lost_outcome(blob):
return "未成交"
if "已成交" in blob:
return "已成交"
if "协商中" in blob:
return "协商中"
return ""
def notify_app_ready() -> bool:
"""询价消息服务已配 AgentId+Secret 才推。空则只成交不推。"""
settings = get_settings()
+40 -17
View File
@@ -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 = {
@@ -60,7 +63,8 @@ _SEA_EXAMPLES = {
"货量": "20吨",
"整柜或拼柜": "整柜",
"箱型箱量": "1x40HQ",
"贸易条款": "FOB",
# 销售看到的举例是门到门、港到港,不是 FOB。举例行不参与已填判断。
"贸易条款": "门到门、港到港",
"运输分类": "Port to Port",
"报价日期": "2026-09-15",
}
@@ -477,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,
@@ -1031,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}"
@@ -1093,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"))
# 空运、陆运的价格明细正文都要带有效期。海运仍放在按钮卡副标题,避免和原口径打架。
+21 -5
View File
@@ -4,7 +4,7 @@
本文件职责:三项选项各自必填(不联动)→ 按类型补货物 → 无工单号核对卡 →(确定后)建单查 TMS。
查价后:1 条完整线路直接出价格明细;多于 1 条发入口卡,H5 选定后再出该条明细。
查价后半段复用海运拉群/报价单/成交,拉人按线路类别,禁止陆运岗全员。
禁止:字段齐了就建单;直连 TMS;把中转段当成另一条线路;把海运「出卡即查价」改掉。
禁止:字段齐了就建单;直连 TMS;把中转段当成另一条线路。海运私聊同样是核对后确定再查价。
调用:handler 在 Worker/inbox 线程;禁止回调线程同步跑。
"""
@@ -17,7 +17,13 @@ from typing import Any, Optional
from agent.ledger.memory_ledger import MemoryLedger
from agent.llm.extract_text import extract_inquiry_snapshot
from agent.policy import inquiry_copy as copy
from agent.policy.air_text_flow import FlowSession, ReplyFn
from agent.policy.air_text_flow import (
FlowSession,
ReplyFn,
is_waiting_mode_session,
stack_user_text,
waiting_mode_source,
)
from agent.policy.sea_text_flow import SeaTextInquiryFlow
from agent.schema.field_validate import harvest_oral_measures, validate_required_fields
from agent.schema.land_options import (
@@ -149,9 +155,11 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
cargo = {k: v for k, v in facts.items() if k not in option_keys}
merged = dict(base)
merged.update(cargo)
facts = harvest_oral_measures(text, merged)
# 还在问运输方式时,重量体积和陆运选项要对照前面几句,不只看「1」。
source = waiting_mode_source(sess, text)
facts = harvest_oral_measures(source, merged)
# 选项只从原话+能对上枚举的模型值收;禁止模型「陆运」盖掉已选的国内运输拼车。
facts = harvest_land_options(text, facts)
facts = harvest_land_options(source, facts)
b_opt = " ".join(
str(x).strip()
for x in (
@@ -174,7 +182,10 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
phase="need_mode",
business_line="",
facts=facts,
immutable_text=sess.immutable_text if sess and sess.immutable_text else text,
immutable_text=stack_user_text(
sess.immutable_text if sess else "",
text,
),
)
)
return "need_mode"
@@ -211,6 +222,11 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
inbound_raw=inbound_raw,
)
if is_waiting_mode_session(sess):
from agent.schema.field_validate import shape_facts_for_chosen_mode
# 刚选定陆运:不留空运件数、海运箱型和贸易条款。重量体积仍按陆运货物字段留。
facts = shape_facts_for_chosen_mode(facts, "LAND")
return self._advance_front(
sender_id=sender_id,
facts=facts,
+106 -14
View File
@@ -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
+3 -1
View File
@@ -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(
+145 -9
View File
@@ -1,7 +1,7 @@
"""
海运文字询价确定性流程。
本文件职责:海运七项补问 → 建单出确认卡 → 主账查 TMS → 有价/无价卡 → 跳过协同或拉群。
本文件职责:海运补问 → 无工单号核对卡 → 回复确定后建单查 TMS → 有价/无价卡 → 跳过协同或拉群。
空运 Owner 仍在 air_text_flow,禁止把海运再堆回去。
调用:handler 在 Worker/inbox 线程;禁止回调线程同步跑。
禁止:直连 TMS;本地拆箱型;用户文字冒充群报价版本。
@@ -23,6 +23,9 @@ from agent.policy.air_text_flow import (
FlowSession,
ReplyFn,
build_ledger,
is_waiting_mode_session,
stack_user_text,
waiting_mode_source,
)
from agent.schema.field_validate import validate_required_fields
from agent.schema.tms_sea_query import assemble_sea_query
@@ -105,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)
@@ -129,13 +147,13 @@ 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
merged = dict(sess.facts)
merged.update(facts)
facts = harvest_oral_measures(text, merged)
facts = harvest_oral_measures(waiting_mode_source(sess, text), merged)
mode = mode or (sess.business_line or "").upper()
if not mode:
mode = detect_transport_mode(f"{sess.immutable_text or ''}\n{text}")
@@ -149,7 +167,10 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
phase="need_mode",
business_line="",
facts=facts,
immutable_text=sess.immutable_text if sess and sess.immutable_text else text,
immutable_text=stack_user_text(
sess.immutable_text if sess else "",
text,
),
)
)
return "need_mode"
@@ -187,13 +208,29 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
inbound_raw=inbound_raw,
)
from agent.schema.sea_options import drop_invented_door_trade_terms, harvest_sea_class
from agent.schema.field_validate import harvest_spoken_trade_term, shape_facts_for_chosen_mode
from agent.schema.sea_options import (
apply_spoken_sea_trade_reply,
harvest_sea_class,
short_sea_trade_reply,
)
facts = harvest_sea_class(text, facts)
# 贸易条款标签行原样优先(Port to Port 等);再丢掉无标签的冒充「门到门」
facts = drop_invented_door_trade_terms(text, facts)
# 刚选定海运时,贸易条款、运输分类要对照定方式前的全部原话,不只看「海运」两个字。
source = waiting_mode_source(sess, text)
# 整句只回「港到港」是贸易条款举例,不要改写成运输分类。
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():
facts["贸易条款"] = spoken["贸易条款"]
check = validate_required_fields(facts=facts, business_line="SEA")
facts = dict(check["facts"])
if is_waiting_mode_session(sess):
# 件数里的数量已进货物数量;空运的件数、毛重、体积、包装不再留在海运单上。
facts = shape_facts_for_chosen_mode(facts, "SEA")
tms_ready = assemble_sea_query(facts)
facts = dict(tms_ready["facts"])
if not check["ok"] or not tms_ready["ok"]:
@@ -222,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,
*,
@@ -245,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,
@@ -253,6 +387,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
business_line="SEA",
facts=facts,
immutable_text=text,
pending_files=pending,
)
)
created = self._ledger.create_ticket(
@@ -265,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(
+1 -1
View File
@@ -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 时,销售用自然语言改当前已有报价:"
@@ -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
@@ -516,6 +523,36 @@ def harvest_oral_collab(text: str) -> dict[str, str]:
return out
# 原话里单独出现才收下。举例行「(如:DDP、DAP、CIF、CIP)」整行跳过,避免示例被当成已填。
_TRADE_TERM_CODES = ("DDP", "DAP", "DDU", "CIF", "CIP", "CPT", "CFR", "FOB", "FCA", "EXW")
_TRADE_TERM_RE = re.compile(
r"(?i)(?<![A-Za-z])(" + "|".join(_TRADE_TERM_CODES) + r")(?![A-Za-z])"
)
_TRADE_TERM_EXAMPLE_LINE = re.compile(r"^\s*[((]如\s*[::]")
def harvest_spoken_trade_term(text: str) -> dict[str, str]:
"""
原话里单独写出的 DAP、DDP、CIF 等记成贸易条款。
只认这一组术语本身,不从「门到门」猜。同一句出现两个不同术语时不选。
补问举例行整行不看。已有「贸易条款」标签值的,由调用方决定不覆盖。
"""
kept: list[str] = []
for line in (text or "").splitlines() or [""]:
if _TRADE_TERM_EXAMPLE_LINE.match((line or "").strip()):
continue
kept.append(line)
found: list[str] = []
for match in _TRADE_TERM_RE.finditer("\n".join(kept)):
code = match.group(1).upper()
if code not in found:
found.append(code)
if len(found) != 1:
return {}
return {"贸易条款": found[0]}
# 箱型原文:40HQ*1、1x40HQ、40尺高柜。货物数量不能收这种串。
_CONTAINER_SPEC = re.compile(
r"(?i)(20|40|45)\s*['\"′尺]?\s*(HQ|GP|NOR|RH|HC|高柜|平柜|柜)"
@@ -549,6 +586,39 @@ def project_sea_cargo_qty(facts: dict[str, str] | None) -> dict[str, str]:
return merged
# 选定方式后,不属于该方式首次询价的键拿掉。海运件数先投影成货量再删。
_SEA_DROP_AFTER_PROJECT = ("件数", "毛重", "体积", "包装方式", "包装类型")
_LAND_DROP_FOREIGN = ("件数", "货量", "货物数量", "整柜或拼柜", "箱型箱量", "贸易条款")
_AIR_DROP_FOREIGN = ("货量", "货物数量", "整柜或拼柜", "箱型箱量")
def shape_facts_for_chosen_mode(facts: dict[str, str] | None, business_line: str) -> dict[str, str]:
"""
运输方式刚定下来时,按该方式的询价字段收口。
海运留下货物数量、整柜或拼柜、箱型箱量、贸易条款、运输分类;件数里的数量先写入货量。
陆运留下运输类型、线路类别、运输分类和重量体积或车型,不留海运箱型、空运件数。
空运留下件数、毛重、体积、包装方式,不留海运箱型。
不猜新值,只删不属于本方式的键。返回新 dict。
"""
line = (business_line or "").strip().upper()
merged = {str(k): str(v).strip() for k, v in dict(facts or {}).items() if str(v).strip()}
if line == "SEA":
merged = project_sea_cargo_qty(merged)
for key in _SEA_DROP_AFTER_PROJECT:
merged.pop(key, None)
return merged
if line == "LAND":
for key in _LAND_DROP_FOREIGN:
merged.pop(key, None)
return merged
if line == "AIR":
for key in _AIR_DROP_FOREIGN:
merged.pop(key, None)
return merged
return merged
def apply_quote_date_default(facts: dict[str, str]) -> dict[str, str]:
"""销售未提供报价日期时填当天;已有则不覆盖。"""
merged = dict(facts)
+241
View File
@@ -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],
+78
View File
@@ -144,6 +144,33 @@ 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,
)
def short_sea_trade_reply(text: str) -> str:
"""
销售整句只回门到门或港到港时,收成贸易条款。
举例行不算。长句里没写这两个词时,不能从这里编出一个条款。
返回「门到门」或「港到港」;对不上返回空串。
"""
cleaned = _without_clarify_examples(text or "").strip()
hit = _SHORT_SEA_TRADE_REPLY.match(cleaned)
if not hit:
return ""
raw = (hit.group(1) or "").strip()
folded = re.sub(r"[\s-]+", "", raw).lower()
if raw == "港到港":
return "港到港"
if raw == "门到门" or folded in {"doortodoor", "d2d"}:
return "门到门"
return ""
def drop_invented_door_trade_terms(text: str, facts: dict[str, str] | None = None) -> dict[str, str]:
"""
丢掉冒充贸易条款的「门到门」。
@@ -169,6 +196,57 @@ 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]:
"""
先丢掉模型冒充的门到门,再按原话收回贸易条款。
整句只回「门到门」或「港到港」要收下。长句里写了其中一个(例如先报货、再回「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
def sea_class_prompt() -> str:
"""缺运输分类时只列两项,不再追问「其他是什么」。"""
return "运输分类(必填):请回复 Port to Port 或 其他。"
@@ -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:
"""
体积出站用数字:尺寸式先相乘;否则取第一个数。
+9 -1
View File
@@ -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=" ")
@@ -963,6 +963,7 @@ class AirGroupCollabTests(unittest.TestCase):
ledger=self.ledger,
injected_intent="deal_lost",
)
before = len(self._texts())
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo}释放舱位", chat="wr_air_1", mid="r2"),
flow=self.flow,
@@ -970,6 +971,9 @@ class AirGroupCollabTests(unittest.TestCase):
injected_intent="air_release",
)
self.assertEqual(phase, "release_ok")
added = "\n".join(self._texts()[before:])
self.assertIn("释放", added)
self.assertNotIn("请确认成交状态", added)
def test_sales_cannot_lock(self) -> None:
wo = self._create_air()
@@ -1087,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(
@@ -1372,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()
@@ -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))
@@ -230,6 +230,93 @@ class ContinueTicketTests(unittest.TestCase):
self.assertEqual(phase, "wait_deal")
self.assertFalse(any("成交" in x or copy.BTN_DEAL in x for x in replies))
def test_resume_spoken_deal_closes(self) -> None:
"""报价文件已出后,原话写工单号加已成交,要入账并回复,不能没反应。"""
ledger = MemoryLedger()
created = ledger.create_ticket(
sender_id="u6b", 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
phase = flow.resume_historical(
sender_id="u6b",
work_order_no=wo,
reply=reply,
text=f"{wo}已成交",
)
self.assertEqual(phase, "done")
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] = []
+51
View File
@@ -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")
@@ -70,6 +101,18 @@ class OralExtractTests(unittest.TestCase):
self.assertEqual(snap["business_line"], "AIR")
self.assertFalse(snap["facts"].get("起运港"))
def test_bare_dap_is_trade_term(self) -> None:
"""句尾单独的 DAP 记成贸易条款,不依赖模型有没有这个字段。"""
snap = extract_inquiry_snapshot("查一下空运,出货日期9月30日广州到香港的普货价格,DAP")
self.assertEqual(snap["business_line"], "AIR")
self.assertEqual(snap["facts"].get("贸易条款"), "DAP")
def test_trade_term_example_line_is_not_filled(self) -> None:
snap = extract_inquiry_snapshot("贸易条款(非必填):\n(如:DDP、DAP、CIF、CIP)")
self.assertFalse(snap["facts"].get("贸易条款"))
labeled = extract_inquiry_snapshot("贸易条款:CIF\n(如:DDP、DAP、CIF、CIP)")
self.assertEqual(labeled["facts"].get("贸易条款"), "CIF")
def test_labeled_customs_port(self) -> None:
self.assertEqual(parse_labeled_facts("通关口岸:皇岗").get("通关口岸"), "皇岗")
snap = extract_inquiry_snapshot("通关口岸(非必填):皇岗")
@@ -292,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 托盘"
+19 -1
View File
@@ -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)
+154 -1
View File
@@ -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:
@@ -414,6 +425,148 @@ class LandDispatchTests(unittest.TestCase):
self.assertNotIn("你好,我是询价机器人", last)
self.assertNotIn("请直接描述客户需求", last)
def test_lost_reason_not_taken_as_new_inquiry(self) -> None:
"""未成交后下一句是原因。旧报价单被判成调价时,不能改问运输方式。"""
from agent.policy.air_text_flow import FlowSession
sea = get_sea_text_flow()
created = sea._ledger.create_ticket(
sender_id="lost-1",
business_line="SEA",
facts={"起运港": "广州港", "目的港": "巴生港", "品名": "机器设备"},
)
wo = str(created.get("work_order_no") or "")
sea._ledger.transition(work_order_no=wo, to_status="未成交")
sea._save(
FlowSession(
sender_id="lost-1",
thread_id="lost-1",
phase="wait_lost_reason",
business_line="SEA",
work_order_no=wo,
status="未成交",
wait_version=2,
)
)
get_land_text_flow()._save(
FlowSession(
sender_id="lost-1",
thread_id="lost-1",
phase="wait_deal",
business_line="LAND",
work_order_no="WO202609240045",
status="已报价",
quote={"total": "CNY 1"},
)
)
phase = handle_text_inquiry(
InboundMessage(sender_id="lost-1", message_id="m-lost", content="客户不需要了"),
prefer_quoted=True,
store=self.store,
)
self.assertEqual(phase, "done")
body = self._outbox_text()
self.assertIn("已记下未成交原因:客户不需要了", body)
self.assertNotIn("告知我询价的运输方式", body)
def test_several_turns_before_mode_follow_the_chosen_line(self) -> None:
"""定运输方式前连续几句询价字段都要留下,选定后再整份带上。不只认上一句。"""
from agent.policy.air_text_flow import FlowSession, get_air_text_flow
turns = (
(
"a",
"10月1日,深圳-吉隆坡,散货、普货报价",
{
"起运港": "深圳",
"目的港": "吉隆坡",
"品名": "普货",
"包装方式": "散货",
"报价日期": "2026-10-01",
},
),
(
"b",
"10件,毛重100公斤,体积2方",
{"件数": "10", "毛重": "100", "体积": "2"},
),
)
for label, reply, mode in (
("空运", "3", "AIR"),
("海运", "海运", "SEA"),
("陆运", "1", "LAND"),
):
reset_sea_text_flow_for_test()
reset_land_text_flow_for_test()
reset_air_text_flow_for_test()
sid = "premode-" + mode
# 上一单还在海运,新的几句里没有运输方式,不能把这些字段丢掉。
get_sea_text_flow()._save(
FlowSession(
sender_id=sid,
thread_id=sid,
phase="tms_miss",
business_line="SEA",
work_order_no="WO-OLD",
facts={"目的港": "旧港口"},
)
)
for mid, content, facts in turns:
phase = handle_text_inquiry(
InboundMessage(sender_id=sid, message_id=sid + mid, content=content),
injected_facts=facts,
injected_mode="",
store=self.store,
)
self.assertEqual(phase, "need_mode", label)
waiting = get_sea_text_flow().session_of(sid)
self.assertEqual(waiting.facts.get("目的港"), "吉隆坡", label)
self.assertIn("10", waiting.facts.get("件数") or "", label)
self.assertIn("深圳-吉隆坡", waiting.immutable_text)
self.assertIn("10件", waiting.immutable_text)
before = len(self.store._outbox)
phase = handle_text_inquiry(
InboundMessage(sender_id=sid, message_id=sid + "mode", content=reply),
store=self.store,
)
self.assertNotEqual(phase, "need_mode", label)
added = "\n".join(i.content for i in list(self.store._outbox.values())[before:])
owner = {
"AIR": get_air_text_flow(),
"SEA": get_sea_text_flow(),
"LAND": get_land_text_flow(),
}[mode]
sess = owner.session_of(sid)
self.assertIsNotNone(sess, label)
self.assertEqual((sess.business_line or "").upper(), mode, label)
self.assertEqual(sess.facts.get("起运港"), "深圳", label)
self.assertEqual(sess.facts.get("目的港"), "吉隆坡", label)
self.assertEqual(sess.facts.get("品名"), "普货", label)
self.assertEqual(sess.facts.get("报价日期"), "2026-10-01", label)
self.assertNotEqual(sess.facts.get("目的港"), "旧港口", label)
if mode == "AIR":
self.assertIn("10", sess.facts.get("件数") or "", label)
self.assertNotIn("整柜或拼柜", sess.facts, label)
self.assertNotIn("箱型箱量", sess.facts, label)
elif mode == "SEA":
self.assertIn("10", sess.facts.get("货量") or "", label)
self.assertNotIn("件数", sess.facts, label)
self.assertNotIn("毛重", sess.facts, label)
self.assertNotIn("包装方式", sess.facts, label)
self.assertIn("货物数量", added)
self.assertIn("整柜或拼柜", added)
self.assertIn("箱型箱量", added)
self.assertIn("贸易条款", added)
self.assertNotIn("件数(必填)", added)
else:
self.assertIn("100", sess.facts.get("毛重") or "", label)
self.assertIn("2", sess.facts.get("体积") or "", label)
self.assertNotIn("件数", sess.facts, label)
self.assertNotIn("箱型箱量", sess.facts, label)
self.assertNotIn("贸易条款", sess.facts, label)
self.assertIn("运输类型", added)
self.assertNotIn("件数(必填)", added)
if __name__ == "__main__":
unittest.main()
@@ -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
@@ -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
+98
View File
@@ -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()
+37
View File
@@ -60,6 +60,43 @@ class SeaOptionsTest(unittest.TestCase):
kept = drop_invented_door_trade_terms("贸易条款:门到门", {"贸易条款": "门到门"})
self.assertEqual(kept["贸易条款"], "门到门")
def test_short_reply_door_or_port_is_trade_term(self):
from agent.schema.sea_options import apply_spoken_sea_trade_reply
door = apply_spoken_sea_trade_reply("门到门", {})
self.assertEqual(door.get("贸易条款"), "门到门")
port = apply_spoken_sea_trade_reply("港到港", {"运输分类": "Port to Port"})
self.assertEqual(port.get("贸易条款"), "港到港")
self.assertEqual(port.get("运输分类"), "Port to Port")
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 = (
+228 -24
View File
@@ -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 = {
"起运港": "上海",
@@ -206,6 +288,13 @@ class SeaTextInquiryTests(unittest.TestCase):
self.assertIn("运输分类", check["missing"])
self.assertIn("贸易条款", check["missing_display"])
self.assertIn("运输分类", check["missing_display"])
text = copy.ask_clarify(
facts=check["facts"],
missing_keys=check["missing"],
transport_mode="海运",
)
self.assertIn("贸易条款(必填):\n(如:门到门、港到港)", text)
self.assertNotIn("如:FOB", text)
def test_sea_rejects_door_to_door_class(self) -> None:
facts = {
@@ -280,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,
@@ -311,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,
@@ -324,10 +413,115 @@ class SeaTextInquiryTests(unittest.TestCase):
self.assertIn("贸易条款", joined)
self.assertNotEqual(self.flow.session_of("s-door").facts.get("贸易条款"), "门到门")
def test_reply_door_to_door_fills_trade_terms(self) -> None:
"""补问后只回门到门,要记成贸易条款,不能回参数提取不全。"""
first = dict(COMPLETE_SEA)
first.pop("贸易条款")
phase = drive_sea(self.flow,
sender_id="s-door-reply",
text="先缺条款",
reply=self._reply,
injected_facts=first,
injected_mode="SEA",
)
self.assertEqual(phase, "clarify")
self.replies.clear()
phase = drive_sea(self.flow,
sender_id="s-door-reply",
text="门到门",
reply=self._reply,
)
self.assertEqual(phase, "wait_collab")
joined = "\n".join(self.replies)
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,
@@ -336,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,
@@ -348,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,
@@ -374,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,
@@ -394,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,
@@ -427,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")
@@ -539,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
)
@@ -584,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,
@@ -609,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,
@@ -634,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,
@@ -678,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,
@@ -790,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,
@@ -827,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,
@@ -934,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,
@@ -988,7 +1192,7 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
}
]
)
self.flow.on_text(
drive_sea(self.flow,
sender_id="sales1",
text="海运有价",
reply=self._reply,
@@ -1044,7 +1248,7 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
}
]
)
self.flow.on_text(
drive_sea(self.flow,
sender_id="sales1",
text="海运有价",
reply=self._reply,
@@ -1228,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,
@@ -1328,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,
@@ -1560,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,
@@ -2145,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,