落地转人工:三次补问或明确接管后出入口,真正转人工后冻结本票并允许新开询价。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-20 16:42:13 +08:00
co-authored by Cursor
parent 26dd5d72a1
commit 7eedf28e8d
23 changed files with 1499 additions and 132 deletions
+224 -16
View File
@@ -54,6 +54,12 @@ class FlowSession:
deal_version: int = 0
collab_ended: bool = False
pending_product_quote: dict[str, Any] = field(default_factory=dict)
clarify_missing_sig: str = ""
clarify_retry: int = 0
# clarify=3 次反问入口;intent=销售要求人来接管。空=没有待点的转人工卡。
handoff_offer: str = ""
handoff_committed: bool = False
virtual_work_order_no: str = ""
def _session_payload(sess: FlowSession) -> dict[str, Any]:
@@ -91,6 +97,11 @@ def _session_from_payload(raw: dict[str, Any]) -> FlowSession:
deal_version=int(raw.get("deal_version") or 0),
collab_ended=bool(raw.get("collab_ended")),
pending_product_quote=dict(raw.get("pending_product_quote") or {}),
clarify_missing_sig=str(raw.get("clarify_missing_sig") or ""),
clarify_retry=int(raw.get("clarify_retry") or 0),
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 ""),
)
@@ -191,8 +202,199 @@ class AirTextInquiryFlow:
no = (sess.work_order_no or "").strip()
if no:
self._by_ticket[no] = sess
vt = (sess.virtual_work_order_no or "").strip()
if vt:
self._by_ticket[vt] = sess
self._persist_bookmark(sess)
def _handoff_state(self, sess: Optional[FlowSession]) -> dict[str, Any]:
"""新建书签时带上转人工计数,避免补问一轮就把 3 次清掉。已冻结的票不当新书签底。"""
from agent.policy.handoff_human import is_handoff_frozen
if sess is None or is_handoff_frozen(sess):
return {}
return {
"clarify_missing_sig": sess.clarify_missing_sig,
"clarify_retry": sess.clarify_retry,
"handoff_offer": sess.handoff_offer,
"handoff_committed": sess.handoff_committed,
"virtual_work_order_no": sess.virtual_work_order_no,
"collab_chat_id": sess.collab_chat_id,
"work_order_no": sess.work_order_no,
"status": sess.status,
"quote": dict(sess.quote or {}),
"tms_quote": dict(sess.tms_quote or {}),
"allowed": sess.allowed,
"invited_userids": sess.invited_userids,
"invited_sales_name": sess.invited_sales_name,
"invited_product_names": sess.invited_product_names,
}
def _block_if_handoff_committed(self, sess: Optional[FlowSession], reply: ReplyFn) -> bool:
"""真正转人工之后这一票不再跟。返回 True 表示已经拦住。"""
from agent.policy.handoff_human import is_handoff_frozen
if sess is None:
return False
if is_handoff_frozen(sess):
no = (sess.work_order_no or sess.virtual_work_order_no or "").strip()
reply(f"工单 {no} 已转人工,本单不再自动跟进。如需新询价请直接说明新的运输需求。")
return True
return False
def _detach_frozen_bookmark(
self, sess: Optional[FlowSession], sender_id: str
) -> Optional[FlowSession]:
"""
真正转人工后丢掉当前私聊书签,好开新询价。
旧单仍留在工单号索引,群里带号仍能对上。
"""
from agent.policy.handoff_human import is_handoff_frozen
if sess is None or not is_handoff_frozen(sess):
return sess
logger.info(
"handoff.detach_frozen sender=%s wo=%s vt=%s",
sender_id,
sess.work_order_no or "-",
sess.virtual_work_order_no or "-",
)
self.drop_current_bookmark(sender_id)
return None
def offer_handoff(self, *, sender_id: str, text: str, reply: ReplyFn) -> str:
"""
路由判定销售明确要求人来接管。运输方式不明则继续补问运输方式。
"""
from agent.llm.extract_text import detect_transport_mode
from agent.policy.handoff_human import is_closed_for_handoff
sess = self.session_of(sender_id)
if sess and is_closed_for_handoff(sess.status):
reply("该工单已结束,不能再转人工。")
return "handoff_closed"
if sess and self._block_if_handoff_committed(sess, reply):
return "handoff_done"
mode = (sess.business_line if sess else "") or detect_transport_mode(text) or ""
mode = (mode or "").upper()
if not mode:
reply(copy.ASK_TRANSPORT)
self._save(
FlowSession(
sender_id=sender_id,
thread_id=sender_id,
phase="need_mode",
facts=dict(sess.facts) if sess else {},
immutable_text=(sess.immutable_text if sess and sess.immutable_text else text),
**self._handoff_state(sess),
)
)
return "need_mode"
if mode == "SEA":
from agent.policy.sea_text_flow import get_sea_text_flow
return get_sea_text_flow()._offer_handoff_now(
sender_id=sender_id, reason="intent", reply=reply, text=text
)
if mode == "LAND":
from agent.policy.land_text_flow import get_land_text_flow
return get_land_text_flow()._offer_handoff_now(
sender_id=sender_id, reason="intent", reply=reply, text=text
)
return self._offer_handoff_now(
sender_id=sender_id, reason="intent", reply=reply, text=text
)
def _offer_handoff_now(
self,
*,
sender_id: str,
reason: str,
reply: ReplyFn,
text: str = "",
sess: Optional[FlowSession] = None,
) -> str:
"""
发出转人工入口。reason=clarify|intent。
点卡/空运群发号之前不冻结。
"""
from agent.policy.handoff_human import (
has_collab_group,
ticket_no_for_handoff_offer,
transport_label,
)
sess = sess or self.session_of(sender_id)
if sess is None:
sess = FlowSession(
sender_id=sender_id,
thread_id=sender_id,
phase="handoff_offer",
business_line=self.bookmark_kind.upper() if self.bookmark_kind != "sea" else "SEA",
immutable_text=text,
)
if self.bookmark_kind == "air":
sess.business_line = "AIR"
elif self.bookmark_kind == "land":
sess.business_line = "LAND"
else:
sess.business_line = "SEA"
line = (sess.business_line or "").upper()
if has_collab_group(sess):
reply(copy.HANDOFF_ALREADY_COLLAB)
return "handoff_already"
why = copy.HANDOFF_CLARIFY_REASON if reason == "clarify" else copy.HANDOFF_INTENT_REASON
if line == "AIR":
no = ticket_no_for_handoff_offer(sess)
reply(copy.handoff_air_hint(work_order_no=no))
sess.handoff_offer = reason
sess.phase = "handoff_offer"
self._save(sess)
return "handoff_offer"
token = (sess.work_order_no or "").strip() or sender_id
reply(
copy.handoff_offer_text(reason=why, transport_mode=transport_label(line)),
copy.handoff_offer_wecom_payload(reason=why, task_token=token),
)
sess.handoff_offer = reason
sess.phase = "handoff_offer"
sess.allowed = (copy.BTN_PULL_COLLAB,)
self._save(sess)
return "handoff_offer"
def _clarify_or_handoff(
self,
*,
sess: Optional[FlowSession],
keys: list[str],
new_sess: FlowSession,
reply: ReplyFn,
clarify_text: str,
) -> str:
"""缺项:第一次不计数;满 3 次先发(3/3)再出转人工入口,否则照常补问。"""
from agent.policy.handoff_human import inherit_pre_create_ticket, note_clarify_round
if sess is not None:
new_sess.clarify_missing_sig = sess.clarify_missing_sig
new_sess.clarify_retry = sess.clarify_retry
new_sess.handoff_offer = sess.handoff_offer
new_sess.handoff_committed = sess.handoff_committed
new_sess.virtual_work_order_no = sess.virtual_work_order_no
inherit_pre_create_ticket(sess, new_sess)
decision = note_clarify_round(new_sess, keys)
reply(copy.with_clarify_progress(clarify_text, new_sess.clarify_retry))
if decision == "handoff":
return self._offer_handoff_now(
sender_id=new_sess.sender_id,
reason="clarify",
reply=reply,
sess=new_sess,
)
self._save(new_sess)
return new_sess.phase or "clarify"
def drop_current_bookmark(self, sender_id: str) -> Optional[FlowSession]:
"""
发图新开:丢掉当前书签,已出号会话仍留在 _by_ticket。
@@ -467,6 +669,7 @@ class AirTextInquiryFlow:
if not (sender_id or "").strip():
return "reject_no_sender"
sess = self.session_for_action(sender_id, inbound_raw, text=text)
sess = self._detach_frozen_bookmark(sess, sender_id)
btn = self.match_button(text, sess)
if btn:
return self.on_button(
@@ -565,30 +768,30 @@ class AirTextInquiryFlow:
tms_ready = assemble_air_query(facts)
facts = dict(tms_ready["facts"])
if not check["ok"] or not tms_ready["ok"]:
# 询价必填或 TMS 查价参数不齐:回显已识别 + 待补充示例,不出询价确认
keys = list(check["missing"] or [])
for key in tms_ready.get("missing") or []:
if key not in keys:
keys.append(key)
reply(
copy.ask_clarify(
new_sess = FlowSession(
sender_id=sender_id,
thread_id=sender_id,
phase="clarify",
business_line="AIR",
facts=facts,
immutable_text=sess.immutable_text if sess and sess.immutable_text else text,
wait_version=(sess.wait_version + 1) if sess else 1,
)
return self._clarify_or_handoff(
sess=sess,
keys=keys,
new_sess=new_sess,
reply=reply,
clarify_text=copy.ask_clarify(
facts=facts,
missing_keys=keys,
transport_mode="空运",
)
),
)
self._save(
FlowSession(
sender_id=sender_id,
thread_id=sender_id,
phase="clarify",
business_line="AIR",
facts=facts,
immutable_text=sess.immutable_text if sess and sess.immutable_text else text,
wait_version=(sess.wait_version + 1) if sess else 1,
)
)
return "clarify"
return self._create_and_quote(
sender_id=sender_id,
@@ -606,6 +809,11 @@ class AirTextInquiryFlow:
reply: ReplyFn,
) -> str:
# 先落已确认字段,避免建单失败后补问轮把毛重/体积弄丢
from agent.policy.handoff_human import void_virtual_no
old = self.session_of(sender_id)
if old:
void_virtual_no(old)
self._save(
FlowSession(
sender_id=sender_id,