From 185abbf45e489517055d6cc3363763e4af4a6351 Mon Sep 17 00:00:00 2001 From: jillion <439912596@qq.com> Date: Sun, 11 Oct 2026 20:45:53 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A9=BA=E8=BF=90=E3=80=8C=E8=B4=A7=E4=BA=A4?= =?UTF-8?q?=E3=80=8D=E5=8F=AA=E8=AE=B0=E6=8F=90=E8=B4=A7=E5=9C=B0=E5=9D=80?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=BD=93=E6=88=90=E8=B5=B7=E8=BF=90?= =?UTF-8?q?=E5=9C=B0=E3=80=82=E8=88=B1=E4=BD=8D=E6=97=A7=E8=AF=B4=E6=B3=95?= =?UTF-8?q?=E3=80=8C=E9=87=8A=E6=94=BE=E3=80=8D=E5=85=88=E9=97=AE=E8=88=AA?= =?UTF-8?q?=E7=BA=BF=E6=98=AF=E5=90=A6=E5=8F=96=E6=B6=88=EF=BC=8C=E9=99=86?= =?UTF-8?q?=E8=BF=90=E9=80=9A=E5=85=B3=E5=8F=A3=E5=B2=B8=E7=A4=BA=E4=BE=8B?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=8F=8B=E8=B0=8A=E5=85=B3=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../agent/handlers/air_group_collab.py | 99 ++++++++++++++++++- inquiry-agent/agent/llm/extract_text.py | 64 ++++++++++++ .../agent/llm/mode_extract_fields.py | 3 + inquiry-agent/agent/policy/air_group_ops.py | 4 +- inquiry-agent/agent/policy/inquiry_copy.py | 60 ++++++++--- inquiry-agent/agent/policy/multi_group_ops.py | 20 +++- .../agent/policy/system_exception.py | 2 +- inquiry-agent/agent/redis_coord/keys.py | 2 + .../agent/redis_coord/pending_cabin_cancel.py | 72 ++++++++++++++ .../agent/routing/air_group_intent.py | 58 ++++++++++- inquiry-agent/tests/test_air_group_collab.py | 89 ++++++++++++++++- inquiry-agent/tests/test_extract_oral.py | 73 ++++++++++++++ inquiry-agent/tests/test_land_copy.py | 2 +- inquiry-agent/tests/test_system_exception.py | 8 +- .../inquiry/tms/InquiryTmsCabinService.java | 4 +- .../src/views/inquiry/mock/botReplies.ts | 22 ++--- 16 files changed, 533 insertions(+), 49 deletions(-) create mode 100644 inquiry-agent/agent/redis_coord/pending_cabin_cancel.py diff --git a/inquiry-agent/agent/handlers/air_group_collab.py b/inquiry-agent/agent/handlers/air_group_collab.py index e182377..dcb0e86 100644 --- a/inquiry-agent/agent/handlers/air_group_collab.py +++ b/inquiry-agent/agent/handlers/air_group_collab.py @@ -53,6 +53,8 @@ from agent.routing.air_group_intent import ( INTENT_PICK_OPTION, INTENT_QUOTE, INTENT_RELEASE, + INTENT_RELEASE_ASK, + cabin_confirm_answer, classify_air_group_text, ) @@ -167,6 +169,9 @@ def handle_air_group( return "duplicate_inbound" mark_air_text(chat_id=chat_id, sender_id=message.sender_id, text=text) wo = copy.extract_work_order_no(text) + answered = _answer_pending_cabin_cancel(engine, message, text) + if answered: + return answered # 句中有多段工单号,或本群已经激活过多段单:跟这张单,不必每句再带工单号。 followed = _follow_activated_multi( engine, @@ -200,7 +205,7 @@ def handle_air_group( intent, wo or "-", ) - if intent in {INTENT_LOCK, INTENT_RELEASE}: + if intent in {INTENT_LOCK, INTENT_RELEASE, INTENT_RELEASE_ASK}: return _handle_cabin( engine, message, @@ -309,6 +314,92 @@ def _should_speak_activate(intent: str) -> bool: return intent in {INTENT_ACTIVATE, INTENT_OTHER} +def _answer_pending_cabin_cancel(engine, message: InboundMessage, text: str) -> str: + """ + 上一句已经问过是否取消舱位。 + + 航线回是/需要/确认/取消舱位才执行。回否/不用/不需要就停。 + 销售回这些词不算。说了别的业务就清掉询问,按那句话继续办。 + 没有待确认时返回空串。 + """ + from agent.policy.air_group_ops import is_airline + from agent.redis_coord.pending_cabin_cancel import ( + clear_pending_cabin_cancel, + load_pending_cabin_cancel, + ) + + chat_id = (message.chat_id or "").strip() + pending_wo = load_pending_cabin_cancel(chat_id) + if not pending_wo: + return "" + kind = cabin_confirm_answer(text) + if not kind: + clear_pending_cabin_cancel(chat_id) + logger.info("cabin.ask dropped chat=%s wo=%s", chat_id, pending_wo) + return "" + if not is_airline(engine, message.sender_id): + send_group_text(group_client_of(engine), chat_id, copy.air_airline_only_cabin()) + return "not_airline" + clear_pending_cabin_cancel(chat_id) + if kind == "no": + send_group_text(group_client_of(engine), chat_id, copy.air_release_ask_dropped()) + return "release_ask_dropped" + ticket, phase = resolve_cabin_ticket( + flow=engine, + chat_id=chat_id, + work_order_no=pending_wo, + allow_terminal=True, + ) + if ticket is None: + return phase + return apply_air_release( + flow=engine, + ticket=ticket, + sender_id=message.sender_id, + chat_id=chat_id, + instruction=text, + ) + + +def _ask_cabin_cancel( + engine, + message: InboundMessage, + *, + work_order_no: str, +) -> str: + """航线说了释放舱位:只提问,不打 TMS。""" + from agent.policy.air_group_ops import is_airline + from agent.redis_coord.pending_cabin_cancel import save_pending_cabin_cancel + + chat_id = message.chat_id + if not is_airline(engine, message.sender_id): + send_group_text(group_client_of(engine), chat_id, copy.air_airline_only_cabin()) + return "not_airline" + no = (work_order_no or "").strip() + if not no: + bound = current_ticket(engine, chat_id) + if bound is not None and is_air_ticket(bound): + from agent.policy.sea_group_ops import ticket_no + + no = ticket_no(bound) + if not no: + send_group_text(group_client_of(engine), chat_id, copy.air_lock_need_work_order()) + return "cabin_need_work_order" + ticket, phase = resolve_cabin_ticket( + flow=engine, + chat_id=chat_id, + work_order_no=no, + allow_terminal=True, + ) + if ticket is None: + return phase + from agent.policy.sea_group_ops import ticket_no + + save_pending_cabin_cancel(chat_id, ticket_no(ticket)) + send_group_text(group_client_of(engine), chat_id, copy.air_release_ask()) + return "release_ask" + + def _handle_cabin( engine, message: InboundMessage, @@ -319,6 +410,12 @@ def _handle_cabin( ) -> str: _ = injected_quote chat_id = message.chat_id + if intent == INTENT_RELEASE_ASK: + return _ask_cabin_cancel( + engine, + message, + work_order_no=work_order_no, + ) if not work_order_no: # 本群已经在锁这张单时,补舱位不用再报工单号。 bound = current_ticket(engine, chat_id) diff --git a/inquiry-agent/agent/llm/extract_text.py b/inquiry-agent/agent/llm/extract_text.py index 712c530..751814d 100644 --- a/inquiry-agent/agent/llm/extract_text.py +++ b/inquiry-agent/agent/llm/extract_text.py @@ -371,6 +371,68 @@ def _tighten_sheet_labels(text: str) -> str: return body +# 「货交上海」是提货地址,不是起运地。地址/地、到/于只是口语粘连,不进地址正文。 +_RE_CARGO_HANDOVER = re.compile( + r"货交\s*(?:地址|地)?\s*[::]?\s*(?:到|于|至|往)?\s*([^\n,,。;;]{1,60})" +) +# 同一行跟在地址后面的询价口头禅或下一栏字段,不是地址的一部分。 +_RE_HANDOVER_TAIL = re.compile( + r"(?:\s+|,|,)?(?:看看价格|看一下价格|看价格|询价|报价|多少钱|多少|" + r"目的地|目的港|起运地|起运港|品名|件数|毛重|体积|包装|空运|海运|陆运).*$" +) +# 另有起运说法时保留起运地。先抠掉「货交…」,避免「货交到上海」被当成「到上海」。 +_RE_EXPLICIT_ORIGIN = re.compile( + r"起运地|起运港|" + r"[A-Za-z]{3}\s*[-–—//]\s*[A-Za-z]{3}|" + r"[A-Za-z]{3}\s*(?:到|至)\s*[A-Za-z]{3}|" + r"从\s*\S{1,16}\s*(?:飞|到|至)|" + r"[\u4e00-\u9fff]{2,12}\s*飞|" + r"[\u4e00-\u9fff]{2,12}\s*到\s*[\u4e00-\u9fffA-Za-z]{2,12}" +) + + +def _handover_place(raw: str) -> str: + """货交后面的地名。去掉口头禅和首尾标点,空的不当地址。""" + place = _RE_HANDOVER_TAIL.sub("", (raw or "").strip()) + return place.strip(" ::,,。;;") + + +def _has_explicit_origin(text: str) -> bool: + """原话里另写了起运地、三字码航线或「从哪飞/到」。货交那一段先拿掉。""" + stripped = _RE_CARGO_HANDOVER.sub(" ", text or "") + return _RE_EXPLICIT_ORIGIN.search(stripped) is not None + + +def apply_cargo_handover_synonym( + text: str, + facts: dict[str, str] | None, + *, + business_line: str = "", +) -> dict[str, str]: + """ + 货交 = 提货地址。例:货交上海 → 提货地址上海。 + + 只认原话里的「货交」,不把裸城市猜成港口。 + 空运且没有另写起运地时,清掉被误收成起运地的同一处地名,否则 TMS 会按错误起运查价。 + 海运/陆运只补提货地址,不动起运港。副作用:无。 + """ + merged = dict(facts or {}) + places: list[str] = [] + for hit in _RE_CARGO_HANDOVER.finditer(text or ""): + place = _handover_place(hit.group(1)) + if place and place not in places: + places.append(place) + if not places: + return merged + merged["提货地址"] = "、".join(places) + is_air = (business_line or "").strip().upper() == "AIR" or "空运" in (text or "") + if is_air and not _has_explicit_origin(text): + merged.pop("起运港", None) + merged.pop("起运地", None) + merged.pop("originCode", None) + return merged + + def _drop_fee_place_values(facts: dict[str, str]) -> dict[str, str]: """起运地/目的地收成「操作费、文件费」时丢掉。那是费用栏,不是港口。""" out = dict(facts or {}) @@ -407,6 +469,7 @@ def extract_inquiry_snapshot( facts = harvest_oral_measures(text, normalize_facts(dict(injected_facts))) facts = _merge_spoken_collab(text, facts) facts = _merge_optional_inquiry_labels(text, facts) + facts = apply_cargo_handover_synonym(text, facts, business_line=mode) for key in ("货好时间", "提货地址", "收货地址", "货源地", "贸易条款"): if str(facts.get(key) or "").strip(): facts[key] = clip_inquiry_value(str(facts[key])) @@ -453,6 +516,7 @@ def extract_inquiry_snapshot( facts = harvest_oral_measures(text, normalize_facts(facts)) facts = _merge_spoken_collab(text, facts) facts = _merge_optional_inquiry_labels(text, facts) + facts = apply_cargo_handover_synonym(text, facts, business_line=mode) for key in ("货好时间", "提货地址", "收货地址", "货源地", "贸易条款"): if str(facts.get(key) or "").strip(): facts[key] = clip_inquiry_value(str(facts[key])) diff --git a/inquiry-agent/agent/llm/mode_extract_fields.py b/inquiry-agent/agent/llm/mode_extract_fields.py index 1431019..b57b4e3 100644 --- a/inquiry-agent/agent/llm/mode_extract_fields.py +++ b/inquiry-agent/agent/llm/mode_extract_fields.py @@ -35,6 +35,7 @@ _FIELD_KEYS = ( "车型数量", "通关口岸", "贸易条款", + "提货地址", ) @@ -134,6 +135,8 @@ def build_extract_messages(text: str) -> list[dict[str, str]]: "陆运「通关口岸:皇岗」「通关口岸皇岗」「过皇岗」有说就写入通关口岸,没说留空,禁止编造口岸。" "原话单独出现 DAP、DDP、CIF、CIP、FOB、EXW 时写入贸易条款;没出现留空,禁止编造。" "「从南京飞吉隆坡」起运港=南京、目的港=吉隆坡;「北京飞吉隆坡」起运港=北京。" + "「货交上海」「货交:上海」「货交到上海」是提货地址=上海,不是起运港。" + "没有另写起运地、三字码航线或「从哪飞/到」时,起运港必须空串。" "原话是机场三字码或航线就原样记,禁止翻译成城市名:" "SZX-CGK / SZX/CGK / SZX到CGK → 起运港=SZX、目的港=CGK,不要写成深圳、雅加达。" "换城市必须覆盖旧值,不要沿用上一句的港口。" diff --git a/inquiry-agent/agent/policy/air_group_ops.py b/inquiry-agent/agent/policy/air_group_ops.py index dc7d9ec..7732800 100644 --- a/inquiry-agent/agent/policy/air_group_ops.py +++ b/inquiry-agent/agent/policy/air_group_ops.py @@ -1068,7 +1068,7 @@ def apply_air_release( send_group_text( group_client_of(flow), chat_id, - copy.air_release_fail(work_order_no=no, operator=name, reason="主账未开放释放"), + copy.air_release_fail(work_order_no=no, operator=name, reason="主账未开放取消舱位"), ) return "release_unavailable" from agent.policy.system_exception import ( @@ -1104,7 +1104,7 @@ def apply_air_release( event=SystemExceptionEvent( exception_type=TYPE_TMS, step=STEP_TMS_RELEASE, - reason="空运释放舱位接口调用失败", + reason="空运取消舱位接口调用失败", service="空运舱位", error_code=why or "RELEASE_FAIL", work_order_no=no, diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index 97f3860..bf2e7c7 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -39,6 +39,24 @@ def _r(code: str, default: str, **kwargs: Any) -> str: return render(code, default, **kwargs) +def _cabin_copy(code: str, default: str, **kwargs: Any) -> str: + """ + 舱位话术。库里旧句仍写「释放」时,新发出的改用当前默认句。 + + 先把填进去的原因、工单号抠掉再看骨架,避免 TMS 原文里的「释放」把新句子打回去。 + 已经发出的历史消息不在这里改。 + """ + text = _r(code, default, **kwargs) + skeleton = text + for value in kwargs.values(): + token = str(value or "") + if token: + skeleton = skeleton.replace(token, "") + if "释放" in skeleton: + return default + return text + + # 空运补问:必填 7 项 + 非必填。报价日期默认当天,不进待补充。 _AIR_EXAMPLES = { "起运港": "南京", @@ -314,7 +332,7 @@ _LAND_EXAMPLES = { "体积": "100CBM", "毛重": "50kg", "车型/数量": "9米6/1", - "通关口岸": "皇岗", + "通关口岸": "友谊关", "运输类型": "国内运输拼车", "线路类别": "国内长途/零担", "运输分类": "急件", @@ -2879,9 +2897,9 @@ def air_need_work_order() -> str: def air_lock_need_work_order() -> str: """锁舱/释放必须自带工单号。后台 inquiring.air_lock_need_wo。""" - return _r( + return _cabin_copy( "inquiring.air_lock_need_wo", - "锁舱或释放舱位请带上工单号,例如 WO202609160001锁舱。", + "锁舱或取消舱位请带上工单号,例如 WO202609160001取消舱位。", ) @@ -3041,9 +3059,9 @@ def air_airline_only_quote() -> str: def air_airline_only_cabin() -> str: """只有航线能锁舱或释放。后台 inquiring.air_airline_only_cabin。""" - return _r( + return _cabin_copy( "inquiring.air_airline_only_cabin", - "只有岗位为「航线·空运」的人能锁舱或释放舱位。", + "只有岗位为「航线·空运」的人能锁舱或取消舱位。", ) @@ -3264,13 +3282,13 @@ def air_release_ok( ) -> str: """空运释放成功。后台 inquiring.air_release_ok。""" default = ( - f"工单{work_order_no} 已通知TMS执行空运释放舱位成功\n" + f"工单{work_order_no} 已通知TMS执行空运取消舱位成功\n" f"操作人:{operator}\n" f"舱位选项:{option_no}\n" f"执行编号:{exec_no}\n" f"报价编号:{quote_no}" ) - return _r( + return _cabin_copy( "inquiring.air_release_ok", default, ticket_no=work_order_no, @@ -3285,12 +3303,12 @@ def air_release_fail(*, work_order_no: str, operator: str, reason: str) -> str: """空运释放失败。后台 inquiring.air_release_fail。""" why = (reason or "").strip() or "TMS未返回原因" default = ( - f"工单{work_order_no} 空运释放舱位失败。\n" + f"工单{work_order_no} 空运取消舱位失败。\n" f"操作人:{operator}\n" - f"释放舱位失败的原因:{why}\n" + f"取消舱位失败的原因:{why}\n" "已通知IT运维排查,请稍等。" ) - return _r( + return _cabin_copy( "inquiring.air_release_fail", default, ticket_no=work_order_no, @@ -3302,9 +3320,9 @@ def air_release_fail(*, work_order_no: str, operator: str, reason: str) -> str: def air_release_deal_blocked(work_order_no: str) -> str: """已成交不能释放舱位。后台 inquiring.air_release_deal_blocked。""" no = (work_order_no or "").strip() - return _r( + return _cabin_copy( "inquiring.air_release_deal_blocked", - f"释放舱位失败,工单{no}已成交。", + f"取消舱位失败,工单{no}已成交。", ticket_no=no, ) @@ -3312,9 +3330,9 @@ def air_release_deal_blocked(work_order_no: str) -> str: def air_release_need_lock(work_order_no: str) -> str: """没有有效锁舱不能释放。后台 inquiring.air_release_need_lock。""" no = (work_order_no or "").strip() - return _r( + return _cabin_copy( "inquiring.air_release_need_lock", - f"工单{no}还没有有效锁舱,不能释放。", + f"工单{no}还没有有效锁舱,不能取消舱位。", ticket_no=no, ) @@ -3342,9 +3360,19 @@ SYS_EXC_RECOVERY_GROUP = "建群已恢复正常,感谢您的耐心等待。" IMPACT_LLM_NO_TICKET = "尚未建单,不落后台工单" IMPACT_LLM = "当前识别暂停,已有字段与工单状态保持不变" IMPACT_TMS_QUOTE = "当前询价流程暂停,识别字段与原业务节点保持不变" -IMPACT_TMS_CABIN = "当前锁舱/释放暂停,工单状态保持不变" +IMPACT_TMS_CABIN = "当前锁舱/取消舱位暂停,工单状态保持不变" IMPACT_TMS_LOCK = "锁舱失败,工单状态保持不变" -IMPACT_TMS_RELEASE = "释放舱位失败,工单状态保持不变" +IMPACT_TMS_RELEASE = "取消舱位失败,工单状态保持不变" + + +def air_release_ask() -> str: + """航线说了和舱位有关的「释放」时先问,不直接取消。""" + return "是否需要取消舱位?" + + +def air_release_ask_dropped() -> str: + """航线否认这次取消。不打 TMS。""" + return "已取消这次操作" IMPACT_GROUP = "当前建群/加人暂停,已建群不新建第二个群" diff --git a/inquiry-agent/agent/policy/multi_group_ops.py b/inquiry-agent/agent/policy/multi_group_ops.py index 6efc823..91c6b85 100644 --- a/inquiry-agent/agent/policy/multi_group_ops.py +++ b/inquiry-agent/agent/policy/multi_group_ops.py @@ -289,6 +289,15 @@ def _cabin(flow: Any, ticket: object, chat_id: str, sender_id: str, text: str, i operator_name, ) + if intent == "ask": + if not is_airline(flow, sender_id): + send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin()) + return "not_airline" + from agent.redis_coord.pending_cabin_cancel import save_pending_cabin_cancel + + save_pending_cabin_cancel(chat_id, ticket_no(ticket)) + send_group_text(group_client_of(flow), chat_id, copy.air_release_ask()) + return "release_ask" if intent == "release": return apply_air_release( flow=flow, @@ -314,10 +323,15 @@ def _cabin(flow: Any, ticket: object, chat_id: str, sender_id: str, text: str, i def _cabin_intent(text: str) -> str: - raw = text or "" - if "释放" in raw and "舱" in raw: + """取消舱位马上办;和舱位有关的「释放」只提问。""" + from agent.routing.air_group_intent import cabin_release_kind + + kind = cabin_release_kind(text or "") + if kind == "direct": return "release" - if "锁舱" in raw: + if kind == "ask": + return "ask" + if "锁舱" in (text or ""): return "lock" return "" diff --git a/inquiry-agent/agent/policy/system_exception.py b/inquiry-agent/agent/policy/system_exception.py index 3d2a8ec..f899aa7 100644 --- a/inquiry-agent/agent/policy/system_exception.py +++ b/inquiry-agent/agent/policy/system_exception.py @@ -888,7 +888,7 @@ def _resume_cabin_step( is_ok=lambda x: bool((x or {}).get("ok")), ) service = "空运舱位" - reason = "空运释放舱位接口调用失败" + reason = "空运取消舱位接口调用失败" else: # 与首次群锁舱对齐:重试必须带 operatorName / airline,否则 MANUAL 出站缺姓名。 locker = getattr(ledger, "lock_cabin", None) diff --git a/inquiry-agent/agent/redis_coord/keys.py b/inquiry-agent/agent/redis_coord/keys.py index 8cd9b25..d4588f1 100644 --- a/inquiry-agent/agent/redis_coord/keys.py +++ b/inquiry-agent/agent/redis_coord/keys.py @@ -57,6 +57,8 @@ QUOTE_FILE_READY = "quote_file_ready" PENDING_PRODUCT_QUOTE = "pending_product_quote" # 陆运核对卡已出、等确定查价:短协调,不当六态、不当工单号 PENDING_LAND_CONFIRM = "pending_land_confirm" +# 空运群问过「是否需要取消舱位」:短协调,不当六态 +PENDING_CABIN_CANCEL = "pending_cabin_cancel" # 空运/海运/陆运当前书签:HTTP 与 Worker 共用,不当六态 FLOW_BOOKMARK = "flow_bookmark" # 私聊图片波次:HTTP 与 Worker 必须共用,不当六态 diff --git a/inquiry-agent/agent/redis_coord/pending_cabin_cancel.py b/inquiry-agent/agent/redis_coord/pending_cabin_cancel.py new file mode 100644 index 0000000..bea993b --- /dev/null +++ b/inquiry-agent/agent/redis_coord/pending_cabin_cancel.py @@ -0,0 +1,72 @@ +""" +空运群取消舱位待确认。 + +航线说出和舱位有关的「释放」后先问一句,确认前不打 TMS。 +短协调,不当六态、不当 checkpoint。按群记下工单号,双进程仍能读到。 +两小时没人接话就过期,避免过后回一个「是」误取消。 +""" + +from __future__ import annotations + +import json +import logging + +from agent.redis_coord.keys import PENDING_CABIN_CANCEL +from agent.redis_coord.runtime import get_redis_runtime + +logger = logging.getLogger(__name__) + +# 问句只在这一阵有效。过期后「是」不再取消舱位。 +_TTL_SECONDS = 2 * 60 * 60 + + +def save_pending_cabin_cancel(chat_id: str, work_order_no: str) -> None: + """记下本群正在等航线确认取消哪张单。""" + room = (chat_id or "").strip() + no = (work_order_no or "").strip() + if not room or not no: + return + payload = {"work_order_no": no} + try: + client = get_redis_runtime().client + client.raw.set( + client.key(PENDING_CABIN_CANCEL, room), + json.dumps(payload, ensure_ascii=False), + ex=_TTL_SECONDS, + ) + logger.info("cabin.ask saved chat=%s wo=%s", room, no) + except Exception: + logger.exception("写取消舱位待确认失败 chat=%s", room) + + +def load_pending_cabin_cancel(chat_id: str) -> str: + """本群正在等确认的工单号。没有则空串。""" + room = (chat_id or "").strip() + if not room: + return "" + try: + client = get_redis_runtime().client + raw = client.raw.get(client.key(PENDING_CABIN_CANCEL, room)) + if not raw: + return "" + if isinstance(raw, bytes): + raw = raw.decode("utf-8") + data = json.loads(raw) + if not isinstance(data, dict): + return "" + return str(data.get("work_order_no") or "").strip() + except Exception: + logger.exception("读取消舱位待确认失败 chat=%s", room) + return "" + + +def clear_pending_cabin_cancel(chat_id: str) -> None: + """确认、否认或改口办别的事时清掉。""" + room = (chat_id or "").strip() + if not room: + return + try: + client = get_redis_runtime().client + client.raw.delete(client.key(PENDING_CABIN_CANCEL, room)) + except Exception: + logger.exception("清取消舱位待确认失败 chat=%s", room) diff --git a/inquiry-agent/agent/routing/air_group_intent.py b/inquiry-agent/agent/routing/air_group_intent.py index e14a7b5..f074524 100644 --- a/inquiry-agent/agent/routing/air_group_intent.py +++ b/inquiry-agent/agent/routing/air_group_intent.py @@ -35,12 +35,15 @@ logger = logging.getLogger(__name__) INTENT_ACTIVATE = "air_activate" INTENT_LOCK = "air_lock" INTENT_RELEASE = "air_release" +# 说出和舱位有关的「释放」:先问是否取消,不直接执行。 +INTENT_RELEASE_ASK = "air_release_ask" INTENT_PICK_OPTION = "air_pick_option" _VALID = { INTENT_ACTIVATE, INTENT_LOCK, INTENT_RELEASE, + INTENT_RELEASE_ASK, INTENT_PICK_OPTION, INTENT_FIELDS, INTENT_QUOTE, @@ -56,7 +59,10 @@ _VALID = { # 相近说法回退,不是唯一口令。 _LOCK_HINTS = ("锁舱", "锁仓", "锁定舱位", "锁住舱位", "帮我锁舱") -_RELEASE_HINTS = ("释放舱位", "放舱", "解锁舱", "取消锁舱", "释放舱") +# 这四句马上取消舱位。「释放舱位」不在这里。 +_RELEASE_DIRECT = ("取消舱位", "放舱", "解锁舱", "取消锁舱") +_CONFIRM_YES = ("是", "需要", "确认") +_CONFIRM_NO = ("不需要", "不用", "否") def is_missed_air_bot_at(text: str, *, room_already_air: bool = False) -> bool: @@ -154,10 +160,53 @@ def classify_air_group_text( return INTENT_OTHER +def _compact_cabin_text(text: str) -> str: + """去掉空白,方便认「取消舱位」「释放舱位」。""" + return (text or "").replace(" ", "").replace(" ", "") + + +def cabin_release_kind(text: str) -> str: + """ + 舱位取消怎么认。 + + direct:马上执行。ask:先问「是否需要取消舱位」。 + 跟舱位有关的「释放」只问不办。没说舱位的「释放」不当取消。 + """ + blob = _compact_cabin_text(text) + # 「释放舱位」里含着「放舱」,必须先认释放,避免被放舱直接取消。 + if "释放" in blob and "舱" in blob: + return "ask" + if any(hint in blob for hint in _RELEASE_DIRECT): + return "direct" + return "" + + +def cabin_confirm_answer(text: str) -> str: + """ + 问过之后这一句是确认、否认,还是别的业务。 + + 只认整句(可带工单号和 @)。「不需要」不能被看成「需要」。 + 返回 yes / no / 空串。空串表示询问作废,调用方按原句继续办。 + """ + raw = text or "" + no = copy.extract_work_order_no(raw) + rest = raw.replace(no, "").replace(no.lower(), "") + rest = rest.replace("@询价小助手", "").replace("询价小助手", "") + rest = "".join(ch for ch in rest if ch.isalnum() or "\u4e00" <= ch <= "\u9fff") + if rest in _CONFIRM_NO: + return "no" + if rest in _CONFIRM_YES or rest == "取消舱位": + return "yes" + return "" + + def _hint_lock_or_release(text: str) -> str: - blob = (text or "").replace(" ", "").replace(" ", "") - if any(x in blob for x in _RELEASE_HINTS): + kind = cabin_release_kind(text) + if kind == "direct": return INTENT_RELEASE + if kind == "ask": + return INTENT_RELEASE_ASK + blob = _compact_cabin_text(text) if any(x in blob for x in _LOCK_HINTS): return INTENT_LOCK return "" @@ -191,7 +240,8 @@ def _classify_with_model(text: str, settings) -> str: client = LlmHttpClient.from_settings(settings) system = ( "你是空运协同群意图分类。只回复一个英文词,不要解释。" - "air_lock=航线要锁舱;air_release=航线要释放舱位;" + "air_lock=航线要锁舱;air_release=航线要取消舱位(取消舱位、放舱、解锁舱、取消锁舱);" + "说出释放舱位、释放舱、把舱位释放了时不要回 air_release;" "air_activate=只是指定/切换工单号;" "air_pick_option=在补充或选择舱位编号、航司、航班号,写法不限;" "collab_fields=在补是否含电/是否含磁;" diff --git a/inquiry-agent/tests/test_air_group_collab.py b/inquiry-agent/tests/test_air_group_collab.py index 1b4eed8..d3b508e 100644 --- a/inquiry-agent/tests/test_air_group_collab.py +++ b/inquiry-agent/tests/test_air_group_collab.py @@ -20,6 +20,7 @@ from agent.policy import inquiry_copy as copy from agent.routing.air_group_intent import ( INTENT_LOCK, INTENT_RELEASE, + INTENT_RELEASE_ASK, classify_air_group_text, ) from agent.routing.dispatch import dispatch_inbound @@ -201,7 +202,12 @@ class AirGroupCollabTests(unittest.TestCase): def test_intent_lock_is_not_hardcoded(self) -> None: self.assertEqual(classify_air_group_text("帮我锁舱 WO202609160001", allow_network=False), INTENT_LOCK) - self.assertEqual(classify_air_group_text("WO202609160001释放舱位", allow_network=False), INTENT_RELEASE) + self.assertEqual(classify_air_group_text("WO202609160001取消舱位", allow_network=False), INTENT_RELEASE) + self.assertEqual(classify_air_group_text("放舱", allow_network=False), INTENT_RELEASE) + self.assertEqual(classify_air_group_text("解锁舱", allow_network=False), INTENT_RELEASE) + self.assertEqual(classify_air_group_text("取消锁舱", allow_network=False), INTENT_RELEASE) + self.assertEqual(classify_air_group_text("WO202609160001释放舱位", allow_network=False), INTENT_RELEASE_ASK) + self.assertEqual(classify_air_group_text("把舱位释放了", allow_network=False), INTENT_RELEASE_ASK) def test_airline_fee_wording_is_quote_not_adjust(self) -> None: """航线写「空运费改为900」是手工报价,不能先判成调价。比例和改总价仍是调价。""" @@ -1278,9 +1284,84 @@ class AirGroupCollabTests(unittest.TestCase): ) self.assertEqual(phase, "release_ok") added = "\n".join(self._texts()[before:]) - self.assertIn("释放", added) + self.assertIn("取消舱位", added) self.assertNotIn("请确认成交状态", added) + def test_release_wording_asks_before_cancel(self) -> None: + """说出释放舱位只提问。销售回「是」不算,航线确认后才取消。""" + wo = self._create_air() + handle_air_group( + self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_ask", mid="ask0"), + flow=self.flow, + ledger=self.ledger, + injected_intent="air_lock", + ) + self.groups.sent.clear() + phase = handle_air_group( + self._msg(sender="air1", text=f"{wo}释放舱位", chat="wr_air_ask", mid="ask1"), + flow=self.flow, + ledger=self.ledger, + ) + self.assertEqual(phase, "release_ask") + self.assertIn("是否需要取消舱位?", "\n".join(self._texts())) + self.assertNotIn("已通知TMS执行空运取消舱位成功", "\n".join(self._texts())) + phase = handle_air_group( + self._msg(sender="sales1", text="是", chat="wr_air_ask", mid="ask2"), + flow=self.flow, + ledger=self.ledger, + ) + self.assertEqual(phase, "not_airline") + self.assertNotIn("已通知TMS执行空运取消舱位成功", "\n".join(self._texts())) + phase = handle_air_group( + self._msg(sender="air1", text="是", chat="wr_air_ask", mid="ask3"), + flow=self.flow, + ledger=self.ledger, + ) + self.assertEqual(phase, "release_ok") + self.assertIn("已通知TMS执行空运取消舱位成功", "\n".join(self._texts())) + + def test_release_wording_can_be_declined(self) -> None: + wo = self._create_air() + handle_air_group( + self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_ask_no", mid="no0"), + flow=self.flow, + ledger=self.ledger, + injected_intent="air_lock", + ) + self.groups.sent.clear() + handle_air_group( + self._msg(sender="air1", text=f"{wo}把舱位释放了", chat="wr_air_ask_no", mid="no1"), + flow=self.flow, + ledger=self.ledger, + ) + phase = handle_air_group( + self._msg(sender="air1", text="不用", chat="wr_air_ask_no", mid="no2"), + flow=self.flow, + ledger=self.ledger, + ) + self.assertEqual(phase, "release_ask_dropped") + self.assertIn("已取消这次操作", "\n".join(self._texts())) + self.assertNotIn("已通知TMS执行空运取消舱位成功", "\n".join(self._texts())) + + def test_cancel_cabin_words_release_immediately(self) -> None: + wo = self._create_air() + handle_air_group( + self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_now", mid="now0"), + flow=self.flow, + ledger=self.ledger, + injected_intent="air_lock", + ) + self.groups.sent.clear() + phase = handle_air_group( + self._msg(sender="air1", text=f"{wo}取消舱位", chat="wr_air_now", mid="now1"), + flow=self.flow, + ledger=self.ledger, + ) + self.assertEqual(phase, "release_ok") + joined = "\n".join(self._texts()) + self.assertIn("已通知TMS执行空运取消舱位成功", joined) + self.assertNotIn("是否需要取消舱位?", joined) + def test_sales_cannot_lock(self) -> None: wo = self._create_air() phase = handle_air_group( @@ -1825,9 +1906,9 @@ class AirGroupCollabTests(unittest.TestCase): ) self.assertEqual(phase, "system_exception") joined = "\n".join(self._texts()) - self.assertIn(f"工单{wo} 空运释放舱位失败。", joined) + self.assertIn(f"工单{wo} 空运取消舱位失败。", joined) self.assertIn("操作人:HangXian", joined) - self.assertIn("释放舱位失败的原因:舱位不存在", joined) + self.assertIn("取消舱位失败的原因:舱位不存在", joined) self.assertIn("已通知IT运维排查,请稍等。", joined) self.assertNotIn("TMS接口异常,IT运维排查中", joined) diff --git a/inquiry-agent/tests/test_extract_oral.py b/inquiry-agent/tests/test_extract_oral.py index 09ae2bc..a6b2a34 100644 --- a/inquiry-agent/tests/test_extract_oral.py +++ b/inquiry-agent/tests/test_extract_oral.py @@ -624,6 +624,79 @@ AIR_QUOTE_SHEET = """ """ +class CargoHandoverSynonymTests(unittest.TestCase): + """货交是提货地址,不能当成起运地。""" + + def test_huojiao_shanghai_is_pickup_not_origin(self) -> None: + text = ( + "KUL 空运 普货 散货\n" + "8686KG//9PLT//11.6CBM\n" + "110*110*118CM*7\n" + "110*110*80CM*1\n" + "110*110*53CM*1\n" + "货交上海\n" + "看看价格 几号" + ) + guessed = parse_extract_payload( + { + "transport_mode": "AIR", + "fields": { + "起运港": "上海", + "目的港": "KUL", + "品名": "普货", + "件数": "9", + "毛重": "8686KG", + "体积": "11.6CBM", + "包装方式": "散货", + }, + } + ) + facts = extract_inquiry_snapshot(text, b_result=guessed)["facts"] + self.assertEqual(facts.get("提货地址"), "上海") + self.assertNotIn("起运港", facts) + self.assertEqual(facts.get("目的港"), "KUL") + + def test_huojiao_colon_and_dao_same_as_pickup(self) -> None: + for spoken in ("货交:上海", "货交到上海", "货交地:上海"): + guessed = parse_extract_payload( + { + "transport_mode": "AIR", + "fields": {"起运港": "上海", "目的港": "KUL", "品名": "普货"}, + } + ) + facts = extract_inquiry_snapshot( + f"空运 {spoken} 目的地KUL", b_result=guessed + )["facts"] + self.assertEqual(facts.get("提货地址"), "上海", spoken) + self.assertNotIn("起运港", facts, spoken) + + def test_explicit_route_keeps_origin(self) -> None: + text = "SZX-CGK 空运 货交深圳" + guessed = parse_extract_payload( + { + "transport_mode": "AIR", + "fields": {"起运港": "SZX", "目的港": "CGK", "品名": "普货"}, + } + ) + facts = extract_inquiry_snapshot(text, b_result=guessed)["facts"] + self.assertEqual(facts.get("提货地址"), "深圳") + self.assertEqual(facts.get("起运港"), "SZX") + self.assertEqual(facts.get("目的港"), "CGK") + + def test_fly_from_city_keeps_origin(self) -> None: + text = "从南京飞吉隆坡 空运 货交上海" + guessed = parse_extract_payload( + { + "transport_mode": "AIR", + "fields": {"起运港": "南京", "目的港": "吉隆坡", "品名": "普货"}, + } + ) + facts = extract_inquiry_snapshot(text, b_result=guessed)["facts"] + self.assertEqual(facts.get("提货地址"), "上海") + self.assertEqual(facts.get("起运港"), "南京") + self.assertEqual(facts.get("目的港"), "吉隆坡") + + class AirQuoteSheetInquiryOnlyTests(unittest.TestCase): def test_quote_sheet_keeps_inquiry_fields_only(self) -> None: """费用明细、报价条件、落款不能进补问。起运地不能收成操作费。""" diff --git a/inquiry-agent/tests/test_land_copy.py b/inquiry-agent/tests/test_land_copy.py index 1adad0c..6057dbc 100644 --- a/inquiry-agent/tests/test_land_copy.py +++ b/inquiry-agent/tests/test_land_copy.py @@ -123,7 +123,7 @@ class LandCopyTest(unittest.TestCase): self.assertIn("始发站(必填):广州", text) self.assertIn(copy.CLARIFY_PASTE_HINT, text) self.assertIn("1.体积(CBM)(必填):\n(如:100CBM)", text) - self.assertIn("2.通关口岸(非必填):\n(如:皇岗)", text) + self.assertIn("2.通关口岸(非必填):\n(如:友谊关)", text) self.assertNotIn("体积(必填):(如:", text) self.assertNotIn(copy.LAND_CONFIRM_TAIL, text) diff --git a/inquiry-agent/tests/test_system_exception.py b/inquiry-agent/tests/test_system_exception.py index 850063d..f50b285 100644 --- a/inquiry-agent/tests/test_system_exception.py +++ b/inquiry-agent/tests/test_system_exception.py @@ -65,7 +65,7 @@ class TestSystemExceptionCopy(unittest.TestCase): ) self.assertEqual( copy.system_exception_impact(copy.STEP_TMS_RELEASE), - "释放舱位失败,工单状态保持不变", + "取消舱位失败,工单状态保持不变", ) def test_alert_title_never_has_test_prefix(self) -> None: @@ -118,9 +118,9 @@ class TestSystemExceptionCopy(unittest.TestCase): ) self.assertEqual( text, - "工单WO202609210001 空运释放舱位失败。\n" + "工单WO202609210001 空运取消舱位失败。\n" "操作人:吴季林\n" - "释放舱位失败的原因:舱位不存在\n" + "取消舱位失败的原因:舱位不存在\n" "已通知IT运维排查,请稍等。", ) @@ -409,7 +409,7 @@ class TestConfirmSystemException(unittest.TestCase): ) card = queued[0]["payload"]["template_card"] values = {row["keyname"]: row["value"] for row in card["horizontal_content_list"]} - self.assertEqual(values["影响范围"], "释放舱位失败,工单状态保持不变") + self.assertEqual(values["影响范围"], "取消舱位失败,工单状态保持不变") def test_closed_ticket_not_marked(self) -> None: self.ledger.transition(work_order_no=self.wo, to_status="已关闭") diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java index 407e107..32efbf9 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java @@ -294,7 +294,7 @@ public class InquiryTmsCabinService { return fail("release_need_lock", "本单还没有成功锁舱记录"); } if (!properties.lockConfigured()) { - return fail("lock_disabled", "主账未开放释放"); + return fail("lock_disabled", "主账未开放取消舱位"); } Map req = new LinkedHashMap<>(); req.put("lockId", lockId); @@ -363,7 +363,7 @@ public class InquiryTmsCabinService { return out; } catch (RuntimeException ex) { log.warn("tms.release fail wo={} err={}", ticket.getWorkOrderNo(), ex.getMessage()); - return fail("release_fail", firstNonBlank(ex.getMessage(), "释放失败")); + return fail("release_fail", firstNonBlank(ex.getMessage(), "取消舱位失败")); } } diff --git a/inquiry-backend/src/views/inquiry/mock/botReplies.ts b/inquiry-backend/src/views/inquiry/mock/botReplies.ts index ba44e79..5fd2a72 100644 --- a/inquiry-backend/src/views/inquiry/mock/botReplies.ts +++ b/inquiry-backend/src/views/inquiry/mock/botReplies.ts @@ -190,8 +190,8 @@ export const mockReplyCategories: ReplyCategory[] = [ { id: "brv10000000000000000000000000024", replyCode: "inquiring.air_lock_need_wo", - trigger: "空运锁舱/释放未带工单号", - text: "锁舱或释放舱位请带上工单号,例如 WO202609160001锁舱。", + trigger: "空运锁舱/取消舱位未带工单号", + text: "锁舱或取消舱位请带上工单号,例如 WO202609160001取消舱位。", enabled: true, }, { @@ -205,7 +205,7 @@ export const mockReplyCategories: ReplyCategory[] = [ id: "brv10000000000000000000000000026", replyCode: "inquiring.air_airline_only_cabin", trigger: "只有航线能锁舱或释放", - text: "只有岗位为「航线·空运」的人能锁舱或释放舱位。", + text: "只有岗位为「航线·空运」的人能锁舱或取消舱位。", enabled: true, }, { @@ -225,29 +225,29 @@ export const mockReplyCategories: ReplyCategory[] = [ { id: "brv10000000000000000000000000029", replyCode: "inquiring.air_release_ok", - trigger: "空运释放成功", - text: "工单{{ticket_no}} 已通知TMS执行空运释放舱位成功\n操作人:{{operator}}\n舱位选项:{{option_no}}\n执行编号:{{exec_no}}\n报价编号:{{quote_no}}", + trigger: "空运取消舱位成功", + text: "工单{{ticket_no}} 已通知TMS执行空运取消舱位成功\n操作人:{{operator}}\n舱位选项:{{option_no}}\n执行编号:{{exec_no}}\n报价编号:{{quote_no}}", enabled: true, }, { id: "brv10000000000000000000000000030", replyCode: "inquiring.air_release_fail", - trigger: "空运释放失败", - text: "工单{{ticket_no}} 空运释放舱位失败。\n操作人:{{operator}}\n释放舱位失败的原因:{{reason}}\n已通知IT运维排查,请稍等。", + trigger: "空运取消舱位失败", + text: "工单{{ticket_no}} 空运取消舱位失败。\n操作人:{{operator}}\n取消舱位失败的原因:{{reason}}\n已通知IT运维排查,请稍等。", enabled: true, }, { id: "brv10000000000000000000000000031", replyCode: "inquiring.air_release_deal_blocked", - trigger: "已成交不能释放舱位", - text: "释放舱位失败,工单{{ticket_no}}已成交。", + trigger: "已成交不能取消舱位", + text: "取消舱位失败,工单{{ticket_no}}已成交。", enabled: true, }, { id: "brv10000000000000000000000000032", replyCode: "inquiring.air_release_need_lock", - trigger: "没有有效锁舱不能释放", - text: "工单{{ticket_no}}还没有有效锁舱,不能释放。", + trigger: "没有有效锁舱不能取消舱位", + text: "工单{{ticket_no}}还没有有效锁舱,不能取消舱位。", enabled: true, } ],