落地私聊空运询价与TMS报价回填,按后台模板出单。
查到价后把币种、单位、数量、单价、备注写入报价单,避免销售只看到光秃秃的金额。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]: ...
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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"}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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} 跟进周期已顺延。"
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user