落地海运私聊文字询价全流程,海运多项费用改走文本明细,避免企微卡片引用条截断。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-15 14:26:09 +08:00
co-authored by Cursor
parent 58b3c2f3e6
commit 0a3c74e3a1
41 changed files with 3702 additions and 48 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
Handler 包:一类意图 / 一类卡片一个模块。
禁止:单文件处理全部卡片;禁止把 Schema 校验复制粘贴进每个 handler(放 schema/policy)。
当前:文字询价空运闭环 + echo 联调残留。
当前:文字询价空运/海运闭环 + 海运群协同字段 + echo 联调残留。
"""
from agent.handlers.echo_text import handle_echo_text
+12 -2
View File
@@ -1,5 +1,5 @@
"""
意图:询价卡片按钮(复制工单号 / 跳过协同 / 采用 / 不采用 / 成交)。
意图:询价卡片按钮(复制工单号 / 拉产品进群 / 跳过协同 / 采用 / 不采用 / 成交)。
本文件职责:校验当前会话允许的动作后交给流程执行。
禁止:不校验 wait/允许集合就改主账;禁止一个函数处理未声明的新按钮。
@@ -13,10 +13,20 @@ from typing import Any
from agent.channel.queue import get_message_store
from agent.policy import inquiry_copy as copy
from agent.policy.air_text_flow import get_air_text_flow
from agent.policy.sea_text_flow import get_sea_text_flow
logger = logging.getLogger(__name__)
def _pick_card_flow(sender_id: str, payload: dict | None):
"""点卡:海运书签能找回工单就走海运,否则空运。"""
sea = get_sea_text_flow()
sess = sea.session_for_action(sender_id, payload)
if sess and (sess.business_line or "").upper() == "SEA":
return sea
return get_air_text_flow()
def handle_card_action(
*,
sender_id: str,
@@ -36,7 +46,7 @@ def handle_card_action(
_ = thread_id
if not (sender_id or "").strip():
return "reject_no_sender"
engine = flow or get_air_text_flow()
engine = flow or _pick_card_flow(sender_id, payload)
sess = engine.session_for_action(sender_id, payload)
if sess and wait_version and sess.wait_version != wait_version:
logger.info("card waitVersion 不匹配 expect=%s got=%s", sess.wait_version, wait_version)
@@ -14,10 +14,38 @@ from agent.channel.queue import get_message_store
from agent.channel.wecom.models import InboundMessage
from agent.policy import inquiry_copy as copy
from agent.policy.air_text_flow import get_air_text_flow
from agent.policy.sea_text_flow import get_sea_text_flow
logger = logging.getLogger(__name__)
def _pick_continue_flow(*, sender_id: str, work_order_no: str, flow=None):
"""
续办按工单选空运/海运流程。
海运书签或主账业务线是 SEA 必须走海运 Owner。
一律走空运会把已报价海运重放成空运协同卡(件数/毛重/包装)。
单测传入 flow 时不改选路,保持原断言。
"""
if flow is not None:
return flow
sea = get_sea_text_flow()
no = (work_order_no or "").strip()
hit = sea.session_by_ticket(sender_id, no) if no else None
if hit and (hit.business_line or "").upper() == "SEA":
return sea
getter = getattr(sea.ledger, "get_for_agent", None)
if callable(getter) and no:
try:
view = getter(work_order_no=no, sender_id=sender_id) or {}
except Exception:
logger.warning("continue_thread 读主账业务线失败 wo=%s", no)
view = {}
if str(view.get("business_line") or "").upper() == "SEA":
return sea
return get_air_text_flow()
def handle_continue_thread(
message: InboundMessage,
*,
@@ -34,7 +62,10 @@ def handle_continue_thread(
logger.warning("continue_thread 拒绝:无 sender_id")
return "reject_no_sender"
msg_store = store or get_message_store()
engine = flow or get_air_text_flow()
wo = (thread_id or "").strip() or copy.extract_work_order_no(message.content or "")
engine = _pick_continue_flow(
sender_id=message.sender_id, work_order_no=wo, flow=flow
)
replies: list[str] = []
def reply(text: str, extra: dict | None = None) -> str:
@@ -48,7 +79,6 @@ def handle_continue_thread(
logger.info("continue_thread 出站 ok=%s info=%s", ok, info)
return info if ok else ""
wo = (thread_id or "").strip() or copy.extract_work_order_no(message.content or "")
phase = engine.resume_historical(
sender_id=message.sender_id,
work_order_no=wo,
@@ -0,0 +1,93 @@
"""
意图:海运协同群里补协同字段。
本文件职责:群聊文字 → 只抽协同六字段 → 静默写主账。
禁止:回「已记下」;把闲聊/报价金额写成报价版本;改工单六态。
"""
from __future__ import annotations
import logging
from agent.channel.wecom.models import InboundMessage
from agent.llm.extract_text import extract_collab_fields
from agent.policy.sea_text_flow import get_sea_text_flow
from agent.schema.field_validate import SEA_COLLAB_FIELDS
logger = logging.getLogger(__name__)
def handle_group_collab(
message: InboundMessage,
*,
flow=None,
ledger=None,
) -> str:
"""
处理一条群聊文字。
必须能对上已绑定工单,且发言人是被拉进来的销售或产品。
识别到协同字段才写库;否则忽略。不回复。
"""
if (message.chat_type or "") != "group":
return "group_ignore"
if not message.has_sender():
return "reject_no_sender"
engine = flow or get_sea_text_flow()
book = ledger or engine.ledger
chat_id = (message.chat_id or "").strip()
if not chat_id:
return "group_ignore"
ticket = _find_ticket(book, chat_id)
if ticket is None:
return "group_ignore"
if not _sender_allowed(ticket, message.sender_id):
logger.info("group_collab 忽略非成员 sender=%s chat=%s", message.sender_id, chat_id)
return "group_ignore"
facts = extract_collab_fields(message.content or "")
if not facts:
return "group_ignore"
if isinstance(ticket, dict):
wo = str(ticket.get("work_order_no") or "")
else:
wo = str(getattr(ticket, "work_order_no", "") or "")
if not wo:
return "group_ignore"
engine.apply_collab_facts(
work_order_no=wo,
facts=facts,
sender_id=message.sender_id,
)
logger.info(
"group_collab 写入 wo=%s keys=%s sender=%s",
wo,
sorted(k for k in facts if k in SEA_COLLAB_FIELDS),
message.sender_id,
)
return "collab_field_saved"
def _find_ticket(book: object, chat_id: str):
finder = getattr(book, "find_by_collab_chat", None)
if callable(finder):
return finder(chat_id=chat_id)
return None
def _sender_allowed(ticket: object, sender_id: str) -> bool:
uid = (sender_id or "").strip()
if not uid:
return False
if isinstance(ticket, dict):
sales = str(ticket.get("sales_wecom_id") or ticket.get("salesWecomId") or "")
members = ticket.get("collab_member_ids") or ticket.get("memberIds") or []
else:
sales = str(getattr(ticket, "sales_wecom_id", "") or "")
members = list(getattr(ticket, "collab_member_ids", []) or [])
if uid == sales:
return True
return uid in {str(x) for x in members}
+27 -4
View File
@@ -1,5 +1,5 @@
"""
意图:普通文字询价(空运闭环)。
意图:普通文字询价(空运 / 海运闭环)。
本文件职责:入站文字 → 流程编排;回复只写入 outbox。
禁止:在本文件抽字段语义(交给 extract);禁止直连 TMS;禁止回调线程外的额外阻塞。
@@ -13,11 +13,29 @@ 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.sea_text_flow import get_sea_text_flow
logger = logging.getLogger(__name__)
def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = ""):
"""
按当前书签或本句运输方式选空运/海运流程。
已有海运书签优先;新句写了海运走海运;其余仍走空运(含补问运输方式)。
"""
sea = get_sea_text_flow()
sea_sess = sea.session_of(sender_id)
if sea_sess and (sea_sess.business_line or "").upper() == "SEA":
return sea
mode = (injected_mode or detect_transport_mode(text) or "").upper()
if mode == "SEA":
return sea
return get_air_text_flow()
def handle_text_inquiry(
message: InboundMessage,
*,
@@ -39,7 +57,11 @@ def handle_text_inquiry(
return "reject_no_sender"
settings = get_settings()
msg_store = store or get_message_store()
engine = flow or get_air_text_flow()
engine = flow or _pick_text_flow(
sender_id=message.sender_id,
text=message.content or "",
injected_mode=injected_mode,
)
replies: list[str] = []
def reply(text: str, extra: dict | None = None) -> str:
@@ -75,12 +97,13 @@ def handle_text_other(message: InboundMessage, *, store=None) -> str:
闲聊/其它:若有待点按钮则当按钮;否则回首次单聊话术。
"""
from agent.policy import inquiry_copy as copy
from agent.policy.air_text_flow import get_air_text_flow
if not message.has_sender():
return "reject_no_sender"
msg_store = store or get_message_store()
engine = get_air_text_flow()
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 ""
)