""" 意图:普通文字询价(空运 / 海运 / 陆运 / 多段联运)。 本文件职责:入站文字 → 流程编排;回复只写入 outbox。 禁止:在本文件抽字段语义(交给 extract);禁止直连 TMS;禁止回调线程外的额外阻塞。 """ from __future__ import annotations import logging from typing import Any, Optional from agent.channel.queue import get_message_store from agent.channel.wecom.models import InboundMessage from agent.config import get_settings from agent.llm.extract_text import detect_transport_mode from agent.policy.air_text_flow import get_air_text_flow from agent.policy.land_text_flow import get_land_text_flow from agent.policy.sea_text_flow import get_sea_text_flow logger = logging.getLogger(__name__) def _front_bookmark(sess) -> bool: """补问/核对手稿优先于已出票旧书签。空的 need_mode 不算(那是丢会话后的误伤)。已转人工的票也不再当当前询价。""" if sess is None: return False from agent.policy.handoff_human import is_handoff_frozen 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: return False return phase in {"clarify", "need_mode", "need_land_options", "wait_confirm"} def _drop_other_mode_drafts(*, owner: str, sender_id: str) -> None: """ 本句已点名空运/海运/陆运:丢掉其它方式未出号的补问/选项草稿。 测服复现:旧陆运「选项未齐」书签还在,海运补「贸易条款 / 运输分类」会被陆运抢走, 回「参数提取不全」并发送填写样例图。已出号会话留在工单号索引,不当当前草稿。 """ kind = (owner or "").upper() sid = (sender_id or "").strip() if not sid or kind not in {"SEA", "AIR", "LAND", "MULTI"}: return land = get_land_text_flow() air = get_air_text_flow() sea = get_sea_text_flow() others = [] if kind != "LAND": others.append(land) if kind != "AIR": others.append(air) if kind != "SEA": others.append(sea) for flow in others: sess = flow.session_of(sid) phase = (getattr(sess, "phase", None) or "").strip() if sess else "" if _front_bookmark(sess) or phase == "need_mode": flow.drop_current_bookmark(sid) logger.info( "text_inquiry 丢掉其它方式草稿 owner=%s dropped_phase=%s sender=%s", kind, phase or "-", sid, ) if kind != "LAND": clearer = getattr(land, "_clear_parked_confirm", None) if callable(clearer): clearer(sid) 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): """ 按当前书签或本句运输方式选空运/海运/陆运流程。 陆运核对卡上的「确定」优先于旧海运书签,避免「确定」被海运/空运吃掉。 本句点名海运/空运/陆运时盖掉其它方式的旧补问草稿。 核对卡优先于选项补问;海运/空运补问优先于旧陆运选项草稿,避免补贸易条款发陆运样例图。 正在补问时,补问书签优先于已报价调价,避免第 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 from agent.policy.multi_segments import classify_multi_text from agent.policy.multi_text_flow import get_multi_text_flow multi = get_multi_text_flow() # 多段核对上的「确定」优先于陆运旧核对,避免建出海运/陆运单段工单。 if looks_like_confirm(text): multi_hit = multi.session_of(sender_id) if ( multi_hit and (multi_hit.business_line or "").upper() == "MULTI" and (multi_hit.phase or "") == "wait_confirm" ): return multi land = get_land_text_flow() if looks_like_confirm(text): restored = land.restore_confirm_session(sender_id) if restored: return land retry = land.restore_tms_retry_session(sender_id) if retry: return land sea = get_sea_text_flow() air = get_air_text_flow() spoken = (injected_mode or detect_transport_mode(text) or "").upper() multi_kind = classify_multi_text(text) if not injected_mode else "single" if multi_kind in {"multi", "same_mode"}: _drop_other_mode_drafts(owner="MULTI", sender_id=sender_id) return multi # 本句点名运输方式:盖掉其它方式的旧补问,避免海运补字段走陆运样例图。 # 多段正在补问时例外:销售会写「段2:运输方式:空运」,不能把多段草稿丢掉改走单段。 # 已出号等拉群不算补问:再发陆运附件要走陆运,不能粘在旧工单的手动拉群提示上。 if spoken in {"SEA", "AIR", "LAND"}: multi_draft = multi.session_of(sender_id) if ( multi_draft and (multi_draft.business_line or "").upper() == "MULTI" and (multi_draft.phase or "") == "clarify" ): return multi _drop_other_mode_drafts(owner=spoken, sender_id=sender_id) stale_handoff = (multi_draft.phase or "") in { "wait_collab", "wait_manual_group", "collab_group", } if multi_draft else False if _front_bookmark(multi_draft) or stale_handoff: multi.drop_current_bookmark(sender_id) if spoken == "SEA": return sea if spoken == "LAND": return land return air multi_sess = multi.session_of(sender_id) if ( multi_sess and (multi_sess.business_line or "").upper() == "MULTI" and (multi_sess.phase or "") in {"clarify", "wait_confirm"} and not (looks_like_confirm(text) and (multi_sess.phase or "") != "wait_confirm") ): return multi sea_sess = sea.session_of(sender_id) land_sess = land.session_of(sender_id) air_sess = air.session_of(sender_id) # 核对卡仍优先:销售正在改体积/点确定,不能被另一方式的补问草稿抢走。 for sess, flow in ((land_sess, land), (air_sess, air), (sea_sess, sea)): if sess is not None and (sess.phase or "").strip() == "wait_confirm" and not is_handoff_frozen(sess): return flow # 补问书签:海运/空运优先于旧陆运选项草稿。陆运「确定」已在上面拦过。 for sess, flow in ((sea_sess, sea), (air_sess, air), (land_sess, land)): if _front_bookmark(sess): return flow 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 not is_handoff_frozen(land_sess) and (land_sess.business_line or "").upper() == "LAND": return land return air def is_pending_land_confirm(message: InboundMessage) -> bool: """ 陆运核对卡已出、本句是确定查价。 测服实测「确定」两字会被 DeepSeek A 判成闲聊,必须在分发层拦住, 不能回首次问候、不能跳过建单。 """ if not message.has_sender(): return False from agent.schema.land_options import looks_like_confirm if not looks_like_confirm(message.content or ""): return False sess = get_land_text_flow().restore_confirm_session(message.sender_id) ok = bool( sess and (sess.business_line or "").upper() == "LAND" and sess.phase == "wait_confirm" ) if not ok: logger.info( "land.confirm miss sender=%s phase=%s line=%s", message.sender_id, getattr(sess, "phase", "-"), getattr(sess, "business_line", "-"), ) return ok def is_pending_land_tms_retry(message: InboundMessage) -> bool: """ 陆运工单已建、上次查价失败,本句是确定重查。 不能回首次问候、不能再开一张新工单。 """ if not message.has_sender(): return False from agent.schema.land_options import looks_like_confirm if not looks_like_confirm(message.content or ""): return False sess = get_land_text_flow().restore_tms_retry_session(message.sender_id) return bool( sess and (sess.business_line or "").upper() == "LAND" and sess.phase == "tms_tech" and (sess.work_order_no or "").strip() ) def is_land_confirm_action(message: InboundMessage) -> bool: """核对卡确定建单,或查价失败后确定重查。""" return is_pending_land_confirm(message) or is_pending_land_tms_retry(message) def handle_text_inquiry( message: InboundMessage, *, injected_facts: Optional[dict[str, Any]] = None, injected_mode: str = "", b_result: Optional[dict[str, Any]] = None, chat_fn=None, flow=None, store=None, prefer_quoted: bool = False, prefer_handoff: bool = False, ) -> str: """ 处理一条私聊文字询价。 prefer_quoted:A 已判成调价时交给当前已报价工单。 须已有 sender_id。副作用:outbox 多条回复;主账经 flow.ledger。 返回:流程阶段名。 """ if not message.has_sender(): logger.warning("text_inquiry 拒绝:无 sender_id") return "reject_no_sender" from agent.handlers.image_inquiry import note_private_text note_private_text(message.sender_id, message.content or "") settings = get_settings() msg_store = store or get_message_store() engine = flow or _pick_text_flow( sender_id=message.sender_id, text=message.content or "", injected_mode=injected_mode, prefer_quoted=prefer_quoted, ) replies: list[str] = [] def reply(text: str, extra: dict | None = None) -> str: # 销售询价正文不加 [test];[test] 只给运维告警 body = text replies.append(body) ok, info = msg_store.enqueue_outbound( touser=message.sender_id, content=body, dedupe_key=f"inq:{message.message_id}:{len(replies)}", payload=dict(extra or {}), ) logger.info("text_inquiry 出站 ok=%s info=%s phase_reply=%s", ok, info, body[:80]) return info if ok else "" from agent.policy.system_exception import ( extract_work_order_nos, reply_paused_ticket, ticket_is_paused, ) ledger = getattr(engine, "_ledger", None) for no in extract_work_order_nos(message.content or ""): ticket = None if ledger is not None: getter = getattr(ledger, "get_ticket", None) if callable(getter): ticket = getter(work_order_no=no) if ticket is None: getter = getattr(ledger, "get_for_agent", None) if callable(getter): ticket = getter(work_order_no=no, sender_id=message.sender_id) if ticket_is_paused(ticket): reply_paused_ticket(ticket=ticket, reply=reply) logger.info("text_inquiry 暂停工单只回兜底 wo=%s sender=%s", no, message.sender_id) return "system_exception_paused" 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 "", reply=reply, injected_facts=injected_facts, injected_mode=injected_mode, b_result=b_result, allow_b=bool(settings.llm_allow_network and settings.llm_data_usage_confirmed), chat_fn=chat_fn, inbound_raw=message.raw, ) logger.info("text_inquiry phase=%s sender=%s", phase, message.sender_id) return phase 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 "" ) btn = engine.match_button(message.content or "", sess) if btn: return handle_text_inquiry(message, store=msg_store) if is_land_confirm_action(message): logger.info("text_other 陆运确定/重查,改走询价流程 sender=%s", message.sender_id) return handle_text_inquiry(message, store=msg_store) from agent.schema.land_options import looks_like_confirm if looks_like_confirm(message.content or ""): body = copy.LAND_CONFIRM_EXPIRED msg_store.enqueue_outbound( touser=message.sender_id, content=body, dedupe_key=f"land-confirm-expired:{message.message_id}", ) return "land_confirm_expired" body = copy.FIRST_CHAT msg_store.enqueue_outbound( touser=message.sender_id, content=body, dedupe_key=f"other:{message.message_id}", ) return "text_other"