落地多段联运私聊:齐了才建单拉群,附件只收格子值,核对改正不再整单重开。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,778 @@
|
||||
"""
|
||||
多段联运私聊:补问、核对、建单、拉群。
|
||||
|
||||
本文件职责:一段一段收齐必填后才建一张工单;不去 TMS 查价。
|
||||
不含空运时,销售点「拉产品进群」后按各段线路把人拉进同一个群,并发群摘要。
|
||||
含空运时只提示手动拉人到原有群。报价和成交跟进不在本文件。
|
||||
调用:文字询价 Handler 在 Worker 线程;禁止回调线程同步跑。
|
||||
禁止:直连 TMS;用两段同一种运输方式建单;把协同字段当成必填。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.air_text_flow import (
|
||||
FlowSession,
|
||||
ReplyFn,
|
||||
_session_from_payload,
|
||||
_session_payload,
|
||||
build_ledger,
|
||||
)
|
||||
from agent.policy.multi_segments import (
|
||||
Segment,
|
||||
classify_multi_text,
|
||||
confirm_text,
|
||||
group_brief,
|
||||
includes_air,
|
||||
merge_supplement,
|
||||
refresh_segments,
|
||||
segments_from_payload,
|
||||
segments_to_payload,
|
||||
split_segments,
|
||||
)
|
||||
from agent.schema.land_options import (
|
||||
looks_like_confirm,
|
||||
option_prompt_needs_sample,
|
||||
sample_pair_allowed,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 只有补问/核对还能续同一张多段草稿。已出号的拉群停点不能拦住新询价。
|
||||
_FRONT = frozenset({"clarify", "wait_confirm"})
|
||||
|
||||
|
||||
class MultiTextInquiryFlow:
|
||||
"""多段联运私聊 Owner。一张工单、一个群,段存在书签 quote.multi_segments。"""
|
||||
|
||||
bookmark_kind = "multi"
|
||||
|
||||
def __init__(self, ledger: Any = None, group_client: Any = None, *, bookmark_store: Any = None) -> None:
|
||||
self._ledger = ledger or build_ledger()
|
||||
self._lock = threading.Lock()
|
||||
self._by_sender: dict[str, FlowSession] = {}
|
||||
self._by_ticket: dict[str, FlowSession] = {}
|
||||
self._group_client = group_client
|
||||
self._bookmark_store = bookmark_store
|
||||
|
||||
@property
|
||||
def ledger(self) -> Any:
|
||||
return self._ledger
|
||||
|
||||
def session_of(self, sender_id: str) -> Optional[FlowSession]:
|
||||
"""当前销售的多段书签。进程内没有就读 Redis 书签。"""
|
||||
sid = (sender_id or "").strip()
|
||||
with self._lock:
|
||||
hit = self._by_sender.get(sid)
|
||||
if hit is not None:
|
||||
return hit
|
||||
loaded = self._load_bookmark(sid)
|
||||
if loaded is None:
|
||||
return None
|
||||
self._save(loaded)
|
||||
return loaded
|
||||
|
||||
def session_for_action(
|
||||
self,
|
||||
sender_id: str,
|
||||
card_meta: Optional[dict[str, Any]] = None,
|
||||
*,
|
||||
text: str = "",
|
||||
) -> Optional[FlowSession]:
|
||||
"""
|
||||
点卡:用工单号找回这一单。HTTP 进程往往没有 Worker 内存书签。
|
||||
|
||||
卡上有工单号时,不对上就不拿当前草稿,避免串到别人/最新单。
|
||||
进程刚重启再从主账收回 MULTI 工单。
|
||||
"""
|
||||
sid = (sender_id or "").strip()
|
||||
if not sid:
|
||||
return None
|
||||
no = copy.work_order_from_card_meta(card_meta, text=text)
|
||||
if no:
|
||||
with self._lock:
|
||||
hit = self._by_ticket.get(no)
|
||||
if hit and hit.sender_id == sid:
|
||||
return hit
|
||||
sess = self.session_of(sid)
|
||||
if sess and (sess.business_line or "").upper() == "MULTI":
|
||||
if not no:
|
||||
return sess
|
||||
if (sess.work_order_no or "").strip() == no:
|
||||
return sess
|
||||
if no:
|
||||
return self._load_ticket_session(sid, no)
|
||||
return None
|
||||
|
||||
def on_button(
|
||||
self,
|
||||
*,
|
||||
sender_id: str,
|
||||
action: str,
|
||||
reply: ReplyFn,
|
||||
card_meta: Optional[dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
处理多段拉群卡。错工单/未允许直接丢弃,不改主账。
|
||||
|
||||
副作用:点「拉产品进群协同」时建一个群并发摘要、各段模板。
|
||||
调用:卡片 Handler,HTTP 回调线程只入队后由 Worker/本流程执行;本函数会打企微建群。
|
||||
"""
|
||||
sess = self.session_for_action(sender_id, card_meta)
|
||||
card_wo = copy.work_order_from_card_meta(card_meta)
|
||||
if not sess or action not in (sess.allowed or ()):
|
||||
logger.info(
|
||||
"multi card 丢弃 sender=%s action=%s card_wo=%s sess_wo=%s phase=%s",
|
||||
sender_id,
|
||||
action,
|
||||
card_wo or "-",
|
||||
(sess.work_order_no if sess else "-"),
|
||||
(sess.phase if sess else "-"),
|
||||
)
|
||||
return "card_discard"
|
||||
if card_wo and sess.work_order_no and card_wo != sess.work_order_no:
|
||||
logger.warning(
|
||||
"multi card 工单不一致 card_wo=%s sess_wo=%s,拒绝串单",
|
||||
card_wo,
|
||||
sess.work_order_no,
|
||||
)
|
||||
return "card_discard"
|
||||
if action == copy.BTN_PULL_COLLAB:
|
||||
return self._pull_group(sess, reply)
|
||||
return "card_discard"
|
||||
|
||||
def drop_current_bookmark(self, sender_id: str) -> Optional[FlowSession]:
|
||||
"""销售改发单段时丢掉未完成的多段草稿。已出号的仍留在工单号索引。"""
|
||||
sid = (sender_id or "").strip()
|
||||
if not sid:
|
||||
return None
|
||||
with self._lock:
|
||||
old = self._by_sender.pop(sid, None)
|
||||
self._delete_bookmark(sid)
|
||||
return old
|
||||
|
||||
def match_button(self, text: str, sess: Optional[FlowSession]) -> str:
|
||||
"""拉群按钮。没到可点阶段就不当按钮。"""
|
||||
if sess is None:
|
||||
return ""
|
||||
action = copy.canonical_button(text)
|
||||
if action == copy.BTN_PULL_COLLAB and action in (sess.allowed or ()):
|
||||
return action
|
||||
return ""
|
||||
|
||||
def offer_handoff(self, *, sender_id: str, text: str, reply: ReplyFn) -> str:
|
||||
"""多段还在收字段时不转人工,请销售先把各段发齐。"""
|
||||
_ = sender_id, text
|
||||
reply("多段询价请先把各段需求发齐并回复确定。需要人来接管时,请在工单创建后再说。")
|
||||
return "multi_handoff_later"
|
||||
|
||||
def on_text(
|
||||
self,
|
||||
*,
|
||||
sender_id: str,
|
||||
text: str,
|
||||
reply: ReplyFn,
|
||||
injected_facts: Optional[dict[str, Any]] = None,
|
||||
injected_mode: str = "",
|
||||
b_result: Optional[dict[str, Any]] = None,
|
||||
allow_b: bool = False,
|
||||
chat_fn: Any = None,
|
||||
inbound_raw: Optional[dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
处理一条多段私聊。
|
||||
|
||||
副作用:写书签、可能建主账工单、点拉群时建企微群。不查 TMS。
|
||||
新开多段仍按原文切段;补问中的口语句走 injected_* / DeepSeek B,与单聊同一套抽取。
|
||||
已出号等拉群时,再发一段陆运+海运视为新询价,不能回旧工单的手动拉群提示。
|
||||
"""
|
||||
_ = inbound_raw
|
||||
sid = (sender_id or "").strip()
|
||||
raw = text or ""
|
||||
sess = self.session_of(sid)
|
||||
if sess and self.match_button(raw, sess):
|
||||
return self._pull_group(sess, reply)
|
||||
verdict = classify_multi_text(raw)
|
||||
if verdict == "same_mode":
|
||||
reply(copy.MULTI_SAME_MODE)
|
||||
return "reject_same_mode"
|
||||
if verdict == "multi":
|
||||
# 补问里再写段1/段2是补字段,不能当新开把已填冲掉。
|
||||
if sess and (sess.phase or "") == "clarify":
|
||||
return self._continue_clarify(
|
||||
sess,
|
||||
raw,
|
||||
reply,
|
||||
injected_facts=injected_facts,
|
||||
injected_mode=injected_mode,
|
||||
b_result=b_result,
|
||||
allow_b=allow_b,
|
||||
chat_fn=chat_fn,
|
||||
)
|
||||
return self._open(
|
||||
sid,
|
||||
split_segments(raw).segments,
|
||||
reply,
|
||||
immutable=raw,
|
||||
allow_b=allow_b,
|
||||
chat_fn=chat_fn,
|
||||
)
|
||||
if sess and sess.phase == "wait_confirm" and looks_like_confirm(raw):
|
||||
return self._create_ticket(sess, reply)
|
||||
if sess and sess.phase in {"wait_collab", "collab_group"}:
|
||||
if (sess.collab_chat_id or "").strip():
|
||||
reply(copy.sea_group_exists(work_order_no=sess.work_order_no))
|
||||
return "collab_group"
|
||||
reply(copy.multi_pull_text(work_order_no=sess.work_order_no))
|
||||
return "wait_collab"
|
||||
if sess and sess.phase == "wait_manual_group":
|
||||
reply(copy.multi_air_manual_hint(work_order_no=sess.work_order_no))
|
||||
return "wait_manual_group"
|
||||
if sess and sess.phase in _FRONT and not looks_like_confirm(raw):
|
||||
if sess.phase == "clarify":
|
||||
return self._continue_clarify(
|
||||
sess,
|
||||
raw,
|
||||
reply,
|
||||
injected_facts=injected_facts,
|
||||
injected_mode=injected_mode,
|
||||
b_result=b_result,
|
||||
allow_b=allow_b,
|
||||
chat_fn=chat_fn,
|
||||
)
|
||||
if sess.phase == "wait_confirm":
|
||||
# 核对卡写了「内容有误请重新发」:销售会只改正字段,按补问叠上去。
|
||||
return self._continue_clarify(
|
||||
sess,
|
||||
raw,
|
||||
reply,
|
||||
injected_facts=injected_facts,
|
||||
injected_mode=injected_mode,
|
||||
b_result=b_result,
|
||||
allow_b=allow_b,
|
||||
chat_fn=chat_fn,
|
||||
)
|
||||
if sess and sess.phase == "wait_confirm":
|
||||
reply(copy.MULTI_CONFIRM_EXPIRED)
|
||||
return "multi_confirm_expired"
|
||||
reply(copy.MULTI_SAME_MODE if verdict == "same_mode" else "请按段发多段需求,例如段1海运、段2空运。")
|
||||
return "multi_need_text"
|
||||
|
||||
def _open(
|
||||
self,
|
||||
sender_id: str,
|
||||
segments: list[Segment],
|
||||
reply: ReplyFn,
|
||||
*,
|
||||
immutable: str,
|
||||
allow_b: bool = False,
|
||||
chat_fn: Any = None,
|
||||
) -> str:
|
||||
"""
|
||||
新的多段原文:缺必填就一次说完,齐了出核对。不建单。
|
||||
|
||||
附件口播经常没有「起运港:」这种标签,要按段走和单聊一样的抽取,
|
||||
不能只认标签行,否则会整段待补充。
|
||||
"""
|
||||
filled = self._enrich_segments(segments, allow_b=allow_b, chat_fn=chat_fn)
|
||||
ready, updated, gaps = refresh_segments(filled)
|
||||
if not ready:
|
||||
self._reply_gaps(updated, gaps, reply)
|
||||
self._save(self._draft(sender_id, updated, phase="clarify", immutable=immutable))
|
||||
return "clarify"
|
||||
reply(confirm_text(updated))
|
||||
self._save(self._draft(sender_id, updated, phase="wait_confirm", immutable=immutable))
|
||||
return "wait_confirm"
|
||||
|
||||
def _reply_gaps(self, segments: list[Segment], gaps: str, reply: ReplyFn) -> None:
|
||||
"""
|
||||
补问正文。陆运还缺类型或线路时,再发一张和单聊相同的填写样例图。
|
||||
|
||||
副作用:出站文字,可能再出一张图片。只在 Worker 里调。
|
||||
"""
|
||||
reply(gaps)
|
||||
for seg in segments:
|
||||
if seg.mode != "LAND":
|
||||
continue
|
||||
if option_prompt_needs_sample(current=seg.facts):
|
||||
reply(copy.land_sample_image_fallback(), copy.land_sample_image_payload())
|
||||
return
|
||||
|
||||
def _enrich_segments(
|
||||
self,
|
||||
segments: list[Segment],
|
||||
*,
|
||||
allow_b: bool,
|
||||
chat_fn: Any,
|
||||
) -> list[Segment]:
|
||||
"""
|
||||
各段正文再抽一遍字段。标签行已经在切段时收过,这里只补口语/附件里没写冒号的值。
|
||||
|
||||
没有正文就不调模型,避免补问合并后空跑。
|
||||
"""
|
||||
if not segments:
|
||||
return segments
|
||||
if not allow_b and chat_fn is None:
|
||||
return segments
|
||||
from agent.llm.extract_text import extract_inquiry_snapshot
|
||||
|
||||
out: list[Segment] = []
|
||||
for seg in segments:
|
||||
body = (seg.source or "").strip()
|
||||
if not body:
|
||||
out.append(seg)
|
||||
continue
|
||||
snap = extract_inquiry_snapshot(
|
||||
body,
|
||||
injected_mode=seg.mode,
|
||||
allow_b=allow_b,
|
||||
chat_fn=chat_fn,
|
||||
)
|
||||
extra = {
|
||||
str(key): str(val).strip()
|
||||
for key, val in dict(snap.get("facts") or {}).items()
|
||||
if str(val or "").strip()
|
||||
}
|
||||
subtype = str(snap.get("land_subtype") or "").strip()
|
||||
if seg.mode == "LAND" and subtype and not str(extra.get("运输类型") or "").strip():
|
||||
extra["运输类型"] = subtype
|
||||
facts = dict(seg.facts)
|
||||
for key, val in extra.items():
|
||||
if not str(facts.get(key) or "").strip():
|
||||
facts[key] = val
|
||||
out.append(
|
||||
Segment(index=seg.index, mode=seg.mode, facts=facts, source=seg.source)
|
||||
)
|
||||
return out
|
||||
|
||||
def _continue_clarify(
|
||||
self,
|
||||
sess: FlowSession,
|
||||
text: str,
|
||||
reply: ReplyFn,
|
||||
*,
|
||||
injected_facts: Optional[dict[str, Any]] = None,
|
||||
injected_mode: str = "",
|
||||
b_result: Optional[dict[str, Any]] = None,
|
||||
allow_b: bool = False,
|
||||
chat_fn: Any = None,
|
||||
) -> str:
|
||||
"""
|
||||
补问中的下一条:能整段重发就替换,否则按段号或唯一缺口段补上。
|
||||
|
||||
口语补句走单聊同一套抽取,不在这里猜「广州-深圳」。
|
||||
"""
|
||||
from agent.llm.extract_text import extract_inquiry_snapshot
|
||||
from agent.policy.system_exception import handle_extract_tech_fail
|
||||
|
||||
snap = extract_inquiry_snapshot(
|
||||
text,
|
||||
injected_facts=injected_facts,
|
||||
injected_mode=injected_mode,
|
||||
b_result=b_result,
|
||||
allow_b=allow_b,
|
||||
chat_fn=chat_fn,
|
||||
)
|
||||
llm_phase = handle_extract_tech_fail(
|
||||
snap=snap,
|
||||
ledger=self._ledger,
|
||||
reply=reply,
|
||||
sender_id=sess.sender_id,
|
||||
sess=sess,
|
||||
)
|
||||
if llm_phase:
|
||||
return llm_phase
|
||||
extra = {
|
||||
str(key): str(val).strip()
|
||||
for key, val in dict(snap.get("facts") or {}).items()
|
||||
if str(val or "").strip()
|
||||
}
|
||||
merged = merge_supplement(self._segments(sess), text, extra_facts=extra)
|
||||
return self._open(
|
||||
sess.sender_id,
|
||||
merged,
|
||||
reply,
|
||||
immutable=sess.immutable_text or text,
|
||||
allow_b=allow_b,
|
||||
chat_fn=chat_fn,
|
||||
)
|
||||
|
||||
def _restart(self, sender_id: str, text: str, reply: ReplyFn) -> str:
|
||||
"""核对之后没有回复确定,而是重发内容:整段重来,不在旧核对上改一个字。"""
|
||||
verdict = classify_multi_text(text)
|
||||
if verdict == "same_mode":
|
||||
reply(copy.MULTI_SAME_MODE)
|
||||
return "reject_same_mode"
|
||||
if verdict != "multi":
|
||||
reply("请把多段需求再发一次。")
|
||||
return "wait_confirm"
|
||||
return self._open(sender_id, split_segments(text).segments, reply, immutable=text)
|
||||
|
||||
def _create_ticket(self, sess: FlowSession, reply: ReplyFn) -> str:
|
||||
"""回复确定后建一张 MULTI 工单。含空运只给手动拉群提示,不含空运出拉群卡。"""
|
||||
segments = self._segments(sess)
|
||||
ready, updated, gaps = refresh_segments(segments)
|
||||
if not ready:
|
||||
self._reply_gaps(updated, gaps, reply)
|
||||
sess.phase = "clarify"
|
||||
self._put_segments(sess, updated)
|
||||
self._save(sess)
|
||||
return "clarify"
|
||||
payload = segments_to_payload(updated)
|
||||
created = self._ledger.create_ticket(
|
||||
sender_id=sess.sender_id,
|
||||
business_line="MULTI",
|
||||
facts={"segments_json": json.dumps(payload, ensure_ascii=False)},
|
||||
idempotency_key=f"multi:{sess.sender_id}:{sess.wait_version}",
|
||||
)
|
||||
if not created.get("ok"):
|
||||
reply("工单创建失败,请稍后再回复确定。")
|
||||
return "wait_confirm"
|
||||
no = str(created.get("work_order_no") or "").strip()
|
||||
sess.work_order_no = no
|
||||
sess.status = "询价中"
|
||||
self._put_segments(sess, updated)
|
||||
if includes_air(updated):
|
||||
sess.phase = "wait_manual_group"
|
||||
sess.allowed = ()
|
||||
self._save(sess)
|
||||
reply(copy.multi_air_manual_hint(work_order_no=no))
|
||||
logger.info("multi.created air wo=%s segments=%s", no, len(updated))
|
||||
return "wait_manual_group"
|
||||
sess.phase = "wait_collab"
|
||||
sess.allowed = (copy.BTN_PULL_COLLAB,)
|
||||
self._save(sess)
|
||||
reply(copy.multi_pull_text(work_order_no=no), copy.multi_pull_wecom_payload(work_order_no=no))
|
||||
logger.info("multi.created pull wo=%s segments=%s", no, len(updated))
|
||||
return "wait_collab"
|
||||
|
||||
def _pull_group(self, sess: FlowSession, reply: ReplyFn) -> str:
|
||||
"""
|
||||
点拉群:海运段按港口拉海运产品,陆运段按线路拉陆运产品,进同一个群。
|
||||
|
||||
任一段没有人,或陆运类型+线路不在填写样例里,不建群,按钮仍可点。
|
||||
"""
|
||||
no = (sess.work_order_no or "").strip()
|
||||
if (sess.collab_chat_id or "").strip():
|
||||
reply(copy.sea_group_exists(work_order_no=no))
|
||||
return "collab_group"
|
||||
segments = self._segments(sess)
|
||||
if includes_air(segments):
|
||||
reply(copy.multi_air_manual_hint(work_order_no=no))
|
||||
return "wait_manual_group"
|
||||
for seg in segments:
|
||||
if seg.mode != "LAND":
|
||||
continue
|
||||
land_type = str(seg.facts.get("运输类型") or "").strip()
|
||||
route = str(seg.facts.get("线路类别") or "").strip()
|
||||
if not sample_pair_allowed(land_type, route):
|
||||
reply(copy.land_group_pair_rejected())
|
||||
return sess.phase or "wait_collab"
|
||||
userids = [sess.sender_id]
|
||||
product_names: list[str] = []
|
||||
product_ids: list[str] = []
|
||||
for seg in segments:
|
||||
staff = self._staff_for(seg)
|
||||
if not staff:
|
||||
reply(copy.land_group_no_staff() if seg.mode == "LAND" else copy.sea_group_no_staff())
|
||||
return sess.phase or "wait_collab"
|
||||
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(copy.sea_group_no_staff())
|
||||
return sess.phase or "wait_collab"
|
||||
for uid in self._archive_seats():
|
||||
if uid not in userids:
|
||||
userids.append(uid)
|
||||
client = self._client()
|
||||
from agent.policy.system_exception import call_with_retries
|
||||
|
||||
created = call_with_retries(
|
||||
lambda: client.create_group(name=no, userids=userids) or {},
|
||||
is_ok=lambda x: bool((x or {}).get("ok") and (x or {}).get("chat_id")),
|
||||
)
|
||||
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 "wait_collab"
|
||||
chat_id = str(created.get("chat_id") or "").strip()
|
||||
sales_name = self._sales_name(sess.sender_id)
|
||||
binder = getattr(self._ledger, "bind_collab_group", None)
|
||||
if callable(binder):
|
||||
binder(
|
||||
work_order_no=no,
|
||||
chat_id=chat_id,
|
||||
member_ids=userids,
|
||||
product_names=product_names,
|
||||
product_ids=product_ids,
|
||||
)
|
||||
sess.collab_chat_id = chat_id
|
||||
sess.phase = "collab_group"
|
||||
sess.allowed = ()
|
||||
sess.invited_product_names = tuple(product_names)
|
||||
sess.invited_sales_name = sales_name
|
||||
self._save(sess)
|
||||
from agent.policy.sea_group_ops import send_group_text
|
||||
|
||||
brief = group_brief(
|
||||
work_order_no=no,
|
||||
segments=segments,
|
||||
sales_name=sales_name,
|
||||
mention_names=product_names,
|
||||
)
|
||||
send_group_text(client, chat_id, brief, mention_userids=product_ids)
|
||||
ticket = self._ledger.get_ticket(work_order_no=no) if hasattr(self._ledger, "get_ticket") else None
|
||||
if ticket is not None:
|
||||
from agent.policy.multi_group_ops import send_segment_templates
|
||||
|
||||
send_segment_templates(flow=self, ticket=ticket, chat_id=chat_id)
|
||||
reply(
|
||||
copy.sea_group_created(
|
||||
work_order_no=no,
|
||||
sales_name=sales_name,
|
||||
product_names=product_names,
|
||||
)
|
||||
)
|
||||
logger.info("multi.group wo=%s chat=%s products=%s", no, chat_id, product_names)
|
||||
return "collab_group"
|
||||
|
||||
def _staff_for(self, seg: Segment) -> list[dict[str, Any]]:
|
||||
"""按这一段自己的线路找产品。空运段不在这里拉人。"""
|
||||
if seg.mode == "SEA":
|
||||
matcher = getattr(self._ledger, "match_sea_staff", None)
|
||||
if not callable(matcher):
|
||||
return []
|
||||
try:
|
||||
rows = matcher(
|
||||
origin=str(seg.facts.get("起运港") or ""),
|
||||
destination=str(seg.facts.get("目的港") or ""),
|
||||
) or []
|
||||
except Exception:
|
||||
logger.exception("多段匹配海运产品失败")
|
||||
return []
|
||||
return [dict(x) for x in rows if isinstance(x, dict)]
|
||||
if seg.mode == "LAND":
|
||||
matcher = getattr(self._ledger, "match_land_staff", None)
|
||||
if not callable(matcher):
|
||||
return []
|
||||
try:
|
||||
rows = matcher(route_category=str(seg.facts.get("线路类别") or "")) or []
|
||||
except Exception:
|
||||
logger.exception("多段匹配陆运产品失败")
|
||||
return []
|
||||
return [dict(x) for x in rows if isinstance(x, dict)]
|
||||
return []
|
||||
|
||||
def _client(self) -> Any:
|
||||
"""建群客户端。单测注入;生产与海运共用企微应用客户端。"""
|
||||
if self._group_client is not None:
|
||||
return self._group_client
|
||||
from agent.policy.sea_text_flow import _default_group_client
|
||||
|
||||
self._group_client = _default_group_client()
|
||||
return self._group_client
|
||||
|
||||
def _archive_seats(self) -> list[str]:
|
||||
"""会话存档账号。查本账本,没有也不挡建群。"""
|
||||
from agent.config import get_settings
|
||||
from agent.policy.sea_text_flow import ARCHIVE_SEAT_NAMES
|
||||
|
||||
ids: list[str] = []
|
||||
seat = str(getattr(get_settings(), "wecom_archive_seat_user_ids", "") or "").strip()
|
||||
for part in seat.replace(";", ",").split(","):
|
||||
uid = part.strip()
|
||||
if uid and uid not in ids:
|
||||
ids.append(uid)
|
||||
finder = getattr(self._ledger, "find_staff_by_name", None)
|
||||
if not callable(finder):
|
||||
return ids
|
||||
for name in ARCHIVE_SEAT_NAMES:
|
||||
try:
|
||||
row = finder(name=name) or {}
|
||||
except Exception:
|
||||
logger.exception("多段查会话存档账号失败 name=%s", name)
|
||||
continue
|
||||
uid = str(row.get("wecomId") or row.get("wecom_id") or "").strip()
|
||||
if uid and uid not in ids:
|
||||
ids.append(uid)
|
||||
return ids
|
||||
|
||||
def _sales_name(self, sender_id: str) -> str:
|
||||
"""群里 @ 的销售名。主账没有姓名时退回 userid。"""
|
||||
finder = getattr(self._ledger, "get_staff_by_wecom_id", None)
|
||||
if callable(finder):
|
||||
try:
|
||||
row = finder(wecom_id=sender_id) or {}
|
||||
except Exception:
|
||||
row = {}
|
||||
name = str(row.get("name") or "").strip()
|
||||
if name:
|
||||
return name
|
||||
return (sender_id or "").strip() or "销售"
|
||||
|
||||
def _draft(self, sender_id: str, segments: list[Segment], *, phase: str, immutable: str) -> FlowSession:
|
||||
sess = FlowSession(
|
||||
sender_id=sender_id,
|
||||
thread_id=sender_id,
|
||||
phase=phase,
|
||||
business_line="MULTI",
|
||||
immutable_text=immutable,
|
||||
wait_version=1,
|
||||
)
|
||||
self._put_segments(sess, segments)
|
||||
return sess
|
||||
|
||||
def _segments(self, sess: FlowSession) -> list[Segment]:
|
||||
return segments_from_payload((sess.quote or {}).get("multi_segments"))
|
||||
|
||||
def _put_segments(self, sess: FlowSession, segments: list[Segment]) -> None:
|
||||
quote = dict(sess.quote or {})
|
||||
quote["multi_segments"] = segments_to_payload(segments)
|
||||
sess.quote = quote
|
||||
|
||||
def _load_ticket_session(self, sender_id: str, work_order_no: str) -> Optional[FlowSession]:
|
||||
"""
|
||||
点卡时本进程没有书签:只读主账收回这一张 MULTI 工单。
|
||||
|
||||
找不到、不是本人、不是多段:返回 None,绝不改成当前最新单或空运停点。
|
||||
"""
|
||||
getter = getattr(self._ledger, "get_for_agent", None)
|
||||
if not callable(getter):
|
||||
logger.warning("multi card 点到 %s 但无主账可读", work_order_no)
|
||||
return None
|
||||
view = getter(work_order_no=work_order_no, sender_id=sender_id) or {}
|
||||
if not view.get("found") or view.get("owned") is False:
|
||||
logger.warning(
|
||||
"multi card 点到 %s 主账未收回 found=%s owned=%s",
|
||||
work_order_no,
|
||||
view.get("found"),
|
||||
view.get("owned"),
|
||||
)
|
||||
return None
|
||||
if (str(view.get("business_line") or "")).upper() != "MULTI":
|
||||
return None
|
||||
sess = self._session_from_ledger(sender_id=sender_id, view=view)
|
||||
self._save(sess)
|
||||
logger.info("multi card 从主账收回 wo=%s phase=%s", sess.work_order_no, sess.phase)
|
||||
return sess
|
||||
|
||||
def _session_from_ledger(self, *, sender_id: str, view: dict[str, Any]) -> FlowSession:
|
||||
"""
|
||||
主账快照收成多段书签。询价中/被空运误标成 tms_miss 都按等拉群处理。
|
||||
|
||||
段列表优先 quote.multi_segments,没有就解 facts.segments_json。
|
||||
"""
|
||||
no = str(view.get("work_order_no") or "").strip()
|
||||
facts = dict(view.get("facts") or {})
|
||||
quote = dict(view.get("quote") or {})
|
||||
raw_segs = quote.get("multi_segments")
|
||||
if not raw_segs:
|
||||
blob = facts.get("segments_json") or facts.get("segmentsJson") or ""
|
||||
if isinstance(blob, str) and blob.strip():
|
||||
try:
|
||||
parsed = json.loads(blob)
|
||||
except json.JSONDecodeError:
|
||||
parsed = None
|
||||
if isinstance(parsed, list):
|
||||
quote["multi_segments"] = parsed
|
||||
raw_segs = parsed
|
||||
segments = segments_from_payload(raw_segs)
|
||||
status = str(view.get("status") or "").strip()
|
||||
chat = str(view.get("collab_chat_id") or view.get("collabChatId") or "").strip()
|
||||
phase = str(view.get("wait_phase") or view.get("waitPhase") or "").strip()
|
||||
allowed: tuple[str, ...] = ()
|
||||
if chat:
|
||||
phase = "collab_group"
|
||||
elif includes_air(segments):
|
||||
phase = "wait_manual_group"
|
||||
elif status in {"已成交", "已关闭"}:
|
||||
phase = "done"
|
||||
elif status == "未成交":
|
||||
phase = "wait_lost_reason"
|
||||
elif status == "协商中" or phase == "wait_deal":
|
||||
phase = "wait_deal"
|
||||
allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
|
||||
else:
|
||||
# 询价中、tms_miss、wait_collab:销售还没拉群
|
||||
phase = "wait_collab"
|
||||
allowed = (copy.BTN_PULL_COLLAB,)
|
||||
return FlowSession(
|
||||
sender_id=sender_id,
|
||||
thread_id=sender_id,
|
||||
phase=phase,
|
||||
business_line="MULTI",
|
||||
facts={str(k): str(v) for k, v in facts.items()},
|
||||
work_order_no=no,
|
||||
quote=quote,
|
||||
status=status,
|
||||
allowed=allowed,
|
||||
wait_version=1,
|
||||
invited_sales_name=str(view.get("sales_name") or view.get("salesName") or ""),
|
||||
collab_chat_id=chat,
|
||||
)
|
||||
|
||||
def _save(self, sess: FlowSession) -> None:
|
||||
with self._lock:
|
||||
self._by_sender[sess.sender_id] = sess
|
||||
no = (sess.work_order_no or "").strip()
|
||||
if no:
|
||||
self._by_ticket[no] = sess
|
||||
self._persist_bookmark(sess)
|
||||
|
||||
def _persist_bookmark(self, sess: FlowSession) -> None:
|
||||
from agent.redis_coord.flow_bookmark import save_flow_bookmark
|
||||
|
||||
save_flow_bookmark(
|
||||
self.bookmark_kind,
|
||||
sess.sender_id,
|
||||
_session_payload(sess),
|
||||
store=self._bookmark_store,
|
||||
)
|
||||
|
||||
def _load_bookmark(self, sender_id: str) -> Optional[FlowSession]:
|
||||
from agent.redis_coord.flow_bookmark import load_flow_bookmark
|
||||
|
||||
raw = load_flow_bookmark(self.bookmark_kind, sender_id, store=self._bookmark_store)
|
||||
if not raw:
|
||||
return None
|
||||
return _session_from_payload(raw)
|
||||
|
||||
def _delete_bookmark(self, sender_id: str) -> None:
|
||||
from agent.redis_coord.flow_bookmark import delete_flow_bookmark
|
||||
|
||||
delete_flow_bookmark(self.bookmark_kind, sender_id, store=self._bookmark_store)
|
||||
|
||||
|
||||
_FLOW: Optional[MultiTextInquiryFlow] = None
|
||||
_FLOW_GUARD = threading.Lock()
|
||||
|
||||
|
||||
def get_multi_text_flow() -> MultiTextInquiryFlow:
|
||||
"""进程内一份多段流程。单测请自己 new,不要碰这份单例。"""
|
||||
global _FLOW
|
||||
with _FLOW_GUARD:
|
||||
if _FLOW is None:
|
||||
_FLOW = MultiTextInquiryFlow()
|
||||
return _FLOW
|
||||
|
||||
|
||||
def reset_multi_text_flow_for_test() -> MultiTextInquiryFlow:
|
||||
"""单测重置全局实例(强制内存账本)。"""
|
||||
global _FLOW
|
||||
from agent.ledger.memory_ledger import MemoryLedger
|
||||
|
||||
with _FLOW_GUARD:
|
||||
_FLOW = MultiTextInquiryFlow(MemoryLedger())
|
||||
return _FLOW
|
||||
Reference in New Issue
Block a user