diff --git a/inquiry-agent/agent/handlers/air_group_collab.py b/inquiry-agent/agent/handlers/air_group_collab.py index 941e43a..8a1544a 100644 --- a/inquiry-agent/agent/handlers/air_group_collab.py +++ b/inquiry-agent/agent/handlers/air_group_collab.py @@ -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, diff --git a/inquiry-agent/agent/handlers/group_collab.py b/inquiry-agent/agent/handlers/group_collab.py index 10c281f..d05a5da 100644 --- a/inquiry-agent/agent/handlers/group_collab.py +++ b/inquiry-agent/agent/handlers/group_collab.py @@ -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 diff --git a/inquiry-agent/agent/handlers/text_inquiry.py b/inquiry-agent/agent/handlers/text_inquiry.py index d1eb04e..42ab7a6 100644 --- a/inquiry-agent/agent/handlers/text_inquiry.py +++ b/inquiry-agent/agent/handlers/text_inquiry.py @@ -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) diff --git a/inquiry-agent/agent/policy/air_group_ops.py b/inquiry-agent/agent/policy/air_group_ops.py index b39d436..ef99167 100644 --- a/inquiry-agent/agent/policy/air_group_ops.py +++ b/inquiry-agent/agent/policy/air_group_ops.py @@ -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 diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py index fee357a..b774c32 100644 --- a/inquiry-agent/agent/policy/air_text_flow.py +++ b/inquiry-agent/agent/policy/air_text_flow.py @@ -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, diff --git a/inquiry-agent/agent/policy/handoff_human.py b/inquiry-agent/agent/policy/handoff_human.py new file mode 100644 index 0000000..2074cae --- /dev/null +++ b/inquiry-agent/agent/policy/handoff_human.py @@ -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 diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index 9285848..857ecca 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -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() diff --git a/inquiry-agent/agent/policy/land_text_flow.py b/inquiry-agent/agent/policy/land_text_flow.py index 006a592..6c3ee93 100644 --- a/inquiry-agent/agent/policy/land_text_flow.py +++ b/inquiry-agent/agent/policy/land_text_flow.py @@ -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]: """ diff --git a/inquiry-agent/agent/policy/quote_adjust_ops.py b/inquiry-agent/agent/policy/quote_adjust_ops.py index 61b732d..eb03111 100644 --- a/inquiry-agent/agent/policy/quote_adjust_ops.py +++ b/inquiry-agent/agent/policy/quote_adjust_ops.py @@ -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")) diff --git a/inquiry-agent/agent/policy/sea_group_ops.py b/inquiry-agent/agent/policy/sea_group_ops.py index 44443d8..cde643a 100644 --- a/inquiry-agent/agent/policy/sea_group_ops.py +++ b/inquiry-agent/agent/policy/sea_group_ops.py @@ -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" diff --git a/inquiry-agent/agent/policy/sea_text_flow.py b/inquiry-agent/agent/policy/sea_text_flow.py index fec08d9..eca3423 100644 --- a/inquiry-agent/agent/policy/sea_text_flow.py +++ b/inquiry-agent/agent/policy/sea_text_flow.py @@ -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) diff --git a/inquiry-agent/agent/routing/deepseek_a.py b/inquiry-agent/agent/routing/deepseek_a.py index 51471d8..e1d45ae 100644 --- a/inquiry-agent/agent/routing/deepseek_a.py +++ b/inquiry-agent/agent/routing/deepseek_a.py @@ -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/不采用等)也标闲聊。" "禁止因为销售没说指定口令就把续办或调价判成新询价或闲聊。" diff --git a/inquiry-agent/agent/routing/dispatch.py b/inquiry-agent/agent/routing/dispatch.py index 62e3283..b328718 100644 --- a/inquiry-agent/agent/routing/dispatch.py +++ b/inquiry-agent/agent/routing/dispatch.py @@ -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 diff --git a/inquiry-agent/agent/routing/ordered_actions.py b/inquiry-agent/agent/routing/ordered_actions.py index 2e4e1a8..a842854 100644 --- a/inquiry-agent/agent/routing/ordered_actions.py +++ b/inquiry-agent/agent/routing/ordered_actions.py @@ -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", } diff --git a/inquiry-agent/agent/schema/quote_adjust.py b/inquiry-agent/agent/schema/quote_adjust.py index 809c541..9ec043c 100644 --- a/inquiry-agent/agent/schema/quote_adjust.py +++ b/inquiry-agent/agent/schema/quote_adjust.py @@ -16,7 +16,7 @@ from typing import Any, Optional logger = logging.getLogger(__name__) -_CLOSED = frozenset({"已成交", "未成交", "已关闭"}) +_CLOSED = frozenset({"已成交", "未成交", "已关闭", "转人工"}) _QUOTED = frozenset({"已报价", "协商中"}) _PERCENT_UP = re.compile( diff --git a/inquiry-agent/tests/test_handoff_human.py b/inquiry-agent/tests/test_handoff_human.py new file mode 100644 index 0000000..06a3f5e --- /dev/null +++ b/inquiry-agent/tests/test_handoff_human.py @@ -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() diff --git a/inquiry-agent/tests/test_land_dispatch.py b/inquiry-agent/tests/test_land_dispatch.py index 743af12..dd1acec 100644 --- a/inquiry-agent/tests/test_land_dispatch.py +++ b/inquiry-agent/tests/test_land_dispatch.py @@ -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() diff --git a/inquiry-agent/tests/test_sea_text_inquiry.py b/inquiry-agent/tests/test_sea_text_inquiry.py index 63f5aef..8566709 100644 --- a/inquiry-agent/tests/test_sea_text_inquiry.py +++ b/inquiry-agent/tests/test_sea_text_inquiry.py @@ -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( diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/domain/TicketStatuses.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/domain/TicketStatuses.java index 64009c7..ea901d6 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/domain/TicketStatuses.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/domain/TicketStatuses.java @@ -8,7 +8,7 @@ import java.util.Map; import java.util.Set; /** - * 询价工单六态与允许流转规则。 + * 询价工单状态与允许流转规则(含转人工)。 *

* 职责:集中定义状态字面量与 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 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> ALLOWED; static { Map> 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。 * diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java index d6b7219..d0055ca 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java @@ -785,6 +785,11 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl merged = new LinkedHashMap<>(); if (StringUtils.isNotBlank(ticket.getCollabFactsJson())) { JSONObject old = JSON.parseObject(ticket.getCollabFactsJson()); @@ -935,6 +940,12 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl quote = raw instanceof Map ? (Map) raw : new LinkedHashMap<>(); String remark = text(body, "remark"); diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java index b2137e2..29b8dc9 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java @@ -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); diff --git a/inquiry-backend/src/views/inquiry/workOrder/index.vue b/inquiry-backend/src/views/inquiry/workOrder/index.vue index 5815b5a..81cdec2 100644 --- a/inquiry-backend/src/views/inquiry/workOrder/index.vue +++ b/inquiry-backend/src/views/inquiry/workOrder/index.vue @@ -442,6 +442,7 @@ function refresh() { + diff --git a/inquiry-backend/src/views/inquiry/workOrder/workOrder.data.ts b/inquiry-backend/src/views/inquiry/workOrder/workOrder.data.ts index 50b2b8b..5e34972 100644 --- a/inquiry-backend/src/views/inquiry/workOrder/workOrder.data.ts +++ b/inquiry-backend/src/views/inquiry/workOrder/workOrder.data.ts @@ -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 })), }, }, {