确定运输方式前的多句询价带到所选方式并按该方式抽字段;成交已定后放舱不再推跟进,未成交原因不再被当成新询价。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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,不要写成深圳、雅加达。"
|
||||
|
||||
@@ -912,6 +912,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 +1019,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"
|
||||
|
||||
|
||||
|
||||
@@ -66,6 +66,38 @@ class FlowSession:
|
||||
virtual_work_order_no: 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]:
|
||||
"""书签转 JSON。tuple 改 list,方便 HTTP/Worker 共用。"""
|
||||
data = asdict(sess)
|
||||
@@ -512,6 +544,7 @@ class AirTextInquiryFlow:
|
||||
sender_id: str,
|
||||
work_order_no: str,
|
||||
reply: ReplyFn,
|
||||
text: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
按工单号回到当时停点并重出对应卡片。
|
||||
@@ -530,6 +563,9 @@ class AirTextInquiryFlow:
|
||||
keep = None
|
||||
if keep:
|
||||
self._align_resume_phase(keep)
|
||||
closed = self._close_spoken_deal(keep, text, reply)
|
||||
if closed:
|
||||
return closed
|
||||
return self._replay_wait(keep, reply, source="session")
|
||||
getter = getattr(self._ledger, "get_for_agent", None)
|
||||
if not callable(getter):
|
||||
@@ -545,6 +581,9 @@ 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
|
||||
return self._replay_wait(sess, reply, source="ledger")
|
||||
|
||||
def _session_from_ledger(self, *, sender_id: str, view: dict[str, Any]) -> FlowSession:
|
||||
@@ -659,8 +698,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 +813,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 +830,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 +874,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 +1679,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()
|
||||
|
||||
@@ -60,7 +60,8 @@ _SEA_EXAMPLES = {
|
||||
"货量": "20吨",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "1x40HQ",
|
||||
"贸易条款": "FOB",
|
||||
# 销售看到的举例是门到门、港到港,不是 FOB。举例行不参与已填判断。
|
||||
"贸易条款": "门到门、港到港",
|
||||
"运输分类": "Port to Port",
|
||||
"报价日期": "2026-09-15",
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -135,7 +138,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
|
||||
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 +152,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 +193,28 @@ 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"]:
|
||||
|
||||
@@ -516,6 +516,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 +579,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)
|
||||
|
||||
@@ -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,20 @@ def drop_invented_door_trade_terms(text: str, facts: dict[str, str] | None = Non
|
||||
return merged
|
||||
|
||||
|
||||
def apply_spoken_sea_trade_reply(text: str, facts: dict[str, str] | None = None) -> dict[str, str]:
|
||||
"""
|
||||
先丢掉模型冒充的门到门,再收下销售整句回复的门到门或港到港。
|
||||
|
||||
测服 2026-10-03:补问举例是门到门、港到港,销售回「门到门」仍被当成没填,并提示参数提取不全。
|
||||
原话没出现这两个词时,旧草稿里的门到门继续丢掉。返回新 dict。
|
||||
"""
|
||||
merged = drop_invented_door_trade_terms(text, facts)
|
||||
spoken = short_sea_trade_reply(text)
|
||||
if spoken:
|
||||
merged["贸易条款"] = spoken
|
||||
return merged
|
||||
|
||||
|
||||
def sea_class_prompt() -> str:
|
||||
"""缺运输分类时只列两项,不再追问「其他是什么」。"""
|
||||
return "运输分类(必填):请回复 Port to Port 或 其他。"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -230,6 +230,41 @@ 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_missing_wo_asks(self) -> None:
|
||||
flow = AirTextInquiryFlow(MemoryLedger())
|
||||
replies: list[str] = []
|
||||
|
||||
@@ -70,6 +70,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("通关口岸(非必填):皇岗")
|
||||
|
||||
@@ -414,6 +414,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()
|
||||
|
||||
@@ -60,6 +60,17 @@ 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_wo012_trade_port_class_other(self):
|
||||
"""条款写 Port to Port、分类写其它:二者各归各栏,禁止互盖。"""
|
||||
text = (
|
||||
|
||||
@@ -206,6 +206,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 = {
|
||||
@@ -324,6 +331,29 @@ 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 = self.flow.on_text(
|
||||
sender_id="s-door-reply",
|
||||
text="先缺条款",
|
||||
reply=self._reply,
|
||||
injected_facts=first,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.assertEqual(phase, "clarify")
|
||||
self.replies.clear()
|
||||
phase = self.flow.on_text(
|
||||
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_harvest_class_from_reply_then_quotes(self) -> None:
|
||||
first = dict(COMPLETE_SEA)
|
||||
first.pop("运输分类")
|
||||
|
||||
Reference in New Issue
Block a user