确定运输方式前的多句询价带到所选方式并按该方式抽字段;成交已定后放舱不再推跟进,未成交原因不再被当成新询价。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-03 09:54:33 +08:00
co-authored by Cursor
parent d989154485
commit 0a14b4cc15
18 changed files with 620 additions and 28 deletions
+14 -3
View File
@@ -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"
+83 -11
View File
@@ -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()
+2 -1
View File
@@ -60,7 +60,8 @@ _SEA_EXAMPLES = {
"货量": "20吨",
"整柜或拼柜": "整柜",
"箱型箱量": "1x40HQ",
"贸易条款": "FOB",
# 销售看到的举例是门到门、港到港,不是 FOB。举例行不参与已填判断。
"贸易条款": "门到门、港到港",
"运输分类": "Port to Port",
"报价日期": "2026-09-15",
}
+20 -4
View File
@@ -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,
+27 -6
View File
@@ -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"]: