499 lines
19 KiB
Python
499 lines
19 KiB
Python
"""
|
|
意图:普通文字询价(空运 / 海运 / 陆运 / 多段联运)。
|
|
|
|
本文件职责:入站文字 → 流程编排;回复只写入 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:
|
|
"""补问/核对手稿优先于已出票旧书签。还没定运输方式的草稿不算当前询价,避免抢走另一条已在补问的单。已转人工的票也不再当当前询价。"""
|
|
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 _is_waiting_mode(sess) -> bool:
|
|
"""运输方式还没定、业务线空着。定方式前的几句询价字段都堆在这份草稿上。"""
|
|
if sess is None:
|
|
return False
|
|
from agent.policy.handoff_human import is_handoff_frozen
|
|
|
|
if is_handoff_frozen(sess):
|
|
return False
|
|
if (sess.phase or "").strip() != "need_mode":
|
|
return False
|
|
return not (sess.business_line or "").strip()
|
|
|
|
|
|
def _carry_waiting_inquiry_to_owner(*, 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"}:
|
|
return
|
|
flows = {
|
|
"AIR": get_air_text_flow(),
|
|
"SEA": get_sea_text_flow(),
|
|
"LAND": get_land_text_flow(),
|
|
}
|
|
owner_flow = flows[kind]
|
|
existing = owner_flow.session_of(sid)
|
|
if _front_bookmark(existing):
|
|
return
|
|
drafts = []
|
|
for flow in flows.values():
|
|
sess = flow.session_of(sid)
|
|
if _is_waiting_mode(sess):
|
|
drafts.append(sess)
|
|
if not drafts:
|
|
return
|
|
# 字段多的打底,其它草稿只补空缺,避免后写的空值盖掉已认出的港口。
|
|
drafts.sort(key=lambda item: len(item.facts or {}), reverse=True)
|
|
facts: dict[str, str] = {}
|
|
texts: list[str] = []
|
|
for sess in drafts:
|
|
for key, value in (sess.facts or {}).items():
|
|
text = str(value).strip()
|
|
if text and not str(facts.get(key) or "").strip():
|
|
facts[str(key)] = text
|
|
raw = (sess.immutable_text or "").strip()
|
|
if raw and raw not in texts:
|
|
texts.append(raw)
|
|
if not facts and not texts:
|
|
return
|
|
from agent.policy.air_text_flow import FlowSession
|
|
|
|
owner_flow._save(
|
|
FlowSession(
|
|
sender_id=sid,
|
|
thread_id=sid,
|
|
phase="need_mode",
|
|
business_line="",
|
|
facts=facts,
|
|
immutable_text="\n".join(texts),
|
|
)
|
|
)
|
|
logger.info(
|
|
"text_inquiry 未定方式的询价字段交给 %s keys=%s sender=%s",
|
|
kind,
|
|
",".join(sorted(facts)),
|
|
sid,
|
|
)
|
|
|
|
|
|
def _drop_other_mode_drafts(*, owner: str, sender_id: str) -> None:
|
|
"""
|
|
本句已点名空运/海运/陆运:先带走「还没定方式」时积下的询价字段,再丢掉其它方式的补问/选项草稿。
|
|
|
|
测服复现:旧陆运「选项未齐」书签还在,海运补「贸易条款 / 运输分类」会被陆运抢走,
|
|
回「参数提取不全」并发送填写样例图。已出号会话留在工单号索引,不当当前草稿。
|
|
定方式前的 need_mode 要整份移交,不能直接删。
|
|
"""
|
|
kind = (owner or "").upper()
|
|
sid = (sender_id or "").strip()
|
|
if not sid or kind not in {"SEA", "AIR", "LAND", "MULTI"}:
|
|
return
|
|
_carry_waiting_inquiry_to_owner(owner=kind, sender_id=sid)
|
|
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 _flow_waiting_lost_reason(sender_id: str, sea, air, land):
|
|
"""谁在等未成交原因就交给谁。刚记下未成交的那条优先。"""
|
|
from agent.policy.handoff_human import is_handoff_frozen
|
|
|
|
waiting = []
|
|
for flow in (sea, air, land):
|
|
sess = flow.session_of(sender_id)
|
|
if sess is None or is_handoff_frozen(sess):
|
|
continue
|
|
if (sess.phase or "").strip() != "wait_lost_reason":
|
|
continue
|
|
waiting.append((int(getattr(sess, "wait_version", 0) or 0), flow))
|
|
if not waiting:
|
|
return None
|
|
waiting.sort(key=lambda item: item[0], reverse=True)
|
|
return waiting[0][1]
|
|
|
|
|
|
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()
|
|
# 正在等未成交原因:下一句就是原因。调价、旧报价单、新的运输方式都不能把这句带走。
|
|
lost_flow = _flow_waiting_lost_reason(sender_id, sea, air, land)
|
|
if lost_flow is not None:
|
|
return lost_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
|
|
# 还在问运输方式:后面几句询价字段继续叠在同一份草稿上,不要另开一条只留最后一句。
|
|
parked = [
|
|
(sess, flow)
|
|
for sess, flow in ((sea_sess, sea), (air_sess, air), (land_sess, land))
|
|
if _is_waiting_mode(sess)
|
|
]
|
|
if parked:
|
|
parked.sort(key=lambda item: len(item[0].facts or {}), reverse=True)
|
|
return parked[0][1]
|
|
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"
|