Compare commits

...
48 changed files with 4127 additions and 203 deletions
+2
View File
@@ -22,6 +22,8 @@ WECOM_CALLBACK_TOKEN=
WECOM_ENCODING_AES_KEY=
WECOM_CALLBACK_REPLY_TEXT=询价智能体接通成功。已收到:{content}
WECOM_OPS_AGENT_ID=1000014
# 会话存档席:后台员工管理里「询价机器人」的企微 userid,每个协同群必拉。可逗号分隔多个。
WECOM_ARCHIVE_SEAT_USER_IDS=
DEV_INJECT_ENABLED=true
# 本机端口(切流前并存:勿占用现网 8080/8810/8813)
@@ -218,6 +218,101 @@ class WeComAppClient:
)
return media_id, WeComSendResult(ok=True, errcode=0, raw=data)
def create_group(self, *, name: str, userids: list[str]) -> dict[str, Any]:
"""
创建企微应用群(appchat/create),群名=工单号。
成员须含销售、产品、会话存档账号(询价机器人);应用自己会进群。失败回 ok=False,不抛给回调。
"""
users = [str(x).strip() for x in userids if str(x).strip()]
if not name or len(users) < 2:
return {"ok": False, "error": "name_or_users", "chat_id": ""}
try:
token = self._get_token()
except Exception as exc: # noqa: BLE001
logger.exception("建群取 token 失败")
return {"ok": False, "error": str(exc), "chat_id": ""}
url = f"{self._api_base}/cgi-bin/appchat/create"
body = {"name": name, "userlist": users}
try:
with httpx.Client(timeout=self._timeout) as client:
resp = client.post(url, params={"access_token": token}, json=body)
data = resp.json()
except Exception as exc: # noqa: BLE001
logger.exception("企微建群网络失败")
return {"ok": False, "error": str(exc), "chat_id": ""}
errcode = int(data.get("errcode") or 0)
chat_id = str(data.get("chatid") or data.get("chat_id") or "")
if errcode != 0 or not chat_id:
logger.warning("企微建群失败 errcode=%s", errcode)
return {
"ok": False,
"error": str(data.get("errmsg") or f"errcode_{errcode}"),
"chat_id": "",
}
logger.info("企微群已创建 name=%s chat=%s", name, chat_id)
return {"ok": True, "chat_id": chat_id, "error": ""}
def add_group_members(self, *, chat_id: str, userids: list[str]) -> dict[str, Any]:
"""
往已有应用群补人(appchat/update add_user_list)。
用于已建群补拉会话存档账号。已在群里的人企微会忽略。不抛给回调。
"""
users = [str(x).strip() for x in userids if str(x).strip()]
if not chat_id or not users:
return {"ok": False, "error": "chat_or_users"}
try:
token = self._get_token()
except Exception as exc: # noqa: BLE001
logger.exception("补拉群成员取 token 失败")
return {"ok": False, "error": str(exc)}
url = f"{self._api_base}/cgi-bin/appchat/update"
body = {"chatid": chat_id, "add_user_list": users}
try:
with httpx.Client(timeout=self._timeout) as client:
resp = client.post(url, params={"access_token": token}, json=body)
data = resp.json()
except Exception as exc: # noqa: BLE001
logger.exception("企微补拉群成员网络失败")
return {"ok": False, "error": str(exc)}
errcode = int(data.get("errcode") or 0)
if errcode != 0:
logger.warning("企微补拉群成员失败 errcode=%s", errcode)
return {"ok": False, "error": str(data.get("errmsg") or f"errcode_{errcode}")}
logger.info("企微群已补人 chat=%s", chat_id)
return {"ok": True, "error": ""}
def send_group(self, *, chat_id: str, content: str) -> dict[str, Any]:
"""
向应用群发文本。必须走 appchat/send,不能当私聊 message/send。
失败回 ok=False,由调用方决定是否重试;不抛给回调线程。
"""
if not chat_id or not content:
return {"ok": False, "error": "chat_or_content"}
if not self._corp_id or not self._secret:
return {"ok": False, "error": "WECOM_CORP_ID/SECRET 未配置"}
try:
token = self._get_token()
except Exception as exc: # noqa: BLE001
logger.exception("群消息取 token 失败")
return {"ok": False, "error": str(exc)}
url = f"{self._api_base}/cgi-bin/appchat/send"
body = {"chatid": chat_id, "msgtype": "text", "text": {"content": content}}
try:
with httpx.Client(timeout=self._timeout) as client:
resp = client.post(url, params={"access_token": token}, json=body)
data = resp.json()
except Exception as exc: # noqa: BLE001
logger.exception("企微群消息网络失败")
return {"ok": False, "error": str(exc)}
errcode = int(data.get("errcode") or 0)
if errcode != 0:
logger.warning("企微群消息失败 errcode=%s", errcode)
return {"ok": False, "error": str(data.get("errmsg") or f"errcode_{errcode}")}
return {"ok": True, "error": ""}
def send_text(self, *, touser: str, content: str) -> WeComSendResult:
"""
向指定企微 userid 发送文本应用消息。
+4
View File
@@ -60,6 +60,10 @@ class Settings(BaseSettings):
wecom_corp_id: str = Field(default="", alias="WECOM_CORP_ID")
wecom_agent_id: str = Field(default="1000010", alias="WECOM_AGENT_ID")
wecom_secret: str = Field(default="", alias="WECOM_SECRET")
wecom_archive_seat_user_ids: str = Field(
default="",
alias="WECOM_ARCHIVE_SEAT_USER_IDS",
)
wecom_callback_token: str = Field(default="", alias="WECOM_CALLBACK_TOKEN")
wecom_encoding_aes_key: str = Field(default="", alias="WECOM_ENCODING_AES_KEY")
wecom_callback_reply_text: str = Field(
+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 ""
)
+81 -2
View File
@@ -102,10 +102,20 @@ class HttpLedger:
return {"ok": False, "found": False, "owned": False, "work_order_no": work_order_no}
return out
def query_tms(self, *, work_order_no: str, facts: dict[str, str]) -> dict[str, Any]:
def query_tms(
self,
*,
work_order_no: str,
facts: dict[str, str],
business_line: str = "",
) -> dict[str, Any]:
body: dict[str, Any] = {"work_order_no": work_order_no, "facts": facts}
line = (business_line or str((facts or {}).get("业务线") or "")).strip()
if line:
body["businessLine"] = line
out = self._post(
"/inquiry/agent/tms/query",
{"work_order_no": work_order_no, "facts": facts},
body,
idem=f"tms:{work_order_no}",
)
if "classification" not in out and not out.get("ok"):
@@ -182,3 +192,72 @@ class HttpLedger:
if not out.get("ok") and out.get("status"):
out["ok"] = True
return out if out.get("ok") else {"ok": False, "error": out.get("error") or "transition_failed"}
def match_sea_staff(self, *, origin: str, destination: str) -> list[dict[str, Any]]:
"""主账按线路返回启用的海运产品岗。"""
out = self._post(
"/inquiry/agent/staff/matchSea",
{"origin": origin, "destination": destination},
)
rows = out.get("staff") or out.get("result") or []
if isinstance(rows, list):
return [dict(x) for x in rows if isinstance(x, dict)]
return []
def get_staff_by_wecom_id(self, *, wecom_id: str) -> dict[str, Any]:
out = self._post("/inquiry/agent/staff/byWecom", {"wecomId": wecom_id})
if out.get("name") or out.get("wecomId"):
return out
return {}
def find_staff_by_name(self, *, name: str) -> dict[str, Any]:
"""按后台员工姓名取企微 userid。会话存档账号「询价机器人」走这里。"""
out = self._post("/inquiry/agent/staff/byName", {"name": name})
if out.get("name") or out.get("wecomId"):
return out
return {}
def bind_collab_group(
self,
*,
work_order_no: str,
chat_id: str,
member_ids: list[str],
product_names: list[str] | None = None,
) -> dict[str, Any]:
out = self._post(
"/inquiry/agent/ticket/bindCollabGroup",
{
"workOrderNo": work_order_no,
"chatId": chat_id,
"memberIds": member_ids,
"productNames": product_names or [],
},
idem=f"bind-group:{work_order_no}",
)
out.setdefault("ok", bool(out.get("chat_id") or out.get("chatId")))
return out
def patch_collab_facts(
self,
*,
work_order_no: str,
facts: dict[str, str],
sender_id: str = "",
) -> dict[str, Any]:
return self._post(
"/inquiry/agent/ticket/patchCollabFacts",
{
"workOrderNo": work_order_no,
"facts": facts,
"senderId": sender_id,
},
idem=f"collab-facts:{work_order_no}:{sorted(facts)}",
)
def find_by_collab_chat(self, *, chat_id: str) -> dict[str, Any] | None:
"""用协同群 chat_id 找回工单。对不上返回 None。"""
out = self._post("/inquiry/agent/ticket/byCollabChat", {"chatId": chat_id})
if not out.get("found") and not out.get("work_order_no"):
return None
return out
+133 -2
View File
@@ -33,6 +33,10 @@ class MemoryTicket:
status: str = "询价中"
quote: dict[str, Any] = field(default_factory=dict)
quote_version: int = 0
collab_chat_id: str = ""
collab_facts: dict[str, str] = field(default_factory=dict)
collab_member_ids: list[str] = field(default_factory=list)
quote_file_ready: bool = False
class MemoryLedger:
@@ -57,6 +61,9 @@ class MemoryLedger:
}
]
self.last_render: dict[str, Any] = {}
self._sea_staff: list[dict[str, Any]] = []
self._staff_by_wecom: dict[str, dict[str, Any]] = {}
self._staff_by_name: dict[str, dict[str, Any]] = {}
def _next_no(self) -> str:
self._seq += 1
@@ -135,7 +142,14 @@ class MemoryLedger:
"status": "询价中",
}
def query_tms(self, *, work_order_no: str, facts: dict[str, str]) -> dict[str, Any]:
def query_tms(
self,
*,
work_order_no: str,
facts: dict[str, str],
business_line: str = "",
) -> dict[str, Any]:
_ = business_line
dest = str(facts.get("目的港") or "")
force = str(facts.get("tms_force") or "")
if force == "1002" or force == "1003" or "无价" in dest or "歧义" in dest:
@@ -243,6 +257,10 @@ class MemoryLedger:
"fileBase64": "UEsDBAoAAAAAA",
"needsPdf": False,
}
with self._lock:
ticket = self._tickets.get((work_order_no or "").strip())
if ticket is not None:
ticket.quote_file_ready = True
self.last_render = out
return out
@@ -261,7 +279,13 @@ class MemoryLedger:
if status in {"已成交", "未成交", "协商中", "已关闭"}:
wait_phase = "done"
elif status == "已报价" and quote:
wait_phase = "wait_adopt"
# 文件已发给销售 → 成交跟进;否则海运协同卡、空运方案卡
if ticket.quote_file_ready or quote.get("quoteFileIssued"):
wait_phase = "wait_deal"
elif (ticket.business_line or "").upper() == "SEA":
wait_phase = "wait_collab"
else:
wait_phase = "wait_adopt"
else:
wait_phase = "tms_miss"
return {
@@ -274,4 +298,111 @@ class MemoryLedger:
"facts": dict(ticket.facts),
"quote": quote,
"wait_phase": wait_phase,
"collab_chat_id": ticket.collab_chat_id,
"collab_facts": dict(ticket.collab_facts),
}
def upsert_staff(self, row: dict[str, Any]) -> None:
"""单测注入任意岗位(含会话存档账号「询价机器人」)。"""
data = dict(row)
uid = str(data.get("wecomId") or data.get("wecom_id") or "").strip()
name = str(data.get("name") or "").strip()
with self._lock:
if uid:
self._staff_by_wecom[uid] = data
if name:
self._staff_by_name[name] = data
def set_sea_staff(self, rows: list[dict[str, Any]]) -> None:
"""单测注入海运产品岗。"""
with self._lock:
self._sea_staff = [dict(x) for x in rows]
for row in self._sea_staff:
uid = str(row.get("wecomId") or row.get("wecom_id") or "").strip()
name = str(row.get("name") or "").strip()
if uid:
self._staff_by_wecom[uid] = dict(row)
if name:
self._staff_by_name[name] = dict(row)
def match_sea_staff(self, *, origin: str, destination: str) -> list[dict[str, Any]]:
"""
岗位=产品·海运、启用:写了「海运·全部线路」必拉;
写了具体港口的,对得上本单起运港/目的港才拉。
"""
origin = (origin or "").strip()
dest = (destination or "").strip()
hits: list[dict[str, Any]] = []
with self._lock:
for row in self._sea_staff:
if str(row.get("roleCode") or row.get("role_code") or "") != "sea":
continue
if str(row.get("status") or "active") != "active":
continue
routes = str(row.get("routes") or "")
if "海运·全部线路" in routes or "海运全线" in routes:
hits.append(dict(row))
continue
if origin and origin in routes:
hits.append(dict(row))
continue
if dest and dest in routes:
hits.append(dict(row))
return hits
def get_staff_by_wecom_id(self, *, wecom_id: str) -> dict[str, Any]:
"""按企微 userid 取员工展示名。"""
with self._lock:
return dict(self._staff_by_wecom.get((wecom_id or "").strip()) or {})
def find_staff_by_name(self, *, name: str) -> dict[str, Any]:
"""按员工姓名取企微 userid。"""
with self._lock:
return dict(self._staff_by_name.get((name or "").strip()) or {})
def bind_collab_group(
self,
*,
work_order_no: str,
chat_id: str,
member_ids: list[str],
product_names: list[str] | None = None,
) -> dict[str, Any]:
"""记下协同群,不改六态。"""
_ = product_names
with self._lock:
ticket = self._tickets.get(work_order_no)
if ticket is None:
return {"ok": False, "error": "ticket_not_found"}
ticket.collab_chat_id = (chat_id or "").strip()
ticket.collab_member_ids = [str(x) for x in member_ids]
return {"ok": True, "chat_id": ticket.collab_chat_id}
def patch_collab_facts(
self,
*,
work_order_no: str,
facts: dict[str, str],
sender_id: str = "",
) -> dict[str, Any]:
"""合并协同字段。不改六态、不写报价版本。"""
_ = sender_id
with self._lock:
ticket = self._tickets.get(work_order_no)
if ticket is None:
return {"ok": False, "error": "ticket_not_found"}
ticket.collab_facts.update(
{k: v for k, v in (facts or {}).items() if str(v or "").strip()}
)
return {"ok": True, "facts": dict(ticket.collab_facts)}
def find_by_collab_chat(self, *, chat_id: str) -> MemoryTicket | None:
"""用工微群 chat_id 找回工单。"""
raw = (chat_id or "").strip()
if not raw:
return None
with self._lock:
for ticket in self._tickets.values():
if ticket.collab_chat_id == raw:
return ticket
return None
+21 -2
View File
@@ -22,11 +22,13 @@ _MODE_WORDS = (
# 仅「键:值」一行一条,键必须是合同字段或展示名
_LABEL_LINE = re.compile(
r"^(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
r"包装方式|包装类型|货量|整柜或拼柜|箱型箱量|报价日期|车型数量)\s*[::]\s*(.+)$"
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型数量|"
r"贸易条款|货好时间|HS编码|HS|是否含油|是否含电|是否含磁)\s*[::]\s*(.+)$"
)
_LABEL_INLINE = re.compile(
r"(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
r"包装方式|包装类型|货量|整柜或拼柜|箱型箱量|报价日期|车型数量)\s*[::]\s*(\S+)"
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型数量|"
r"贸易条款|货好时间|HS编码|HS|是否含油|是否含电|是否含磁)\s*[::]\s*(\S+)"
)
@@ -114,3 +116,20 @@ def extract_inquiry_snapshot(
"facts": facts,
"source": source,
}
def extract_collab_fields(text: str) -> dict[str, str]:
"""
群里协同字段:只收带标签的非空值。
闲聊、报价金额、附件本轮不进这里。HS 折成 HS编码。
"""
from agent.schema.field_validate import SEA_COLLAB_FIELDS, normalize_facts
labeled = normalize_facts(parse_labeled_facts(text))
out: dict[str, str] = {}
for key in SEA_COLLAB_FIELDS:
val = (labeled.get(key) or "").strip()
if val:
out[key] = val
return out
@@ -121,8 +121,9 @@ def build_extract_messages(text: str) -> list[dict[str, str]]:
"你是询价字段抽取器。只根据销售原话抽取字段,调用 extract_inquiry_fields。"
"未出现的字段必须空串,禁止编造港口、重量、日期。"
"空运件数/毛重/体积/包装方式;海运货量与箱型箱量;陆运按二级类型。"
"海运时 15件/20吨/3箱 必须写入货量,不要只写到件数把货量留空。"
"原话已带单位必须抽取,不要因为没写「毛重:」就留空:"
"10件/3pcs→件数;100KGS/180kg/1.5KG/100公斤→毛重;2CBM/4.2cbm/2.5立方/2方→体积;"
"10件/3pcs→空运件数;海运同样数字+件也要写入货量;100KGS/180kg/1.5KG/100公斤→毛重;2CBM/4.2cbm/2.5立方/2方→体积;"
"托盘/散货/卡板/纸箱/木箱→包装方式(销售说什么记什么,散货也要抽)。"
"9月15日/9月15号/2026-09-15→报价日期,规范成 yyyy-MM-dd,未写年用今年。"
"「从南京飞吉隆坡」起运港=南京、目的港=吉隆坡;「北京飞吉隆坡」起运港=北京。"
+67 -4
View File
@@ -45,11 +45,16 @@ class FlowSession:
status: str = ""
allowed: tuple[str, ...] = ()
immutable_text: str = ""
collab_chat_id: str = ""
collab_facts: dict[str, str] = field(default_factory=dict)
invited_userids: tuple[str, ...] = ()
invited_sales_name: str = ""
invited_product_names: tuple[str, ...] = ()
class AirTextInquiryFlow:
"""
空运文字询价 Owner(本轮产品范围);海运/陆运先补问运输方式或陆运二级。
空运文字询价 Owner。海运走 SeaTextInquiryFlow,不要再往本文件堆海运。陆运仍补问二级。
同一 sender 新开询价会覆盖「当前书签」(旧单留在主账)。
价格卡按工单号另存一份当时会话:点哪张卡就生成哪张卡当时的报价单。
@@ -69,6 +74,25 @@ class AirTextInquiryFlow:
with self._lock:
return self._by_sender.get(sender_id)
def session_by_ticket(self, sender_id: str, work_order_no: str) -> Optional[FlowSession]:
"""
只查进程内该书签,不读主账。
续办选空运/海运流程用:海运已跳过协同时必须找回 wait_adopt,
不能因为主账把已报价海运一律收成 wait_collab 就套空运协同卡。
"""
no = (work_order_no or "").strip().upper()
if not (sender_id or "").strip() or not no:
return None
with self._lock:
hit = self._by_ticket.get(no)
if hit and hit.sender_id == sender_id:
return hit
for key, sess in self._by_ticket.items():
if (key or "").upper() == no and sess.sender_id == sender_id:
return sess
return None
def session_for_action(
self,
sender_id: str,
@@ -152,6 +176,7 @@ class AirTextInquiryFlow:
else:
keep = None
if keep:
self._align_resume_phase(keep)
return self._replay_wait(keep, reply, source="session")
getter = getattr(self._ledger, "get_for_agent", None)
if not callable(getter):
@@ -165,6 +190,7 @@ class AirTextInquiryFlow:
reply("这张工单不属于当前账号,不能继续。")
return "ticket_forbidden"
sess = self._session_from_ledger(sender_id=sender_id, view=view)
self._align_resume_phase(sess)
self._save(sess)
return self._replay_wait(sess, reply, source="ledger")
@@ -204,6 +230,20 @@ class AirTextInquiryFlow:
return "wait_adopt"
return "tms_miss"
def _align_resume_phase(self, sess: FlowSession) -> None:
"""
报价文件已经发给销售,续办必须回到成交跟进。
主账六态仍是「已报价」,不能据此重放到协同卡或生成报价单。
Redis 就绪键由出文件时写入;主账 wait_phase=wait_deal 也认。
"""
if sess.phase not in {"wait_collab", "wait_adopt", "wait_file"}:
return
issued = bool((sess.quote or {}).get("quoteFileIssued"))
if issued or self._quote_file_already_out(sess.work_order_no):
logger.info("resume 报价文件已出,回到成交跟进 wo=%s was=%s", sess.work_order_no, sess.phase)
self._promote_wait_deal(sess)
def _replay_wait(self, sess: FlowSession, reply: ReplyFn, *, source: str) -> str:
"""按书签重出当时节点,不改六态。"""
logger.info(
@@ -213,18 +253,21 @@ class AirTextInquiryFlow:
source,
)
if sess.phase == "wait_adopt":
mode = copy.scheme_transport_mode(sess.business_line)
reply(
copy.scheme_card(
work_order_no=sess.work_order_no,
status=sess.status or "已报价",
facts=sess.facts,
quote=sess.quote,
transport_mode=mode,
),
copy.scheme_wecom_payload(
work_order_no=sess.work_order_no,
facts=sess.facts,
quote=sess.quote,
status=sess.status or "已报价",
transport_mode=mode,
),
)
return "wait_adopt"
@@ -349,9 +392,23 @@ class AirTextInquiryFlow:
if mode == "LAND":
reply(copy.ASK_LAND_SUBTYPE)
return "need_land_subtype"
# 海运本轮只提示先走空运闭环(不半做海运查价)
reply("本轮先开通空运文字询价。请按空运描述需求,或补充为「空运」。")
return "sea_deferred"
if mode == "SEA":
# 海运交给海运 Owner,空运不再回「本轮先开通空运」
from agent.policy.sea_text_flow import get_sea_text_flow
return get_sea_text_flow().on_text(
sender_id=sender_id,
text=text,
reply=reply,
injected_facts=facts,
injected_mode=mode,
b_result=b_result,
allow_b=allow_b,
chat_fn=chat_fn,
inbound_raw=inbound_raw,
)
reply("请补充运输方式:空运、海运或陆运。")
return "need_mode"
check = validate_required_fields(facts=facts, business_line="AIR")
facts = dict(check["facts"])
@@ -640,18 +697,21 @@ class AirTextInquiryFlow:
sess.allowed = (copy.BTN_ADOPT_EXCEL, copy.BTN_ADOPT_PDF, copy.BTN_REJECT)
sess.wait_version += 1
self._save(sess)
mode = copy.scheme_transport_mode(sess.business_line)
reply(
copy.scheme_card(
work_order_no=sess.work_order_no,
status=sess.status or "已报价",
facts=sess.facts,
quote=sess.quote,
transport_mode=mode,
),
copy.scheme_wecom_payload(
work_order_no=sess.work_order_no,
facts=sess.facts,
quote=sess.quote,
status=sess.status or "已报价",
transport_mode=mode,
),
)
return "wait_adopt"
@@ -763,6 +823,9 @@ class AirTextInquiryFlow:
file_extra,
)
self._wait_outbound_sent(str(out_id or ""), work_order_no=sess.work_order_no, what="报价文件")
from agent.redis_coord.quote_file import mark_quote_file_ready
mark_quote_file_ready(sess.work_order_no)
sess.phase = "wait_deal"
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
sess.wait_version += 1
+376 -10
View File
@@ -13,7 +13,7 @@ import time
import uuid
from typing import Any, Iterable
from agent.schema.field_validate import AIR_REQUIRED_FIELDS
from agent.schema.field_validate import AIR_REQUIRED_FIELDS, SEA_COLLAB_FIELDS, SEA_REQUIRED_FIELDS
# 空运补问:与合同 8 项一致。报价日期默认当天,不进待补充。
_AIR_EXAMPLES = {
@@ -30,11 +30,27 @@ _AIR_CLARIFY = tuple(
(key, label, _AIR_EXAMPLES[key]) for key, label in AIR_REQUIRED_FIELDS
)
# 海运补问:与合同 7 项一致。报价日期默认当天,不进待补充。
_SEA_EXAMPLES = {
"起运港": "上海",
"目的港": "洛杉矶",
"品名": "普货",
"货量": "20吨",
"整柜或拼柜": "整柜",
"箱型箱量": "1x40HQ",
"报价日期": "2026-09-15",
}
_SEA_CLARIFY = tuple(
(key, label, _SEA_EXAMPLES[key]) for key, label in SEA_REQUIRED_FIELDS
)
# 文字按钮(销售点卡或原样回复均可)
BTN_SKIP_COLLAB = "不需要协同,直接生成报价单"
BTN_SKIP_COLLAB_OLD = "不需要协同,直接生成基础报价单"
BTN_SKIP_COLLAB_KEY = "skip_collab"
BTN_SKIP_COLLAB_DONE = "已提交"
BTN_PULL_COLLAB = "拉产品进群协同"
BTN_PULL_COLLAB_KEY = "pull_collab"
BTN_ADOPT_EXCEL = "采用,生成Excel报价单"
BTN_ADOPT_PDF = "采用,生成PDF报价单"
BTN_REJECT = "不采用"
@@ -104,6 +120,14 @@ def _air_known_value(facts: dict[str, str], key: str) -> str:
return (src.get(key) or "").strip()
def _sea_known_value(facts: dict[str, str], key: str) -> str:
"""海运已识别字段的销售侧展示值。"""
src = facts or {}
if key == "货量":
return (src.get("货量") or src.get("货物数量") or "").strip()
return (src.get(key) or "").strip()
def ask_clarify(
*,
facts: dict[str, str] | None = None,
@@ -119,11 +143,14 @@ def ask_clarify(
skip = {"报价日期"}
missing = [k for k in missing_keys if k and k not in skip]
missing_set = set(missing)
sea = (transport_mode or "").strip() == "海运"
rows = _SEA_CLARIFY if sea else _AIR_CLARIFY
known = _sea_known_value if sea else _air_known_value
lines = ["请确认并补充以下信息:"]
if transport_mode:
lines.append(f"运输方式:{transport_mode}")
for key, label, _example in _AIR_CLARIFY:
val = _air_known_value(src, key)
for key, label, _example in rows:
val = known(src, key)
if val:
lines.append(f"{label}:{val}")
lines.append("")
@@ -132,7 +159,7 @@ def ask_clarify(
if "运输方式" in missing_set:
any_missing = True
lines.append("运输方式:(如:空运)")
for key, label, example in _AIR_CLARIFY:
for key, label, example in rows:
if key in missing_set:
any_missing = True
lines.append(f"{label}:(如:{example})")
@@ -155,6 +182,10 @@ def ask_missing(display_names: Iterable[str]) -> str:
"体积(CBM)": "体积",
"包装类型": "包装方式",
"包装方式": "包装方式",
"货物数量": "货量",
"货量": "货量",
"整柜或拼柜": "整柜或拼柜",
"箱型箱量": "箱型箱量",
}
keys: list[str] = []
for name in display_names:
@@ -207,6 +238,21 @@ def inquiry_field_lines(
cargo = (facts.get("品名") or "-").strip() or "-"
pack = (facts.get("包装方式") or facts.get("包装类型") or "-").strip() or "-"
quote_date = (facts.get("报价日期") or "-").strip() or "-"
if (transport_mode or "").strip() == "海运":
qty = (facts.get("货量") or facts.get("货物数量") or "-").strip() or "-"
load = (facts.get("整柜或拼柜") or "-").strip() or "-"
box = (facts.get("箱型箱量") or "-").strip() or "-"
return [
f"工单号:{work_order_no}",
f"运输方式:{transport_mode}",
f"起运港:{origin}",
f"目的港:{dest}",
f"品名:{cargo}",
f"货物数量:{qty}",
f"整柜或拼柜:{load}",
f"箱型箱量:{box}",
f"报价日期:{quote_date}",
]
return [
f"工单号:{work_order_no}",
f"运输方式:{transport_mode}",
@@ -221,14 +267,22 @@ def inquiry_field_lines(
]
def inquiry_card(*, work_order_no: str, facts: dict[str, str], first_or_same: str = "first") -> str:
def inquiry_card(
*,
work_order_no: str,
facts: dict[str, str],
first_or_same: str = "first",
transport_mode: str = "空运",
) -> str:
"""
建单后的询价确认。
无标题、无 [test]。first_or_same 保留兼容,不再对销售展示。
"""
_ = first_or_same
lines = inquiry_field_lines(work_order_no=work_order_no, facts=facts)
lines = inquiry_field_lines(
work_order_no=work_order_no, facts=facts, transport_mode=transport_mode
)
lines.append("工单已创建,正在查询TMS报价。")
return "\n".join(lines)
@@ -241,6 +295,10 @@ def canonical_button(raw: str) -> str:
# 新卡 EventKey 带工单号:skip_collab:WO…,避免点旧卡落到最新单
if text.startswith(f"{BTN_SKIP_COLLAB_KEY}:"):
return BTN_SKIP_COLLAB
if text in {BTN_PULL_COLLAB, BTN_PULL_COLLAB_KEY} or text.startswith(
f"{BTN_PULL_COLLAB_KEY}:"
):
return BTN_PULL_COLLAB
if text in {BTN_ADOPT_EXCEL, BTN_ADOPT_EXCEL_CARD, BTN_ADOPT_EXCEL_KEY} or text.startswith(
f"{BTN_ADOPT_EXCEL_KEY}:"
):
@@ -328,6 +386,7 @@ def _work_order_in_token(raw: str) -> str:
return ""
for prefix in (
f"{BTN_SKIP_COLLAB_KEY}:",
f"{BTN_PULL_COLLAB_KEY}:",
f"{BTN_ADOPT_EXCEL_KEY}:",
f"{BTN_ADOPT_PDF_KEY}:",
f"{BTN_REJECT_KEY}:",
@@ -416,6 +475,51 @@ def quote_area_fee_text(quote: dict[str, Any] | None) -> str:
return "\n".join(fee_lines)
# 企微引用条过长会被客户端裁成省略号。海运常有 6~8 项费用,必须走文本明细。
_QUOTE_AREA_SAFE_CHARS = 80
def quote_area_safe_text(quote: dict[str, Any] | None) -> str:
"""
卡片引用条:短报价原样;超长只留合计,避免半截费用名。
完整明细由 sea_quote_detail_text 另发一条文本。空运卡仍用 quote_area_fee_text。
"""
full = quote_area_fee_text(quote)
if len(full) <= _QUOTE_AREA_SAFE_CHARS:
return full
src = dict(quote or {})
header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip()
total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy)
n = len(quote_fee_lines(src))
if n:
return f"总销售价合计:{total}\n共{n}项费用,详见上一条明细"
return f"总销售价合计:{total}"
def sea_quote_detail_text(
*,
work_order_no: str,
quote: dict[str, Any],
first_or_same: str = "first",
history_work_order_no: str = "",
status: str = "已报价",
) -> str:
"""海运完整价格明细文本。不受卡片引用条字数限制。"""
src = dict(quote or {})
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
lines = [source, f"{work_order_no}价格明细"]
lines.extend(
quote_detail_lines(
src,
first_or_same=first_or_same,
history_work_order_no=history_work_order_no,
status=status,
)
)
return "\n".join(lines)
def collab_hint(work_order_no: str) -> str:
"""价格卡协同说明:无「内部协同」前缀,紧挨生成报价单按钮上方。"""
return (
@@ -681,7 +785,230 @@ def skip_collab_ack() -> str:
return "好的,已跳过内部协同。正在按 TMS 标准报价生成报价单草稿…"
def scheme_card(*, work_order_no: str, status: str, facts: dict[str, str], quote: dict[str, Any]) -> str:
def sea_collab_ask() -> str:
"""海运有价卡协同问句。"""
return "是否拉产品人员进群协同?"
def sea_miss_ask() -> str:
"""海运无价卡协同问句。"""
return "未查到合适报价,是否需要拉内部产品人员进群,进行手工协同报价?"
def sea_tms_hit_card(
*,
work_order_no: str,
quote: dict[str, Any],
first_or_same: str = "first",
history_work_order_no: str = "",
status: str = "已报价",
) -> str: