Compare commits

...
23 changed files with 1499 additions and 132 deletions
@@ -30,6 +30,7 @@ from agent.policy.air_text_flow import get_air_text_flow
from agent.policy.sea_group_ops import (
capture_group_lost_reason,
group_client_of,
is_handoff_ticket,
send_group_text,
ticket_view,
)
@@ -112,6 +113,12 @@ 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)
if wo:
from agent.policy.handoff_human import try_commit_air_group_handoff
handed = try_commit_air_group_handoff(engine, chat_id=chat_id, work_order_no=wo)
if handed:
return handed
intent = classify_air_group_text(text, injected=injected_intent)
logger.info(
"air_group 文字 chat=%s sender=%s intent=%s wo=%s",
@@ -138,6 +145,9 @@ def handle_air_group(
)
if ticket is None:
return phase
if is_handoff_ticket(ticket):
logger.info("air_group 转人工不再跟 chat=%s", chat_id)
return "handoff_ignore"
if intent == INTENT_ACTIVATE:
return phase
else:
@@ -145,6 +155,9 @@ def handle_air_group(
if ticket is None:
send_group_text(group_client_of(engine), chat_id, copy.air_need_work_order())
return "need_work_order"
if is_handoff_ticket(ticket):
logger.info("air_group 转人工不再跟 chat=%s", chat_id)
return "handoff_ignore"
return _handle_bound(
engine,
message,
@@ -177,6 +190,9 @@ def _handle_file(engine, message: InboundMessage) -> str:
if ticket is None:
send_group_text(group_client_of(engine), chat_id, copy.air_need_work_order())
return "need_work_order"
if is_handoff_ticket(ticket):
logger.info("air_group 转人工不再跟 chat=%s", chat_id)
return "handoff_ignore"
return apply_air_file(
flow=engine,
ticket=ticket,
+9 -1
View File
@@ -22,6 +22,7 @@ from agent.policy.sea_group_ops import (
group_client_of,
handle_group_file,
hydrate_ticket,
is_handoff_ticket,
peek_pending_product_quote,
send_group_text,
ticket_no,
@@ -66,7 +67,7 @@ def handle_group_inbound(
群里一条文字或附件。
文字走 DeepSeek A(单测可注入);附件只看扩展名。
协同已结束或终态要回话,不能当没看见。
已成交/未成交/已关闭要回话。真正转人工后群里不再跟、不回。
"""
if (message.chat_type or "") != "group":
return "group_ignore"
@@ -100,6 +101,13 @@ def handle_group_inbound(
return "group_ignore"
sess = _session_of_ticket(engine, ticket)
if is_handoff_ticket(ticket, sess):
logger.info(
"group_inbound 转人工不再跟 wo=%s chat=%s",
ticket_no(ticket),
chat_id,
)
return "handoff_ignore"
view = ticket_view(ticket)
if view.get("collab_ended") or (sess is not None and getattr(sess, "collab_ended", False)):
from agent.policy import inquiry_copy as copy
+56 -11
View File
@@ -22,9 +22,13 @@ logger = logging.getLogger(__name__)
def _front_bookmark(sess) -> bool:
"""补问/核对手稿优先于已出票旧书签。空的 need_mode 不算(那是丢会话后的误伤)。"""
"""补问/核对手稿优先于已出票旧书签。空的 need_mode 不算(那是丢会话后的误伤)。已转人工的票也不再当当前询价。"""
if sess is None:
return False
from agent.policy.handoff_human import is_handoff_frozen
if is_handoff_frozen(sess):
return False
phase = (sess.phase or "").strip()
line = (sess.business_line or "").upper()
if phase == "need_mode" and not line:
@@ -32,16 +36,41 @@ def _front_bookmark(sess) -> bool:
return phase in {"clarify", "need_mode", "need_land_options", "wait_confirm"}
def should_resume_other_as_inquiry(message: InboundMessage) -> bool:
"""
A 把补问短答标成闲聊时,仍续填当前询价。
只拦还在问字段/选项/核对、未成交原因、转人工入口。
已报价协同中不拦,避免「谢谢」被当成新询价。
"""
if not message.has_sender():
return False
engine = _pick_text_flow(
sender_id=message.sender_id, text=message.content or ""
)
sess = engine.session_for_action(
message.sender_id, message.raw, text=message.content or ""
)
if sess is None:
return False
phase = (sess.phase or "").strip()
if phase in {"wait_lost_reason", "handoff_offer"}:
return True
return _front_bookmark(sess)
def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = "", prefer_quoted: bool = False):
"""
按当前书签或本句运输方式选空运/海运/陆运流程。
陆运核对卡上的确定优先于旧海运书签,避免「确定」被海运/空运吃掉。
A 已判成调价时交给当前已报价工单,避免旧海运书签抢走。
正在补问时,补问书签优先于已报价调价,避免第 3 次反问出不了转人工卡。
A 已判成调价且当前不是补问:交给当前已报价工单,避免旧海运书签抢走。
图片识图在 Worker 写下的补问书签,HTTP 必须从 Redis 读到,否则「托盘」会变成再问运输方式。
"""
from agent.schema.land_options import looks_like_confirm
from agent.policy.quote_adjust_ops import quoted_adjust_flow
from agent.policy.handoff_human import is_handoff_frozen
land = get_land_text_flow()
if looks_like_confirm(text):
@@ -51,21 +80,22 @@ def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = "", prefe
retry = land.restore_tms_retry_session(sender_id)
if retry:
return land
if prefer_quoted:
quoted = quoted_adjust_flow(sender_id)
if quoted is not None:
return quoted
sea = get_sea_text_flow()
air = get_air_text_flow()
sea_sess = sea.session_of(sender_id)
land_sess = land.session_of(sender_id)
air_sess = air.session_of(sender_id)
# 补问/核对手稿优先于「已报价调价」,避免第 3 次反问被调价路由冲掉、出不了转人工卡。
for sess, flow in ((land_sess, land), (air_sess, air), (sea_sess, sea)):
if _front_bookmark(sess):
return flow
if sea_sess and (sea_sess.business_line or "").upper() == "SEA":
if prefer_quoted:
quoted = quoted_adjust_flow(sender_id)
if quoted is not None:
return quoted
if sea_sess and not is_handoff_frozen(sea_sess) and (sea_sess.business_line or "").upper() == "SEA":
return sea
if land_sess and (land_sess.business_line or "").upper() == "LAND":
if land_sess and not is_handoff_frozen(land_sess) and (land_sess.business_line or "").upper() == "LAND":
return land
mode = (injected_mode or detect_transport_mode(text) or "").upper()
if mode == "SEA":
@@ -140,6 +170,7 @@ def handle_text_inquiry(
flow=None,
store=None,
prefer_quoted: bool = False,
prefer_handoff: bool = False,
) -> str:
"""
处理一条私聊文字询价。
@@ -177,6 +208,15 @@ def handle_text_inquiry(
logger.info("text_inquiry 出站 ok=%s info=%s phase_reply=%s", ok, info, body[:80])
return info if ok else ""
if prefer_handoff:
phase = engine.offer_handoff(
sender_id=message.sender_id,
text=message.content or "",
reply=reply,
)
logger.info("text_inquiry handoff phase=%s sender=%s", phase, message.sender_id)
return phase
phase = engine.on_text(
sender_id=message.sender_id,
text=message.content or "",
@@ -194,21 +234,26 @@ def handle_text_inquiry(
def handle_text_other(message: InboundMessage, *, store=None) -> str:
"""
闲聊/其它:若有待点按钮则当按钮;否则回首次单聊话术。
闲聊/其它:补问短答续填;待点按钮当按钮;否则回首次单聊话术。
"""
from agent.policy import inquiry_copy as copy
if not message.has_sender():
return "reject_no_sender"
msg_store = store or get_message_store()
if should_resume_other_as_inquiry(message):
logger.info(
"text_other 续填当前询价 sender=%s chars=%s",
message.sender_id,
len((message.content or "").strip()),
)
return handle_text_inquiry(message, store=msg_store)
engine = _pick_text_flow(
sender_id=message.sender_id, text=message.content or ""
)
sess = engine.session_for_action(
message.sender_id, message.raw, text=message.content or ""
)
if sess and sess.phase == "wait_lost_reason":
return handle_text_inquiry(message, store=msg_store)
btn = engine.match_button(message.content or "", sess)
if btn:
return handle_text_inquiry(message, store=msg_store)
+28 -22
View File
@@ -21,7 +21,9 @@ from agent.policy.sea_group_ops import (
emit_group_deal,
filename_is_excel_pdf,
group_client_of,
halt_closed_group,
hydrate_ticket,
is_handoff_ticket,
is_sales,
is_terminal,
merge_quote_fees,
@@ -35,7 +37,7 @@ from agent.policy.sea_group_ops import (
logger = logging.getLogger(__name__)
TERMINAL = {"已成交", "未成交", "已关闭"}
TERMINAL = {"已成交", "未成交", "已关闭", "转人工"}
OPEN_ACTIVATE = {"询价中", "已报价", "协商中"}
AIRLINE_ROLE = "air"
@@ -114,9 +116,12 @@ def activate_ticket(
send_group_text(client, chat_id, copy.air_ticket_not_air(no))
return None, "not_air"
if err == "terminal":
st = str(out.get("status") or "")
if st == "转人工":
return None, "handoff_ignore"
if speak:
send_group_text(
client, chat_id, copy.air_ticket_closed(no, str(out.get("status") or ""))
client, chat_id, copy.air_ticket_closed(no, st)
)
return None, "ticket_closed"
if err == "occupied":
@@ -128,6 +133,8 @@ def activate_ticket(
send_group_text(client, chat_id, copy.air_ticket_not_found(no))
return None, "ticket_missing"
ticket = hydrate_ticket(flow, _ticket_from_activate(book, no, out))
if is_handoff_ticket(ticket):
return None, "handoff_ignore"
if speak:
send_air_brief_and_sheet(flow=flow, ticket=ticket, chat_id=chat_id)
_watch_archive_room(chat_id, no)
@@ -320,6 +327,8 @@ def resolve_cabin_ticket(
if is_terminal(ticket):
if allow_terminal:
return ticket, "terminal_loaded"
if ticket_status(ticket) == "转人工":
return None, "handoff_ignore"
send_group_text(
client, chat_id, copy.air_ticket_closed(no, ticket_status(ticket))
)
@@ -334,9 +343,9 @@ def apply_air_fields(*, flow: Any, ticket: object, text: str, sender_id: str, ch
ticket = hydrate_ticket(flow, ticket)
no = ticket_no(ticket)
client = group_client_of(flow)
if is_terminal(ticket):
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
return "ticket_locked"
stopped = halt_closed_group(flow=flow, ticket=ticket, chat_id=chat_id)
if stopped:
return stopped
if not can_fill_fields(flow, ticket, sender_id):
send_group_text(client, chat_id, copy.air_fields_denied())
return "fields_denied"
@@ -371,9 +380,9 @@ def apply_air_quote(
ticket = hydrate_ticket(flow, ticket)
no = ticket_no(ticket)
client = group_client_of(flow)
if is_terminal(ticket):
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
return "ticket_locked"
stopped = halt_closed_group(flow=flow, ticket=ticket, chat_id=chat_id)
if stopped:
return stopped
if not is_airline(flow, sender_id):
send_group_text(client, chat_id, copy.air_airline_only_quote())
return "not_airline"
@@ -414,9 +423,11 @@ def apply_air_file(
ticket = hydrate_ticket(flow, ticket)
no = ticket_no(ticket)
client = group_client_of(flow)
if is_terminal(ticket):
send_group_text(client, chat_id, copy.ticket_locked_file(work_order_no=no, status=ticket_status(ticket)))
return "ticket_locked_file"
stopped = halt_closed_group(
flow=flow, ticket=ticket, chat_id=chat_id, file=True
)
if stopped:
return stopped
if not filename_is_excel_pdf(filename):
send_group_text(client, chat_id, copy.group_only_excel_pdf())
return "file_type_rejected"
@@ -463,9 +474,9 @@ def apply_air_tms_choice(
ticket = hydrate_ticket(flow, ticket)
client = group_client_of(flow)
no = ticket_no(ticket)
if is_terminal(ticket):
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
return "ticket_locked"
stopped = halt_closed_group(flow=flow, ticket=ticket, chat_id=chat_id)
if stopped:
return stopped
if not is_airline(flow, sender_id):
send_group_text(client, chat_id, copy.air_airline_only_quote())
return "not_airline"
@@ -536,14 +547,9 @@ def apply_air_deal(
if not is_sales(ticket, sender_id):
send_group_text(group_client_of(flow), chat_id, copy.air_sales_only_deal())
return "not_sales"
if is_terminal(ticket) and ticket_status(ticket) != "协商中":
if ticket_status(ticket) in {"已成交", "未成交", "已关闭"}:
send_group_text(
group_client_of(flow),
chat_id,
copy.ticket_locked(work_order_no=ticket_no(ticket), status=ticket_status(ticket)),
)
return "ticket_locked"
stopped = halt_closed_group(flow=flow, ticket=ticket, chat_id=chat_id)
if stopped:
return stopped
from agent.policy.deal_outcome import finish_deal_outcome
from agent.policy.sea_group_ops import facts_of_group
+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,
+296
View File
@@ -0,0 +1,296 @@
"""
转人工:缺项连问计数、虚拟工单号、入口/冻结判定。
本文件职责:只给流程提供纯函数与轻量序号;不发企微、不改主账六态。
调用:Worker / 私聊流程;禁止回调线程生成号后同步建群。
为何独立:海运/陆运/空运共用同一套计数与号码,避免三份 if。
"""
from __future__ import annotations
import logging
import threading
from datetime import datetime
from typing import Any, Optional
from zoneinfo import ZoneInfo
logger = logging.getLogger(__name__)
_SHANGHAI = ZoneInfo("Asia/Shanghai")
# 新询价第一次补问不计数;之后同一批缺项再问满 3 次仍不齐,第 3 次出入口。
CLARIFY_LIMIT = 3
VIRTUAL_PREFIX = "VT"
# 陆运类型+线路还对不上时,转人工摘要仍列出这一套协同项,全部为「-」。
_LAND_HANDOFF_COLLAB_FALLBACK = (
"客户名称",
"HS编码",
"贸易条款",
"包装方式",
"是否为危险品",
)
_seq_lock = threading.Lock()
_seq_by_day: dict[str, int] = {}
HANDOFF_STATUS = "转人工"
TERMINAL_STATUSES = frozenset({"已成交", "未成交", "已关闭", HANDOFF_STATUS})
FROZEN_STATUSES = frozenset({HANDOFF_STATUS})
def missing_signature(keys: list[str] | tuple[str, ...] | None) -> str:
"""缺项集合签名:顺序无关,用来判断是不是同一批。"""
return "|".join(sorted({str(k).strip() for k in (keys or []) if str(k).strip()}))
def note_clarify_round(sess: Any, missing_keys: list[str] | tuple[str, ...] | None) -> str:
"""
记下本轮缺项。返回 ask(继续补问)或 handoff(该出转人工入口)。
新询价(或缺项集合刚变)的第一次补问不计数、不打(1/3)。
之后同一签名再问:1/3、2/3;第 3 次仍不齐返回 handoff(调用方先发 3/3 再出入口)。
签名变了:计数清零,并作废「3 次反问」那张入口(handoff_offer=clarify)。
副作用:改 sess.clarify_* / 可能清 handoff_offer。
"""
sig = missing_signature(missing_keys)
if not sig:
if getattr(sess, "handoff_offer", "") == "clarify":
sess.handoff_offer = ""
sess.clarify_missing_sig = ""
sess.clarify_retry = 0
return "ask"
prev = str(getattr(sess, "clarify_missing_sig", "") or "")
retry = int(getattr(sess, "clarify_retry", 0) or 0)
if prev == sig:
retry = retry + 1
sess.clarify_retry = retry
if retry >= CLARIFY_LIMIT:
return "handoff"
return "ask"
if str(getattr(sess, "handoff_offer", "") or "") == "clarify":
sess.handoff_offer = ""
sess.clarify_missing_sig = sig
sess.clarify_retry = 0
return "ask"
def next_virtual_no(*, now: datetime | None = None) -> str:
"""
虚拟工单号:VT + 上海日历日 8 位 + 当日 4 位流水。
不占正式 WO。进程内计数;有 Redis 时尽量用 INCR,失败则回退内存。
"""
day = (now or datetime.now(_SHANGHAI)).strftime("%Y%m%d")
seq = _next_seq(day)
return f"{VIRTUAL_PREFIX}{day}{seq:04d}"
def _next_seq(day: str) -> int:
try:
from agent.config import get_settings
from agent.redis_coord.keys import join_key
settings = get_settings()
raw = getattr(settings, "redis_url", "") or ""
prefix = getattr(settings, "redis_key_prefix", "") or "inquiry_robot:"
if raw:
import redis
client = redis.Redis.from_url(raw, socket_timeout=2, socket_connect_timeout=2)
key = join_key(prefix, "vtseq", day)
n = int(client.incr(key))
client.expire(key, 3 * 24 * 3600)
return n
except Exception:
logger.info("虚拟号 Redis 序号不可用,改用进程内计数 day=%s", day)
with _seq_lock:
n = _seq_by_day.get(day, 0) + 1
_seq_by_day[day] = n
return n
def reset_virtual_seq_for_test() -> None:
"""单测重置当日流水。"""
with _seq_lock:
_seq_by_day.clear()
def is_virtual_no(work_order_no: str) -> bool:
raw = (work_order_no or "").strip().upper()
return raw.startswith(VIRTUAL_PREFIX) and len(raw) >= 10
def is_frozen_status(status: str) -> bool:
return (status or "").strip() in FROZEN_STATUSES
def is_handoff_frozen(sess: Any) -> bool:
"""这一票已经真正转人工,私聊不应再把它当当前询价。"""
if sess is None:
return False
if bool(getattr(sess, "handoff_committed", False)):
return True
return is_frozen_status(str(getattr(sess, "status", "") or ""))
def is_closed_for_handoff(status: str) -> bool:
"""已成交/未成交/已关闭不再出转人工入口。转人工本身也不再重复入口。"""
st = (status or "").strip()
return st in {"已成交", "未成交", "已关闭", HANDOFF_STATUS}
def display_ticket_no(sess: Any) -> str:
"""群/卡片展示号:正式号优先,否则虚拟号。"""
return (
str(getattr(sess, "work_order_no", "") or "").strip()
or str(getattr(sess, "virtual_work_order_no", "") or "").strip()
)
def ensure_virtual_no(sess: Any) -> str:
"""还没有正式号时确保有虚拟号。已有正式号不生成。"""
real = str(getattr(sess, "work_order_no", "") or "").strip()
if real:
return real
vt = str(getattr(sess, "virtual_work_order_no", "") or "").strip()
if vt:
return vt
vt = next_virtual_no()
sess.virtual_work_order_no = vt
return vt
# 这些阶段还没为本票建正式单,转人工入口必须用虚拟号。
_PRE_CREATE_PHASES = frozenset(
{
"",
"idle",
"clarify",
"need_mode",
"need_land_options",
"wait_confirm",
"handoff_offer",
}
)
def ticket_no_for_handoff_offer(sess: Any) -> str:
"""
转人工入口上给销售看的号码。
本票还在补问/核对本、没建正式单:给 VT,丢掉会话里挂着的旧 WO。
已经出过正式单(已报价/协同中等):沿用 WO。
"""
phase = str(getattr(sess, "phase", "") or "").strip()
real = str(getattr(sess, "work_order_no", "") or "").strip()
if real and not is_virtual_no(real) and phase not in _PRE_CREATE_PHASES:
return real
if real and not is_virtual_no(real):
sess.work_order_no = ""
return ensure_virtual_no(sess)
def inherit_pre_create_ticket(sess: Any, new_sess: Any) -> None:
"""
同一轮补问可以接着用号;从已出票旧书签开新补问则不带旧 WO/群。
"""
prev = str(getattr(sess, "phase", "") or "").strip()
if prev not in _PRE_CREATE_PHASES:
return
if not str(getattr(new_sess, "work_order_no", "") or "").strip():
new_sess.work_order_no = str(getattr(sess, "work_order_no", "") or "").strip()
if not str(getattr(new_sess, "collab_chat_id", "") or "").strip():
new_sess.collab_chat_id = str(getattr(sess, "collab_chat_id", "") or "").strip()
def void_virtual_no(sess: Any) -> None:
"""正式建单后作废虚拟号。"""
sess.virtual_work_order_no = ""
def has_collab_group(sess: Any) -> bool:
return bool(str(getattr(sess, "collab_chat_id", "") or "").strip())
def transport_label(business_line: str) -> str:
code = (business_line or "").strip().upper()
return {"SEA": "海运", "AIR": "空运", "LAND": "陆运"}.get(code, "")
def try_commit_air_group_handoff(flow: Any, *, chat_id: str, work_order_no: str) -> str:
"""
空运群里出现待转人工的工单号:发转人工摘要并冻结。
对不上或虚拟号已作废:返回空,交给原激活逻辑。
"""
no = (work_order_no or "").strip().upper()
if not no:
return ""
getter = getattr(flow, "session_by_work_order", None)
sess = getter(no) if callable(getter) else None
if sess is None:
return ""
if not sess.handoff_offer or sess.handoff_committed:
return ""
display = display_ticket_no(sess).upper()
if display != no:
return ""
from agent.policy import inquiry_copy as copy
from agent.policy.sea_group_ops import group_client_of, send_group_text
book = getattr(flow, "ledger", None)
airline_names: list[str] = []
lister = getattr(book, "list_staff_by_role", None) if book else None
if callable(lister):
try:
for row in lister(role_code="air") or []:
name = str(row.get("name") or "").strip()
if name:
airline_names.append(name)
except Exception:
logger.exception("空运转人工取航线名单失败")
sales = ""
namer = getattr(flow, "_sales_display_name", None)
if callable(namer):
sales = namer(sess.sender_id)
brief = copy.handoff_group_brief(
work_order_no=display_ticket_no(sess),
facts=sess.facts,
business_line="AIR",
sales_name=sales,
airline_names=airline_names,
)
send_group_text(group_client_of(flow), chat_id, brief)
real = (sess.work_order_no or "").strip()
if real and not is_virtual_no(real):
trans = getattr(book, "transition", None) if book else None
if callable(trans):
trans(
work_order_no=real,
to_status=HANDOFF_STATUS,
remark=copy.HANDOFF_CLARIFY_REASON
if sess.handoff_offer == "clarify"
else copy.HANDOFF_INTENT_REASON,
)
sess.status = HANDOFF_STATUS
sess.handoff_committed = True
sess.handoff_offer = ""
sess.phase = "handoff_done"
sess.collab_chat_id = chat_id
saver = getattr(flow, "_save", None)
if callable(saver):
saver(sess)
return "handoff_done"
def collab_keys_for_handoff(facts: dict[str, str] | None, business_line: str) -> tuple[str, ...]:
"""转人工摘要要列出的协同键;对不上陆运套餐时用兜底五项。"""
from agent.schema.field_validate import SEA_COLLAB_FIELDS, collab_fields_for_ticket
line = (business_line or "").strip().upper()
keys = collab_fields_for_ticket(facts, line)
if keys:
return keys
if line == "LAND":
return _LAND_HANDOFF_COLLAB_FALLBACK
return SEA_COLLAB_FIELDS
+118 -2
View File
@@ -81,7 +81,7 @@ BTN_NEGOTIATE_KEY = "negotiate"
BTN_COPY_TICKET = "复制工单号"
BTN_COPY_TICKET_KEY = "copy_ticket"
_WO_NO = re.compile(r"WO\d{12}", re.IGNORECASE)
_WO_NO = re.compile(r"(?:WO|VT)\d{12}", re.IGNORECASE)
# 仅给无网 stub / 工单号回填用,正式续办意图由 DeepSeek A 判断,不要求销售说这些词。
_CONTINUE_HINT = (
"继续",
@@ -101,6 +101,10 @@ _CONTINUE_HINT = (
ASK_TRANSPORT = "请补充运输方式:海运 / 空运 / 陆运。"
HANDOFF_ALREADY_COLLAB = "该工单已拉产品进群协同。"
HANDOFF_CLARIFY_REASON = "参数提取不全(连续 3 次反问仍不完整)"
HANDOFF_INTENT_REASON = "已识别您希望转人工处理"
CLARIFY_LIMIT_HINT = "连续 3 次反问仍不完整将触发转人工"
# 私聊图片询价:先回识别中;看不清/失败与补问拼成一条,销售侧不加 [test]。
IMAGE_RECOGNIZING = "正在识别图片"
IMAGE_UNREADABLE = "图片没看清询价信息,请换一张更清楚的,或直接打字。"
@@ -268,6 +272,28 @@ def _land_field_line(key: str, value: str, *, required: bool, example: str = "")
return f"{label}({mark}):"
def clarify_progress_line(retry: int) -> str:
"""补问次数提醒:参数提取不全(1/3)。"""
n = int(retry or 0)
if n < 1:
n = 1
if n > 3:
n = 3
return f"参数提取不全({n}/3)。{CLARIFY_LIMIT_HINT}"
def with_clarify_progress(body: str, retry: int) -> str:
"""把次数提醒顶在补问正文前面。第一次补问(retry=0)不打(1/3)。"""
text = (body or "").strip()
n = int(retry or 0)
if n < 1:
return text
head = clarify_progress_line(n)
if not text:
return head
return head + "\n" + text
def ask_land_fields(
*,
facts: dict[str, str] | None = None,
@@ -563,7 +589,7 @@ def canonical_button(raw: str) -> str:
def extract_work_order_no(text: str) -> str:
"""从任意原话里抽出 WO + 12 位数字;没有则空。不判断意图。"""
"""从任意原话里抽出 WO/VT + 12 位数字;没有则空。不判断意图。"""
hit = _WO_NO.search(text or "")
return hit.group(0).upper() if hit else ""
@@ -1221,6 +1247,96 @@ def sea_tms_miss_wecom_payload(*, work_order_no: str, action_url: str = "") -> d
return {"msgtype": "template_card", "template_card": card}
def handoff_air_hint(*, work_order_no: str) -> str:
"""空运转人工:只发文字,不建群。"""
no = (work_order_no or "").strip()
if no:
return f"触发转人工,请手动把航线和智能体拉进原有空运群,并发送工单号 {no}。"
return "触发转人工,请手动把航线和智能体拉进原有空运群,并发送工单号。"
def handoff_offer_text(*, reason: str, transport_mode: str) -> str:
"""海运/陆运转人工卡片的文字兜底(点卡或原样回复按钮均可)。"""
why = (reason or "").strip() or HANDOFF_INTENT_REASON
mode = (transport_mode or "").strip()
lines = [why]
if mode:
lines.append(f"运输方式:{mode}")
lines.append("是否拉产品人员进群协同?")
lines.append(BTN_PULL_COLLAB)
return "\n".join(lines)
def handoff_offer_wecom_payload(*, reason: str, task_token: str) -> dict[str, Any]:
"""海运/陆运一张卡、一个按钮:拉产品进群协同。"""
token = (task_token or "handoff").strip() or "handoff"
why = (reason or "").strip() or HANDOFF_INTENT_REASON
return {
"msgtype": "template_card",
"template_card": {
"card_type": "button_interaction",
"source": {"desc": "转人工", "desc_color": 0},
"main_title": {"title": why, "desc": "是否拉产品人员进群协同?"},
"task_id": card_task_id("handoff", token),
"button_list": [
{
"text": BTN_PULL_COLLAB,
"style": 1,
"key": f"{BTN_PULL_COLLAB_KEY}:{token}",
}
],
},
}
def handoff_group_brief(
*,
work_order_no: str,
facts: dict[str, str] | None,
business_line: str,
product_names: list[str] | None = None,
sales_name: str = "",
airline_names: list[str] | None = None,
) -> str:
"""
转人工群摘要:@人 +「工单号转人工」+ 已整理询价;未整理与协同全部为 -。
不写 TMS、不写模板。
"""
from agent.policy.handoff_human import collab_keys_for_handoff, transport_label
from agent.schema.field_validate import collab_display_name
no = (work_order_no or "").strip()
line = (business_line or "").strip().upper()
mode = transport_label(line) or "海运"
names: list[str] = []
if line == "AIR":
if (sales_name or "").strip():
names.append(sales_name.strip())
for n in airline_names or []:
if (n or "").strip() and n.strip() not in names:
names.append(n.strip())
else:
for n in product_names or []:
if (n or "").strip() and n.strip() not in names:
names.append(n.strip())
at = " ".join(f"@{n}" for n in names)
src = dict(facts or {})
field_lines = inquiry_field_lines(work_order_no=no, facts=src, transport_mode=mode)
body = field_lines[1:] if field_lines else []
lines: list[str] = []
if at:
lines.append(at)
lines.append(f"{no}转人工")
lines.append("询价字段:")
lines.extend(body)
lines.append("")
lines.append("协同补充字段:")
for key in collab_keys_for_handoff(src, line):
label = collab_display_name(key, line)
lines.append(f"{label}:-")
return "\n".join(lines)
def _role_label(name: str, role: str) -> str:
"""姓名前加岗位,避免「销售甲、乙」被读成两人都是销售。已带岗位前缀的不再重复加。"""
text = (name or "").strip()
+53 -29
View File
@@ -68,6 +68,7 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
retry = self.restore_tms_retry_session(sender_id)
if retry:
sess = retry
sess = self._detach_frozen_bookmark(sess, sender_id)
phase = (sess.phase if sess else "") or ""
btn = self.match_button(text, sess)
if btn:
@@ -467,44 +468,67 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
self._clear_parked_confirm(sender_id)
return "need_land_options"
if state["stage"] == "options_missing":
logger.info(
"land.options_missing missing=%s",
(state.get("opt") or {}).get("missing") or [],
)
self._reply_land_options(reply, facts)
self._save(
FlowSession(
sender_id=sender_id,
thread_id=sender_id,
phase="need_land_options",
business_line="LAND",
facts=facts,
immutable_text=immutable,
wait_version=(sess.wait_version + 1) if sess else 1,
)
)
self._clear_parked_confirm(sender_id)
return "need_land_options"
reply(
copy.ask_land_fields(
facts=facts,
missing_keys=state.get("missing") or [],
land_subtype=state.get("land_type") or "",
)
)
self._save(
FlowSession(
keys = list((state.get("opt") or {}).get("missing") or [])
logger.info("land.options_missing missing=%s", keys)
new_sess = FlowSession(
sender_id=sender_id,
thread_id=sender_id,
phase="clarify",
phase="need_land_options",
business_line="LAND",
facts=facts,
immutable_text=immutable,
wait_version=(sess.wait_version + 1) if sess else 1,
)
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)
self._reply_land_options(
reply,
facts,
message=copy.with_clarify_progress(
option_prompt(current=facts), new_sess.clarify_retry
),
)
if decision == "handoff":
self._clear_parked_confirm(sender_id)
return self._offer_handoff_now(
sender_id=sender_id,
reason="clarify",
reply=reply,
sess=new_sess,
)
self._save(new_sess)
self._clear_parked_confirm(sender_id)
return "need_land_options"
new_sess = FlowSession(
sender_id=sender_id,
thread_id=sender_id,
phase="clarify",
business_line="LAND",
facts=facts,
immutable_text=immutable,
wait_version=(sess.wait_version + 1) if sess else 1,
)
phase = self._clarify_or_handoff(
sess=sess,
keys=list(state.get("missing") or []),
new_sess=new_sess,
reply=reply,
clarify_text=copy.ask_land_fields(
facts=facts,
missing_keys=state.get("missing") or [],
land_subtype=state.get("land_type") or "",
),
)
self._clear_parked_confirm(sender_id)
return "clarify"
return phase
def restore_confirm_session(self, sender_id: str) -> Optional[FlowSession]:
"""
@@ -226,9 +226,9 @@ def apply_group_adjust(
_save_product_quote,
emit_group_deal,
group_client_of,
halt_closed_group,
hydrate_ticket,
is_sales,
is_terminal,
send_group_text,
ticket_no,
ticket_status,
@@ -240,9 +240,9 @@ def apply_group_adjust(
view = ticket_view(ticket)
room = (chat_id or str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")).strip()
client = group_client_of(flow)
if is_terminal(ticket):
send_group_text(client, room, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
return "ticket_locked"
stopped = halt_closed_group(flow=flow, ticket=ticket, sess=sess, chat_id=room)
if stopped:
return stopped
status = ticket_status(ticket)
if status not in {"已报价", "协商中"}:
send_group_text(client, room, copy.adjust_ineligible("no_quote"))
+82 -23
View File
@@ -37,7 +37,9 @@ _COLLAB_FILL_ALIASES = {
logger = logging.getLogger(__name__)
TERMINAL = {"已成交", "未成交", "已关闭"}
TERMINAL = {"已成交", "未成交", "已关闭", "转人工"}
CLOSED = {"已成交", "未成交", "已关闭"}
HANDOFF_STATUS = "转人工"
OPEN_EDIT = {"询价中", "已报价", "协商中"}
EXCEL_PDF = {".xlsx", ".xls", ".xlsm", ".pdf"}
@@ -126,6 +128,57 @@ def is_terminal(ticket: object) -> bool:
return ticket_status(ticket) in TERMINAL
def is_handoff_ticket(ticket: object, sess: Any = None) -> bool:
"""这一票已经真正转人工:群里不再跟、也不回锁定话术。"""
if ticket_status(ticket) == HANDOFF_STATUS:
return True
if sess is None:
return False
if str(getattr(sess, "status", "") or "").strip() == HANDOFF_STATUS:
return True
return bool(getattr(sess, "handoff_committed", False))
def halt_closed_group(
*,
flow: Any,
ticket: object,
sess: Any = None,
chat_id: str = "",
file: bool = False,
) -> str:
"""
转人工:静默。已成交/未成交/已关闭:回不能改/不能传附件。
返回空表示可以继续跟。
"""
no = ticket_no(ticket)
room = (chat_id or "").strip() or str(
(sess.collab_chat_id if sess else "")
or ticket_view(ticket).get("collab_chat_id")
or ""
)
if is_handoff_ticket(ticket, sess):
logger.info("group.handoff_ignore wo=%s chat=%s", no or "-", room or "-")
return "handoff_ignore"
st = ticket_status(ticket) or (
str(getattr(sess, "status", "") or "").strip() if sess is not None else ""
)
if st in CLOSED or is_terminal(ticket) or (
sess is not None and str(getattr(sess, "status", "") or "").strip() in CLOSED
):
client = group_client_of(flow)
if file:
send_group_text(
client, room, copy.ticket_locked_file(work_order_no=no, status=st or "已关闭")
)
return "ticket_locked_file"
send_group_text(
client, room, copy.ticket_locked(work_order_no=no, status=st or "已关闭")
)
return "ticket_locked"
return ""
def is_sales(ticket: object, sender_id: str) -> bool:
uid = (sender_id or "").strip()
view = ticket_view(ticket)
@@ -612,9 +665,9 @@ def apply_collab_update(
no = ticket_no(ticket)
chat_id = str((sess.collab_chat_id if sess else "") or ticket_view(ticket).get("collab_chat_id") or "")
client = group_client_of(flow)
if is_terminal(ticket):
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
return "ticket_locked"
stopped = halt_closed_group(flow=flow, ticket=ticket, sess=sess, chat_id=chat_id)
if stopped:
return stopped
line, keys = _collab_keys_of(ticket, sess)
fields = extract_collab_fields(text, allow_b=True, allowed_keys=keys)
if not fields:
@@ -665,9 +718,9 @@ def apply_product_quote(
view = ticket_view(ticket)
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
client = group_client_of(flow)
if is_terminal(ticket):
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
return "ticket_locked"
stopped = halt_closed_group(flow=flow, ticket=ticket, sess=sess, chat_id=chat_id)
if stopped:
return stopped
if not is_product(ticket, sender_id, flow=flow):
send_group_text(client, chat_id, copy.group_only_product_can_quote())
return "quote_not_product"
@@ -728,9 +781,9 @@ def apply_tms_quote_choice(
view = ticket_view(ticket)
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
client = group_client_of(flow)
if is_terminal(ticket):
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
return "ticket_locked"
stopped = halt_closed_group(flow=flow, ticket=ticket, sess=sess, chat_id=chat_id)
if stopped:
return stopped
if not is_product(ticket, sender_id, flow=flow):
send_group_text(client, chat_id, copy.group_only_product_can_quote())
return "quote_not_product"
@@ -777,9 +830,9 @@ def apply_confirm_tms_quote(
view = ticket_view(ticket)
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
client = group_client_of(flow)
if is_terminal(ticket):
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
return "ticket_locked"
stopped = halt_closed_group(flow=flow, ticket=ticket, sess=sess, chat_id=chat_id)
if stopped:
return stopped
if not is_product(ticket, sender_id, flow=flow):
send_group_text(client, chat_id, copy.group_only_product_can_quote())
return "quote_not_product"
@@ -881,9 +934,11 @@ def handle_group_file(
view = ticket_view(ticket)
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
client = group_client_of(flow)
if is_terminal(ticket):
send_group_text(client, chat_id, copy.ticket_locked_file(work_order_no=no, status=ticket_status(ticket)))
return "ticket_locked_file"
stopped = halt_closed_group(
flow=flow, ticket=ticket, sess=sess, chat_id=chat_id, file=True
)
if stopped:
return stopped
if not filename_is_excel_pdf(filename):
send_group_text(client, chat_id, copy.group_only_excel_pdf())
return "file_type_rejected"
@@ -993,10 +1048,9 @@ def apply_group_deal(
view = ticket_view(ticket)
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
client = group_client_of(flow)
if is_terminal(ticket) or (sess is not None and getattr(sess, "status", "") in TERMINAL):
status = ticket_status(ticket) or (sess.status if sess else "")
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=status))
return "ticket_locked"
stopped = halt_closed_group(flow=flow, ticket=ticket, sess=sess, chat_id=chat_id)
if stopped:
return stopped
if not is_sales(ticket, sender_id):
send_group_text(client, chat_id, copy.group_deal_sales_only())
return "not_sales"
@@ -1097,9 +1151,14 @@ def explain_deal_click(
chat_id = (sess.collab_chat_id or "").strip()
client = group_client_of(flow)
no = sess.work_order_no
if sess.status in TERMINAL:
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=sess.status))
return "ticket_locked"
stopped = halt_closed_group(
flow=flow,
ticket={"work_order_no": no, "status": sess.status, "collab_chat_id": chat_id},
sess=sess,
chat_id=chat_id,
)
if stopped:
return stopped
if sender_id != sess.sender_id:
send_group_text(client, chat_id, copy.group_deal_sales_only())
return "not_sales"
+151 -15
View File
@@ -77,6 +77,7 @@ class SeaTextInquiryFlow(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(
@@ -173,25 +174,26 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
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="SEA",
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="SEA",
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,
@@ -211,6 +213,11 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
"""建海运工单、先出确认卡、再查 TMS。"""
import uuid
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,
@@ -423,6 +430,8 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
) -> str:
sess = self.session_for_action(sender_id, card_meta)
if sess and action == copy.BTN_PULL_COLLAB and action in sess.allowed:
if sess.handoff_offer and not sess.handoff_committed:
return self._commit_handoff_pull(sess, reply, card_meta=card_meta)
return self._pull_collab_group(sess, reply, card_meta=card_meta)
if action in {copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE}:
from agent.policy.sea_group_ops import explain_deal_click
@@ -600,6 +609,133 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
)
return "collab_group"
def _match_handoff_staff(self, sess: FlowSession) -> list[dict[str, Any]]:
"""
转人工拉人:线路/港口已齐走原匹配;未齐则拉该运输方式全部启用产品。
"""
line = (sess.business_line or "SEA").upper()
facts = dict(sess.facts or {})
if line == "LAND":
if str(facts.get("线路类别") or "").strip():
return self._match_sea_staff(sess)
lister = getattr(self._ledger, "list_staff_by_role", None)
if not callable(lister):
return []
try:
rows = lister(role_code="land") or []
except Exception:
logger.exception("转人工拉全部陆运产品失败")
return []
return [dict(x) for x in rows if isinstance(x, dict)]
origin = str(facts.get("起运港") or facts.get("起运地") or "").strip()
dest = str(facts.get("目的港") or facts.get("目的地") or "").strip()
if origin or dest:
return self._match_sea_staff(sess)
lister = getattr(self._ledger, "list_staff_by_role", None)
if not callable(lister):
return []
try:
rows = lister(role_code="sea") or []
except Exception:
logger.exception("转人工拉全部海运产品失败")
return []
return [dict(x) for x in rows if isinstance(x, dict)]
def _commit_handoff_pull(
self,
sess: FlowSession,
reply: ReplyFn,
card_meta: Optional[dict[str, Any]] = None,
) -> str:
"""
点转人工「拉产品进群」:建群、发转人工摘要、正式单改「转人工」。
虚拟单不写主账。不发 TMS、不发模板、不跟成交。
"""
from agent.policy.handoff_human import (
HANDOFF_STATUS,
ensure_virtual_no,
is_virtual_no,
)
if sess.collab_ended or (sess.collab_chat_id or "").strip():
reply(copy.HANDOFF_ALREADY_COLLAB)
return "handoff_already"
display_no = ensure_virtual_no(sess) if not (sess.work_order_no or "").strip() else sess.work_order_no
staff = self._match_handoff_staff(sess)
if not staff:
reply(self._group_no_staff_copy())
return sess.phase or "handoff_offer"
userids = [sess.sender_id]
product_names: list[str] = []
product_ids: list[str] = []
for row in staff:
uid = str(row.get("wecomId") or row.get("wecom_id") or "").strip()
name = str(row.get("name") or "").strip() or uid
if not uid:
continue
if uid not in userids:
userids.append(uid)
if uid not in product_ids:
product_ids.append(uid)
product_names.append(name)
if not product_ids:
reply(self._group_no_staff_copy())
return sess.phase or "handoff_offer"
for uid in self._archive_seat_userids():
if uid not in userids:
userids.append(uid)
client = self.group_client()
created = client.create_group(name=display_no, userids=userids) or {}
if not created.get("ok") or not created.get("chat_id"):
reply(copy.sea_group_create_fail(reason=str(created.get("error") or "")))
return sess.phase or "handoff_offer"
chat_id = str(created.get("chat_id") or "").strip()
brief = copy.handoff_group_brief(
work_order_no=display_no,
facts=sess.facts,
business_line=sess.business_line,
product_names=product_names,
)
client.send_group(chat_id=chat_id, content=brief)
real = (sess.work_order_no or "").strip()
if real and not is_virtual_no(real):
trans = getattr(self._ledger, "transition", None)
if callable(trans):
trans(
work_order_no=real,
to_status=HANDOFF_STATUS,
remark=copy.HANDOFF_CLARIFY_REASON
if sess.handoff_offer == "clarify"
else copy.HANDOFF_INTENT_REASON,
)
binder = getattr(self._ledger, "bind_collab_group", None)
if callable(binder):
binder(
work_order_no=real,
chat_id=chat_id,
member_ids=userids,
product_names=product_names,
product_ids=product_ids,
)
sess.status = HANDOFF_STATUS
sess.collab_chat_id = chat_id
sess.handoff_committed = True
sess.handoff_offer = ""
sess.phase = "handoff_done"
sess.allowed = ()
sess.invited_product_names = tuple(product_names)
self._save(sess)
self._grey_clicked_card(reply, card_meta)
sales_name = self._sales_display_name(sess.sender_id)
reply(
copy.sea_group_created(
work_order_no=display_no,
sales_name=sales_name,
product_names=product_names,
)
)
return "handoff_done"
def _match_sea_staff(self, sess: FlowSession) -> list[dict[str, Any]]:
"""主账按起运/目的匹配启用的海运产品岗。"""
matcher = getattr(self._ledger, "match_sea_staff", None)
@@ -70,6 +70,9 @@ def _prepare_model_tool_args(
if intent == "quote_adjust" or action_type == "QUOTE_ADJUST":
data["intent"] = "quote_adjust"
action["action_type"] = "QUOTE_ADJUST"
if intent == "handoff_human" or action_type == "HANDOFF_HUMAN":
data["intent"] = "handoff_human"
action["action_type"] = "HANDOFF_HUMAN"
target = action.get("target") if isinstance(action.get("target"), dict) else {}
wo = str(target.get("work_order_no") or "").strip()
if data.get("intent") == "continue_thread" and not wo:
@@ -101,10 +104,14 @@ _SYSTEM = (
"原话在描述新的运输需求(港口/机场、货物、件数重量体积、空运海运陆运等),"
"即使偶尔拿旧单号当参考,也走新询价,work_order_no 留空。"
"陆运核对卡之后回复确定、确认、查价、可以查了,仍是新询价,禁止标闲聊。"
"机器人正在补问缺项时,销售只回件数、品名、重量、体积等短答(如 111、3件),仍是新询价续填,禁止标闲聊。"
"【调价】intent=quote_adjust,action_type=QUOTE_ADJUST。"
"inbound_facts 里 session_quoted=true 时,销售用自然语言改当前已有报价:"
"涨、跌、加、减、上升、上浮、往上抬、改到某金额、在现有报价基础上调整,口令不写死。"
"只说报价/总价、没点名费用,仍是调价,不是闲聊也不是新询价。"
"【转人工】intent=handoff_human,action_type=HANDOFF_HUMAN。"
"销售明确要求人来报价或接管:转人工、人工报价、找产品/航线、别自动查了、不要机器人了。"
"口令不写死。抱怨慢、骂两句、只问进度不算,那些仍走新询价或闲聊。"
"【闲聊】intent=ordinary_text_other,action_type=ORDINARY_TEXT_OTHER。"
"问候、谢谢、无关闲聊。点卡片按钮字(生成Excel/生成PDF/不采用等)也标闲聊。"
"禁止因为销售没说指定口令就把续办或调价判成新询价或闲聊。"
+15 -1
View File
@@ -16,7 +16,12 @@ from agent.handlers.echo_text import handle_echo_text
from agent.handlers.group_collab import handle_group_collab
from agent.handlers.image_inquiry import handle_image_inquiry
from agent.handlers.attachment_inquiry import handle_attachment_inquiry
from agent.handlers.text_inquiry import handle_text_inquiry, handle_text_other, is_land_confirm_action
from agent.handlers.text_inquiry import (
handle_text_inquiry,
handle_text_other,
is_land_confirm_action,
should_resume_other_as_inquiry,
)
from agent.routing.decision import RouteDecision
logger = logging.getLogger(__name__)
@@ -124,6 +129,7 @@ HANDLER_REGISTRY: dict[str, HandlerFn] = {
"ordinary_text_inquiry": _wrap_text_inquiry,
"ordinary_text_other": _wrap_text_other,
"quote_adjust": _wrap_text_inquiry,
"handoff_human": _wrap_text_inquiry,
"attachment_inquiry": _wrap_attachment,
"image_inquiry": _wrap_image,
"multi_segment_transport": _wrap_new,
@@ -263,10 +269,18 @@ def dispatch_inbound(message: InboundMessage, decision: RouteDecision) -> str:
logger.info("dispatch 陆运核对卡确定:纠正闲聊路由 sender=%s", message.sender_id)
handle_text_inquiry(message)
return "ordinary_text_inquiry"
if decision.intent == "ordinary_text_other" and should_resume_other_as_inquiry(message):
logger.info("dispatch 补问续填:纠正闲聊路由 sender=%s", message.sender_id)
handle_text_inquiry(message)
return "ordinary_text_inquiry"
if decision.intent == "quote_adjust":
logger.info("dispatch 调价意图 sender=%s", message.sender_id)
handle_text_inquiry(message, prefer_quoted=True)
return "quote_adjust"
if decision.intent == "handoff_human":
logger.info("dispatch 转人工意图 sender=%s", message.sender_id)
handle_text_inquiry(message, prefer_handoff=True)
return "handoff_human"
if decision.intent == "continue_thread":
continue_thread.handle_continue_thread(message, thread_id=decision.thread_id)
return decision.intent
@@ -33,6 +33,7 @@ ACTION_INTENTS: dict[str, str] = {
"ORDINARY_TEXT_OTHER": "ordinary_text_other",
"CONTINUE_HISTORICAL_TICKET": "continue_thread",
"QUOTE_ADJUST": "quote_adjust",
"HANDOFF_HUMAN": "handoff_human",
}
+1 -1
View File
@@ -16,7 +16,7 @@ from typing import Any, Optional
logger = logging.getLogger(__name__)
_CLOSED = frozenset({"已成交", "未成交", "已关闭"})
_CLOSED = frozenset({"已成交", "未成交", "已关闭", "转人工"})
_QUOTED = frozenset({"已报价", "协商中"})
_PERCENT_UP = re.compile(
+349
View File
@@ -0,0 +1,349 @@
"""
转人工:缺项连问、虚拟号、摘要骨架。
"""
from __future__ import annotations
import os
import sys
import unittest
from datetime import datetime
from zoneinfo import ZoneInfo
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
from agent.policy import handoff_human as hh
from agent.policy import inquiry_copy as copy
from agent.policy.air_text_flow import FlowSession
class _Sess:
def __init__(self) -> None:
self.clarify_missing_sig = ""
self.clarify_retry = 0
self.handoff_offer = ""
self.work_order_no = ""
self.virtual_work_order_no = ""
class HandoffCoreTests(unittest.TestCase):
def setUp(self) -> None:
hh.reset_virtual_seq_for_test()
def test_same_missing_set_offers_after_three_asks(self) -> None:
sess = _Sess()
keys = ["起运港", "目的港"]
self.assertEqual(hh.note_clarify_round(sess, keys), "ask")
self.assertEqual(sess.clarify_retry, 0)
self.assertEqual(hh.note_clarify_round(sess, keys), "ask")
self.assertEqual(sess.clarify_retry, 1)
self.assertEqual(hh.note_clarify_round(sess, keys), "ask")
self.assertEqual(sess.clarify_retry, 2)
self.assertEqual(hh.note_clarify_round(sess, keys), "handoff")
self.assertEqual(sess.clarify_retry, 3)
def test_changed_missing_resets_and_voids_clarify_offer(self) -> None:
sess = _Sess()
hh.note_clarify_round(sess, ["起运港"])
hh.note_clarify_round(sess, ["起运港"])
sess.handoff_offer = "clarify"
self.assertEqual(hh.note_clarify_round(sess, ["目的港"]), "ask")
self.assertEqual(sess.clarify_retry, 0)
self.assertEqual(sess.handoff_offer, "")
def test_virtual_no_prefix_not_wo(self) -> None:
no = hh.next_virtual_no(now=datetime(2026, 9, 20, tzinfo=ZoneInfo("Asia/Shanghai")))
self.assertTrue(no.startswith("VT20260920"))
self.assertEqual(len(no), 14)
self.assertFalse(no.startswith("WO"))
self.assertTrue(hh.is_virtual_no(no))
self.assertFalse(hh.is_virtual_no("WO202609200001"))
def test_handoff_brief_dashes_collab_and_glues_ticket(self) -> None:
text = copy.handoff_group_brief(
work_order_no="WO202609190018",
facts={
"运输类型": "跨境集拼",
"线路类别": "中港/中亚/中欧",
"运输分类": "普件(东南亚、中亚、中欧涉及境外段清派等特殊要求)",
"起运地": "深圳市",
"目的地": "南京市",
"毛重": "1KG",
"报价日期": "2026-09-19",
},
business_line="LAND",
product_names=["林绵勋", "罗小华", "吴季林"],
)
self.assertIn("@林绵勋 @罗小华 @吴季林", text)
self.assertIn("WO202609190018转人工", text)
self.assertNotIn("工单号:", text.split("转人工")[0][-6:])
self.assertIn("货物品名:-", text)
self.assertIn("客户名称:-", text)
self.assertNotIn("TMS报价", text)
def test_already_collab_copy(self) -> None:
self.assertEqual(copy.HANDOFF_ALREADY_COLLAB, "该工单已拉产品进群协同。")
def test_air_hint_contains_virtual(self) -> None:
text = copy.handoff_air_hint(work_order_no="VT202609200001")
self.assertEqual(
text,
"触发转人工,请手动把航线和智能体拉进原有空运群,并发送工单号 VT202609200001。",
)
self.assertNotIn("拉产品进群", text)
def test_clarify_progress_and_stale_wo(self) -> None:
self.assertTrue(copy.clarify_progress_line(1).startswith("参数提取不全(1/3)"))
self.assertEqual(copy.with_clarify_progress("请确认并补充以下信息:", 0), "请确认并补充以下信息:")
wrapped = copy.with_clarify_progress("请确认并补充以下信息:", 2)
self.assertTrue(wrapped.startswith("参数提取不全(2/3)"))
self.assertIn("请确认并补充以下信息", wrapped)
sess = _Sess()
sess.work_order_no = "WO202609180018"
sess.phase = "clarify"
no = hh.ticket_no_for_handoff_offer(sess)
self.assertTrue(str(no).startswith("VT"))
self.assertEqual(sess.work_order_no, "")
kept = _Sess()
kept.work_order_no = "WO202609180018"
kept.phase = "wait_collab"
self.assertEqual(hh.ticket_no_for_handoff_offer(kept), "WO202609180018")
class HandoffFlowOfferTests(unittest.TestCase):
"""海运:首次补问不计数,之后连问三次仍不齐出卡;点卡前仍可继续补字段。"""
def setUp(self) -> None:
hh.reset_virtual_seq_for_test()
from agent.policy.sea_text_flow import reset_sea_text_flow_for_test
self.flow = reset_sea_text_flow_for_test()
self.replies: list[str] = []
def reply(text: str, extra=None):
self.replies.append(text)
return "out"
self.reply = reply
def _partial(self, text: str) -> str:
return self.flow.on_text(sender_id="s1", text=text, reply=self.reply)
def test_three_same_clarify_then_card(self) -> None:
self._partial("海运询价")
self._partial("还是那些")
self._partial("不知道")
phase = self._partial("还没有")
self.assertEqual(phase, "handoff_offer")
joined = "\n".join(self.replies)
self.assertIn("参数提取不全(3/3)", joined)
self.assertIn(copy.BTN_PULL_COLLAB, joined)
idx3 = next(i for i, t in enumerate(self.replies) if "参数提取不全(3/3)" in t)
self.assertLess(idx3 + 1, len(self.replies))
self.assertIn(copy.BTN_PULL_COLLAB, self.replies[idx3 + 1])
sess = self.flow.session_of("s1")
self.assertEqual(sess.handoff_offer, "clarify")
self.assertIn(copy.BTN_PULL_COLLAB, sess.allowed)
def test_click_pull_sends_handoff_brief_without_tms(self) -> None:
class FakeGroup:
def __init__(self) -> None:
self.created: list[dict] = []
self.sent: list[dict] = []
def create_group(self, *, name: str, userids: list[str]) -> dict:
self.created.append({"name": name, "userids": list(userids)})
return {"ok": True, "chat_id": "wr_h1", "error": ""}
def send_group(self, *, chat_id: str, content: str, mention_userids=None) -> dict:
self.sent.append({"chat_id": chat_id, "content": content})
return {"ok": True}
def send_group_file(self, **kwargs) -> dict:
return {"ok": True}
groups = FakeGroup()
self.flow._group_client = groups
self.flow.ledger.set_sea_staff(
[
{
"name": "林绵勋",
"wecomId": "prod1",
"roleCode": "sea",
"routes": "海运·全部线路",
"status": "active",
}
]
)
for text in ("海运询价", "还是那些", "不知道", "还没有"):
self._partial(text)
phase = self.flow.on_button(
sender_id="s1",
action=copy.BTN_PULL_COLLAB,
reply=self.reply,
)
self.assertEqual(phase, "handoff_done")
self.assertTrue(groups.created)
self.assertTrue(groups.created[0]["name"].startswith("VT"))
brief = groups.sent[0]["content"]
self.assertIn("转人工", brief)
self.assertNotIn("TMS报价", brief)
sess = self.flow.session_of("s1")
self.assertTrue(sess.handoff_committed)
self.assertTrue(sess.work_order_no == "" or sess.status == "转人工")
def test_new_inquiry_after_commit_starts_fresh(self) -> None:
class FakeGroup:
def create_group(self, *, name: str, userids: list[str]) -> dict:
return {"ok": True, "chat_id": "wr_h1", "error": ""}
def send_group(self, *, chat_id: str, content: str, mention_userids=None) -> dict:
return {"ok": True}
def send_group_file(self, **kwargs) -> dict:
return {"ok": True}
self.flow._group_client = FakeGroup()
self.flow.ledger.set_sea_staff(
[
{
"name": "林绵勋",
"wecomId": "prod1",
"roleCode": "sea",
"routes": "海运·全部线路",
"status": "active",
}
]
)
for text in ("海运询价", "还是那些", "不知道", "还没有"):
self._partial(text)
self.flow.on_button(
sender_id="s1",
action=copy.BTN_PULL_COLLAB,
reply=self.reply,
)
self.replies.clear()
phase = self._partial("海运询价")
self.assertNotEqual(phase, "handoff_done")
joined = "\n".join(self.replies)
self.assertNotIn("已转人工,本单不再自动跟进", joined)
self.assertIn("请确认并补充以下信息", joined)
sess = self.flow.session_of("s1")
self.assertFalse(sess.handoff_committed)
self.assertEqual(sess.phase, "clarify")
def test_already_has_group_only_prompts(self) -> None:
self.flow._save(
FlowSession(
sender_id="s1",
thread_id="s1",
phase="collab_group",
business_line="SEA",
work_order_no="WO202609200099",
collab_chat_id="wr_exist",
status="已报价",
)
)
phase = self.flow.offer_handoff(sender_id="s1", text="转人工", reply=self.reply)
self.assertEqual(phase, "handoff_already")
self.assertEqual(self.replies[-1], copy.HANDOFF_ALREADY_COLLAB)
self.assertFalse(self.flow.session_of("s1").handoff_committed)
def test_route_contract_accepts_handoff_human(self) -> None:
from agent.routing.deepseek_a import DeepSeekARouter
from agent.routing.ordered_actions import ACTION_INTENTS, parse_ordered_actions_arguments
self.assertEqual(ACTION_INTENTS.get("HANDOFF_HUMAN"), "handoff_human")
parsed = parse_ordered_actions_arguments(
{
"decision_id": "d-h",
"intent": "handoff_human",
"ordered_action": {
"action_type": "HANDOFF_HUMAN",
"order": 1,
"target": {"work_order_no": ""},
"evidence": {"source_ref": "inbound:u1", "content_digest": "a" * 64},
},
}
)
self.assertEqual(parsed.to_route_decision().intent, "handoff_human")
router = DeepSeekARouter.from_settings()
sys_msg, _user = router.build_messages(text="帮我转人工", sender_id="u1")
self.assertIn("handoff_human", sys_msg["content"])
self.assertIn("口令不写死", sys_msg["content"])
self.assertIn("抱怨慢", sys_msg["content"])
def test_first_clarify_does_not_count(self) -> None:
self._partial("海运询价")
self.assertTrue(self.replies)
self.assertNotIn("参数提取不全(1/3)", self.replies[0])
self.assertIn("请确认并补充以下信息", self.replies[0])
self._partial("还是那些")
self.assertTrue(self.replies[-1].startswith("参数提取不全(1/3)"))
def test_air_handoff_before_create_uses_virtual_not_old_wo(self) -> None:
from agent.policy.air_text_flow import reset_air_text_flow_for_test
flow = reset_air_text_flow_for_test()
replies: list[str] = []
sess = FlowSession(
sender_id="air1",
thread_id="air1",
phase="clarify",
business_line="AIR",
work_order_no="WO202609180018",
facts={"起运港": "ZUH", "目的港": "CRK", "品名": "衣服"},
)
flow._save(sess)
phase = flow._offer_handoff_now(
sender_id="air1",
reason="clarify",
reply=lambda t, extra=None: replies.append(t) or "out",
sess=sess,
)
self.assertEqual(phase, "handoff_offer")
self.assertTrue(replies)
self.assertIn("VT", replies[0])
self.assertNotIn("WO202609180018", replies[0])
self.assertTrue((sess.virtual_work_order_no or "").startswith("VT"))
self.assertEqual(sess.work_order_no, "")
def test_third_clarify_sends_card_even_if_routed_as_adjust(self) -> None:
from agent.channel.queue import get_message_store
from agent.channel.wecom.models import InboundMessage
from agent.handlers.text_inquiry import handle_text_inquiry
from agent.routing.decision import RouteDecision
from agent.routing.dispatch import dispatch_inbound
store = get_message_store()
handle_text_inquiry(
InboundMessage(sender_id="s1", message_id="h1", content="海运询价"),
store=store,
flow=self.flow,
)
handle_text_inquiry(
InboundMessage(sender_id="s1", message_id="h2", content="还是那些"),
store=store,
flow=self.flow,
)
handle_text_inquiry(
InboundMessage(sender_id="s1", message_id="h3", content="不知道"),
store=store,
flow=self.flow,
)
name = dispatch_inbound(
InboundMessage(sender_id="s1", message_id="h4", content="1"),
RouteDecision(intent="quote_adjust"),
)
self.assertEqual(name, "quote_adjust")
sess = self.flow.session_of("s1")
self.assertEqual(sess.phase, "handoff_offer")
bodies = "\n".join(i.content for i in store._outbox.values())
self.assertIn("参数提取不全(3/3)", bodies)
self.assertIn(copy.BTN_PULL_COLLAB, bodies)
if __name__ == "__main__":
unittest.main()
+29
View File
@@ -323,6 +323,35 @@ class LandDispatchTests(unittest.TestCase):
self.assertNotIn("你好,我是询价机器人", last)
self.assertIn("请说明要调哪一项费用", last)
def test_clarify_short_reply_misrouted_as_other_stays_on_inquiry(self) -> None:
"""补问阶段回「111」即使被标闲聊,也不能发开场白。"""
store = get_message_store()
facts = {
"起运港": "广州",
"目的港": "深圳",
"运输类型": "国内运输拼车",
"线路类别": "国内长途/零担",
"运输分类": "急件",
}
phase = handle_text_inquiry(
InboundMessage(sender_id="ld-111", message_id="m-111a", content="陆运广州到深圳"),
injected_facts=facts,
injected_mode="LAND",
store=store,
)
self.assertEqual(phase, "clarify")
name = dispatch_inbound(
InboundMessage(sender_id="ld-111", message_id="m-111b", content="111"),
RouteDecision(intent="ordinary_text_other"),
)
self.assertEqual(name, "ordinary_text_inquiry")
sess = get_land_text_flow().session_of("ld-111")
self.assertIsNotNone(sess)
self.assertNotEqual(sess.phase, "")
last = list(store._outbox.values())[-1].content
self.assertNotIn("你好,我是询价机器人", last)
self.assertNotIn("请直接描述客户需求", last)
if __name__ == "__main__":
unittest.main()
@@ -1304,6 +1304,34 @@ class SeaGroupOpsTests(unittest.TestCase):
self.assertTrue(any("不能再上传附件" in t for t in texts))
self.assertFalse(self.ledger.get_ticket(work_order_no=wo).attachments)
def test_handoff_group_stays_silent(self) -> None:
from agent.routing.group_intent import INTENT_FIELDS
wo, chat = self._open_group()
self.ledger.transition(work_order_no=wo, to_status="转人工")
sess = self.flow.session_of("sales1")
sess.status = "转人工"
sess.handoff_committed = True
self.flow._save(sess)
before = len(self.groups.sent)
phase = handle_group_collab(
InboundMessage(
sender_id="prod4",
message_id="h1",
content="客户名称张三",
chat_id=chat,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
injected_intent=INTENT_FIELDS,
)
self.assertEqual(phase, "handoff_ignore")
after = [x.get("content") or "" for x in self.groups.sent[before:] if x.get("kind") == "text"]
self.assertFalse(any("不能修改工单信息" in t for t in after))
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertNotIn("客户名称", ticket.collab_facts or {})
def test_dissolve_then_cannot_recreate(self) -> None:
wo, chat = self._open_group()
phase = handle_group_collab(
@@ -8,7 +8,7 @@ import java.util.Map;
import java.util.Set;
/**
* 询价工单六态与允许流转规则。
* 询价工单状态与允许流转规则(含转人工)。
* <p>
* 职责:集中定义状态字面量与 transition 合法性,避免 Controller/Service 散落魔法字符串。
* 副作用:无;纯内存规则。线程安全(不可变 Map)。
@@ -22,24 +22,26 @@ public final class TicketStatuses {
public static final String DEAL = "已成交";
public static final String LOST = "未成交";
public static final String CLOSED = "已关闭";
public static final String HANDOFF = "转人工";
private static final Set<String> ALL = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
INQUIRING, QUOTED, NEGOTIATING, DEAL, LOST, CLOSED
INQUIRING, QUOTED, NEGOTIATING, DEAL, LOST, CLOSED, HANDOFF
)));
/**
* from -> 允许的 to 集合。
* 终态(已成交/未成交/已关闭)默认不可再流转,除非显式「关闭」从非终态进入已关闭。
* 终态:已成交/未成交/已关闭不可再流转;转人工只能再关单。
* 协商中允许再进协商中:只顺延再问,不重开询价。
*/
private static final Map<String, Set<String>> ALLOWED;
static {
Map<String, Set<String>> m = new HashMap<>();
m.put(INQUIRING, setOf(QUOTED, NEGOTIATING, CLOSED));
m.put(QUOTED, setOf(NEGOTIATING, DEAL, LOST, CLOSED));
m.put(INQUIRING, setOf(QUOTED, NEGOTIATING, CLOSED, HANDOFF));
m.put(QUOTED, setOf(NEGOTIATING, DEAL, LOST, CLOSED, HANDOFF));
// 协商中可再选协商中:顺延下一轮再问,次数不限。
m.put(NEGOTIATING, setOf(QUOTED, DEAL, LOST, CLOSED, NEGOTIATING));
m.put(NEGOTIATING, setOf(QUOTED, DEAL, LOST, CLOSED, NEGOTIATING, HANDOFF));
m.put(HANDOFF, setOf(CLOSED));
m.put(DEAL, Collections.emptySet());
m.put(LOST, Collections.emptySet());
m.put(CLOSED, Collections.emptySet());
@@ -53,6 +55,13 @@ public final class TicketStatuses {
return status != null && ALL.contains(status);
}
/**
* 转人工后询价字段与报价不再更新。
*/
public static boolean isFrozen(String status) {
return HANDOFF.equals(status);
}
/**
* 校验是否允许从 from 转到 to。
*
@@ -785,6 +785,11 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
out.put("error", "ticket_not_found");
return out;
}
if (TicketStatuses.isFrozen(ticket.getStatus())) {
out.put("ok", false);
out.put("error", "handoff_frozen");
return out;
}
Map<String, String> merged = new LinkedHashMap<>();
if (StringUtils.isNotBlank(ticket.getCollabFactsJson())) {
JSONObject old = JSON.parseObject(ticket.getCollabFactsJson());
@@ -935,6 +940,12 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
out.put("error", "ticket_not_found");
return out;
}
if (TicketStatuses.isFrozen(ticket.getStatus())) {
out.put("ok", false);
out.put("error", "handoff_frozen");
out.put("status", ticket.getStatus());
return out;
}
Object raw = body == null ? null : body.get("quote");
Map<String, Object> quote = raw instanceof Map ? (Map<String, Object>) raw : new LinkedHashMap<>();
String remark = text(body, "remark");
@@ -215,6 +215,10 @@ public class InquiryTmsQueryService {
if (ticket == null) {
return;
}
if (TicketStatuses.isFrozen(ticket.getStatus())) {
log.info("tms.quote skip frozen wo={} status={}", workOrderNo, ticket.getStatus());
return;
}
InquiryQuote row = new InquiryQuote();
row.setTicketId(ticket.getId());
row.setWorkOrderNo(workOrderNo);
@@ -442,6 +442,7 @@ function refresh() {
<option value="询价中">询价中</option>
<option value="已报价">已报价</option>
<option value="协商中">协商中</option>
<option value="转人工">转人工</option>
<option value="已成交">已成交</option>
<option value="未成交">未成交</option>
<option value="已关闭">已关闭</option>
@@ -22,7 +22,7 @@ export const searchFormSchema: FormSchema[] = [
component: 'Select',
colProps: { span: 5 },
componentProps: {
options: ['询价中', '已报价', '协商中', '已成交', '未成交', '已关闭'].map((v) => ({ label: v, value: v })),
options: ['询价中', '已报价', '协商中', '转人工', '已成交', '未成交', '已关闭'].map((v) => ({ label: v, value: v })),
},
},
{