落地私聊空运询价与TMS报价回填,按后台模板出单。

查到价后把币种、单位、数量、单价、备注写入报价单,避免销售只看到光秃秃的金额。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-14 20:25:35 +08:00
co-authored by Cursor
parent cea3a6e51e
commit 99da1faf30
78 changed files with 8838 additions and 321 deletions
+180 -12
View File
@@ -54,6 +54,170 @@ class WeComAppClient:
self._token_expire_at: float = 0.0
self._lock = threading.Lock()
def send_outbound(
self,
*,
touser: str,
content: str,
payload: dict[str, Any] | None = None,
) -> WeComSendResult:
"""
按 outbox 内容发应用消息:有模板卡先发卡,失败再回退文本。
副作用:出站 HTTP 调企微;失败返回 ok=False,由 outbox 重试。
"""
extra = dict(payload or {})
if extra.get("msgtype") == "file":
return self.send_file(
touser=touser,
filename=str(extra.get("filename") or "quote.xlsx"),
filepath=str(extra.get("filepath") or ""),
file_b64=str(extra.get("file_b64") or extra.get("fileBase64") or ""),
)
if extra.get("msgtype") == "update_template_card":
return self.update_template_card(
touser=touser,
response_code=str(extra.get("response_code") or ""),
replace_name=str((extra.get("button") or {}).get("replace_name") or ""),
)
if extra.get("msgtype") == "template_card" and extra.get("template_card"):
card_result = self._post_message(
{
"touser": touser,
"msgtype": "template_card",
"agentid": self._agent_id,
"template_card": extra["template_card"],
"enable_id_trans": 0,
},
preview="[template_card]",
)
if card_result.ok:
return card_result
logger.warning(
"模板卡发送失败,回退文本 err=%s:%s",
card_result.errcode,
card_result.errmsg,
)
return self.send_text(touser=touser, content=content)
def update_template_card(
self,
*,
touser: str,
response_code: str,
replace_name: str,
) -> WeComSendResult:
"""
按回调 ResponseCode 更新原模板卡按钮文案,保持置灰不可再点。
失败不回落成新文本气泡,避免销售看到重复「已提交」。
"""
if not touser or not response_code or not replace_name:
return WeComSendResult(ok=True, errcode=0, errmsg="skip_card_update")
try:
token = self._get_token()
except Exception as exc: # noqa: BLE001
logger.exception("更新模板卡取 token 失败")
return WeComSendResult(ok=True, errcode=-1, errmsg=str(exc))
url = f"{self._api_base}/cgi-bin/message/update_template_card"
body = {
"userids": [touser],
"agentid": self._agent_id,
"response_code": response_code,
"enable_id_trans": 0,
"button": {"replace_name": replace_name},
}
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("企微 update_template_card 网络失败")
return WeComSendResult(ok=True, errcode=-1, errmsg=str(exc))
errcode = int(data.get("errcode") or 0)
errmsg = str(data.get("errmsg") or "")
if errcode != 0:
logger.warning("更新模板卡失败 errcode=%s errmsg=%s", errcode, errmsg)
return WeComSendResult(ok=True, errcode=errcode, errmsg=errmsg, raw=data)
logger.info("企微模板卡已置灰 touser=%s", touser)
return WeComSendResult(ok=True, errcode=0, errmsg=errmsg, raw=data)
def send_file(
self,
*,
touser: str,
filename: str,
filepath: str = "",
file_b64: str = "",
) -> WeComSendResult:
"""
先上传临时素材再发文件消息。
字节来自本机路径或 Base64;失败回 ok=False 由 outbox 重试。
禁止在回调线程调用;本方法只给出站消费线程用。
"""
if not touser:
return WeComSendResult(ok=False, errcode=-1, errmsg="touser 为空")
raw = b""
if filepath:
try:
from pathlib import Path
raw = Path(filepath).read_bytes()
except OSError as exc:
return WeComSendResult(ok=False, errcode=-1, errmsg=f"read_file:{exc}")
elif file_b64:
import base64
try:
raw = base64.b64decode(file_b64)
except Exception as exc: # noqa: BLE001
return WeComSendResult(ok=False, errcode=-1, errmsg=f"b64:{exc}")
if not raw:
return WeComSendResult(ok=False, errcode=-1, errmsg="file_empty")
media_id, up = self._upload_media(filename=filename or "quote.xlsx", content=raw)
if not media_id:
return up
return self._post_message(
{
"touser": touser,
"msgtype": "file",
"agentid": self._agent_id,
"file": {"media_id": media_id},
},
preview=f"[file]{filename}",
)
def _upload_media(self, *, filename: str, content: bytes) -> tuple[str, WeComSendResult]:
"""cgi-bin/media/upload;密钥不写日志。"""
if not self._corp_id or not self._secret:
return "", WeComSendResult(ok=False, errcode=-1, errmsg="WECOM_CORP_ID/SECRET 未配置")
try:
token = self._get_token()
except Exception as exc: # noqa: BLE001
logger.exception("上传素材取 token 失败")
return "", WeComSendResult(ok=False, errcode=-1, errmsg=str(exc))
url = f"{self._api_base}/cgi-bin/media/upload"
try:
with httpx.Client(timeout=max(self._timeout, 30.0)) as client:
resp = client.post(
url,
params={"access_token": token, "type": "file"},
files={"media": (filename, content, "application/octet-stream")},
)
data = resp.json()
except Exception as exc: # noqa: BLE001
logger.exception("企微 media/upload 网络失败")
return "", WeComSendResult(ok=False, errcode=-1, errmsg=str(exc))
errcode = int(data.get("errcode") or 0)
media_id = str(data.get("media_id") or "")
if errcode != 0 or not media_id:
logger.warning("企微上传文件失败 errcode=%s", errcode)
return "", WeComSendResult(
ok=False, errcode=errcode, errmsg=str(data.get("errmsg") or "upload_fail"), raw=data
)
return media_id, WeComSendResult(ok=True, errcode=0, raw=data)
def send_text(self, *, touser: str, content: str) -> WeComSendResult:
"""
向指定企微 userid 发送文本应用消息。
@@ -61,11 +225,23 @@ class WeComAppClient:
参数:touser 必须是入站 sender_id;content 测试环境应由上层加 [test] 前缀。
副作用:出站 HTTP 调企微;失败返回 ok=False,不抛给回调线程(由 outbox 重试)。
"""
if not self._corp_id or not self._secret:
return WeComSendResult(ok=False, errcode=-1, errmsg="WECOM_CORP_ID/SECRET 未配置")
if not touser or not content:
return WeComSendResult(ok=False, errcode=-1, errmsg="touser/content 为空")
return self._post_message(
{
"touser": touser,
"msgtype": "text",
"agentid": self._agent_id,
"text": {"content": content},
"safe": 0,
},
preview=content,
)
def _post_message(self, body: dict[str, Any], *, preview: str) -> WeComSendResult:
"""真正打企微 message/send;密钥不写日志。"""
if not self._corp_id or not self._secret:
return WeComSendResult(ok=False, errcode=-1, errmsg="WECOM_CORP_ID/SECRET 未配置")
try:
token = self._get_token()
except Exception as exc: # noqa: BLE001
@@ -73,13 +249,6 @@ class WeComAppClient:
return WeComSendResult(ok=False, errcode=-1, errmsg=str(exc))
url = f"{self._api_base}/cgi-bin/message/send"
body = {
"touser": touser,
"msgtype": "text",
"agentid": self._agent_id,
"text": {"content": content},
"safe": 0,
}
try:
with httpx.Client(timeout=self._timeout) as client:
resp = client.post(url, params={"access_token": token}, json=body)
@@ -91,15 +260,14 @@ class WeComAppClient:
errcode = int(data.get("errcode") or 0)
errmsg = str(data.get("errmsg") or "")
if errcode != 0:
# token 失效则清空缓存,下次重试
if errcode in (40014, 42001, 42007):
with self._lock:
self._token = None
self._token_expire_at = 0.0
logger.warning("企微发送失败 errcode=%s errmsg=%s", errcode, errmsg)
return WeComSendResult(ok=False, errcode=errcode, errmsg=errmsg, raw=data)
preview = content if len(content) <= 200 else (content[:200] + "…")
logger.info("企微发送成功 touser=%s reply=%s", touser, preview)
shown = preview if len(preview) <= 200 else (preview[:200] + "…")
logger.info("企微发送成功 touser=%s reply=%s", body.get("touser"), shown)
return WeComSendResult(ok=True, errcode=0, errmsg=errmsg, raw=data)
def _get_token(self) -> str:
+17 -3
View File
@@ -157,6 +157,7 @@ class PostgresMessageStore:
touser: str,
content: str,
dedupe_key: str = "",
payload: Optional[dict[str, Any]] = None,
) -> tuple[bool, str]:
if not touser or not content:
return False, "invalid_outbox"
@@ -167,9 +168,15 @@ class PostgresMessageStore:
"""
INSERT INTO agent_outbox (
id, dedupe_key, touser, content, payload, state
) VALUES (%s, %s, %s, %s, '{}'::jsonb, 'pending')
) VALUES (%s, %s, %s, %s, %s::jsonb, 'pending')
""",
(item_id, dedupe_key or "", touser, content),
(
item_id,
dedupe_key or "",
touser,
content,
json.dumps(dict(payload or {}), ensure_ascii=False),
),
)
conn.commit()
self._outbox_event.set()
@@ -209,7 +216,7 @@ class PostgresMessageStore:
FROM cte
WHERE o.id = cte.id
RETURNING o.id, o.touser, o.content, o.attempts, o.last_error,
o.created_at, o.dedupe_key, o.claimed_at
o.created_at, o.dedupe_key, o.claimed_at, o.payload
""",
(str(timeout), self._worker_id),
).fetchone()
@@ -221,6 +228,12 @@ class PostgresMessageStore:
created_ts = created.timestamp() if hasattr(created, "timestamp") else time.time()
claimed = row[7]
claimed_ts = claimed.timestamp() if hasattr(claimed, "timestamp") else time.time()
raw_payload = row[8] if len(row) > 8 else {}
if isinstance(raw_payload, str):
try:
raw_payload = json.loads(raw_payload)
except json.JSONDecodeError:
raw_payload = {}
return OutboxItem(
id=str(row[0]),
touser=str(row[1]),
@@ -231,6 +244,7 @@ class PostgresMessageStore:
last_error=str(row[4] or ""),
created_at=created_ts,
dedupe_key=str(row[6] or ""),
payload=dict(raw_payload or {}),
)
def complete_outbound(
+4 -1
View File
@@ -59,6 +59,7 @@ class OutboxItem:
last_error: str = ""
created_at: float = field(default_factory=time.time)
dedupe_key: str = ""
payload: dict[str, Any] = field(default_factory=dict)
class MemoryMessageStore:
@@ -140,8 +141,9 @@ class MemoryMessageStore:
touser: str,
content: str,
dedupe_key: str = "",
payload: Optional[dict[str, Any]] = None,
) -> tuple[bool, str]:
"""写入 outbox;dedupe_key 重复则跳过。"""
"""写入 outbox;dedupe_key 重复则跳过。payload 可带企微模板卡。"""
if not touser or not content:
return False, "invalid_outbox"
with self._lock:
@@ -154,6 +156,7 @@ class MemoryMessageStore:
touser=touser,
content=content,
dedupe_key=dedupe_key,
payload=dict(payload or {}),
)
self._outbox[item.id] = item
self._outbox_event.set()
+14 -7
View File
@@ -27,7 +27,7 @@ class ChannelRuntime:
企微通道运行时:inbox/outbox 各一条守护线程。
启动时机:FastAPI lifespan;停止:进程退出。
inbox:IntentRouter.route(默认 stub)→ dispatch 查表;无业务分支森林。
inbox:自由文字走 DeepSeek A 认意图(本线程,非回调);点卡不进模型。
"""
def __init__(self, settings: Settings) -> None:
@@ -35,7 +35,8 @@ class ChannelRuntime:
self._store = get_message_store()
self._stop = threading.Event()
self._threads: list[threading.Thread] = []
self._router = IntentRouter(allow_network=False)
# 开网后销售原话交给模型认「续办/新询价」;未开网才走 stub。
self._router = IntentRouter(allow_network=bool(settings.llm_allow_network))
agent_id = int(settings.wecom_agent_id or "0") or 0
self._client = WeComAppClient(
corp_id=settings.wecom_corp_id,
@@ -68,11 +69,13 @@ class ChannelRuntime:
self._store.wait_inbox(1.0)
continue
try:
decision = self._router.route(
text=item.message.content or "",
sender_id=item.message.sender_id or "",
message = item.message
decision = self._router.route_inbound(
text=message.content or "",
sender_id=message.sender_id or "",
event=str((message.raw or {}).get("event") or ""),
)
dispatch_inbound(item.message, decision)
dispatch_inbound(message, decision)
self._store.complete_inbound(item.id)
except Exception as exc: # noqa: BLE001
logger.exception("inbox 处理失败 id=%s", item.id)
@@ -91,7 +94,11 @@ class ChannelRuntime:
logger.warning("outbox 过期丢弃 id=%s", item.id)
self._store.complete_outbound(item.id)
continue
result = self._client.send_text(touser=item.touser, content=item.content)
result = self._client.send_outbound(
touser=item.touser,
content=item.content,
payload=getattr(item, "payload", None) or {},
)
if result.ok:
self._store.complete_outbound(item.id)
elif result.errcode in PERMANENT_SEND_ERRORS:
@@ -27,6 +27,7 @@ class MessageStore(Protocol):
touser: str,
content: str,
dedupe_key: str = "",
payload: Optional[dict[str, Any]] = None,
) -> tuple[bool, str]: ...
def claim_outbound(self) -> Optional[OutboxItem]: ...
+13 -2
View File
@@ -35,8 +35,13 @@ def parse_plain_xml(xml_text: str, *, default_agent_id: str = "") -> InboundMess
create_time = int(create_time_raw) if create_time_raw.isdigit() else 0
agent_id = _text(root, "AgentID") or _text(root, "AgentId") or default_agent_id
content = _text(root, "Content") if msg_type == "text" else ""
# 事件类可带 Event Key;骨架阶段仅记 raw,不进 Graph
# 事件类可带 Event Key;模板卡点击要把按钮键收成可入询价流程的正文
event = _text(root, "Event")
event_key = _text(root, "EventKey")
response_code = _text(root, "ResponseCode")
task_id = _text(root, "TaskId") or _text(root, "TaskID")
if not content and event.lower() in {"template_card_event", "click", "templatecardevent"}:
content = event_key
if not msg_id:
# 无 MsgId(如部分事件)时用发送人+时间拼幂等键,避免空键撞车
msg_id = f"{sender_id}:{create_time}:{msg_type}:{event}"
@@ -58,7 +63,13 @@ def parse_plain_xml(xml_text: str, *, default_agent_id: str = "") -> InboundMess
agent_id=agent_id,
create_time=create_time,
media={},
raw={"event": event, "xml": xml_text},
raw={
"event": event,
"event_key": event_key,
"response_code": response_code,
"task_id": task_id,
"xml": xml_text,
},
)
+2
View File
@@ -46,6 +46,8 @@ class Settings(BaseSettings):
biz_api_username: str = Field(default="", alias="BIZ_API_USERNAME")
biz_api_password: str = Field(default="", alias="BIZ_API_PASSWORD")
biz_api_timeout_seconds: float = Field(default=10.0, alias="BIZ_API_TIMEOUT_SECONDS")
# http=打 8180 真主账/真 TMS;memory=单测假账本
ledger_backend: str = Field(default="http", alias="LEDGER_BACKEND")
agent_callback_token: str = Field(default="", alias="AGENT_CALLBACK_TOKEN")
# 兼容现网变量名
inquiry_agent_callback_token: str = Field(default="", alias="INQUIRY_AGENT_CALLBACK_TOKEN")
+1 -1
View File
@@ -248,7 +248,7 @@ def main() -> int:
(lt.error or "")[:80],
)
vf = validate_required_fields_shell(facts={}, require_formal=True)
assert vf.get("implemented") is False
assert vf.get("implemented") is True
_ = list_template_registry()
s3 = S3ObjectStorage.from_settings(get_settings(), allow_network=False)
+3 -2
View File
@@ -2,9 +2,10 @@
Handler 包:一类意图 / 一类卡片一个模块。
禁止:单文件处理全部卡片;禁止把 Schema 校验复制粘贴进每个 handler(放 schema/policy)。
当前:echo 联调 + 其余意图空壳(待接 RouteDecision)。
当前:文字询价空运闭环 + echo 联调残留。
"""
from agent.handlers.echo_text import handle_echo_text
from agent.handlers.text_inquiry import handle_text_inquiry, handle_text_other
__all__ = ["handle_echo_text"]
__all__ = ["handle_echo_text", "handle_text_inquiry", "handle_text_other"]
+50 -11
View File
@@ -1,7 +1,8 @@
"""
意图:模板卡 / 补问卡动作(骨架)。
意图:询价卡片按钮(跳过协同 / 采用 / 不采用 / 成交)。
正式:校验 clarification token 绑定的 waitVersion + 允许动作集合。
本文件职责:校验当前会话允许的动作后交给流程执行。
禁止:不校验 wait/允许集合就改主账;禁止一个函数处理未声明的新按钮。
"""
from __future__ import annotations
@@ -9,6 +10,10 @@ from __future__ import annotations
import logging
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
logger = logging.getLogger(__name__)
@@ -19,14 +24,48 @@ def handle_card_action(
wait_version: int,
action: str,
payload: dict[str, Any] | None = None,
) -> None:
"""骨架:仅记日志;无 sender_id 直接返回。"""
flow=None,
store=None,
) -> str:
"""
处理一次卡片动作。
wait_version:会话书签版本;错版本丢弃,不重放副作用。
payload 须带 task_id / event_key,才能找回点卡当时的工单。
"""
_ = thread_id
if not (sender_id or "").strip():
return
logger.info(
"handler.card_action stub sender=%s thread=%s wait=%s action=%s",
sender_id,
thread_id,
wait_version,
action,
return "reject_no_sender"
engine = flow or get_air_text_flow()
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)
return "card_discard"
msg_store = store or get_message_store()
def reply(text: str, extra: dict | None = None) -> None:
# 销售询价正文不加 [test];[test] 只给运维告警
body = text
extra = dict(extra or {})
wo = ""
if sess is not None:
wo = (sess.work_order_no or "").strip()
if not wo:
wo = copy.work_order_from_card_meta(payload)
code = str((payload or {}).get("response_code") or extra.get("response_code") or "")[:24]
ok, info = msg_store.enqueue_outbound(
touser=sender_id,
content=body,
dedupe_key=(
f"card:{sender_id}:{wo}:{code}:"
f"{sess.wait_version if sess else 0}:{action}:{body[:20]}"
),
payload=extra,
)
if not ok:
logger.warning("card 出站被去重 sender=%s wo=%s info=%s", sender_id, wo or "-", info)
return engine.on_button(
sender_id=sender_id, action=action, reply=reply, card_meta=payload
)
@@ -1,24 +1,62 @@
"""
意图:继续本单 / Graph resume(骨架)。
意图:继续历史工单。
正式:校验 thread_id + waitVersion 后 Worker 槽 resume;禁止在 HTTP 回调线程 invoke。
本文件职责:把 DeepSeek A 认出的续办意图交给流程,按工单号回到当时停点。
工单号优先用路由结论;模型漏填时再从原话机读 WO 号(不在这里判断意图)。
禁止:在本文件抽询价字段;禁止回调线程跑完整 LLM;禁止直连 TMS。
"""
from __future__ import annotations
import logging
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
logger = logging.getLogger(__name__)
def handle_continue_thread(message: InboundMessage, *, thread_id: str = "") -> None:
"""骨架:仅记日志。"""
def handle_continue_thread(
message: InboundMessage,
*,
thread_id: str = "",
flow=None,
store=None,
) -> str:
"""
续办一张历史工单:回到该单当时节点的卡片/话术。
thread_id:RouteDecision 里的工单号。副作用:outbox 回复;读主账。
"""
if not message.has_sender():
return
logger.info(
"handler.continue_thread stub sender=%s thread_id=%s",
message.sender_id,
thread_id or "-",
logger.warning("continue_thread 拒绝:无 sender_id")
return "reject_no_sender"
msg_store = store or get_message_store()
engine = flow or get_air_text_flow()
replies: list[str] = []
def reply(text: str, extra: dict | None = None) -> None:
replies.append(text)
ok, info = msg_store.enqueue_outbound(
touser=message.sender_id,
content=text,
dedupe_key=f"cont:{message.message_id}:{len(replies)}",
payload=dict(extra or {}),
)
logger.info("continue_thread 出站 ok=%s info=%s", ok, info)
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,
reply=reply,
)
logger.info(
"continue_thread phase=%s sender=%s wo=%s",
phase,
message.sender_id,
wo or "-",
)
return phase
@@ -0,0 +1,96 @@
"""
意图:普通文字询价(空运闭环)。
本文件职责:入站文字 → 流程编排;回复只写入 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.policy.air_text_flow import get_air_text_flow
logger = logging.getLogger(__name__)
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,
) -> str:
"""
处理一条私聊文字询价。
须已有 sender_id。副作用:outbox 多条回复;主账经 flow.ledger。
返回:流程阶段名。
"""
if not message.has_sender():
logger.warning("text_inquiry 拒绝:无 sender_id")
return "reject_no_sender"
settings = get_settings()
msg_store = store or get_message_store()
engine = flow or get_air_text_flow()
replies: list[str] = []
def reply(text: str, extra: dict | None = None) -> None:
# 销售询价正文不加 [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])
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
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()
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)
body = copy.FIRST_CHAT
msg_store.enqueue_outbound(
touser=message.sender_id,
content=body,
dedupe_key=f"other:{message.message_id}",
)
return "text_other"
+45 -2
View File
@@ -12,7 +12,6 @@ from dataclasses import dataclass
from typing import Any, Optional
from agent.jobs.libreoffice import LibreOfficeConverter, LibreOfficeResult
from agent.jobs.quote_fill import fill_then_convert_pdf
from agent.redis_coord import get_redis_runtime, try_acquire_idempotency
from agent.redis_coord.keys import STREAM_PDF
from agent.redis_coord.runtime import RedisRuntime
@@ -40,6 +39,8 @@ def enqueue_pdf_job(
quote_version: int = 0,
idempotency_key: str = "",
redis: Optional[RedisRuntime] = None,
touser: str = "",
filename: str = "",
) -> PdfEnqueueResult:
"""Excel→PDF 任务入队;真正转换只在 LibreOffice 槽。"""
rt = redis or get_redis_runtime()
@@ -58,6 +59,8 @@ def enqueue_pdf_job(
"xlsx_path": xlsx_path,
"inquiry_no": inquiry_no,
"quote_version": quote_version,
"touser": touser,
"filename": filename,
}
entry_id = q.enqueue(payload=payload, idempotency_key=idempotency_key)
logger.info("pdf.enqueue id=%s inquiry=%s", entry_id, inquiry_no or "-")
@@ -84,6 +87,11 @@ def process_pdf_job(
from pathlib import Path
import tempfile
try:
from agent.jobs.quote_fill import fill_then_convert_pdf
except ImportError:
return LibreOfficeResult(ok=False, error="quote_fill_missing")
wd = Path(work_dir) if work_dir else Path(tempfile.mkdtemp(prefix="quote_fill_"))
fill, pdf = fill_then_convert_pdf(
source_xlsx=template_xlsx,
@@ -95,9 +103,44 @@ def process_pdf_job(
if not fill.ok:
return LibreOfficeResult(ok=False, error=f"fill_failed:{fill.error}")
assert pdf is not None
_deliver_quote_file(data, pdf)
return pdf
path = str(data.get("xlsx_path") or "")
if not path:
return LibreOfficeResult(ok=False, error="missing_xlsx_path")
return conv.convert_xlsx_to_pdf(path)
result = conv.convert_xlsx_to_pdf(path)
_deliver_quote_file(data, result)
return result
def _deliver_quote_file(data: dict[str, Any], result: LibreOfficeResult) -> None:
"""
转换成功后把 PDF 写入 outbox,由企微通道发文件。
无 touser 则只落盘(旧任务);失败不抛,避免卡 LibreOffice 槽。
"""
if not result.ok:
return
touser = str(data.get("touser") or "").strip()
if not touser:
return
wo = str(data.get("inquiry_no") or "")
filename = str(data.get("filename") or "").strip() or f"{wo}_quote.pdf"
try:
from agent.channel.queue import get_message_store
from agent.policy.inquiry_copy import file_ready
store = get_message_store()
store.enqueue_outbound(
touser=touser,
content=file_ready(kind="PDF", work_order_no=wo, filename=filename),
dedupe_key=f"pdf-file:{wo}:{filename}",
payload={
"msgtype": "file",
"filepath": result.pdf_path,
"filename": filename,
},
)
except Exception:
logger.exception("PDF 出站入队失败 inquiry=%s", wo)
+13 -4
View File
@@ -47,17 +47,24 @@ class LedgerClient:
def from_settings(cls, settings: Optional[Settings] = None) -> "LedgerClient":
"""从环境组装客户端。"""
cfg = settings or get_settings()
token = (
getattr(cfg, "biz_api_token", None)
or getattr(cfg, "agent_callback_token", None)
or getattr(cfg, "inquiry_agent_callback_token", None)
or ""
)
return cls(
base_url=(cfg.biz_api_base_url or "").rstrip("/"),
timeout_seconds=float(getattr(cfg, "biz_api_timeout_seconds", 10) or 10),
service_token=(getattr(cfg, "biz_api_token", None) or ""),
service_token=str(token),
)
def _http(self) -> httpx.Client:
if self._client is None:
headers = {"Accept": "application/json"}
if self.service_token:
headers["X-Access-Token"] = self.service_token
# 回调令牌不是 Jeecg 登录 JWT。写入 X-Access-Token 会被 JwtFilter 验签失败并 401。
headers["X-Agent-Token"] = self.service_token
self._client = httpx.Client(
base_url=self.base_url,
timeout=self.timeout_seconds,
@@ -123,7 +130,9 @@ class LedgerClient:
"""
headers: dict[str, str] = {}
if idempotency_key:
headers["X-Idempotency-Key"] = idempotency_key
from agent.schema.fingerprint import ascii_idempotency_key
headers["X-Idempotency-Key"] = ascii_idempotency_key(idempotency_key)
try:
resp = self._http().request(
method.upper(),
@@ -145,7 +154,7 @@ class LedgerClient:
path,
)
return LedgerResponse(ok=ok, status_code=resp.status_code, data=data)
except httpx.HTTPError as exc:
except (httpx.HTTPError, UnicodeEncodeError, ValueError) as exc:
logger.warning("ledger.request 异常 path=%s err=%s", path, exc)
return LedgerResponse(ok=False, status_code=0, error=str(exc))
+184
View File
@@ -0,0 +1,184 @@
"""
主账 HTTP 账本:建单 / 查价 / 报价 / 六态只打 8180。
本文件职责:把 MemoryLedger 同名方法接到 /inquiry/agent/**;查价走真 TMS(主账出站)。
禁止:直连 TMS / MySQL;把 Jeecg 包装失败当成有价。
"""
from __future__ import annotations
import logging
from typing import Any, Optional
from agent.ledger import LedgerClient
logger = logging.getLogger(__name__)
def _unwrap(data: Any) -> dict[str, Any]:
"""解开 Jeecg Result:{success, result} → result。"""
if not isinstance(data, dict):
return {}
if "result" in data and isinstance(data.get("result"), dict):
inner = data["result"]
if data.get("success") is False:
return {"ok": False, "error": str(data.get("message") or "ledger_error"), **inner}
return inner
return data
class HttpLedger:
"""
生产主账客户端。
超时随 LedgerClient;查价在主账线程打 TMS,本类只转发分类。
"""
def __init__(self, client: LedgerClient | None = None) -> None:
self._client = client or LedgerClient.from_settings()
def _post(self, path: str, body: dict[str, Any], *, idem: str = "") -> dict[str, Any]:
resp = self._client.request_json("POST", path, json_body=body, idempotency_key=idem)
if not resp.ok:
logger.warning("http_ledger fail path=%s status=%s err=%s", path, resp.status_code, resp.error)
return {"ok": False, "error": resp.error or f"http_{resp.status_code}"}
return _unwrap(resp.data)
def find_similar_open(
self,
*,
facts: dict[str, str],
business_line: str,
exclude_work_order_no: str = "",
) -> dict[str, Any]:
"""全库相同需求检索;exclude_work_order_no 排除刚建的本单。"""
out = self._post(
"/inquiry/agent/ticket/findSimilar",
{
"salesWecomId": "",
"businessLine": business_line,
"facts": facts,
"excludeWorkOrderNo": exclude_work_order_no,
},
)
if "found" not in out:
return {"found": False, "first_or_same": "first", "work_order_no": "", "status": ""}
return out
def create_ticket(
self,
*,
sender_id: str,
business_line: str,
facts: dict[str, str],
idempotency_key: str = "",
) -> dict[str, Any]:
out = self._post(
"/inquiry/agent/ticket/create",
{
"salesWecomId": sender_id,
"businessLine": business_line,
"facts": facts,
"idempotencyKey": idempotency_key,
},
idem=idempotency_key,
)
if not out.get("ok") and not out.get("work_order_no"):
return {"ok": False, "created": False, "work_order_no": "", "status": "", "first_or_same": "first"}
out.setdefault("ok", True)
if out.get("similar_work_order_no"):
out.setdefault("first_or_same", "same")
else:
out.setdefault("first_or_same", "first")
return out
def get_for_agent(self, *, work_order_no: str, sender_id: str) -> dict[str, Any]:
"""读主账工单快照,供续办回到当时节点。"""
out = self._post(
"/inquiry/agent/ticket/get",
{"work_order_no": work_order_no, "salesWecomId": sender_id},
)
if not out.get("found") and not out.get("ok"):
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]:
out = self._post(
"/inquiry/agent/tms/query",
{"work_order_no": work_order_no, "facts": facts},
idem=f"tms:{work_order_no}",
)
if "classification" not in out and not out.get("ok"):
return {
"ok": False,
"classification": "TMS_TECH_FAILURE",
"has_price": False,
"quote": {},
}
out.setdefault("quote", {})
return out
def upsert_quote(
self,
*,
work_order_no: str,
quote: dict[str, Any],
to_status: str = "",
) -> dict[str, Any]:
# 有价时主账 query 已写快照并迁「已报价」;此处只做幂等确认,避免二次假写入。
_ = (work_order_no, quote, to_status)
return {"ok": True, "quote_version": 1, "status": to_status or "已报价"}
def render_quote(
self,
*,
work_order_no: str,
business_line: str,
facts: dict[str, Any],
quote: Optional[dict[str, Any]] = None,
format: str = "xlsx",
sales_wecom_id: str = "",
) -> dict[str, Any]:
"""
打主账按后台报价模板填 Excel。
失败(无关键词且无默认)原样带回销售话术。Java 不转 PDF。
"""
from agent.quote_templates.match import FAIL_MESSAGE
# 填表+回文件可能超过默认 10s,单独放宽,仍禁止无限等。
prev_timeout = self._client.timeout_seconds
self._client.timeout_seconds = max(float(prev_timeout or 10), 45.0)
self._client.close()
try:
out = self._post(
"/inquiry/agent/quote/render",
{
"workOrderNo": work_order_no,
"businessLine": business_line,
"facts": facts,
"quote": quote or {},
"salesWecomId": sales_wecom_id,
},
idem=f"quote-render:{work_order_no}:{format}",
)
finally:
self._client.timeout_seconds = prev_timeout
self._client.close()
if not out.get("ok"):
return {
"ok": False,
"error": out.get("error") or "NO_TEMPLATE",
"message": out.get("message") or FAIL_MESSAGE,
}
out.setdefault("needsPdf", str(format).lower() == "pdf")
return out
def transition(self, *, work_order_no: str, to_status: str) -> dict[str, Any]:
out = self._post(
"/inquiry/agent/ticket/transition",
{"workOrderNo": work_order_no, "toStatus": to_status},
)
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"}
+277
View File
@@ -0,0 +1,277 @@
"""
主账内存假账本(单测 / 无 8180 时)。
本文件职责:实现建单(每次新开)、相似检索、TMS fixture、报价写入、六态流转,语义对齐 Java 主账。
禁止:冒充已打真实 TMS;禁止被生产 Worker 当默认 Owner(生产必须 HTTP 主账)。
"""
from __future__ import annotations
import logging
import threading
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Optional
from zoneinfo import ZoneInfo
from agent.schema.fingerprint import inquiry_fingerprint
logger = logging.getLogger(__name__)
_SHANGHAI = ZoneInfo("Asia/Shanghai")
@dataclass
class MemoryTicket:
"""内存工单。"""
work_order_no: str
sales_wecom_id: str
business_line: str
facts: dict[str, str]
fingerprint: str
status: str = "询价中"
quote: dict[str, Any] = field(default_factory=dict)
quote_version: int = 0
class MemoryLedger:
"""
线程安全内存主账。
TMS fixture:facts.tms_force=1002 或目的港含「无价」→ 无价;否则有价。
"""
def __init__(self) -> None:
self._lock = threading.Lock()
self._tickets: dict[str, MemoryTicket] = {}
self._seq = 0
# 单测默认同业务有一张默认模板,避免「生成 Excel」误走失败话术
self._templates: list[dict[str, Any]] = [
{
"templateId": "mem-default-air",
"name": "空运默认",
"bizType": "空运",
"keywords": [],
"isDefault": True,
}
]
self.last_render: dict[str, Any] = {}
def _next_no(self) -> str:
self._seq += 1
day = datetime.now(_SHANGHAI).strftime("%Y%m%d")
return f"WO{day}{self._seq:04d}"
def find_similar_open(
self,
*,
facts: dict[str, str],
business_line: str,
exclude_work_order_no: str = "",
) -> dict[str, Any]:
"""
全库相同需求检索:需求指纹一模一样才算,不限六态。
多条只回最新一条;exclude_work_order_no 排除刚建的本单。
只供卡片展示,不复用当前工单号。
"""
fp = inquiry_fingerprint(facts, business_line=business_line)
skip = (exclude_work_order_no or "").strip()
latest: MemoryTicket | None = None
with self._lock:
for t in self._tickets.values():
if t.fingerprint != fp:
continue
if skip and t.work_order_no == skip:
continue
latest = t
if latest is None:
return {"found": False, "first_or_same": "first", "work_order_no": "", "status": ""}
return {
"found": True,
"first_or_same": "same",
"work_order_no": latest.work_order_no,
"status": latest.status,
}
def create_ticket(
self,
*,
sender_id: str,
business_line: str,
facts: dict[str, str],
idempotency_key: str = "",
) -> dict[str, Any]:
_ = idempotency_key
# 出询价确认必须新开一单,相同需求不复用未关闭工单
fp = inquiry_fingerprint(facts, business_line=business_line)
with self._lock:
no = self._next_no()
ticket = MemoryTicket(
work_order_no=no,
sales_wecom_id=sender_id,
business_line=business_line,
facts=dict(facts),
fingerprint=fp,
)
self._tickets[no] = ticket
similar = self.find_similar_open(
facts=facts, business_line=business_line, exclude_work_order_no=no
)
logger.info(
"memory_ledger.create %s first_or_same=%s similar=%s",
no,
similar.get("first_or_same"),
similar.get("work_order_no"),
)
return {
"ok": True,
"created": True,
"first_or_same": similar.get("first_or_same") or "first",
"found": bool(similar.get("found")),
"similar_work_order_no": similar.get("work_order_no") or "",
"work_order_no": no,
"status": "询价中",
}
def query_tms(self, *, work_order_no: str, facts: dict[str, str]) -> dict[str, Any]:
dest = str(facts.get("目的港") or "")
force = str(facts.get("tms_force") or "")
if force == "1002" or force == "1003" or "无价" in dest or "歧义" in dest:
return {
"ok": True,
"classification": "TMS_NO_QUOTE_1002",
"has_price": False,
"quote": {},
}
if force == "tech":
return {"ok": False, "classification": "TMS_TECH_FAILURE", "has_price": False, "quote": {}}
quote = {
"freight": "USD 1,200",
"other_fee": "USD 80",
"total": "USD 1,280",
"eta": "3-5 天",
"valid_until": "2026-09-20",
"source": "TMS",
"source_label": "TMS 标准报价",
"fee_lines": [
{"name": "空运费", "amount": "USD 1,200"},
{"name": "其他附加费", "amount": "USD 80"},
],
}
return {
"ok": True,
"classification": "HAS_PRICE",
"has_price": True,
"quote": quote,
}
def upsert_quote(
self,
*,
work_order_no: str,
quote: dict[str, Any],
to_status: str = "",
) -> dict[str, Any]:
with self._lock:
ticket = self._tickets.get(work_order_no)
if ticket is None:
return {"ok": False, "error": "ticket_not_found"}
ticket.quote_version += 1
ticket.quote = dict(quote)
if to_status:
ticket.status = to_status
return {
"ok": True,
"quote_version": ticket.quote_version,
"status": ticket.status,
}
def transition(self, *, work_order_no: str, to_status: str) -> dict[str, Any]:
with self._lock:
ticket = self._tickets.get(work_order_no)
if ticket is None:
return {"ok": False, "error": "ticket_not_found"}
ticket.status = to_status
return {"ok": True, "status": ticket.status}
def get_ticket(self, *, work_order_no: str) -> MemoryTicket | None:
with self._lock:
return self._tickets.get(work_order_no)
def set_quote_templates(self, templates: list[dict[str, Any]]) -> None:
"""单测注入后台模板列表;空列表=未维护关键词也未维护默认。"""
with self._lock:
self._templates = list(templates)
def render_quote(
self,
*,
work_order_no: str,
business_line: str,
facts: dict[str, Any],
quote: Optional[dict[str, Any]] = None,
format: str = "xlsx",
sales_wecom_id: str = "",
) -> dict[str, Any]:
"""
按询价需求匹配内存模板。不填真实 xlsx,只回假字节供流程/单测。
"""
from agent.quote_templates.match import FAIL_MESSAGE, pick_quote_template
_ = sales_wecom_id
with self._lock:
templates = list(self._templates)
picked = pick_quote_template(
templates=templates,
business_line=business_line,
facts=facts,
quote=quote,
)
if picked is None:
out = {"ok": False, "error": "NO_TEMPLATE", "message": FAIL_MESSAGE}
self.last_render = out
return out
ext = "pdf" if str(format).lower() == "pdf" else "xlsx"
out = {
"ok": True,
"matchType": picked.match_type,
"templateId": picked.template_id,
"templateName": picked.name,
"fileName": f"{work_order_no}_quote.{ext}",
"fileBase64": "UEsDBAoAAAAAA",
"needsPdf": False,
}
self.last_render = out
return out
def get_for_agent(self, *, work_order_no: str, sender_id: str) -> dict[str, Any]:
"""
智能体续办读取:工单是否存在、是否本人、字段/报价/推断停点。
"""
no = (work_order_no or "").strip()
ticket = self.get_ticket(work_order_no=no)
if ticket is None:
return {"ok": False, "found": False, "owned": False, "work_order_no": no}
owned = ticket.sales_wecom_id == sender_id
status = ticket.status
quote = dict(ticket.quote or {})
wait_phase = "done"
if status in {"已成交", "未成交", "协商中", "已关闭"}:
wait_phase = "done"
elif status == "已报价" and quote:
wait_phase = "wait_adopt"
else:
wait_phase = "tms_miss"
return {
"ok": True,
"found": True,
"owned": owned,
"work_order_no": ticket.work_order_no,
"status": status,
"business_line": ticket.business_line,
"facts": dict(ticket.facts),
"quote": quote,
"wait_phase": wait_phase,
}
+116
View File
@@ -0,0 +1,116 @@
"""
文字询价字段抽取入口。
本文件职责:生产走 DeepSeek B;单测可注入 facts;无网时只认「键:值」结构化行,不猜港口。
禁止:用正则把「上海到洛杉矶」拆成起运目的;禁止改六态。
"""
from __future__ import annotations
import logging
import re
from typing import Any, Optional
logger = logging.getLogger(__name__)
_MODE_WORDS = (
("空运", "AIR"),
("海运", "SEA"),
("陆运", "LAND"),
)
# 仅「键:值」一行一条,键必须是合同字段或展示名
_LABEL_LINE = re.compile(
r"^(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
r"包装方式|包装类型|货量|整柜或拼柜|箱型箱量|报价日期|车型数量)\s*[::]\s*(.+)$"
)
_LABEL_INLINE = re.compile(
r"(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
r"包装方式|包装类型|货量|整柜或拼柜|箱型箱量|报价日期|车型数量)\s*[::]\s*(\S+)"
)
def detect_transport_mode(text: str) -> str:
"""
运输方式:空运/海运/陆运,或空运口语「飞」(南京飞吉隆坡)。
写了海运/陆运以书面为准。只说飞就是空运,补问件数时不得再问运输方式。
"""
raw = text or ""
found = [code for word, code in _MODE_WORDS if word in raw]
if len(found) == 1:
return found[0]
if len(found) > 1:
return ""
if "飞" in raw:
return "AIR"
return ""
def parse_labeled_facts(text: str) -> dict[str, str]:
"""只采集「字段名:值」,整行优先;同一句里的标签也收。不猜港口。"""
facts: dict[str, str] = {}
raw = text or ""
for line in raw.splitlines():
m = _LABEL_LINE.match(line.strip())
if m:
facts[m.group(1)] = m.group(2).strip()
if facts:
return facts
for m in _LABEL_INLINE.finditer(raw):
facts[m.group(1)] = m.group(2).strip()
return facts
def extract_inquiry_snapshot(
text: str,
*,
injected_facts: Optional[dict[str, Any]] = None,
injected_mode: str = "",
b_result: Optional[dict[str, Any]] = None,
allow_b: bool = False,
chat_fn: Optional[Any] = None,
) -> dict[str, Any]:
"""
抽出业务线 + 字段快照。
优先 injected_*(单测);其次合并标签行与 DeepSeek B(口语)。
标签行覆盖 B 的同名字段。B 未出现的字段保持空,不本地猜港口。
件数/毛重/体积若原话已带 TMS 单位(件/KGS/CBM),空字段可机读补上。
"""
from agent.schema.field_validate import harvest_oral_measures, normalize_facts
if injected_facts is not None:
mode = (injected_mode or detect_transport_mode(text) or "").upper()
return {
"business_line": mode,
"land_subtype": "",
"facts": harvest_oral_measures(text, normalize_facts(dict(injected_facts))),
"source": "injected",
}
mode = detect_transport_mode(text)
facts = parse_labeled_facts(text)
source = "labeled_or_mode"
b_payload = b_result
if b_payload is None and (allow_b or chat_fn is not None):
from agent.llm.mode_extract_fields import invoke_extract_fields
b_payload = invoke_extract_fields(
text, allow_network=allow_b, chat_fn=chat_fn
)
if b_payload and b_payload.get("ok"):
merged = dict(b_payload.get("facts") or {})
merged.update(facts)
facts = merged
mode = mode or str(b_payload.get("business_line") or "")
source = "deepseek_b"
facts = harvest_oral_measures(text, normalize_facts(facts))
if not mode and facts.get("起运港") and facts.get("目的港") and "飞" in (text or ""):
mode = "AIR"
logger.info("extract.snapshot mode=%s keys=%s src=%s", mode or "-", sorted(facts), source)
return {
"business_line": mode,
"land_subtype": (b_payload or {}).get("land_subtype") or "",
"facts": facts,
"source": source,
}
+8 -1
View File
@@ -119,15 +119,22 @@ class LlmHttpClient:
body["tools"] = tools
if tool_choice is not None:
body["tool_choice"] = tool_choice
# deepseek-v4-flash 思考模式拒绝 named/required tool_choice,抽字段会 400
if provider == "deepseek":
body["thinking"] = {"type": "disabled"}
try:
with httpx.Client(timeout=self.timeout_seconds) as client:
resp = client.post(url, headers=headers, json=body)
data = resp.json() if resp.content else {}
if resp.status_code >= 400:
err_msg = ""
if isinstance(data, dict) and isinstance(data.get("error"), dict):
err_msg = str(data["error"].get("message") or "")[:200]
logger.warning(
"llm.http 失败 provider=%s status=%s",
"llm.http 失败 provider=%s status=%s err=%s",
provider,
resp.status_code,
err_msg,
)
return LlmHttpResult(
ok=False,
+176 -7
View File
@@ -1,31 +1,149 @@
"""
DeepSeek B:文本字段抽取 mode 壳。
DeepSeek B:文字询价字段抽取。
本文件职责:声明 mode 元数据与空 invoke_shell;不写字段合同激活规则。
禁止:在本文件改主账六态。
本文件职责:定义抽取 tool、解析模型 JSON、可选真网调用。
禁止:本地正则猜港口/箱型;禁止改六态;未确认数据用途不得出站。
"""
from __future__ import annotations
from typing import Any
import json
import logging
from typing import Any, Optional
from agent.llm.modes import LlmMode
logger = logging.getLogger(__name__)
MODE = LlmMode.EXTRACT_FIELDS
EXTRACT_TOOL_NAME = "extract_inquiry_fields"
_FIELD_KEYS = (
"起运港",
"目的港",
"品名",
"件数",
"货量",
"毛重",
"体积",
"包装方式",
"报价日期",
"整柜或拼柜",
"箱型箱量",
"车型数量",
)
def mode_meta() -> dict[str, Any]:
"""返回本 mode 的框架元数据(无 prompt 正文业务)。"""
"""返回本 mode 元数据。"""
return {
"mode": MODE.value,
"provider": "deepseek",
"purpose": "text_field_extract",
"implemented": False,
"implemented": True,
}
def extract_tool_definition() -> dict[str, Any]:
"""
DeepSeek B 唯一抽取 tool。
模型必须调用本 tool;不得输出给销售看的正文。
未在原话出现的字段填空串,禁止编造。
"""
field_props = {k: {"type": "string"} for k in _FIELD_KEYS}
return {
"type": "function",
"function": {
"name": EXTRACT_TOOL_NAME,
"description": "从销售原话抽取询价字段,未出现的填空串",
"parameters": {
"type": "object",
"additionalProperties": False,
"properties": {
"transport_mode": {
"type": "string",
"enum": ["AIR", "SEA", "LAND", "UNKNOWN"],
},
"land_subtype": {"type": "string"},
"fields": {
"type": "object",
"additionalProperties": False,
"properties": field_props,
},
},
"required": ["transport_mode", "fields"],
},
},
}
def parse_extract_payload(raw: Any) -> dict[str, Any]:
"""
把模型 tool 参数或 JSON 正文变成 business_line + facts。
空串字段丢弃。非法结构返回 ok=False,不猜。
"""
data = raw
if isinstance(raw, str):
try:
data = json.loads(raw)
except json.JSONDecodeError:
return {"ok": False, "error": "invalid_json", "facts": {}, "business_line": ""}
if not isinstance(data, dict):
return {"ok": False, "error": "not_object", "facts": {}, "business_line": ""}
mode = str(data.get("transport_mode") or "").strip().upper()
if mode == "UNKNOWN":
mode = ""
fields_in = data.get("fields") if isinstance(data.get("fields"), dict) else {}
facts: dict[str, str] = {}
for key in _FIELD_KEYS:
val = str(fields_in.get(key) or "").strip()
if val:
facts[key] = val
land = str(data.get("land_subtype") or "").strip()
return {
"ok": True,
"business_line": mode if mode in {"AIR", "SEA", "LAND"} else "",
"land_subtype": land,
"facts": facts,
"source": "deepseek_b",
}
def build_extract_messages(text: str) -> list[dict[str, str]]:
"""B 的系统/用户消息。只抽合同字段,不写销售可见话术。"""
return [
{
"role": "system",
"content": (
"你是询价字段抽取器。只根据销售原话抽取字段,调用 extract_inquiry_fields。"
"未出现的字段必须空串,禁止编造港口、重量、日期。"
"空运件数/毛重/体积/包装方式;海运货量与箱型箱量;陆运按二级类型。"
"原话已带单位必须抽取,不要因为没写「毛重:」就留空:"
"10件/3pcs→件数;100KGS/180kg/1.5KG/100公斤→毛重;2CBM/4.2cbm/2.5立方/2方→体积;"
"托盘/散货/卡板/纸箱/木箱→包装方式(销售说什么记什么,散货也要抽)。"
"9月15日/9月15号/2026-09-15→报价日期,规范成 yyyy-MM-dd,未写年用今年。"
"「从南京飞吉隆坡」起运港=南京、目的港=吉隆坡;「北京飞吉隆坡」起运港=北京。"
"换城市必须覆盖旧值,不要沿用上一句的港口。"
"运输方式仅在原话能判断时填 AIR/SEA/LAND,否则 UNKNOWN。"
),
},
{"role": "user", "content": text},
]
def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]:
"""空壳:不调模型,返回未实现。"""
"""
无网入口:有 assistant_json / tool_arguments 则解析;否则保持骨架错误码。
"""
raw = payload.get("assistant_json") or payload.get("tool_arguments")
if raw is not None:
parsed = parse_extract_payload(raw)
parsed["stub"] = True
parsed["mode"] = MODE.value
return parsed
return {
"ok": False,
"stub": True,
@@ -33,3 +151,54 @@ def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]:
"error": "extract_fields_shell_not_implemented",
"echo_keys": sorted(payload.keys()),
}
def invoke_extract_fields(
text: str,
*,
allow_network: bool = False,
chat_fn: Optional[Any] = None,
) -> dict[str, Any]:
"""
调用 DeepSeek B 抽字段。
chat_fn:单测注入,签名同 LlmHttpClient.chat。
allow_network=False 且无 chat_fn:不调外网。
"""
if chat_fn is None and not allow_network:
return {"ok": False, "error": "b_network_disabled", "facts": {}, "business_line": ""}
from agent.llm.http_client import LlmHttpClient
from agent.llm.modes import LlmMode
client = LlmHttpClient.from_settings()
chat = chat_fn or client.chat
result = chat(
mode=LlmMode.EXTRACT_FIELDS,
messages=build_extract_messages(text),
tools=[extract_tool_definition()],
tool_choice={"type": "function", "function": {"name": EXTRACT_TOOL_NAME}},
)
if not getattr(result, "ok", False):
logger.info("extract.b 失败 err=%s", getattr(result, "error", ""))
return {
"ok": False,
"error": getattr(result, "error", "b_failed"),
"facts": {},
"business_line": "",
}
raw_args: Any = None
for call in result.tool_calls or []:
fn = call.get("function") or {}
if fn.get("name") == EXTRACT_TOOL_NAME:
raw_args = fn.get("arguments")
break
if raw_args is None and result.content:
raw_args = result.content
parsed = parse_extract_payload(raw_args)
logger.info(
"extract.b ok=%s mode=%s keys=%s",
parsed.get("ok"),
parsed.get("business_line") or "-",
sorted((parsed.get("facts") or {}).keys()),
)
return parsed
+794
View File
@@ -0,0 +1,794 @@
"""
空运文字询价确定性流程(模型只负责抽字段,本文件只编排与执行)。
本文件职责:补问到询价必填齐全 → 建单出卡片 → 必须打主账查 TMS(有价/暂无报价)。
对不上机场也不能跳过查价、本地说暂无报价。
调用:handler 在 Worker/inbox 线程;禁止回调线程同步跑。
禁止:直连 TMS;用图状态冒充主账成功;用户文字冒充文件已生成。
"""
from __future__ import annotations
import logging
import threading
import uuid
from dataclasses import dataclass, field
from typing import Any, Callable, Optional
from agent.config import get_settings
from agent.ledger.http_ledger import HttpLedger
from agent.ledger.memory_ledger import MemoryLedger
from agent.llm.extract_text import extract_inquiry_snapshot
from agent.policy import inquiry_copy as copy
from agent.schema.field_validate import validate_required_fields
from agent.schema.tms_air_query import assemble_air_query
logger = logging.getLogger(__name__)
ReplyFn = Callable[..., None]
@dataclass
class FlowSession:
"""一单会话书签(运行账,不是六态真相)。"""
sender_id: str
thread_id: str
wait_version: int = 0
phase: str = "idle"
business_line: str = ""
facts: dict[str, str] = field(default_factory=dict)
work_order_no: str = ""
first_or_same: str = "first"
history_work_order_no: str = ""
quote: dict[str, Any] = field(default_factory=dict)
status: str = ""
allowed: tuple[str, ...] = ()
immutable_text: str = ""
class AirTextInquiryFlow:
"""
空运文字询价 Owner(本轮产品范围);海运/陆运先补问运输方式或陆运二级。
同一 sender 新开询价会覆盖「当前书签」(旧单留在主账)。
价格卡按工单号另存一份当时会话:点哪张卡就生成哪张卡当时的报价单。
"""
def __init__(self, ledger: 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] = {}
@property
def ledger(self) -> Any:
return self._ledger
def session_of(self, sender_id: str) -> Optional[FlowSession]:
with self._lock:
return self._by_sender.get(sender_id)
def session_for_action(
self,
sender_id: str,
card_meta: Optional[dict[str, Any]] = None,
*,
text: str = "",
) -> Optional[FlowSession]:
"""
点卡:用工单号找回出卡当时的会话;对不上才用该销售当前书签。
必须校验 sender_id,禁止用别人的工单号串会话。
"""
no = copy.work_order_from_card_meta(card_meta, text=text)
with self._lock:
if no:
hit = self._by_ticket.get(no)
if hit and hit.sender_id == sender_id:
return hit
elif not no:
return self._by_sender.get(sender_id)
if no:
# 卡上有工单号就只动这一单;进程刚重启时从主账收回,禁止落到别人/最新单。
return self._load_ticket_session(sender_id, no)
return None
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
def _load_ticket_session(self, sender_id: str, work_order_no: str) -> Optional[FlowSession]:
"""
点卡时进程内没有该书签:只读主账收回这一单。
找不到或不是本人:返回 None,调用方丢弃,绝不改成当前最新单。
"""
getter = getattr(self._ledger, "get_for_agent", None)
if not callable(getter):
logger.warning("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(
"card 点到 %s 主账未收回 found=%s owned=%s",
work_order_no,
view.get("found"),
view.get("owned"),
)
return None
sess = self._session_from_ledger(sender_id=sender_id, view=view)
self._save(sess)
logger.info(
"card 从主账收回 wo=%s phase=%s",
sess.work_order_no,
sess.phase,
)
return sess
def resume_historical(
self,
*,
sender_id: str,
work_order_no: str,
reply: ReplyFn,
) -> str:
"""
按工单号回到当时停点并重出对应卡片。
先用进程内该书签;没有再读主账推断节点。不重跑 TMS、不新建工单。
"""
no = (work_order_no or "").strip().upper()
if not no:
reply("请告诉我要继续的工单号,例如 WO202609140034。")
return "need_work_order"
with self._lock:
sess = self._by_ticket.get(no)
if sess and sess.sender_id == sender_id:
keep = sess
else:
keep = None
if keep:
return self._replay_wait(keep, reply, source="session")
getter = getattr(self._ledger, "get_for_agent", None)
if not callable(getter):
reply(f"找不到工单 {no},请核对工单号后再试。")
return "ticket_missing"
view = getter(work_order_no=no, sender_id=sender_id) or {}
if not view.get("found"):
reply(f"找不到工单 {no},请核对工单号后再试。")
return "ticket_missing"
if view.get("owned") is False:
reply("这张工单不属于当前账号,不能继续。")
return "ticket_forbidden"
sess = self._session_from_ledger(sender_id=sender_id, view=view)
self._save(sess)
return self._replay_wait(sess, reply, source="ledger")
def _session_from_ledger(self, *, sender_id: str, view: dict[str, Any]) -> FlowSession:
"""主账快照收成运行书签。wait_phase 由主账推断,不是六态本身。"""
no = str(view.get("work_order_no") or "").strip()
facts = dict(view.get("facts") or {})
quote = dict(view.get("quote") or {})
status = str(view.get("status") or "").strip()
phase = str(view.get("wait_phase") or "").strip() or self._infer_wait_phase(
status=status, quote=quote
)
allowed: tuple[str, ...] = ()
if phase == "wait_collab":
allowed = (copy.BTN_SKIP_COLLAB,)
elif phase == "wait_adopt":
allowed = (copy.BTN_ADOPT_EXCEL, copy.BTN_ADOPT_PDF, copy.BTN_REJECT)
elif phase == "wait_deal":
allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
return FlowSession(
sender_id=sender_id,
thread_id=sender_id,
phase=phase,
business_line=str(view.get("business_line") or "AIR"),
facts=facts,
work_order_no=no,
quote=quote,
status=status,
allowed=allowed,
wait_version=1,
)
def _infer_wait_phase(self, *, status: str, quote: dict[str, Any]) -> str:
if status in {"已成交", "未成交", "协商中", "已关闭"}:
return "done"
if status == "已报价" and quote:
return "wait_adopt"
return "tms_miss"
def _replay_wait(self, sess: FlowSession, reply: ReplyFn, *, source: str) -> str:
"""按书签重出当时节点,不改六态。"""
logger.info(
"resume replay wo=%s phase=%s source=%s",
sess.work_order_no,
sess.phase,
source,
)
if sess.phase == "wait_adopt":
reply(
copy.scheme_card(
work_order_no=sess.work_order_no,
status=sess.status or "已报价",
facts=sess.facts,
quote=sess.quote,
),
copy.scheme_wecom_payload(
work_order_no=sess.work_order_no,
facts=sess.facts,
quote=sess.quote,
status=sess.status or "已报价",
),
)
return "wait_adopt"
if sess.phase == "wait_collab":
reply(
copy.tms_hit_card(
work_order_no=sess.work_order_no,
quote=sess.quote,
first_or_same=sess.first_or_same,
history_work_order_no=sess.history_work_order_no,
status=sess.status or "已报价",
),
copy.tms_hit_wecom_payload(
work_order_no=sess.work_order_no,
quote=sess.quote,
first_or_same=sess.first_or_same,
history_work_order_no=sess.history_work_order_no,
status=sess.status or "已报价",
),
)
return "wait_collab"
if sess.phase == "tms_miss":
self._emit_tms_miss(reply, sess.work_order_no)
return "tms_miss"
if sess.phase == "wait_deal":
summary = (
f"{sess.facts.get('起运港', '')} → {sess.facts.get('目的港', '')} "
f"{sess.facts.get('品名', '')}"
)
reply(
copy.deal_card(
work_order_no=sess.work_order_no,
status=sess.status or "已报价",
summary=summary,
)
)
return "wait_deal"
if sess.phase == "done":
reply(copy.deal_ack(sess.status or "已关闭", sess.work_order_no))
return "done"
reply(copy.inquiry_card(work_order_no=sess.work_order_no, facts=sess.facts))
return sess.phase or "replay"
def match_button(self, text: str, sess: Optional[FlowSession]) -> str:
"""精确匹配当前允许的按钮文案 / 企微 EventKey;不是按钮返回空。"""
raw = (text or "").strip()
if not sess or not raw:
return ""
canonical = copy.canonical_button(raw)
if canonical and canonical in sess.allowed:
return canonical
for btn in sess.allowed:
if raw == btn or raw == f"【{btn}】":
return btn
return ""
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: Optional[Any] = None,
inbound_raw: Optional[dict[str, Any]] = None,
) -> str:
"""
处理一句私聊文字。返回阶段名供单测断言。
先认按钮(系统交互),再抽字段(模型/注入/B)。
点价格卡必须按卡片工单号找回当时会话,不能默认最新一单。
"""
if not (sender_id or "").strip():
return "reject_no_sender"
sess = self.session_for_action(sender_id, inbound_raw, text=text)
btn = self.match_button(text, sess)
if btn:
return self.on_button(
sender_id=sender_id,
action=btn,
reply=reply,
card_meta=inbound_raw,
)
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,
)
mode = (snap.get("business_line") or "").upper()
facts = dict(snap.get("facts") or {})
if sess and sess.phase in {"clarify", "need_mode"}:
# 补问轮:旧字段打底;运输方式沿用上一句(飞=空运)
from agent.llm.extract_text import detect_transport_mode
from agent.schema.field_validate import harvest_oral_measures
merged = dict(sess.facts)
merged.update(facts)
facts = harvest_oral_measures(text, merged)
mode = mode or (sess.business_line or "").upper()
if not mode:
mode = detect_transport_mode(
f"{sess.immutable_text or ''}\n{text}"
)
if not mode:
reply(copy.ASK_TRANSPORT)
self._save(
FlowSession(
sender_id=sender_id,
thread_id=sender_id,
phase="need_mode",
business_line="",
facts=facts,
immutable_text=sess.immutable_text if sess and sess.immutable_text else text,
)
)
return "need_mode"
if mode != "AIR":
if mode == "LAND":
reply(copy.ASK_LAND_SUBTYPE)
return "need_land_subtype"
# 海运本轮只提示先走空运闭环(不半做海运查价)
reply("本轮先开通空运文字询价。请按空运描述需求,或补充为「空运」。")
return "sea_deferred"
check = validate_required_fields(facts=facts, business_line="AIR")
facts = dict(check["facts"])
tms_ready = assemble_air_query(facts)
facts = dict(tms_ready["facts"])
if not check["ok"] or not tms_ready["ok"]:
# 询价必填或 TMS 查价参数不齐:回显已识别 + 待补充示例,不出询价确认
keys = list(check["missing"] or [])
for key in tms_ready.get("missing") or []:
if key not in keys:
keys.append(key)
reply(
copy.ask_clarify(
facts=facts,
missing_keys=keys,
transport_mode="空运",
)
)
self._save(
FlowSession(
sender_id=sender_id,
thread_id=sender_id,
phase="clarify",
business_line="AIR",
facts=facts,
immutable_text=sess.immutable_text if sess and sess.immutable_text else text,
wait_version=(sess.wait_version + 1) if sess else 1,
)
)
return "clarify"
return self._create_and_quote(
sender_id=sender_id,
facts=facts,
text=text,
reply=reply,
)
def _create_and_quote(
self,
*,
sender_id: str,
facts: dict[str, str],
text: str,
reply: ReplyFn,
) -> str:
# 先落已确认字段,避免建单失败后补问轮把毛重/体积弄丢
self._save(
FlowSession(
sender_id=sender_id,
thread_id=sender_id,
phase="quoting",
business_line="AIR",
facts=facts,
immutable_text=text,
)
)
created = self._ledger.create_ticket(
sender_id=sender_id,
business_line="AIR",
facts=facts,
# 每次出询价确认都新开一单;不用内容指纹,避免相同需求复用旧工单号
idempotency_key=f"create:{sender_id}:{uuid.uuid4().hex}",
)
no = str(created.get("work_order_no") or "").strip()
if not no:
reply("建单失败,请稍后把刚才那条再发一次。重量和体积已经记下。")
return "ledger_fail"
first, history_wo = self._resolve_similar(created, facts=facts, current_no=no)
reply(copy.inquiry_card(work_order_no=no, facts=facts, first_or_same=first))
tms = self._ledger.query_tms(work_order_no=no, facts=facts)
sess = FlowSession(
sender_id=sender_id,
thread_id=sender_id,
business_line="AIR",
facts=facts,
work_order_no=no,
first_or_same=first,
history_work_order_no=history_wo,
immutable_text=text,
wait_version=1,
)
if not tms.get("ok"):
# 没拿到 TMS 回包(超时/熔断/主账未出站)不能冒充「暂无报价」
if tms.get("classification") == "TMS_QUERY_INCOMPLETE":
logger.warning("tms.query 未出站 wo=%s class=%s", no, tms.get("classification"))
reply("TMS 查询失败,请稍后重试。工单已创建:" + no)
sess.phase = "tms_tech"
self._save(sess)
return "tms_tech"
if not tms.get("has_price"):
# ok=true 才是 TMS 已查过、确认没价
self._emit_tms_miss(reply, no)
sess.phase = "tms_miss"
sess.status = "询价中"
self._save(sess)
return "tms_miss"
quote = tms["quote"]
up = self._ledger.upsert_quote(work_order_no=no, quote=quote, to_status="已报价")
if not up.get("ok"):
reply("主账写入报价失败,不能当作已报价。")
sess.phase = "ledger_fail"
self._save(sess)
return "ledger_fail"
sess.quote = quote
sess.status = "已报价"
sess.phase = "wait_collab"
sess.allowed = (copy.BTN_SKIP_COLLAB,)
sess.wait_version += 1
self._save(sess)
reply(
copy.tms_hit_card(
work_order_no=no,
quote=quote,
first_or_same=first,
history_work_order_no=history_wo,
status="已报价",
),
copy.tms_hit_wecom_payload(
work_order_no=no,
quote=quote,
first_or_same=first,
history_work_order_no=history_wo,
status="已报价",
),
)
return "wait_collab"
def _emit_tms_miss(self, reply: ReplyFn, work_order_no: str) -> None:
"""
TMS 已查过且确认没价:文字兜底 + 暂无报价卡片。
禁止在没打 query_tms 前回这个。
"""
reply(
copy.tms_miss(work_order_no=work_order_no),
copy.tms_miss_wecom_payload(
work_order_no=work_order_no,
action_url=(get_settings().public_base_url or "").strip(),
),
)
def _resolve_similar(
self,
created: dict[str, Any],
*,
facts: dict[str, str],
current_no: str,
) -> tuple[str, str]:
"""
全库相同需求:建单结果已带则用;否则再问主账。检索失败当首次,不影响出卡。
"""
history = str(created.get("similar_work_order_no") or "").strip()
first = str(created.get("first_or_same") or "first")
if first == "same" and history and history != current_no:
return "same", history
finder = getattr(self._ledger, "find_similar_open", None)
if not callable(finder):
return "first", ""
try:
similar = finder(
facts=facts, business_line="AIR", exclude_work_order_no=current_no
) or {}
except TypeError:
similar = finder(facts=facts, business_line="AIR") or {}
except Exception:
logger.warning("find_similar 失败,按首次询价展示 current=%s", current_no)
return "first", ""
history = str(similar.get("work_order_no") or "").strip()
if similar.get("found") and history and history != current_no:
return "same", history
return "first", ""
def on_button(
self,
*,
sender_id: str,
action: str,
reply: ReplyFn,
card_meta: Optional[dict[str, Any]] = None,
) -> str:
"""处理已允许的按钮;错版本/未允许/已点过直接丢弃。"""
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:
logger.info(
"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(
"card 工单不一致 card_wo=%s sess_wo=%s,拒绝串单",
card_wo,
sess.work_order_no,
)
return "card_discard"
if action == copy.BTN_SKIP_COLLAB:
# 先摘掉按钮,连点第二次不再进生成
sess.allowed = tuple(x for x in sess.allowed if x != copy.BTN_SKIP_COLLAB)
self._save(sess)
return self._skip_collab(sess, reply, card_meta=card_meta)
if action == copy.BTN_REJECT:
self._grey_clicked_card(reply, card_meta)
sess.phase = "wait_adjust"
sess.allowed = ()
sess.wait_version += 1
self._save(sess)
reply(copy.reject_ack())
return "wait_adjust"
if action == copy.BTN_ADOPT_EXCEL:
return self._adopt_file(sess, reply, kind="Excel", card_meta=card_meta)
if action == copy.BTN_ADOPT_PDF:
return self._adopt_file(sess, reply, kind="PDF", card_meta=card_meta)
if action == copy.BTN_DEAL:
return self._close(sess, reply, "已成交")
if action == copy.BTN_LOST:
return self._close(sess, reply, "未成交")
if action == copy.BTN_NEGOTIATE:
return self._close(sess, reply, "协商中")
return "card_unknown"
def _skip_collab(
self,
sess: FlowSession,
reply: ReplyFn,
card_meta: Optional[dict[str, Any]] = None,
) -> str:
code = str((card_meta or {}).get("response_code") or "").strip()
if code:
reply(
copy.BTN_SKIP_COLLAB_DONE,
copy.skip_collab_card_update(response_code=code),
)
reply(copy.skip_collab_ack())
sess.phase = "wait_adopt"
sess.allowed = (copy.BTN_ADOPT_EXCEL, copy.BTN_ADOPT_PDF, copy.BTN_REJECT)
sess.wait_version += 1
self._save(sess)
reply(
copy.scheme_card(
work_order_no=sess.work_order_no,
status=sess.status or "已报价",
facts=sess.facts,
quote=sess.quote,
),
copy.scheme_wecom_payload(
work_order_no=sess.work_order_no,
facts=sess.facts,
quote=sess.quote,
status=sess.status or "已报价",
),
)
return "wait_adopt"
def _grey_clicked_card(self, reply: ReplyFn, card_meta: Optional[dict[str, Any]]) -> None:
"""点过方案卡后把原卡按钮改成已提交;无 ResponseCode 则不更新。"""
code = str((card_meta or {}).get("response_code") or "").strip()
if not code:
return
reply(
copy.BTN_SKIP_COLLAB_DONE,
copy.skip_collab_card_update(response_code=code),
)
def _adopt_file(
self,
sess: FlowSession,
reply: ReplyFn,
*,
kind: str,
card_meta: Optional[dict[str, Any]] = None,
) -> str:
"""
点生成 Excel/PDF:按询价需求匹配后台报价模板再出单。
关键词未命中走默认模板;双无则只回话术,不进成交卡。
Excel 当场发文件;PDF 主账只填 xlsx,转 PDF 进 LibreOffice 单槽。
"""
self._grey_clicked_card(reply, card_meta)
if kind == "Excel":
reply(copy.adopt_excel_ack())
else:
reply(copy.adopt_pdf_ack())
fmt = "xlsx" if kind == "Excel" else "pdf"
renderer = getattr(self._ledger, "render_quote", None)
if not callable(renderer):
reply(copy.quote_template_missing())
return "wait_adopt"
result = renderer(
work_order_no=sess.work_order_no,
business_line=sess.business_line or "AIR",
facts=dict(sess.facts or {}),
quote=dict(sess.quote or {}),
format=fmt,
sales_wecom_id=sess.sender_id,
) or {}
if not result.get("ok"):
reply(str(result.get("message") or copy.quote_template_missing()))
return "wait_adopt"
filename = str(result.get("fileName") or f"{sess.work_order_no}_quote.{fmt}")
file_extra = self._quote_file_payload(result, filename=filename)
if kind == "PDF" and result.get("needsPdf"):
queued = self._enqueue_quote_pdf(
result,
work_order_no=sess.work_order_no,
sender_id=sess.sender_id,
filename=filename if filename.lower().endswith(".pdf") else f"{sess.work_order_no}_quote.pdf",
)
if not queued:
fallback = str(result.get("fileName") or f"{sess.work_order_no}_quote.xlsx")
if not fallback.lower().endswith(".xlsx"):
fallback = f"{sess.work_order_no}_quote.xlsx"
reply("PDF 转换排队失败,已按后台模板发送 Excel 报价单。")
reply(
copy.file_ready(kind="Excel", work_order_no=sess.work_order_no, filename=fallback),
file_extra,
)
# 已入队:Worker 出文件;此处不冒充 soffice 已成功
else:
reply(
copy.file_ready(kind=kind, work_order_no=sess.work_order_no, filename=filename),
file_extra,
)
summary = f"{sess.facts.get('起运港', '')} → {sess.facts.get('目的港', '')} {sess.facts.get('品名', '')}"
sess.phase = "wait_deal"
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
sess.wait_version += 1
self._save(sess)
reply(
copy.deal_card(
work_order_no=sess.work_order_no,
status=sess.status or "已报价",
summary=summary,
)
)
return "wait_deal"
def _quote_file_payload(self, result: dict[str, Any], *, filename: str) -> dict[str, Any]:
"""出站文件载荷:优先本机路径,否则主账回的 Base64。"""
return {
"msgtype": "file",
"filename": filename,
"filepath": str(result.get("filePath") or ""),
"file_b64": str(result.get("fileBase64") or ""),
}
def _enqueue_quote_pdf(
self,
result: dict[str, Any],
*,
work_order_no: str,
sender_id: str,
filename: str,
) -> bool:
"""
把主账已填好的 xlsx 交给 Worker LibreOffice 单槽转 PDF。
失败返回 False,调用方改发 Excel,不在 inbox 线程跑 soffice。
"""
raw_b64 = str(result.get("fileBase64") or "").strip()
if not raw_b64:
return False
try:
import base64
import tempfile
from pathlib import Path
from agent.jobs.pdf_job import enqueue_pdf_job
data = base64.b64decode(raw_b64)
work_dir = Path(tempfile.gettempdir()) / "inquiry_quote"
work_dir.mkdir(parents=True, exist_ok=True)
xlsx_path = work_dir / f"{work_order_no}_quote.xlsx"
xlsx_path.write_bytes(data)
queued = enqueue_pdf_job(
xlsx_path=str(xlsx_path),
inquiry_no=work_order_no,
quote_version=1,
idempotency_key=f"pdf:{work_order_no}:{uuid.uuid4().hex[:8]}",
touser=sender_id,
filename=filename,
)
return bool(queued.accepted)
except Exception:
logger.exception("报价 PDF 入队失败 wo=%s", work_order_no)
return False
def _close(self, sess: FlowSession, reply: ReplyFn, status: str) -> str:
result = self._ledger.transition(work_order_no=sess.work_order_no, to_status=status)
if not result.get("ok"):
reply("主账状态更新失败,成交结果未入账。")
return "ledger_fail"
sess.status = status
sess.phase = "done"
sess.allowed = ()
self._save(sess)
reply(copy.deal_ack(status, sess.work_order_no))
return "done"
_FLOW: Optional[AirTextInquiryFlow] = None
_FLOW_GUARD = threading.Lock()
def build_ledger() -> Any:
"""生产打 8180;单测 LEDGER_BACKEND=memory。"""
from agent.config import get_settings
backend = (get_settings().ledger_backend or "http").strip().lower()
if backend == "memory":
return MemoryLedger()
return HttpLedger()
def get_air_text_flow() -> AirTextInquiryFlow:
"""进程内一份流程(与 inbox 线程共用);测试可 new 独立实例。"""
global _FLOW
with _FLOW_GUARD:
if _FLOW is None:
_FLOW = AirTextInquiryFlow()
return _FLOW
def reset_air_text_flow_for_test() -> AirTextInquiryFlow:
"""单测重置全局实例(强制内存账本)。"""
global _FLOW
with _FLOW_GUARD:
_FLOW = AirTextInquiryFlow(MemoryLedger())
return _FLOW
+797
View File
@@ -0,0 +1,797 @@
"""
空运文字询价出站话术(销售可见)。
本文件职责:集中文案;按钮字面量必须与 handler 精确匹配。
禁止:在本文件调主账/TMS;禁止把密钥写进话术。
"""
from __future__ import annotations
import json
import re
import time
import uuid
from typing import Any, Iterable
from agent.schema.field_validate import AIR_REQUIRED_FIELDS
# 空运补问:与合同 8 项一致。报价日期默认当天,不进待补充。
_AIR_EXAMPLES = {
"起运港": "南京",
"目的港": "吉隆坡",
"品名": "茶叶",
"件数": "10件",
"毛重": "100KGS",
"体积": "2CBM",
"包装方式": "托盘",
"报价日期": "2026-09-15",
}
_AIR_CLARIFY = tuple(
(key, label, _AIR_EXAMPLES[key]) for key, label in AIR_REQUIRED_FIELDS
)
# 文字按钮(销售点卡或原样回复均可)
BTN_SKIP_COLLAB = "不需要协同,直接生成报价单"
BTN_SKIP_COLLAB_OLD = "不需要协同,直接生成基础报价单"
BTN_SKIP_COLLAB_KEY = "skip_collab"
BTN_SKIP_COLLAB_DONE = "已提交"
BTN_ADOPT_EXCEL = "采用,生成Excel报价单"
BTN_ADOPT_PDF = "采用,生成PDF报价单"
BTN_REJECT = "不采用"
# 企微一张卡三个按钮只能横排,长文案会被裁。卡片用短文案才能看全。
BTN_ADOPT_EXCEL_CARD = "生成Excel"
BTN_ADOPT_PDF_CARD = "生成PDF"
BTN_ADOPT_EXCEL_KEY = "adopt_excel"
BTN_ADOPT_PDF_KEY = "adopt_pdf"
BTN_REJECT_KEY = "reject"
BTN_DEAL = "成交"
BTN_LOST = "未成交"
BTN_NEGOTIATE = "协商中"
_WO_NO = re.compile(r"WO\d{12}", re.IGNORECASE)
# 仅给无网 stub / 工单号回填用,正式续办意图由 DeepSeek A 判断,不要求销售说这些词。
_CONTINUE_HINT = (
"继续",
"接着",
"回到",
"再看",
"再出",
"找回",
"重开",
"那单",
"这单",
"历史工单",
"咨询工单",
"刚才那",
"上次那",
)
ASK_TRANSPORT = "请补充运输方式:海运 / 空运 / 陆运。"
ASK_LAND_SUBTYPE = "请补充陆运类型:国内运输拼车 / 国内运输整车 / 跨境集拼 / 跨境整车 / 中港。"
FIRST_CHAT = "你好,我是询价机器人。请直接描述客户需求,例如「上海到洛杉矶空运,3 件 180kg,下周三前到」,我会解析需求并查询 TMS 标准报价。"
ROUTE_MODEL_FAILED = "刚才没听清,请再说一次:是新询价,还是要接着办哪张工单?"
def _air_known_value(facts: dict[str, str], key: str) -> str:
"""已识别字段的销售侧展示值;没有则空串。"""
src = facts or {}
if key == "起运港":
return (src.get("起运港") or "").strip()
if key == "目的港":
return (src.get("目的港") or "").strip()
if key == "品名":
return (src.get("品名") or "").strip()
if key == "件数":
raw = (src.get("件数") or "").strip()
return _keep_or_add_unit(raw, ("件", "PCS"), "件") if raw else ""
if key == "毛重":
raw = (src.get("毛重") or "").strip()
return _keep_or_add_unit(raw, ("KGS", "KG", "公斤", "千克"), "") if raw else ""
if key == "体积":
raw = (src.get("体积") or "").strip()
return _keep_or_add_unit(raw, ("CBM", "M3", "M³", "立方米", "立方", "方"), "") if raw else ""
if key == "包装方式":
return (src.get("包装方式") or src.get("包装类型") or "").strip()
if key == "报价日期":
return (src.get("报价日期") or "").strip()
return (src.get(key) or "").strip()
def ask_clarify(
*,
facts: dict[str, str] | None = None,
missing_keys: Iterable[str] = (),
transport_mode: str = "空运",
) -> str:
"""
缺项补问:先回显已识别,再列待补充并给示例。
报价日期写进已识别(销售说了用原话,没说用当天),不出现在待补充。
"""
src = dict(facts or {})
skip = {"报价日期"}
missing = [k for k in missing_keys if k and k not in skip]
missing_set = set(missing)
lines = ["请确认并补充以下信息:"]
if transport_mode:
lines.append(f"运输方式:{transport_mode}")
for key, label, _example in _AIR_CLARIFY:
val = _air_known_value(src, key)
if val:
lines.append(f"{label}:{val}")
lines.append("")
lines.append("待补充:")
any_missing = False
if "运输方式" in missing_set:
any_missing = True
lines.append("运输方式:(如:空运)")
for key, label, example in _AIR_CLARIFY:
if key in missing_set:
any_missing = True
lines.append(f"{label}:(如:{example})")
if not any_missing:
return ""
return "\n".join(lines)
def ask_missing(display_names: Iterable[str]) -> str:
"""兼容旧调用:仅有展示名时仍走补问,但没有已识别回显。"""
alias = {
"起运地": "起运港",
"目的地": "目的港",
"起运地(机场名或三字码)": "起运港",
"目的地(机场名或三字码)": "目的港",
"毛重": "毛重",
"重量": "毛重",
"重量(KG)": "毛重",
"体积": "体积",
"体积(CBM)": "体积",
"包装类型": "包装方式",
"包装方式": "包装方式",
}
keys: list[str] = []
for name in display_names:
key = alias.get(name, name)
if key and key not in keys and key != "报价日期":
keys.append(key)
return ask_clarify(facts={}, missing_keys=keys, transport_mode="空运")
def _keep_or_add_unit(raw: str, known: tuple[str, ...], default: str) -> str:
"""
销售侧单位原样回显。
写了 1.5KG 就回 1.5KG,禁止改成 1.5KGS。没写单位才补 default(可空)。
"""
text = (raw or "").strip()
if not text:
return "-"
upper = text.upper()
for unit in known:
if upper.endswith(unit.upper()):
return text
if default and not any(ch.isalpha() or ch in "立方件" for ch in text):
return f"{text}{default}"
return text
def _with_unit(raw: str, suffix: str) -> str:
"""兼容旧调用:已带单位则原样。重量不要走这里补 KGS。"""
if suffix.upper() == "KGS":
return _keep_or_add_unit(raw, ("KGS", "KG", "公斤", "千克"), "")
if suffix.upper() == "CBM":
return _keep_or_add_unit(raw, ("CBM", "M3", "M³", "立方米", "立方", "方"), "")
return _keep_or_add_unit(raw, (suffix,), suffix)
def inquiry_field_lines(
*,
work_order_no: str,
facts: dict[str, str],
transport_mode: str = "空运",
) -> list[str]:
"""
询价确认字段行(销售可见)。
起运/目的展示销售原文(南京/北京),三字码只给出站 TMS,不对销售展示。
"""
origin = (facts.get("起运港") or "-").strip()
dest = (facts.get("目的港") or "-").strip()
cargo = (facts.get("品名") or "-").strip() or "-"
pack = (facts.get("包装方式") or facts.get("包装类型") or "-").strip() or "-"
quote_date = (facts.get("报价日期") or "-").strip() or "-"
return [
f"工单号:{work_order_no}",
f"运输方式:{transport_mode}",
f"起运地:{origin}",
f"目的地:{dest}",
f"品名:{cargo}",
f"件数:{_with_unit(str(facts.get('件数') or ''), '件')}",
f"重量:{_with_unit(str(facts.get('毛重') or ''), 'KGS')}",
f"体积:{_with_unit(str(facts.get('体积') or ''), 'CBM')}",
f"包装类型:{pack}",
f"报价日期:{quote_date}",
]
def inquiry_card(*, work_order_no: str, facts: dict[str, str], first_or_same: str = "first") -> str:
"""
建单后的询价确认。
无标题、无 [test]。first_or_same 保留兼容,不再对销售展示。
"""
_ = first_or_same
lines = inquiry_field_lines(work_order_no=work_order_no, facts=facts)
lines.append("工单已创建,正在查询TMS报价。")
return "\n".join(lines)
def canonical_button(raw: str) -> str:
"""把销售点卡 / 原文 / 企微 EventKey 收成标准按钮文案;对不上返回空。"""
text = (raw or "").strip().strip("【】")
if text in {BTN_SKIP_COLLAB, BTN_SKIP_COLLAB_OLD, BTN_SKIP_COLLAB_KEY}:
return BTN_SKIP_COLLAB
# 新卡 EventKey 带工单号:skip_collab:WO…,避免点旧卡落到最新单
if text.startswith(f"{BTN_SKIP_COLLAB_KEY}:"):
return BTN_SKIP_COLLAB
if text in {BTN_ADOPT_EXCEL, BTN_ADOPT_EXCEL_CARD, BTN_ADOPT_EXCEL_KEY} or text.startswith(
f"{BTN_ADOPT_EXCEL_KEY}:"
):
return BTN_ADOPT_EXCEL
if text in {BTN_ADOPT_PDF, BTN_ADOPT_PDF_CARD, BTN_ADOPT_PDF_KEY} or text.startswith(
f"{BTN_ADOPT_PDF_KEY}:"
):
return BTN_ADOPT_PDF
if text in {BTN_REJECT_KEY} or text.startswith(f"{BTN_REJECT_KEY}:"):
return BTN_REJECT
if text == BTN_SKIP_COLLAB_DONE:
return ""
for btn in (BTN_ADOPT_EXCEL, BTN_ADOPT_PDF, BTN_REJECT, BTN_DEAL, BTN_LOST, BTN_NEGOTIATE):
if text == btn:
return btn
return ""
def extract_work_order_no(text: str) -> str:
"""从任意原话里抽出 WO + 12 位数字;没有则空。不判断意图。"""
hit = _WO_NO.search(text or "")
return hit.group(0).upper() if hit else ""
def card_task_id(kind: str, work_order_no: str) -> str:
"""
每次出卡一个新 task_id。企微 42014:同一 task_id 不能再发,会整卡失败只剩文字。
工单号仍嵌在前缀里,点卡回调靠 work_order_from_card_meta 找回。
"""
stamp = f"{int(time.time() * 1000):x}{uuid.uuid4().hex[:4]}"
return f"{kind}-{work_order_no}-{stamp}"
def looks_like_continue_ticket(text: str) -> bool:
"""
无网夹具用的弱提示。正式环境必须由大模型判续办意图。
禁止把本函数当成生产唯一路由。
"""
raw = (text or "").strip()
if not raw:
return False
return any(token in raw for token in _CONTINUE_HINT)
def work_order_from_card_meta(
card_meta: dict[str, Any] | None = None,
*,
text: str = "",
) -> str:
"""
从企微点卡回调用找回「点的是哪一张卡当时的工单」。
优先 TaskId / EventKey 里的工单号(quote-WO…、scheme-adopt_excel-WO…)。
对不上返回空:调用方按该销售当前最新书签处理。
"""
src = card_meta or {}
for raw in (
str(src.get("task_id") or "").strip(),
str(src.get("event_key") or "").strip(),
(text or "").strip(),
):
no = _work_order_in_token(raw)
if no:
return no
return ""
def _work_order_in_token(raw: str) -> str:
"""从 TaskId / EventKey 里抽出 WO 工单号;没有则空。"""
text = (raw or "").strip()
if not text:
return ""
for prefix in (
f"{BTN_SKIP_COLLAB_KEY}:",
f"{BTN_ADOPT_EXCEL_KEY}:",
f"{BTN_ADOPT_PDF_KEY}:",
f"{BTN_REJECT_KEY}:",
):
if text.startswith(prefix):
rest = text[len(prefix) :].strip()
if rest.upper().startswith("WO"):
return extract_work_order_no(rest) or rest.split("-", 1)[0]
# 卡片 task_id 会带时间后缀(scheme-WO…-abc),只取 WO+12 位。
return extract_work_order_no(text)
_CURRENCY_PREFIX = re.compile(r"^[A-Za-z]{3}\b")
def money_text(amount: Any, currency: str = "") -> str:
"""
销售可见金额:TMS 有币种就写成「CNY 14669.60」。
金额已带三字码前缀则原样。没有币种不编造 CNY/USD。
"""
raw = str(amount if amount not in (None, "") else "").strip()
if not raw or raw == "-":
return raw or "-"
if _CURRENCY_PREFIX.match(raw):
return raw
code = str(currency or "").strip().upper()
if code == "RMB":
code = "CNY"
if not code:
return raw
return f"{code} {raw}"
def quote_fee_lines(quote: dict[str, Any] | None) -> list[tuple[str, str]]:
"""
价格明细行:优先 TMS fee_lines,否则用运费/其它/合计兜底。
返回 (费用名, 金额) ,空金额不展示。金额带 TMS 币种,不编造。
"""
src = dict(quote or {})
header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip()
raw = src.get("fee_lines")
parsed: Any = raw
if isinstance(raw, str) and raw.strip():
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
parsed = None
lines: list[tuple[str, str]] = []
if isinstance(parsed, list):
for item in parsed:
if not isinstance(item, dict):
continue
name = str(item.get("name") or item.get("label") or "").strip()
amount = str(item.get("amount") or item.get("value") or "").strip()
line_ccy = str(item.get("currency") or item.get("currencyCode") or header_ccy).strip()
if name and amount and amount != "-":
lines.append((name, money_text(amount, line_ccy)))
if lines:
return lines
freight = str(src.get("freight") or "").strip()
other = str(src.get("other_fee") or "").strip()
if freight and freight != "-":
lines.append(("空运费", money_text(freight, header_ccy)))
if other and other != "-":
lines.append(("其他附加费", money_text(other, header_ccy)))
return lines
def quote_area_fee_text(quote: dict[str, Any] | None) -> str:
"""
卡片引用条正文:费用行 + 总销售价合计。
必须走 quote_text。塞进 quote_area.title 会被企微整段藏掉。
"""
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)
fee_lines = [f"{name}:{amount}" for name, amount in quote_fee_lines(src)]
fee_lines.append(f"总销售价合计:{total}")
return "\n".join(fee_lines)
def collab_hint(work_order_no: str) -> str:
"""价格卡协同说明:无「内部协同」前缀,紧挨生成报价单按钮上方。"""
return (
f"若需要咨询内部航线人员,请手动拉新人与智能体到原有群,"
f"并在群聊发送工单编号{work_order_no}。"
)
def _strip_leading_dash(raw: str) -> str:
"""去掉时效前导 '-' / '—',避免销售看到「-3-5天」或「-;3-5天」。"""
text = (raw or "").strip()
while text.startswith(("-", "—", "–")):
rest = text[1:].lstrip(" \t;;,,")
if not rest:
return ""
text = rest
return text
def tms_eta_display(quote: dict[str, Any] | None) -> str:
"""时效只取 TMS 回的 eta / 预计送达;没有就「-」。禁止本地编。前导横杠会剥掉。"""
src = dict(quote or {})
eta = _strip_leading_dash(str(src.get("eta") or ""))
if eta:
return eta
dated = _strip_leading_dash(
str(src.get("eta_date") or src.get("estimated_delivery") or "")
)
return dated or "-"
def tms_valid_until_display(quote: dict[str, Any] | None) -> str:
"""报价有效期只取 TMS 原文,没有就 "-"。"""
src = dict(quote or {})
raw = str(
src.get("valid_until")
or src.get("validUntil")
or src.get("quoteValidUntil")
or ""
).strip()
return raw if raw else "-"
def similar_inquiry_line(*, first_or_same: str, history_work_order_no: str = "") -> str:
"""
相同询价内容检索:首次询价,或引用全库最新一条一模一样的历史工单号。
"""
no = (history_work_order_no or "").strip()
if first_or_same == "same" and no:
return f"相同询价内容检索:引用历史工单号{no}"
return "相同询价内容检索:首次询价"
def quote_detail_lines(
quote: dict[str, Any] | None,
*,
first_or_same: str = "first",
history_work_order_no: str = "",
status: str = "已报价",
) -> list[str]:
"""
价格明细正文:TMS 费用、合计、时效、有效期、相同检索。
给文本兜底和企微引用条共用,禁止在这里编时效/有效期。价格卡不再展示工单状态。
"""
_ = status
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)
lines = [f"{name}:{amount}" for name, amount in quote_fee_lines(src)]
lines.append(f"总销售价合计:{total}")
lines.append(f"时效:{tms_eta_display(src)}")
lines.append(f"报价有效期:{tms_valid_until_display(src)}")
lines.append(
similar_inquiry_line(
first_or_same=first_or_same, history_work_order_no=history_work_order_no
)
)
return lines
def tms_hit_card(
*,
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,
)
)
lines.append("")
lines.append(collab_hint(work_order_no))
lines.append(BTN_SKIP_COLLAB)
return "\n".join(lines)
def tms_hit_wecom_payload(
*,
work_order_no: str,
quote: dict[str, Any],
first_or_same: str = "first",
history_work_order_no: str = "",
status: str = "已报价",
) -> dict[str, Any]:
"""
一张价格卡,销售侧顺序:
价格明细
┌ 引用条:空运费 / 其它费用 / 总销售价合计
时效、报价有效期、相同询价内容检索
协同说明(普通文字,贴按钮上方)
按钮
企微 quote_area.title 只能很短,超长会被整段藏掉。报价必须放 quote_text。
不用 horizontal:它会排到副标题后面,把费用挤到按钮前。
"""
src = dict(quote or {})
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
meta = "\n".join(
[
f"时效:{tms_eta_display(src)}",
f"报价有效期:{tms_valid_until_display(src)}",
similar_inquiry_line(
first_or_same=first_or_same, history_work_order_no=history_work_order_no
),
"",
collab_hint(work_order_no),
]
)
return {
"msgtype": "template_card",
"template_card": {
"card_type": "button_interaction",
"source": {"desc": source, "desc_color": 0},
"main_title": {"title": f"{work_order_no}价格明细"},
"quote_area": {
"type": 0,
"quote_text": quote_area_fee_text(src),
},
"sub_title_text": meta,
"task_id": card_task_id("quote", work_order_no),
"button_list": [
{
"text": BTN_SKIP_COLLAB,
"style": 1,
# EventKey 也带工单号:TaskId 偶发缺失时仍能找回当时那一单
"key": f"{BTN_SKIP_COLLAB_KEY}:{work_order_no}",
}
],
},
}
def skip_collab_card_update(*, response_code: str) -> dict[str, Any]:
"""
点过「生成报价单」后更新原卡:按钮改成已提交并保持置灰。
必须带企微回调 ResponseCode;无码则不要入出站。
"""
return {
"msgtype": "update_template_card",
"response_code": response_code,
"button": {"replace_name": BTN_SKIP_COLLAB_DONE},
}
def tms_miss_quote_text(*, work_order_no: str) -> str:
"""引用条正文:只放「请务必在群聊发送工单编号」。"""
return f"请务必在群聊发送工单编号{work_order_no}"
def tms_miss_hint() -> str:
"""卡片正文:无价说明 + 拉群提示,不单独再写标题下灰字。"""
return "TMS标准价库暂无匹配报价,请手动拉新人与智能体到原有群"
def tms_miss(*, work_order_no: str) -> str:
"""
TMS 已经查过、确认没标准价时的文字兜底。
禁止在没打 TMS 前用本句冒充无价。
"""
return (
f"TMS暂无报价。{tms_miss_hint()},"
f"{tms_miss_quote_text(work_order_no=work_order_no)}"
)
def tms_miss_wecom_payload(*, work_order_no: str, action_url: str = "") -> dict[str, Any]:
"""
TMS 已查过、确认没价:一张 text_notice 卡。
顺序:来源 → 标题(仅工单号+暂无报价)→ 引用条(工单编号)→ 拉群说明。
禁止:标题下灰字、工单状态、查价结果/来源行、按钮。
企微 text_notice 必须带可信域名 card_action,否则整卡发失败只剩文字。
"""
card: dict[str, Any] = {
"card_type": "text_notice",
"source": {"desc": "TMS查价结果", "desc_color": 0},
"main_title": {"title": f"{work_order_no}暂无报价"},
"quote_area": {
"type": 0,
"quote_text": tms_miss_quote_text(work_order_no=work_order_no),
},
"sub_title_text": tms_miss_hint(),
"task_id": card_task_id("miss", work_order_no),
}
url = (action_url or "").strip()
if url:
card["card_action"] = {"type": 1, "url": url}
return {"msgtype": "template_card", "template_card": card}
def tms_contract_gap(*, work_order_no: str) -> str:
"""已废弃:不再因字典对不上拦查价;保留以免旧单测/引用报错。"""
return tms_miss(work_order_no=work_order_no)
def tms_need_more(*, work_order_no: str, hint: str = "") -> str:
"""已废弃:TMS 回 1003 按合同收成暂无报价。保留以免旧引用报错。"""
_ = hint
return tms_miss(work_order_no=work_order_no)
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:
"""
报价方案文本兜底(模板卡发失败时仍给销售看)。
与卡片同一套字段,禁止漏项。不再展示工单状态。
"""
_ = status
src = dict(quote or {})
# 方案卡不展示询价「报价日期」,改展示 TMS 报价有效期
body = "\n".join(
line
for line in inquiry_field_lines(work_order_no=work_order_no, facts=facts)
if not line.startswith("报价日期:")
)
eta = tms_eta_display(src)
valid = tms_valid_until_display(src)
lines = [
f"【{work_order_no}报价方案】",
body,
"价格明细",
quote_area_fee_text(src),
f"时效:{eta or '-'}",
f"报价有效期:{valid}",
"请确认是否采用该报价方案:",
f"【{BTN_ADOPT_EXCEL_CARD}】",
f"【{BTN_ADOPT_PDF_CARD}】",
f"【{BTN_REJECT}】",
]
return "\n".join(lines)
def _clip(raw: str, limit: int) -> str:
"""企微列表格有字数建议;超长才裁,避免空白。"""
text = (raw or "").strip() or "-"
return text if len(text) <= limit else text[: limit - 1] + "…"
def scheme_horizontal_rows(
*,
work_order_no: str,
facts: dict[str, str],
quote: dict[str, Any],
) -> list[dict[str, str]]:
"""
卡片字段:一行一项,每格独立字数,不会像副标题整段截成 8CB…。
企微 horizontal 最多 6 条。运输方式写进航线行,避免丢字段。
不用 sub_title 堆字段(它会排到列表前面,且超长必截断)。
"""
src = facts or {}
q = dict(quote or {})
origin = (src.get("起运港") or "-").strip() or "-"
dest = (src.get("目的港") or "-").strip() or "-"
cargo = (src.get("品名") or "-").strip() or "-"
pieces = _with_unit(str(src.get("件数") or ""), "件")
weight = _with_unit(str(src.get("毛重") or ""), "KGS")
volume = _with_unit(str(src.get("体积") or ""), "CBM")
pack = (src.get("包装方式") or src.get("包装类型") or "-").strip() or "-"
return [
{"keyname": "工单号", "value": _clip(work_order_no, 26)},
{"keyname": "航线", "value": _clip(f"空运 {origin}→{dest}", 26)},
{"keyname": "货物", "value": _clip(f"{cargo};{pieces};{weight};{volume}", 26)},
{"keyname": "包装", "value": _clip(pack, 26)},
{"keyname": "时效", "value": _clip(tms_eta_display(q), 26)},
{"keyname": "报价有效期", "value": _clip(tms_valid_until_display(q), 26)},
]
def scheme_wecom_payload(
*,
work_order_no: str,
facts: dict[str, str],
quote: dict[str, Any],
status: str = "已报价",
) -> dict[str, Any]:
"""
一张报价方案卡:标题带工单号、引用条、字段、三个短按钮。
按钮文案必须短:生成Excel / 生成PDF / 不采用。长文案横排会被裁。
"""
_ = status
src = dict(quote or {})
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
return {
"msgtype": "template_card",
"template_card": {
"card_type": "button_interaction",
"source": {"desc": source, "desc_color": 0},
"main_title": {"title": f"{work_order_no}报价方案"},
"quote_area": {
"type": 0,
"quote_text": quote_area_fee_text(src),
},
"horizontal_content_list": scheme_horizontal_rows(
work_order_no=work_order_no, facts=facts, quote=src
),
"task_id": card_task_id("scheme", work_order_no),
"button_list": [
{
"text": BTN_ADOPT_EXCEL_CARD,
"style": 1,
"key": f"{BTN_ADOPT_EXCEL_KEY}:{work_order_no}",
},
{
"text": BTN_ADOPT_PDF_CARD,
"style": 1,
"key": f"{BTN_ADOPT_PDF_KEY}:{work_order_no}",
},
{
"text": BTN_REJECT,
"style": 2,
"key": f"{BTN_REJECT_KEY}:{work_order_no}",
},
],
},
}
def reject_ack() -> str:
return "请问如何调整报价单,以满足您的需求?您可使用「报价加 10%」等调价指令。"
def adopt_excel_ack() -> str:
return "已确认采用该方案,正在生成 Excel 最终报价单…"
def adopt_pdf_ack() -> str:
return "已确认采用该方案,正在生成PDF最终报价单…"
QUOTE_TEMPLATE_MISSING = "生成报价单失败,请联系IT运维维护后台报价模板。"
def quote_template_missing() -> str:
"""关键词与默认模板都未维护时的销售提示(产品口径,勿改字)。"""
return QUOTE_TEMPLATE_MISSING
def file_ready(*, kind: str, work_order_no: str, filename: str) -> str:
return f"已生成{kind}最终报价单:{filename}(工单 {work_order_no})"
def deal_card(*, work_order_no: str, status: str, summary: str) -> str:
return (
"【咨询成交状态】\n"
f"工单编号:{work_order_no}\n"
f"工单状态:{status}\n"
f"询价内容:{summary}\n"
"请立即确认当前与客户沟通的成交状态:\n"
f"【{BTN_DEAL}】【{BTN_LOST}】【{BTN_NEGOTIATE}】"
)
def deal_ack(outcome: str, work_order_no: str) -> str:
if outcome == "已成交":
return f"已记录为「已成交」。工单 {work_order_no} 已关闭智能体跟进。"
if outcome == "未成交":
return f"已记录为「未成交」。请补充未成交原因,工单 {work_order_no} 将关闭智能体跟进。"
return f"已记录为「协商中」。工单 {work_order_no} 跟进周期已顺延。"
+29 -12
View File
@@ -35,6 +35,8 @@ class TemplateEntry:
mapping_json: str = ""
biz_type: str = ""
category: str = ""
keywords: tuple[str, ...] = ()
is_default: bool = False
def local_cache_dir() -> Path:
@@ -93,6 +95,8 @@ def entries_from_ledger_response(resp: LedgerResponse) -> list[TemplateEntry]:
mapping_json=str(it.get("mappingJson") or ""),
biz_type=str(it.get("bizType") or ""),
category=str(it.get("category") or ""),
keywords=tuple(str(x) for x in (it.get("keywords") or []) if str(x).strip()),
is_default=bool(it.get("isDefault")),
)
)
return out
@@ -160,22 +164,35 @@ def registry_as_dicts() -> list[dict[str, Any]]:
def pick_template_for_biz(
*,
biz_type: str,
facts: Optional[dict[str, Any]] = None,
quote: Optional[dict[str, Any]] = None,
keywords: Optional[list[str]] = None,
entries: Optional[list[TemplateEntry]] = None,
) -> Optional[TemplateEntry]:
"""
按业务线 + 关键词挑选模板(优先 is_default 由主账排序保证)。
按询价需求匹配模板关键词;都未命中则同业务默认模板。
关键词命中任一即优;否则同 biz 第一条。
keywords 入参已废弃(兼容旧调用,忽略)。生产出单以主账 render 为准。
"""
_ = keywords
from agent.quote_templates.match import pick_quote_template
items = entries if entries is not None else list_template_registry()
same = [e for e in items if (e.biz_type == biz_type or not e.biz_type)]
if not same:
same = list(items)
if keywords:
kw_l = [k.lower() for k in keywords if k]
for e in same:
blob = f"{e.filename} {e.category} {e.code}".lower()
if any(k in blob for k in kw_l):
return e
return same[0] if same else None
rows = [
{
"templateId": e.template_id or e.code,
"name": e.filename or e.code,
"bizType": e.biz_type,
"keywords": list(e.keywords),
"isDefault": e.is_default,
"_entry": e,
}
for e in items
]
hit = pick_quote_template(
templates=rows, business_line=biz_type, facts=facts, quote=quote
)
if hit is None:
return None
entry = hit.raw.get("_entry")
return entry if isinstance(entry, TemplateEntry) else None
@@ -0,0 +1,159 @@
"""
报价模板匹配:询价需求对模板关键词,未命中则同业务默认模板。
本文件职责:纯函数,与 Java QuoteTemplateMatcher 同一套规则。
调用:MemoryLedger / 单测;生产出单以主账 render 为准。
禁止:在此发明价格;禁止读仓库 xlsx 当生产真相。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Iterable, Optional
# 与 Java QuoteTemplateMatcher.FAIL_MESSAGE 保持一字不差
FAIL_MESSAGE = "生成报价单失败,请联系IT运维维护后台报价模板。"
@dataclass(frozen=True)
class TemplatePick:
"""一次匹配结果。"""
template_id: str
name: str
match_type: str
raw: dict[str, Any]
def normalize_biz(raw: str) -> str:
"""业务线归一:AIR/空 → 空运,SEA/海 → 海运,LAND/陆 → 陆运。"""
t = (raw or "").strip()
if not t:
return ""
lower = t.lower()
if lower == "air" or t == "空" or "空运" in t or t == "AIR":
return "空运"
if lower == "sea" or t == "海" or "海运" in t or t == "SEA":
return "海运"
if lower == "land" or t == "陆" or "陆运" in t or t == "LAND":
return "陆运"
return t
def _as_keywords(raw: Any) -> list[str]:
if raw is None:
return []
if isinstance(raw, (list, tuple)):
return [str(x).strip() for x in raw if str(x).strip()]
text = str(raw).strip()
if not text or text.lower() == "null":
return []
if text.startswith("["):
inner = text[1:-1] if text.endswith("]") else text[1:]
out: list[str] = []
for part in inner.split(","):
s = part.strip().strip("\"'")
if s:
out.append(s)
return out
return [text]
def _is_default(raw: Any) -> bool:
if raw is True:
return True
if isinstance(raw, (int, float)) and int(raw) == 1:
return True
s = str(raw or "").strip().lower()
return s in {"1", "true"}
def _append(parts: list[str], val: Any) -> None:
if val is None:
return
s = str(val).strip()
if not s or s.lower() == "null":
return
parts.append(s)
def build_inquiry_haystack(
*,
business_line: str,
facts: Optional[dict[str, Any]] = None,
quote: Optional[dict[str, Any]] = None,
) -> str:
"""拼匹配文本:已冻结字段 + 业务线 + 报价费用名。"""
parts: list[str] = []
_append(parts, business_line)
_append(parts, normalize_biz(business_line))
for key, val in (facts or {}).items():
_append(parts, key)
_append(parts, val)
q = quote or {}
for key in ("source", "source_label", "freight", "total"):
_append(parts, q.get(key))
lines = q.get("fee_lines") or []
if isinstance(lines, Iterable) and not isinstance(lines, (str, bytes)):
for row in lines:
if isinstance(row, dict):
_append(parts, row.get("name"))
_append(parts, row.get("amount"))
else:
_append(parts, row)
return " ".join(parts)
def _row_of(item: Any) -> dict[str, Any]:
if isinstance(item, dict):
return item
return {
"templateId": getattr(item, "template_id", "") or getattr(item, "code", ""),
"name": getattr(item, "filename", "") or getattr(item, "code", ""),
"bizType": getattr(item, "biz_type", ""),
"keywords": list(getattr(item, "keywords", None) or []),
"isDefault": bool(getattr(item, "is_default", False)),
}
def pick_quote_template(
*,
templates: Optional[list[Any]],
business_line: str,
facts: Optional[dict[str, Any]] = None,
quote: Optional[dict[str, Any]] = None,
) -> Optional[TemplatePick]:
"""
同业务线:先关键词命中,再默认模板。
参数 templates:主账 listEnabled 行或 TemplateEntry。
返回 None 时销售侧用 FAIL_MESSAGE。
"""
want = normalize_biz(business_line)
haystack = build_inquiry_haystack(
business_line=business_line, facts=facts, quote=quote
).lower()
keyword_hit: Optional[dict[str, Any]] = None
default_hit: Optional[dict[str, Any]] = None
for item in templates or []:
row = _row_of(item)
biz = normalize_biz(str(row.get("bizType") or ""))
if want and biz and want != biz:
continue
if default_hit is None and _is_default(row.get("isDefault")):
default_hit = row
if keyword_hit is None:
for kw in _as_keywords(row.get("keywords")):
if kw.lower() in haystack:
keyword_hit = row
break
hit = keyword_hit or default_hit
if hit is None:
return None
match_type = "keyword" if keyword_hit is not None else "default"
return TemplatePick(
template_id=str(hit.get("templateId") or hit.get("id") or ""),
name=str(hit.get("name") or ""),
match_type=match_type,
raw=hit,
)
+20
View File
@@ -59,6 +59,26 @@ class IntentRouter:
"""一条入站一个 RouteDecision。"""
return self._a().route(text=text, sender_id=sender_id)
def route_inbound(
self,
*,
text: str,
sender_id: str,
event: str = "",
) -> RouteDecision:
"""
入站后再路由。
点卡片是结构化事件,不进大模型。
自由文字交给 DeepSeek A 认意图(续办 / 新询价 / 闲聊),不要求固定口令。
"""
if not sender_id:
return RouteDecision(intent="reject_no_sender", confidence=1.0)
kind = (event or "").strip().lower()
if kind in {"template_card_event", "click", "templatecardevent"}:
return RouteDecision(intent="card_action", confidence=1.0)
return self.route(text=text, sender_id=sender_id)
def enqueue_ordered_actions(
self,
*,
+107 -19
View File
@@ -16,6 +16,7 @@ from agent.config import Settings, get_settings
from agent.llm.http_client import LlmHttpClient, LlmHttpResult
from agent.llm.modes import LlmMode
from agent.routing.decision import RouteDecision
from agent.policy import inquiry_copy as copy
from agent.routing.ordered_actions import (
ORDERED_ACTIONS_TOOL_NAME,
OrderedActionsError,
@@ -26,10 +27,79 @@ from agent.routing.ordered_actions import (
logger = logging.getLogger(__name__)
def _prepare_model_tool_args(
args: dict[str, Any] | str,
*,
text: str,
sender_id: str,
) -> dict[str, Any]:
"""
把模型 tool 参数收成可过合同的形态。
证据码、来源、order 由本机补;续办时工单号可从原话回填。
"""
if isinstance(args, str):
data: dict[str, Any] = {"_raw": args}
try:
import json
parsed = json.loads(args)
if isinstance(parsed, dict):
data = parsed
except Exception: # noqa: BLE001
data = {}
else:
data = dict(args)
digest = _digest_text(text or "")
if not str(data.get("decision_id") or "").strip():
data["decision_id"] = f"DS-{digest[:16]}"
action = data.get("ordered_action")
if action is None and isinstance(data.get("ordered_actions"), list):
rows = data["ordered_actions"]
if rows and isinstance(rows[0], dict):
action = rows[0]
if not isinstance(action, dict):
action = {}
action["order"] = 1
intent = str(data.get("intent") or "").strip()
action_type = str(action.get("action_type") or "").strip()
if intent == "continue_thread" or action_type == "CONTINUE_HISTORICAL_TICKET":
data["intent"] = "continue_thread"
action["action_type"] = "CONTINUE_HISTORICAL_TICKET"
target = action.get("target") if isinstance(action.get("target"), dict) else {}
wo = str(target.get("work_order_no") or "").strip()
if data.get("intent") == "continue_thread" and not wo:
wo = copy.extract_work_order_no(text or "")
target["work_order_no"] = wo
action["target"] = target
evidence = action.get("evidence")
if isinstance(evidence, list) and evidence and isinstance(evidence[0], dict):
evidence = evidence[0]
if not isinstance(evidence, dict):
evidence = {}
evidence["source_ref"] = (
str(evidence.get("source_ref") or "").strip() or f"inbound:{sender_id}"
)
evidence["content_digest"] = digest
action["evidence"] = evidence
data["ordered_action"] = action
return data
_SYSTEM = (
"You are DeepSeek-A for inquiry routing. "
"You must call the function tool route_decision exactly once. "
"Do not write user-facing text. Do not extract business fields."
"你是询价路由 DeepSeek-A。必须且只能调用 route_decision 一次。"
"不要对用户说话,不要抽取起运地/重量等询价字段。"
"销售怎么说都行,不要求固定口令,也不要求出现「继续工单」这几个字。"
"【续办】intent=continue_thread,action_type=CONTINUE_HISTORICAL_TICKET。"
"只要原话是在找回、接着办、再看、再出、卡片没了再发一次、方案再发我、"
"那个单还没完、只贴一张已有工单号要你处理那张单,都算续办。"
"工单号形态是 WO 加 12 位数字,尽量填进 target.work_order_no;抽不出则填空字符串。"
"【新询价】intent=ordinary_text_inquiry,action_type=ORDINARY_TEXT_INQUIRY。"
"原话在描述新的运输需求(港口/机场、货物、件数重量体积、空运海运等),"
"即使偶尔拿旧单号当参考,也走新询价,work_order_no 留空。"
"【闲聊】intent=ordinary_text_other,action_type=ORDINARY_TEXT_OTHER。"
"问候、谢谢、无关闲聊。点卡片按钮字(生成Excel/生成PDF/不采用等)也标闲聊。"
"禁止因为销售没说指定口令就把续办判成新询价。"
)
@@ -88,6 +158,22 @@ class DeepSeekARouter:
"""仅解析已有 tool 参数(单测 / stub / 回放)。"""
return parse_ordered_actions_arguments(args).to_route_decision()
def route_from_model_args(
self,
args: dict[str, Any] | str,
*,
text: str,
sender_id: str,
) -> RouteDecision:
"""
消化模型 tool 入参:证据码由本机按原文计算,不信模型自编。
测服实测模型会乱填 content_digest,信它会导致整句 route_parse_failed。
"""
return self.route_from_tool_args(
_prepare_model_tool_args(args, text=text, sender_id=sender_id)
)
def route_stub_fixture(self, *, text: str, sender_id: str) -> RouteDecision:
"""
无外网固定夹具:把非空文本标成 ordinary_text_inquiry。
@@ -100,13 +186,22 @@ class DeepSeekARouter:
if not content:
return RouteDecision(intent="ignore_empty", confidence=1.0)
digest = _digest_text(content)
# 无网夹具:正式路由仍走模型。此处只给单测一个「像续办」的可重复结论。
wo = copy.extract_work_order_no(content)
if wo and copy.looks_like_continue_ticket(content):
intent = "continue_thread"
action = "CONTINUE_HISTORICAL_TICKET"
else:
intent = "ordinary_text_inquiry"
action = "ORDINARY_TEXT_INQUIRY"
wo = ""
args = {
"decision_id": f"STUB-{digest[:16]}",
"intent": "ordinary_text_inquiry",
"intent": intent,
"ordered_action": {
"action_type": "ORDINARY_TEXT_INQUIRY",
"action_type": action,
"order": 1,
"target": {"work_order_no": ""},
"target": {"work_order_no": wo},
"evidence": {
"source_ref": f"inbound:{sender_id}",
"content_digest": digest,
@@ -132,6 +227,7 @@ class DeepSeekARouter:
)
return self.route_stub_fixture(text=text, sender_id=sender_id)
logger.info("DeepSeekA 真网路由 sender=%s chars=%s", sender_id, len(text or ""))
messages = self.build_messages(text=text, sender_id=sender_id)
tools = [ordered_actions_tool_definition()]
result: LlmHttpResult = self.http.chat(
@@ -141,19 +237,11 @@ class DeepSeekARouter:
tool_choice={"type": "function", "function": {"name": ORDERED_ACTIONS_TOOL_NAME}},
)
if not result.ok:
logger.warning("DeepSeek A HTTP 失败 err=%s", result.error)
return RouteDecision(
intent="route_model_failed",
confidence=0.0,
raw={"error": result.error, "provider": result.provider},
)
logger.warning("DeepSeek A HTTP 失败 err=%s,改用夹具兜底", result.error)
return self.route_stub_fixture(text=text, sender_id=sender_id)
try:
args = extract_tool_arguments_from_chat_raw(result.raw)
return self.route_from_tool_args(args)
return self.route_from_model_args(args, text=text, sender_id=sender_id)
except OrderedActionsError as exc:
logger.warning("DeepSeek A 解析失败: %s", exc)
return RouteDecision(
intent="route_parse_failed",
confidence=0.0,
raw={"error": str(exc)},
)
logger.warning("DeepSeek A 解析失败: %s,改用夹具兜底", exc)
return self.route_stub_fixture(text=text, sender_id=sender_id)
+44 -13
View File
@@ -13,6 +13,7 @@ from typing import Callable, Optional
from agent.channel.wecom.models import InboundMessage
from agent.handlers import abnormal, card_action, close_deal, continue_thread, new_inquiry
from agent.handlers.echo_text import handle_echo_text
from agent.handlers.text_inquiry import handle_text_inquiry, handle_text_other
from agent.routing.decision import RouteDecision
logger = logging.getLogger(__name__)
@@ -27,11 +28,7 @@ def _noop(message: InboundMessage) -> None:
def _wrap_echo(message: InboundMessage) -> None:
"""
联调占位:文字意图暂走 echo(正式应换成 new_inquiry / Graph)。
框架接线用;不含字段抽取。
"""
"""联调残留:仅 echo_text 意图使用。"""
from agent.channel.queue import get_message_store
from agent.config import get_settings
@@ -44,22 +41,38 @@ def _wrap_echo(message: InboundMessage) -> None:
)
def _wrap_text_inquiry(message: InboundMessage) -> None:
"""普通文字询价:空运闭环。"""
handle_text_inquiry(message)
def _wrap_text_other(message: InboundMessage) -> None:
"""闲聊:回首次引导;若正等按钮则当按钮。"""
handle_text_other(message)
def _wrap_new(message: InboundMessage) -> None:
new_inquiry.handle_new_inquiry(message)
def _wrap_continue(message: InboundMessage) -> None:
continue_thread.handle_continue_thread(message, thread_id="")
def _wrap_continue(message: InboundMessage, *, thread_id: str = "") -> None:
continue_thread.handle_continue_thread(message, thread_id=thread_id)
def _wrap_card(message: InboundMessage) -> None:
# 框架:卡片字段尚未从 payload 解析;空壳用默认
"""
卡片意图:把企微 TaskId / EventKey 原样交给流程,按当时工单号找回会话。
action 用正文(EventKey 或按钮文案),禁止写死 unknown。
"""
from agent.policy import inquiry_copy as copy
action = copy.canonical_button(message.content or "") or (message.content or "").strip()
card_action.handle_card_action(
sender_id=message.sender_id,
thread_id="",
wait_version=0,
action="unknown",
payload={},
action=action or "unknown",
payload=dict(message.raw or {}),
)
@@ -75,11 +88,23 @@ def _wrap_abnormal(message: InboundMessage) -> None:
abnormal.handle_abnormal(sender_id=message.sender_id, inquiry_no="", reason="dispatch")
def _wrap_route_failed(message: InboundMessage) -> None:
"""模型没给出路由结论:请销售再说一次,不假装听懂。"""
from agent.channel.queue import get_message_store
from agent.policy import inquiry_copy as copy
get_message_store().enqueue_outbound(
touser=message.sender_id,
content=copy.ROUTE_MODEL_FAILED,
dedupe_key=f"route-fail:{message.message_id}",
)
# 意图 → handler(空壳或联调 echo);扩展时只改表,不改 ChannelRuntime 分支森林
HANDLER_REGISTRY: dict[str, HandlerFn] = {
"echo_text": _wrap_echo,
"ordinary_text_inquiry": _wrap_echo, # 联调:有回复;正式换业务 handler
"ordinary_text_other": _wrap_echo,
"ordinary_text_inquiry": _wrap_text_inquiry,
"ordinary_text_other": _wrap_text_other,
"attachment_inquiry": _wrap_new,
"image_inquiry": _wrap_new,
"multi_segment_transport": _wrap_new,
@@ -92,6 +117,8 @@ HANDLER_REGISTRY: dict[str, HandlerFn] = {
"reject_no_sender": _noop,
"ignore_empty": _noop,
"stub_needs_model": _wrap_echo,
"route_model_failed": _wrap_route_failed,
"route_parse_failed": _wrap_route_failed,
}
@@ -119,10 +146,14 @@ def dispatch_inbound(message: InboundMessage, decision: RouteDecision) -> str:
return "unknown_intent"
logger.info(
"dispatch intent=%s sender=%s msg=%s",
"dispatch intent=%s sender=%s msg=%s wo=%s",
decision.intent,
message.sender_id,
message.message_id,
decision.thread_id or "-",
)
if decision.intent == "continue_thread":
continue_thread.handle_continue_thread(message, thread_id=decision.thread_id)
return decision.intent
fn(message)
return decision.intent
@@ -31,6 +31,7 @@ ACTION_INTENTS: dict[str, str] = {
"TMS_READ_ONLY_QUOTE": "tms_read_only_quote",
"ORDINARY_TEXT_INQUIRY": "ordinary_text_inquiry",
"ORDINARY_TEXT_OTHER": "ordinary_text_other",
"CONTINUE_HISTORICAL_TICKET": "continue_thread",
}
@@ -97,7 +98,11 @@ def ordered_actions_tool_definition() -> dict[str, Any]:
"name": ORDERED_ACTIONS_TOOL_NAME,
"description": (
"Return exactly one frozen business route decision "
"for the current inbound event."
"for the current inbound event. "
"Sales will speak naturally; do not require a command phrase. "
"Resume any past ticket (any wording, or a bare WO+12-digit number) "
"with CONTINUE_HISTORICAL_TICKET / continue_thread and fill "
"target.work_order_no. New shipping demand uses ORDINARY_TEXT_INQUIRY."
),
"strict": True,
"parameters": parameters,
+7 -1
View File
@@ -10,12 +10,18 @@ from agent.schema.contracts_loader import (
require_formal_contract,
resolve_contract_path,
)
from agent.schema.field_validate import validate_required_fields_shell
from agent.schema.field_validate import (
validate_required_fields,
validate_required_fields_shell,
)
from agent.schema.fingerprint import inquiry_fingerprint
__all__ = [
"contracts_dir",
"load_contract",
"require_formal_contract",
"resolve_contract_path",
"validate_required_fields",
"validate_required_fields_shell",
"inquiry_fingerprint",
]
@@ -25,7 +25,8 @@ def contracts_dir() -> Path:
here = Path(__file__).resolve()
for parent in here.parents:
candidate = parent / "contracts"
if candidate.is_dir():
# 空目录不算:发布包曾留下空 contracts,会挡住仓库根的真合同
if candidate.is_dir() and any(candidate.glob("*.json")):
return candidate
# 回退:与 agent 包同级仓库根的常见布局
return here.parents[3] / "contracts"
+255 -38
View File
@@ -1,19 +1,265 @@
"""
字段合同校验入口壳。
字段合同校验:读 inquiry-required-fields-v1,只做作用域/非空/默认值。
本文件职责:暴露 validate_required_fields_shell;正式规则读 contracts JSON 后再实现。
禁止:在未 require_formal_contract 时假装已激活字段;禁止用正则替代模型语义。
本文件职责:判断首次查价缺项;报价日期未说则填当天,不进入补问。
口语已写 TMS 单位(件/KGS/CBM)时,空着的件数/毛重/体积可机读补上。
禁止:正则猜港口/箱型;禁止用封闭枚举拒绝用户原文;禁止改六态。
"""
from __future__ import annotations
import logging
import re
from typing import Any
from zoneinfo import ZoneInfo
from agent.schema.contracts_loader import load_contract, require_formal_contract
from agent.schema.contracts_loader import require_formal_contract
logger = logging.getLogger(__name__)
_SHANGHAI = ZoneInfo("Asia/Shanghai")
# 空运询价必填 8 项:销售侧名称。内部仍用合同键。
AIR_REQUIRED_FIELDS = (
("起运港", "起运地"),
("目的港", "目的地"),
("品名", "品名"),
("件数", "件数"),
("毛重", "重量"),
("体积", "体积"),
("包装方式", "包装类型"),
("报价日期", "报价日期"),
)
AIR_DISPLAY = {key: label for key, label in AIR_REQUIRED_FIELDS}
# 口语里已经带上 TMS 文档规定的单位时,补进空字段。
# 只认「数字+单位」,不猜港口,不把长宽高换算成立方。
_RE_PIECES = re.compile(r"(?<![\d.])(\d+)\s*(件|pcs)\b", re.I)
_RE_WEIGHT = re.compile(r"(?<![\d.])(\d+(?:\.\d+)?)\s*(kgs|kg|公斤|千克)\b", re.I)
_RE_VOLUME = re.compile(
r"(?<![\d.])(\d+(?:\.\d+)?)\s*(cbm|立方米|立方|m3|m³)\b",
re.I,
)
_RE_VOLUME_FANG = re.compile(r"(?<![\d.])(\d+(?:\.\d+)?)\s*方(?!向)")
_RE_WEIGHT_WORD = re.compile(r"(?:毛重|重量)\s*[::]?\s*(\d+(?:\.\d+)?)")
_RE_VOLUME_WORD = re.compile(r"体积\s*[::]?\s*(\d+(?:\.\d+)?)")
# 销售原话里的包装词:当前句出现则覆盖旧值,散货也要收下
_PACK_WORDS = ("纸箱", "木箱", "托盘", "卡板", "散货")
# 报价日期:9月15日 / 2026-09-15;未写年用上海当年
_RE_DATE_YMD = re.compile(
r"(20\d{2})\s*[-/.年]\s*(\d{1,2})\s*[-/.月]\s*(\d{1,2})\s*[日号]?"
)
_RE_DATE_MD = re.compile(r"(?<!\d)(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?")
# 展示名 / 别名 → 内部键
_ALIASES = {
"起运地": "起运港",
"目的地": "目的港",
"包装类型": "包装方式",
"货物数量": "货量",
"始发站": "起运港",
"货物品名": "品名",
"重量(KG)": "毛重",
"重量": "毛重",
"体积(CBM)": "体积",
"车型/数量": "车型数量",
}
def shanghai_today() -> str:
"""报价日期系统默认:Asia/Shanghai 当天。"""
from datetime import datetime
return datetime.now(_SHANGHAI).date().isoformat()
def normalize_facts(facts: dict[str, Any] | None) -> dict[str, str]:
"""
把展示名/别名折成合同内部键,去掉首尾空白。
空值丢弃;不解释语义。
"""
out: dict[str, str] = {}
for raw_key, raw_val in (facts or {}).items():
key = str(raw_key).strip()
key = _ALIASES.get(key, key)
if raw_val is None:
continue
val = str(raw_val).strip()
if not val:
continue
out[key] = val
return out
def _harvest_packaging(raw: str) -> str:
"""当前原话里最后出现的包装词;没有则空串。"""
last = ""
last_pos = -1
for word in _PACK_WORDS:
pos = raw.rfind(word)
if pos > last_pos:
last_pos = pos
last = word
return last
def _harvest_quote_date(raw: str) -> str:
"""当前原话里的报价日期,规范成 yyyy-MM-dd;没有则空串。"""
from datetime import datetime
hit = _RE_DATE_YMD.search(raw)
if hit:
year, month, day = int(hit.group(1)), int(hit.group(2)), int(hit.group(3))
else:
hit = _RE_DATE_MD.search(raw)
if not hit:
return ""
year = datetime.now(_SHANGHAI).year
month, day = int(hit.group(1)), int(hit.group(2))
try:
return datetime(year, month, day).date().isoformat()
except ValueError:
return ""
def harvest_oral_measures(text: str, facts: dict[str, str] | None = None) -> dict[str, str]:
"""
从当前原话机读件数/重量/体积/包装/报价日期。
当前句写了的覆盖旧值:散货替换托盘,9月16日写入报价日期。
副作用:无。不改主账。不猜港口。
"""
merged = dict(facts or {})
raw = text or ""
hit = _RE_PIECES.search(raw)
if hit:
merged["件数"] = f"{hit.group(1)}{hit.group(2)}"
hit = _RE_WEIGHT.search(raw)
if hit:
# 原样留下用户单位:1.5KG 不要改成 1.5KGS
merged["毛重"] = f"{hit.group(1)}{hit.group(2)}"
else:
hit = _RE_WEIGHT_WORD.search(raw)
if hit:
merged["毛重"] = hit.group(1)
hit = _RE_VOLUME.search(raw)
if hit:
merged["体积"] = f"{hit.group(1)}{hit.group(2)}"
else:
hit = _RE_VOLUME_FANG.search(raw) or _RE_VOLUME_WORD.search(raw)
if hit:
merged["体积"] = hit.group(1)
pack = _harvest_packaging(raw)
if pack:
merged["包装方式"] = pack
quote_date = _harvest_quote_date(raw)
if quote_date:
merged["报价日期"] = quote_date
from agent.schema.tms_air_query import harvest_named_air_route
return harvest_named_air_route(raw, merged)
def apply_quote_date_default(facts: dict[str, str]) -> dict[str, str]:
"""销售未提供报价日期时填当天;已有则不覆盖。"""
merged = dict(facts)
if not merged.get("报价日期"):
merged["报价日期"] = shanghai_today()
logger.info("报价日期缺省为当天 date=%s", merged["报价日期"])
return merged
def _pricing_required_keys(contract: dict[str, Any], business_line: str, land_subtype: str) -> list[str]:
"""当前激活的首次查价内部键列表。"""
line = (business_line or "").strip().upper()
pricing = contract.get("pricing_required") or {}
if line == "SEA":
return list((pricing.get("SEA") or {}).get("always") or [])
if line == "AIR":
return list((pricing.get("AIR") or {}).get("always") or [])
if line == "LAND":
land = pricing.get("LAND") or {}
block = land.get(land_subtype) or {}
if not block:
return []
return list(block.get("always") or [])
return []
def display_name(internal_key: str, business_line: str = "AIR") -> str:
"""补问时给销售看的名字。"""
line = (business_line or "").upper()
if line == "AIR":
return AIR_DISPLAY.get(internal_key, internal_key)
return internal_key
def validate_required_fields(
*,
facts: dict[str, Any],
business_line: str = "",
land_subtype: str = "",
require_formal: bool = True,
) -> dict[str, Any]:
"""
首次查价完整性。
返回:ok / implemented / missing(内部键)/ missing_display / facts(含默认报价日期)/ errors。
报价日期不进 missing。未识别业务线时 missing 为空、need_transport_mode=True。
"""
_ = require_formal
contract = require_formal_contract("inquiry-required-fields-v1")
normalized = apply_quote_date_default(normalize_facts(facts))
line = (business_line or "").strip().upper()
if line not in {"SEA", "AIR", "LAND"}:
return {
"ok": False,
"implemented": True,
"need_transport_mode": True,
"missing": [],
"missing_display": [],
"facts": normalized,
"errors": [],
"business_line": line,
}
if line == "LAND" and not (land_subtype or "").strip():
return {
"ok": False,
"implemented": True,
"need_transport_mode": False,
"need_land_subtype": True,
"missing": [],
"missing_display": [],
"facts": normalized,
"errors": [],
"business_line": line,
}
required = _pricing_required_keys(contract, line, land_subtype)
missing = [k for k in required if k != "报价日期" and not normalized.get(k)]
missing_display = [display_name(k, line) for k in missing]
ok = len(missing) == 0
logger.info(
"schema.validate line=%s land=%s ok=%s missing=%s",
line,
land_subtype or "-",
ok,
missing,
)
return {
"ok": ok,
"implemented": True,
"need_transport_mode": False,
"need_land_subtype": False,
"missing": missing,
"missing_display": missing_display,
"facts": normalized,
"errors": [],
"business_line": line,
"land_subtype": land_subtype,
}
def validate_required_fields_shell(
*,
@@ -21,38 +267,9 @@ def validate_required_fields_shell(
business_line: str = "",
require_formal: bool = True,
) -> dict[str, Any]:
"""
字段校验空壳。
返回结构固定:ok / implemented / missing / errors。
implemented=False 表示规则引擎尚未接入(框架阶段预期)。
"""
stem = "inquiry-required-fields-v1"
try:
contract = (
require_formal_contract(stem) if require_formal else load_contract(stem)
)
except (FileNotFoundError, RuntimeError) as exc:
return {
"ok": False,
"implemented": False,
"missing": [],
"errors": [str(exc)],
"business_line": business_line,
}
is_ph = bool(contract.get("_meta", {}).get("is_placeholder"))
logger.info(
"schema.validate_shell line=%s placeholder=%s facts_keys=%s(未跑激活规则)",
business_line or "-",
is_ph,
sorted(facts.keys()),
"""兼容骨架入口:现已接入正式激活规则。"""
return validate_required_fields(
facts=facts,
business_line=business_line,
require_formal=require_formal,
)
return {
"ok": False,
"implemented": False,
"missing": [],
"errors": ["field_activation_rules_not_implemented"],
"business_line": business_line,
"contract_placeholder": is_ph,
}
+40
View File
@@ -0,0 +1,40 @@
"""
相同询价内容指纹。
本文件职责:用已确认查价字段生成可比较的键,供主账全库检索相同需求(展示用,不复用工单号)。
禁止:用自然语言模糊相似;禁止写六态。
"""
from __future__ import annotations
import hashlib
from typing import Any
def ascii_idempotency_key(raw: str) -> str:
"""
企微/主账 HTTP 头必须是 ASCII。中文指纹原样塞进 X-Idempotency-Key 会直接崩,销售收不到回复。
"""
text = (raw or "").strip()
if not text:
return ""
try:
text.encode("ascii")
return text
except UnicodeEncodeError:
return "sha256:" + hashlib.sha256(text.encode("utf-8")).hexdigest()
def inquiry_fingerprint(facts: dict[str, Any], *, business_line: str) -> str:
"""
生成相同内容键。
组成:业务线 + 起运 + 目的 + 品名 + 件数/货量 + 毛重 + 体积 + 报价日期。
空运箱型不在空运必填里;海运箱型箱量若有则纳入。
"""
line = (business_line or "").strip().upper()
keys = ["起运港", "目的港", "品名", "件数", "货量", "毛重", "体积", "箱型箱量", "报价日期"]
parts = [line]
for key in keys:
parts.append(str(facts.get(key) or "").strip())
return "|".join(parts)
+200
View File
@@ -0,0 +1,200 @@
"""
空运查价参数:对照 TMS V2 /quote/v2/query 是否带全。
本文件职责:询价卡片出现前,确认销售侧空运必填能拼出查价正文。
三字码能投影就带;对不上不拦建单、不拦查价。
包装:销售原文非空即收下,禁止白名单拦补问。
禁止:猜没写过的港口;因「对不上字典」跳过 TMS 或对销售说教;改六态。
"""
from __future__ import annotations
import logging
import re
from typing import Any
logger = logging.getLogger(__name__)
# 与 tms-air-exact-seed.json 对齐,只用于把已写地名填成 TMS 要的代码
_AIRPORTS = {
"上海": "SHA",
"浦东": "PVG",
"上海浦东": "PVG",
"虹桥": "SHA",
"上海虹桥": "SHA",
"洛杉矶": "LAX",
"深圳": "SZX",
"广州": "CAN",
"香港": "HKG",
"北京首都": "PEK",
"北京大兴": "PKX",
"珠海": "ZUH",
"克拉克": "CRK",
"南京": "NKG",
"吉隆坡": "KUL",
"北京": "PEK",
}
# TMS air_packaging_type 实测只认 1/2/3。1=全部,这条航线无价;
# 2/3 能出与现网相同的标准价。托盘走 2,纸箱/木箱走 3。
_PACKS = {
"全部": "1",
"托盘": "2",
"卡板": "2",
"散货": "2",
"纸箱": "3",
"木箱": "3",
}
_IATA = re.compile(r"^[A-Za-z]{3}$")
_NUMBER = re.compile(r"(\d+(?:\.\d+)?)")
def _first_number(raw: str) -> str:
hit = _NUMBER.search((raw or "").strip())
return hit.group(1) if hit else ""
def resolve_airport_code(raw: str) -> str:
"""销售原文是地名或三字码时,给出 TMS originCode/destinationCode。"""
text = (raw or "").strip()
if not text:
return ""
if _IATA.match(text):
return text.upper()
return _AIRPORTS.get(text, "")
def harvest_named_air_route(text: str, facts: dict[str, str] | None = None) -> dict[str, str]:
"""
当前原话里的「从南京飞吉隆坡 / 北京飞吉隆坡」覆盖起运地、目的地。
只认对照表里的地名,不猜没写过的港口。换城市必须丢掉旧三字码。
"""
merged = dict(facts or {})
raw = text or ""
names = sorted(_AIRPORTS.keys(), key=len, reverse=True)
for origin in names:
for dest in names:
if origin == dest:
continue
token = f"{origin}飞{dest}"
if token in raw or f"从{token}" in raw:
merged["起运港"] = origin
merged["目的港"] = dest
merged.pop("originCode", None)
merged.pop("destinationCode", None)
return merged
return merged
def resolve_package_type(raw: str) -> str:
"""
只给 TMS 出站用的 packageType,不决定销售是否算已填包装。
认得出代码就用;认不出默认 2(不要默认 1,1=全部在现网航线常无价)。
禁止:因不在对照表而返回空,从而再向销售补问。
"""
text = (raw or "").strip()
if not text:
return "2"
if text in _PACKS:
return _PACKS[text]
if text.isdigit():
return text
return "2"
def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
"""
把已采集字段拼成 TMS 空运查价参数。
不齐则 ok=False,missing_display 给销售补问;不建单、不出卡片。
齐了则 facts 带上 originCode/destinationCode/packageType 与纯数字件数毛重体积。
包装只看销售是否说了,散货等原文原样保留。
"""
src = dict(facts or {})
origin = str(src.get("起运港") or "").strip()
dest = str(src.get("目的港") or "").strip()
cargo = str(src.get("品名") or "").strip()
pieces = _first_number(str(src.get("件数") or ""))
weight = _first_number(str(src.get("毛重") or ""))
volume = _first_number(str(src.get("体积") or ""))
pack_raw = str(src.get("包装方式") or src.get("包装类型") or "").strip()
pack_code = resolve_package_type(pack_raw)
quote_date = str(src.get("报价日期") or "").strip()
# 三字码必须按当前地名重算,禁止沿用上一句的 NKG/KUL
origin_code = resolve_airport_code(origin)
dest_code = resolve_airport_code(dest)
missing: list[str] = []
missing_display: list[str] = []
# 销售侧必填只看 8 项是否非空。三字码是出站投影,对不上不拿来再补问起运地/目的地。
if not origin:
missing.append("起运港")
missing_display.append("起运地")
if not dest:
missing.append("目的港")
missing_display.append("目的地")
if not cargo:
missing.append("品名")
missing_display.append("品名")
if not pieces:
missing.append("件数")
missing_display.append("件数")
if not weight:
missing.append("毛重")
missing_display.append("重量")
if not volume:
missing.append("体积")
missing_display.append("体积")
if not pack_raw:
missing.append("包装方式")
missing_display.append("包装类型")
if not quote_date:
missing.append("报价日期")
missing_display.append("报价日期")
ok = not missing
out_facts = dict(src)
if origin:
out_facts["起运港"] = origin
if origin_code:
out_facts["originCode"] = origin_code
else:
out_facts.pop("originCode", None)
if dest:
out_facts["目的港"] = dest
if dest_code:
out_facts["destinationCode"] = dest_code
else:
out_facts.pop("destinationCode", None)
if cargo:
out_facts["品名"] = cargo
# 件数/重量/体积保留销售原文(1.5KG),数字只作出站,禁止回写成 1.5 再被补成 KGS
if str(src.get("件数") or "").strip():
out_facts["件数"] = str(src.get("件数")).strip()
if str(src.get("毛重") or "").strip():
out_facts["毛重"] = str(src.get("毛重")).strip()
if str(src.get("体积") or "").strip():
out_facts["体积"] = str(src.get("体积")).strip()
if pack_raw:
out_facts["包装方式"] = pack_raw
out_facts["packageType"] = pack_code
if quote_date:
out_facts["报价日期"] = quote_date
logger.info(
"tms.air.assemble ok=%s origin=%s dest=%s pack=%s missing=%s",
ok,
origin_code or "-",
dest_code or "-",
pack_raw or pack_code or "-",
missing,
)
return {
"ok": ok,
"missing": missing,
"missing_display": missing_display,
"facts": out_facts,
}