diff --git a/.cursor/rules/pm-collaboration.mdc b/.cursor/rules/pm-collaboration.mdc
new file mode 100644
index 0000000..cc384c8
--- /dev/null
+++ b/.cursor/rules/pm-collaboration.mdc
@@ -0,0 +1,36 @@
+---
+description: 用户是产品经理;白话需求;必须沿用仓库现有框架开发
+alwaysApply: true
+---
+
+# 协作:产品经理教需求,助手按框架写代码
+
+用户**不是开发人员**,是产品经理。仓库里现有目录、分包、进程、停点、字段合同是开发人员给的**必须遵守的框架**,不是草稿,禁止另起炉灶。
+
+## 谁做什么
+
+| 角色 | 做 | 不做 |
+|---|---|---|
+| 用户 | 白话讲场景、对错、验收口径 | 不贴路径、不 `@` 文件、不写代码 |
+| 助手 | 自己读 `prompt/` 与现有源码,在框架里实现 | 不要求用户懂代码;不问「该用哪个文件」 |
+
+用户说「先做私聊文字询价 / 工单六态 / 测服能回消息」即可开工。助手自己打开 `prompt/00-INDEX.md`、`prompt/05-总控提示词.md`,说到哪一端再读 `06`~`14`。密钥 `03` 不要每次读进对话。
+
+## 必须沿用的框架
+
+- 智能体只在 `inquiry-agent/` 已有包里填:`channel` / `routing` / `handlers` / `graph` / `jobs` / `ledger` 等。一类意图一个 handler,节点分文件。
+- 主账只在 `inquiry-api/` 的 Jeecg + `jeecg-module-inquiry`。智能体只打主账 HTTP,不直连 TMS、不写 MySQL 工单表。
+- 后台只在 `inquiry-backend/` 的 Jeecg Vue,经 `/jeecgboot` 打 8180。
+- 进程形态不变:一个主账、一个智能体 HTTP、一个 Worker、一个 AIBOT 桥、一个后台。
+- 字段以仓库里两份 JSON 合同为准。未读不准写字段校验。
+
+禁止:新建平行工程、单文件上帝模块、用图状态冒充主账成功、改现网库/端口/企微回调。
+
+## 怎么跟用户说话
+
+用产品语言汇报:做成了什么、销售侧会看到什么、还缺哪段业务。需要拍板时**只问产品选择**(例:空运锁舱本轮做不做),不要问技术选型。
+
+```text
+❌ 请 @ 一下 graph/runtime.py,你看用 interrupt 还是 wait?
+✅ 私聊文字询价:能收回并原样回一句。下一步按合同做「抽字段 + 缺了再补问」,还是先打通建工单?
+```
diff --git a/.cursor/rules/prompt-contract.mdc b/.cursor/rules/prompt-contract.mdc
index 8fdc484..cb000be 100644
--- a/.cursor/rules/prompt-contract.mdc
+++ b/.cursor/rules/prompt-contract.mdc
@@ -7,6 +7,6 @@ alwaysApply: true
`prompt/*.md` 是业务合同。不要给那些 md 加 Cursor 文件头。
-用户是产品经理,不会打 `@`、不会贴路径。禁止要求用户 @ 文件。用户用白话说「开始做 / 先做工单 / 部署测试」即可。助手必须自己打开 `prompt/00-INDEX.md` 和 `prompt/05-总控提示词.md`;说到哪一端再自己读 `06`~`14`。不要把 `03`(密钥)每次都读进对话。
+用户是产品经理,不会打 `@`、不会贴路径。禁止要求用户 @ 文件。用户用白话说「开始做 / 先做工单 / 部署测试」即可。助手必须自己打开 `prompt/00-INDEX.md` 和 `prompt/05-总控提示词.md`;说到哪一端再自己读 `06`~`14`。不要把 `03`(密钥)每次都读进对话。协作方式与「必须沿用现有框架」见 `pm-collaboration.mdc`。
本目录 `.mdc` 才写规则头:`description` + `alwaysApply: true`(或 `globs`)。不要把 `01`~`14` 全文复制进本规则。
diff --git a/.env.example b/.env.example
index 9c72726..7b8b2db 100644
--- a/.env.example
+++ b/.env.example
@@ -30,7 +30,8 @@ AGENT_HTTP_PORT=8910
AIBOT_BRIDGE_PORT=8913
AIBOT_BRIDGE_BIND=127.0.0.1
-BIZ_API_BASE_URL=http://127.0.0.1:8180
+BIZ_API_BASE_URL=http://127.0.0.1:8180/jeecgboot
+LEDGER_BACKEND=http
AGENT_CALLBACK_BASE_URL=http://127.0.0.1:8910
WECOM_AIBOT_BRIDGE_URL=http://127.0.0.1:8913
WECOM_AIBOT_INGRESS_URL=http://127.0.0.1:8910/internal/aibot/inbound
@@ -71,3 +72,7 @@ OBJECT_STORAGE_PATH_STYLE=true
AGENT_CALLBACK_TOKEN=
WECOM_AIBOT_ENABLED=false
+
+# 口语抽字段:测服/正式必须打开,否则只认「毛重:100」这种整行标签
+LLM_ALLOW_NETWORK=true
+LLM_DATA_USAGE_CONFIRMED=true
diff --git a/.gitignore b/.gitignore
index 3f00ddd..d315a28 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
# 环境与密钥(禁止进 Git)
.env
.env.*
+(1).env
!.env.example
!inquiry-backend/.env.production
!inquiry-backend/.env.development
diff --git a/inquiry-agent/agent/channel/outbox/sender.py b/inquiry-agent/agent/channel/outbox/sender.py
index efe0a83..3ebc673 100644
--- a/inquiry-agent/agent/channel/outbox/sender.py
+++ b/inquiry-agent/agent/channel/outbox/sender.py
@@ -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:
diff --git a/inquiry-agent/agent/channel/pg_store.py b/inquiry-agent/agent/channel/pg_store.py
index 7369e50..83e84a6 100644
--- a/inquiry-agent/agent/channel/pg_store.py
+++ b/inquiry-agent/agent/channel/pg_store.py
@@ -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(
diff --git a/inquiry-agent/agent/channel/queue.py b/inquiry-agent/agent/channel/queue.py
index d535e12..085d512 100644
--- a/inquiry-agent/agent/channel/queue.py
+++ b/inquiry-agent/agent/channel/queue.py
@@ -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()
diff --git a/inquiry-agent/agent/channel/runtime.py b/inquiry-agent/agent/channel/runtime.py
index e3f58c6..4427022 100644
--- a/inquiry-agent/agent/channel/runtime.py
+++ b/inquiry-agent/agent/channel/runtime.py
@@ -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:
diff --git a/inquiry-agent/agent/channel/store_protocol.py b/inquiry-agent/agent/channel/store_protocol.py
index 108df3a..db92f8d 100644
--- a/inquiry-agent/agent/channel/store_protocol.py
+++ b/inquiry-agent/agent/channel/store_protocol.py
@@ -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]: ...
diff --git a/inquiry-agent/agent/channel/wecom/parse.py b/inquiry-agent/agent/channel/wecom/parse.py
index 705bcec..bf32b14 100644
--- a/inquiry-agent/agent/channel/wecom/parse.py
+++ b/inquiry-agent/agent/channel/wecom/parse.py
@@ -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,
+ },
)
diff --git a/inquiry-agent/agent/config/__init__.py b/inquiry-agent/agent/config/__init__.py
index 81dbd61..4ec7c99 100644
--- a/inquiry-agent/agent/config/__init__.py
+++ b/inquiry-agent/agent/config/__init__.py
@@ -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")
diff --git a/inquiry-agent/agent/foundation_selftest.py b/inquiry-agent/agent/foundation_selftest.py
index 3e312f9..b5c8fe8 100644
--- a/inquiry-agent/agent/foundation_selftest.py
+++ b/inquiry-agent/agent/foundation_selftest.py
@@ -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)
diff --git a/inquiry-agent/agent/handlers/__init__.py b/inquiry-agent/agent/handlers/__init__.py
index 3d1d329..146b9fc 100644
--- a/inquiry-agent/agent/handlers/__init__.py
+++ b/inquiry-agent/agent/handlers/__init__.py
@@ -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"]
diff --git a/inquiry-agent/agent/handlers/card_action.py b/inquiry-agent/agent/handlers/card_action.py
index ce3fdf4..b3af757 100644
--- a/inquiry-agent/agent/handlers/card_action.py
+++ b/inquiry-agent/agent/handlers/card_action.py
@@ -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
)
diff --git a/inquiry-agent/agent/handlers/continue_thread.py b/inquiry-agent/agent/handlers/continue_thread.py
index 133fe8c..1a2a02b 100644
--- a/inquiry-agent/agent/handlers/continue_thread.py
+++ b/inquiry-agent/agent/handlers/continue_thread.py
@@ -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
diff --git a/inquiry-agent/agent/handlers/text_inquiry.py b/inquiry-agent/agent/handlers/text_inquiry.py
new file mode 100644
index 0000000..004215e
--- /dev/null
+++ b/inquiry-agent/agent/handlers/text_inquiry.py
@@ -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"
diff --git a/inquiry-agent/agent/jobs/pdf_job.py b/inquiry-agent/agent/jobs/pdf_job.py
index f9ca3c8..c3f8a9f 100644
--- a/inquiry-agent/agent/jobs/pdf_job.py
+++ b/inquiry-agent/agent/jobs/pdf_job.py
@@ -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)
diff --git a/inquiry-agent/agent/ledger/__init__.py b/inquiry-agent/agent/ledger/__init__.py
index 36bc9f9..3e05ce8 100644
--- a/inquiry-agent/agent/ledger/__init__.py
+++ b/inquiry-agent/agent/ledger/__init__.py
@@ -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))
diff --git a/inquiry-agent/agent/ledger/http_ledger.py b/inquiry-agent/agent/ledger/http_ledger.py
new file mode 100644
index 0000000..3993a67
--- /dev/null
+++ b/inquiry-agent/agent/ledger/http_ledger.py
@@ -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"}
diff --git a/inquiry-agent/agent/ledger/memory_ledger.py b/inquiry-agent/agent/ledger/memory_ledger.py
new file mode 100644
index 0000000..98873c2
--- /dev/null
+++ b/inquiry-agent/agent/ledger/memory_ledger.py
@@ -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,
+ }
diff --git a/inquiry-agent/agent/llm/extract_text.py b/inquiry-agent/agent/llm/extract_text.py
new file mode 100644
index 0000000..a7e3b85
--- /dev/null
+++ b/inquiry-agent/agent/llm/extract_text.py
@@ -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,
+ }
diff --git a/inquiry-agent/agent/llm/http_client.py b/inquiry-agent/agent/llm/http_client.py
index 0c9b7bc..29d265e 100644
--- a/inquiry-agent/agent/llm/http_client.py
+++ b/inquiry-agent/agent/llm/http_client.py
@@ -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,
diff --git a/inquiry-agent/agent/llm/mode_extract_fields.py b/inquiry-agent/agent/llm/mode_extract_fields.py
index 5575b78..ee171ad 100644
--- a/inquiry-agent/agent/llm/mode_extract_fields.py
+++ b/inquiry-agent/agent/llm/mode_extract_fields.py
@@ -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
diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py
new file mode 100644
index 0000000..c4a9b96
--- /dev/null
+++ b/inquiry-agent/agent/policy/air_text_flow.py
@@ -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
diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py
new file mode 100644
index 0000000..21f5929
--- /dev/null
+++ b/inquiry-agent/agent/policy/inquiry_copy.py
@@ -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} 跟进周期已顺延。"
diff --git a/inquiry-agent/agent/quote_templates/__init__.py b/inquiry-agent/agent/quote_templates/__init__.py
index eb7f766..05ff1f9 100644
--- a/inquiry-agent/agent/quote_templates/__init__.py
+++ b/inquiry-agent/agent/quote_templates/__init__.py
@@ -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
diff --git a/inquiry-agent/agent/quote_templates/match.py b/inquiry-agent/agent/quote_templates/match.py
new file mode 100644
index 0000000..bd4c654
--- /dev/null
+++ b/inquiry-agent/agent/quote_templates/match.py
@@ -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,
+ )
diff --git a/inquiry-agent/agent/routing/__init__.py b/inquiry-agent/agent/routing/__init__.py
index 5d6a145..e0d8302 100644
--- a/inquiry-agent/agent/routing/__init__.py
+++ b/inquiry-agent/agent/routing/__init__.py
@@ -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,
*,
diff --git a/inquiry-agent/agent/routing/deepseek_a.py b/inquiry-agent/agent/routing/deepseek_a.py
index 56b3cd0..40f5179 100644
--- a/inquiry-agent/agent/routing/deepseek_a.py
+++ b/inquiry-agent/agent/routing/deepseek_a.py
@@ -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)
diff --git a/inquiry-agent/agent/routing/dispatch.py b/inquiry-agent/agent/routing/dispatch.py
index 1058737..36e4d91 100644
--- a/inquiry-agent/agent/routing/dispatch.py
+++ b/inquiry-agent/agent/routing/dispatch.py
@@ -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
diff --git a/inquiry-agent/agent/routing/ordered_actions.py b/inquiry-agent/agent/routing/ordered_actions.py
index 738b1cc..78de462 100644
--- a/inquiry-agent/agent/routing/ordered_actions.py
+++ b/inquiry-agent/agent/routing/ordered_actions.py
@@ -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,
diff --git a/inquiry-agent/agent/schema/__init__.py b/inquiry-agent/agent/schema/__init__.py
index ae869f4..b70f008 100644
--- a/inquiry-agent/agent/schema/__init__.py
+++ b/inquiry-agent/agent/schema/__init__.py
@@ -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",
]
diff --git a/inquiry-agent/agent/schema/contracts_loader.py b/inquiry-agent/agent/schema/contracts_loader.py
index 2b0f4c8..a9a1f23 100644
--- a/inquiry-agent/agent/schema/contracts_loader.py
+++ b/inquiry-agent/agent/schema/contracts_loader.py
@@ -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"
diff --git a/inquiry-agent/agent/schema/field_validate.py b/inquiry-agent/agent/schema/field_validate.py
index 82ecc92..3cd0f35 100644
--- a/inquiry-agent/agent/schema/field_validate.py
+++ b/inquiry-agent/agent/schema/field_validate.py
@@ -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"(? 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,
- }
diff --git a/inquiry-agent/agent/schema/fingerprint.py b/inquiry-agent/agent/schema/fingerprint.py
new file mode 100644
index 0000000..d5cfde9
--- /dev/null
+++ b/inquiry-agent/agent/schema/fingerprint.py
@@ -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)
diff --git a/inquiry-agent/agent/schema/tms_air_query.py b/inquiry-agent/agent/schema/tms_air_query.py
new file mode 100644
index 0000000..46e5d06
--- /dev/null
+++ b/inquiry-agent/agent/schema/tms_air_query.py
@@ -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,
+ }
diff --git a/inquiry-agent/tests/test_air_text_inquiry.py b/inquiry-agent/tests/test_air_text_inquiry.py
new file mode 100644
index 0000000..b8803f2
--- /dev/null
+++ b/inquiry-agent/tests/test_air_text_inquiry.py
@@ -0,0 +1,995 @@
+"""
+空运文字询价主路径单测(不连企微 / 真实 TMS / 8180)。
+
+覆盖:无运输方式、缺项补问、建单卡片、有价/无价、跳过协同、采用 Excel/PDF、成交。
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import sys
+import unittest
+
+ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
+if ROOT not in sys.path:
+ sys.path.insert(0, ROOT)
+
+os.environ.setdefault("YTD_ENV", "test")
+os.environ.setdefault("REDIS_BACKEND", "memory")
+os.environ.setdefault("CHECKPOINT_BACKEND", "memory")
+os.environ.setdefault("MESSAGE_STORE_BACKEND", "memory")
+os.environ.setdefault("LLM_DATA_USAGE_CONFIRMED", "false")
+os.environ.setdefault("LLM_ALLOW_NETWORK", "false")
+os.environ.setdefault("LEDGER_BACKEND", "memory")
+
+from agent.channel.queue import MemoryMessageStore
+from agent.channel.wecom.models import InboundMessage
+from agent.handlers.text_inquiry import handle_text_inquiry
+from agent.ledger.memory_ledger import MemoryLedger
+from agent.policy import inquiry_copy as copy
+from agent.policy.air_text_flow import AirTextInquiryFlow
+from agent.schema.field_validate import validate_required_fields
+
+
+COMPLETE_AIR = {
+ "起运港": "上海",
+ "目的港": "洛杉矶",
+ "品名": "普货",
+ "件数": "3",
+ "毛重": "180",
+ "体积": "2.5",
+ "包装方式": "纸箱",
+}
+
+
+class AirTextInquiryTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self.flow = AirTextInquiryFlow(MemoryLedger())
+ self.replies: list[str] = []
+ self.extras: list[dict] = []
+
+ def _reply(self, text: str, extra=None) -> None:
+ self.replies.append(text)
+ self.extras.append(dict(extra) if extra else {})
+
+ def test_clarify_shows_known_and_examples(self) -> None:
+ text = copy.ask_clarify(
+ facts={
+ "起运港": "珠海",
+ "目的港": "克拉克",
+ "originCode": "ZUH",
+ "destinationCode": "CRK",
+ "品名": "茶叶",
+ "件数": "10",
+ },
+ missing_keys=["毛重", "体积", "包装方式"],
+ transport_mode="空运",
+ )
+ self.assertEqual(
+ text,
+ "\n".join(
+ [
+ "请确认并补充以下信息:",
+ "运输方式:空运",
+ "起运地:珠海",
+ "目的地:克拉克",
+ "品名:茶叶",
+ "件数:10件",
+ "",
+ "待补充:",
+ "重量:(如:100KGS)",
+ "体积:(如:2CBM)",
+ "包装类型:(如:托盘)",
+ ]
+ ),
+ )
+
+ def test_inquiry_card_matches_sales_format(self) -> None:
+ text = copy.inquiry_card(
+ work_order_no="WO202609110080",
+ facts={
+ "起运港": "珠海",
+ "目的港": "克拉克",
+ "originCode": "ZUH",
+ "destinationCode": "CRK",
+ "品名": "茶叶",
+ "件数": "10",
+ "毛重": "1",
+ "体积": "8",
+ "包装方式": "托盘",
+ "报价日期": "2026-09-12",
+ },
+ )
+ self.assertEqual(
+ text,
+ "\n".join(
+ [
+ "工单号:WO202609110080",
+ "运输方式:空运",
+ "起运地:珠海",
+ "目的地:克拉克",
+ "品名:茶叶",
+ "件数:10件",
+ "重量:1",
+ "体积:8",
+ "包装类型:托盘",
+ "报价日期:2026-09-12",
+ "工单已创建,正在查询TMS报价。",
+ ]
+ ),
+ )
+
+ def test_quote_date_defaults_today_not_in_missing(self) -> None:
+ result = validate_required_fields(facts=COMPLETE_AIR, business_line="AIR")
+ self.assertTrue(result["ok"])
+ self.assertTrue(result["facts"]["报价日期"])
+ self.assertNotIn("报价日期", result["missing"])
+
+ def test_fly_is_air_and_followup_keeps_mode(self) -> None:
+ phase = self.flow.on_text(
+ sender_id="u_fly",
+ text="从南京飞吉隆坡,9月14日茶叶,托盘,4.2cbm,1.5KG",
+ reply=self._reply,
+ )
+ self.assertEqual(phase, "clarify")
+ self.assertIn("运输方式:空运", self.replies[-1])
+ self.assertNotIn("请补充运输方式", self.replies[-1])
+ self.replies.clear()
+ phase = self.flow.on_text(
+ sender_id="u_fly",
+ text="10件",
+ reply=self._reply,
+ )
+ self.assertNotEqual(phase, "need_mode")
+ joined = "\n".join(self.replies)
+ self.assertNotIn("请补充运输方式", joined)
+ self.assertEqual(self.flow.session_of("u_fly").business_line, "AIR")
+
+ def test_missing_mode_asks_transport(self) -> None:
+ phase = self.flow.on_text(
+ sender_id="u1",
+ text="帮我看看价格",
+ reply=self._reply,
+ injected_facts={},
+ injected_mode="",
+ )
+ self.assertEqual(phase, "need_mode")
+ self.assertIn("运输方式", self.replies[-1])
+
+ def test_pack_bulk_cargo_accepted_as_user_said(self) -> None:
+ facts = dict(COMPLETE_AIR)
+ facts["包装方式"] = "散货"
+ phase = self.flow.on_text(
+ sender_id="u_bulk",
+ text="空运",
+ reply=self._reply,
+ injected_facts=facts,
+ injected_mode="AIR",
+ )
+ self.assertNotEqual(phase, "clarify")
+ joined = "\n".join(self.replies)
+ self.assertIn("包装类型:散货", joined)
+ self.assertIn("工单已创建,正在查询TMS报价", joined)
+ self.assertEqual(self.flow.session_of("u_bulk").facts["包装方式"], "散货")
+
+ def test_oral_kgs_cbm_not_asked_again(self) -> None:
+ phase = self.flow.on_text(
+ sender_id="u_kgs",
+ text="空运 茶叶 10件 100KGS 2CBM",
+ reply=self._reply,
+ injected_facts={
+ "起运港": "珠海",
+ "目的港": "克拉克",
+ "品名": "茶叶",
+ "包装方式": "托盘",
+ },
+ injected_mode="AIR",
+ )
+ self.assertNotEqual(phase, "clarify")
+ sess = self.flow.session_of("u_kgs")
+ self.assertEqual(sess.facts["毛重"], "100KGS")
+ self.assertEqual(sess.facts["体积"], "2CBM")
+ self.assertEqual(sess.facts["originCode"], "ZUH")
+ self.assertEqual(sess.facts["destinationCode"], "CRK")
+ self.assertEqual(sess.facts["packageType"], "2")
+
+ def test_missing_fields_prompt(self) -> None:
+ phase = self.flow.on_text(
+ sender_id="u1",
+ text="空运询价",
+ reply=self._reply,
+ injected_facts={"起运港": "上海"},
+ injected_mode="AIR",
+ )
+ self.assertEqual(phase, "clarify")
+ last = self.replies[-1]
+ self.assertIn("请确认并补充以下信息", last)
+ self.assertIn("运输方式:空运", last)
+ self.assertIn("起运地:上海", last)
+ self.assertIn("待补充:", last)
+ self.assertIn("目的地", last)
+ self.assertIn("件数", last)
+ self.assertIn("如:", last)
+ self.assertIn("报价日期:", last)
+ self.assertNotIn("报价日期:(如", last)
+
+ def test_complete_air_hit_then_skip_adopt_excel_deal(self) -> None:
+ phase = self.flow.on_text(
+ sender_id="u1",
+ text="空运完整",
+ reply=self._reply,
+ injected_facts=COMPLETE_AIR,
+ injected_mode="AIR",
+ )
+ self.assertEqual(phase, "wait_collab")
+ joined = "\n".join(self.replies)
+ self.assertIn("工单号:", joined)
+ self.assertIn("运输方式:空运", joined)
+ self.assertIn("起运地:上海", joined)
+ self.assertIn("目的地:洛杉矶", joined)
+ self.assertIn("重量:", joined)
+ self.assertIn("正在查询TMS报价", joined)
+ self.assertNotIn("【询价卡片】", joined)
+ self.assertNotIn("[test]", joined)
+ self.assertIn("已报价", self.flow.session_of("u1").status)
+ self.assertIn(copy.BTN_SKIP_COLLAB, joined)
+ self.assertIn("价格明细", joined)
+ self.assertIn("总销售价合计", joined)
+ self.assertIn("时效:", joined)
+ self.assertIn("报价有效期:", joined)
+ self.assertIn("相同询价内容检索:首次询价", joined)
+ self.assertNotIn("工单状态:已报价", joined)
+ self.assertNotIn("【价格明细】", joined)
+
+ extras: list[dict] = []
+
+ def _reply_keep(text: str, extra=None) -> None:
+ self.replies.append(text)
+ extras.append(dict(extra or {}))
+
+ phase = self.flow.on_button(
+ sender_id="u1",
+ action=copy.BTN_SKIP_COLLAB,
+ reply=_reply_keep,
+ card_meta={"response_code": "rc-test-1"},
+ )
+ self.assertEqual(phase, "wait_adopt")
+ self.assertIn("已跳过内部协同", "\n".join(self.replies))
+ self.assertTrue(
+ any(x.get("msgtype") == "update_template_card" for x in extras)
+ )
+ wo = self.flow.session_of("u1").work_order_no
+ scheme_cards = [
+ x
+ for x in extras
+ if (x.get("template_card") or {}).get("main_title", {}).get("title")
+ == f"{wo}报价方案"
+ ]
+ self.assertEqual(len(scheme_cards), 1)
+ card = scheme_cards[0]["template_card"]
+ self.assertIn("空运费:USD 1,200", card["quote_area"]["quote_text"])
+ self.assertEqual(len(card["button_list"]), 3)
+ self.assertEqual(
+ [x["text"] for x in card["button_list"]],
+ [copy.BTN_ADOPT_EXCEL_CARD, copy.BTN_ADOPT_PDF_CARD, copy.BTN_REJECT],
+ )
+ rows = {x["keyname"]: x["value"] for x in card["horizontal_content_list"]}
+ self.assertIn("2.5", rows["货物"])
+ self.assertEqual(rows["时效"], "3-5 天")
+ self.assertNotIn("sub_title_text", card)
+ n = len(self.replies)
+ again = self.flow.on_button(
+ sender_id="u1", action=copy.BTN_SKIP_COLLAB, reply=self._reply
+ )
+ self.assertEqual(again, "card_discard")
+ self.assertEqual(len(self.replies), n)
+ self.assertIn(copy.BTN_ADOPT_EXCEL_CARD, "\n".join(self.replies))
+
+ phase = self.flow.on_button(
+ sender_id="u1", action=copy.BTN_ADOPT_EXCEL, reply=self._reply
+ )
+ self.assertEqual(phase, "wait_deal")
+ self.assertIn("Excel", "\n".join(self.replies))
+ self.assertIn("咨询成交状态", "\n".join(self.replies))
+
+ phase = self.flow.on_button(sender_id="u1", action=copy.BTN_DEAL, reply=self._reply)
+ self.assertEqual(phase, "done")
+ ticket = self.flow.ledger.get_ticket(
+ work_order_no=self.flow.session_of("u1").work_order_no
+ )
+ self.assertEqual(ticket.status, "已成交")
+
+ def test_city_name_counts_as_origin_dest(self) -> None:
+ """起运地/目的地说了地名就算齐,不对销售再要三字码。"""
+ facts = {
+ "起运港": "南京",
+ "目的港": "吉隆坡",
+ "品名": "茶叶",
+ "件数": "10",
+ "毛重": "1.5",
+ "体积": "4.2",
+ "包装方式": "散货",
+ "报价日期": "2026-09-16",
+ }
+ phase = self.flow.on_text(
+ sender_id="u_nkg",
+ text="南京飞吉隆坡,9月16日茶叶,散货,4.2cbm,1.5KG,10件",
+ reply=self._reply,
+ injected_facts=facts,
+ injected_mode="AIR",
+ )
+ self.assertNotEqual(phase, "clarify")
+ joined = "\n".join(self.replies)
+ self.assertIn("起运地:南京", joined)
+ self.assertIn("目的地:吉隆坡", joined)
+ self.assertIn("重量:", joined)
+ self.assertIn("体积:", joined)
+ self.assertIn("包装类型:散货", joined)
+ self.assertIn("报价日期:2026-09-16", joined)
+ self.assertNotIn("重量(KG)", joined)
+ self.assertNotIn("体积(CBM)", joined)
+ self.assertNotIn("机场名或三字码", joined)
+
+ def test_unknown_airport_still_queries_tms(self) -> None:
+ """机场对不上也必须先打 query_tms,不能本地直接说暂无报价。"""
+ called: list[dict] = []
+
+ class _Spy:
+ def create_ticket(self, **kwargs):
+ return {
+ "ok": True,
+ "work_order_no": "WO202609140088",
+ "first_or_same": "first",
+ }
+
+ def query_tms(self, **kwargs):
+ called.append(dict(kwargs))
+ return {
+ "ok": True,
+ "has_price": False,
+ "classification": "TMS_NO_QUOTE_1002",
+ }
+
+ facts = dict(COMPLETE_AIR)
+ facts["起运港"] = "阿拉伯"
+ facts["目的港"] = "广州"
+ flow = AirTextInquiryFlow(_Spy())
+ phase = flow.on_text(
+ sender_id="u_arab",
+ text="空运",
+ reply=self._reply,
+ injected_facts=facts,
+ injected_mode="AIR",
+ )
+ self.assertEqual(len(called), 1, "对不上机场也必须去 TMS 查价")
+ self.assertEqual(called[0]["work_order_no"], "WO202609140088")
+ self.assertEqual(called[0]["facts"].get("起运港"), "阿拉伯")
+ self.assertFalse(called[0]["facts"].get("originCode"))
+ self.assertEqual(phase, "tms_miss")
+ self.assertIn("暂无报价", self.replies[-1])
+ self.assertNotIn("查价参数尚未齐全", "\n".join(self.replies))
+ miss = [x for x in self.extras if x.get("msgtype") == "template_card"]
+ self.assertTrue(miss)
+ self.assertEqual(miss[-1]["template_card"]["main_title"]["title"], "WO202609140088暂无报价")
+ self.assertEqual(
+ miss[-1]["template_card"]["quote_area"]["quote_text"],
+ "请务必在群聊发送工单编号WO202609140088",
+ )
+
+ def test_unknown_airport_no_card(self) -> None:
+ facts = dict(COMPLETE_AIR)
+ facts["目的港"] = "歧义港"
+ phase = self.flow.on_text(
+ sender_id="u2c",
+ text="空运",
+ reply=self._reply,
+ injected_facts=facts,
+ injected_mode="AIR",
+ )
+ # 目的地说了就算齐;对不上字典不补问,查价后按 TMS 分类
+ self.assertEqual(phase, "tms_miss")
+ self.assertIn("暂无报价", self.replies[-1])
+
+ def test_tms_miss_copy(self) -> None:
+ facts = dict(COMPLETE_AIR)
+ facts["tms_force"] = "1002"
+ phase = self.flow.on_text(
+ sender_id="u2",
+ text="空运",
+ reply=self._reply,
+ injected_facts=facts,
+ injected_mode="AIR",
+ )
+ self.assertEqual(phase, "tms_miss")
+ self.assertIn("暂无报价", self.replies[-1])
+ self.assertIn("工单编号", self.replies[-1])
+ miss = [x for x in self.extras if x.get("msgtype") == "template_card"]
+ self.assertTrue(miss)
+ card = miss[-1]["template_card"]
+ self.assertEqual(card["card_type"], "text_notice")
+ self.assertEqual(card["source"]["desc"], "TMS查价结果")
+ self.assertEqual(card["main_title"]["title"], self.flow.session_of("u2").work_order_no + "暂无报价")
+ self.assertNotIn("desc", card["main_title"])
+ self.assertEqual(card["sub_title_text"], copy.tms_miss_hint())
+ self.assertEqual(
+ card["quote_area"]["quote_text"],
+ copy.tms_miss_quote_text(work_order_no=self.flow.session_of("u2").work_order_no),
+ )
+ self.assertNotIn("horizontal_content_list", card)
+
+ def test_incomplete_skip_is_not_no_quote(self) -> None:
+ """主账未出站不能对销售说暂无报价。"""
+
+ class _Skip:
+ def create_ticket(self, **kwargs):
+ return {
+ "ok": True,
+ "work_order_no": "WO202609140099",
+ "first_or_same": "first",
+ }
+
+ def query_tms(self, **kwargs):
+ return {
+ "ok": False,
+ "has_price": False,
+ "classification": "TMS_QUERY_INCOMPLETE",
+ }
+
+ flow = AirTextInquiryFlow(_Skip())
+ phase = flow.on_text(
+ sender_id="u_skip",
+ text="空运",
+ reply=self._reply,
+ injected_facts=COMPLETE_AIR,
+ injected_mode="AIR",
+ )
+ self.assertEqual(phase, "tms_tech")
+ joined = "\n".join(self.replies)
+ self.assertIn("TMS 查询失败", joined)
+ self.assertNotIn("暂无报价", joined)
+ self.assertNotIn("查价参数尚未齐全", joined)
+
+ def test_same_content_second_ticket(self) -> None:
+ self.flow.on_text(
+ sender_id="u3",
+ text="第一次",
+ reply=self._reply,
+ injected_facts=COMPLETE_AIR,
+ injected_mode="AIR",
+ )
+ no1 = self.flow.session_of("u3").work_order_no
+ self.replies.clear()
+ self.flow.on_text(
+ sender_id="u4",
+ text="第二次相同",
+ reply=self._reply,
+ injected_facts=COMPLETE_AIR,
+ injected_mode="AIR",
+ )
+ no2 = self.flow.session_of("u4").work_order_no
+ self.assertIn("工单已创建,正在查询TMS报价", "\n".join(self.replies))
+ self.assertIn(f"引用历史工单号{no1}", "\n".join(self.replies))
+ self.assertTrue(no1)
+ self.assertTrue(no2)
+ self.assertNotEqual(no1, no2)
+
+ def test_reject_then_adjust_prompt(self) -> None:
+ self.flow.on_text(
+ sender_id="u5",
+ text="空运",
+ reply=self._reply,
+ injected_facts=COMPLETE_AIR,
+ injected_mode="AIR",
+ )
+ self.flow.on_button(sender_id="u5", action=copy.BTN_SKIP_COLLAB, reply=self._reply)
+ phase = self.flow.on_button(sender_id="u5", action=copy.BTN_REJECT, reply=self._reply)
+ self.assertEqual(phase, "wait_adjust")
+ self.assertIn("报价加 10%", self.replies[-1])
+
+ def test_adopt_pdf(self) -> None:
+ self.flow.on_text(
+ sender_id="u6",
+ text="空运",
+ reply=self._reply,
+ injected_facts=COMPLETE_AIR,
+ injected_mode="AIR",
+ )
+ self.flow.on_button(sender_id="u6", action=copy.BTN_SKIP_COLLAB, reply=self._reply)
+ phase = self.flow.on_button(
+ sender_id="u6", action=copy.BTN_ADOPT_PDF, reply=self._reply
+ )
+ self.assertEqual(phase, "wait_deal")
+ self.assertIn("PDF", "\n".join(self.replies))
+
+ def test_handler_writes_outbox(self) -> None:
+ store = MemoryMessageStore()
+ msg = InboundMessage(
+ sender_id="u7",
+ message_id="m7",
+ content="空运",
+ msg_type="text",
+ )
+ phase = handle_text_inquiry(
+ msg,
+ injected_facts=COMPLETE_AIR,
+ injected_mode="AIR",
+ flow=self.flow,
+ store=store,
+ )
+ self.assertEqual(phase, "wait_collab")
+ pending = [i for i in store._outbox.values()]
+ self.assertGreaterEqual(len(pending), 2)
+ self.assertTrue(any("工单已创建,正在查询TMS报价" in i.content for i in pending))
+ self.assertFalse(any("[test]" in i.content for i in pending))
+ self.assertFalse(any("【询价卡片】" in i.content for i in pending))
+
+ def test_wrong_button_discarded(self) -> None:
+ self.flow.on_text(
+ sender_id="u8",
+ text="空运",
+ reply=self._reply,
+ injected_facts=COMPLETE_AIR,
+ injected_mode="AIR",
+ )
+ n = len(self.replies)
+ phase = self.flow.on_button(
+ sender_id="u8", action=copy.BTN_ADOPT_EXCEL, reply=self._reply
+ )
+ self.assertEqual(phase, "card_discard")
+ self.assertEqual(len(self.replies), n)
+
+ def test_create_fail_keeps_weight_and_replies(self) -> None:
+ class _FailCreate:
+ def create_ticket(self, **kwargs):
+ return {"ok": False, "work_order_no": "", "first_or_same": "first"}
+
+ flow = AirTextInquiryFlow(_FailCreate())
+ replies: list[str] = []
+ phase = flow.on_text(
+ sender_id="u9",
+ text="空运",
+ reply=replies.append,
+ injected_facts=COMPLETE_AIR,
+ injected_mode="AIR",
+ )
+ self.assertEqual(phase, "ledger_fail")
+ self.assertTrue(any("建单失败" in x for x in replies))
+ self.assertEqual(flow.session_of("u9").facts["毛重"], "180")
+
+ def test_tms_miss_card_matches_sales_layout(self) -> None:
+ payload = copy.tms_miss_wecom_payload(work_order_no="WO202606160008")
+ card = payload["template_card"]
+ self.assertEqual(card["card_type"], "text_notice")
+ self.assertEqual(card["source"]["desc"], "TMS查价结果")
+ self.assertEqual(card["main_title"]["title"], "WO202606160008暂无报价")
+ self.assertNotIn("desc", card["main_title"])
+ self.assertEqual(card["sub_title_text"], "TMS标准价库暂无匹配报价,请手动拉新人与智能体到原有群")
+ self.assertEqual(card["quote_area"]["quote_text"], "请务必在群聊发送工单编号WO202606160008")
+ self.assertNotIn("title", card["quote_area"])
+ self.assertNotIn("horizontal_content_list", card)
+ self.assertNotIn("card_action", card)
+ with_url = copy.tms_miss_wecom_payload(
+ work_order_no="WO202606160008",
+ action_url="https://example.test",
+ )["template_card"]
+ self.assertEqual(with_url["card_action"]["url"], "https://example.test")
+
+ def test_quote_card_payload_has_clickable_button(self) -> None:
+ payload = copy.tms_hit_wecom_payload(
+ work_order_no="WO202609140001",
+ quote={
+ "total": "USD 1,280",
+ "eta": "3-5 天",
+ "valid_until": "2026-09-20",
+ "source_label": "TMS 标准报价",
+ "fee_lines": [
+ {"name": "空运费", "amount": "USD 1,200"},
+ {"name": "其他附加费", "amount": "USD 80"},
+ ],
+ },
+ )
+ card = payload["template_card"]
+ self.assertEqual(payload["msgtype"], "template_card")
+ self.assertEqual(card["card_type"], "button_interaction")
+ self.assertEqual(card["main_title"]["title"], "WO202609140001价格明细")
+ self.assertEqual(card["button_list"][0]["text"], copy.BTN_SKIP_COLLAB)
+ self.assertEqual(card["button_list"][0]["key"], "skip_collab:WO202609140001")
+ self.assertTrue(str(card["task_id"]).startswith("quote-WO202609140001-"))
+ self.assertEqual(
+ copy.work_order_from_card_meta({"task_id": card["task_id"]}),
+ "WO202609140001",
+ )
+ self.assertNotIn("horizontal_content_list", card)
+ quote_text = card["quote_area"]["quote_text"]
+ self.assertNotIn("title", card["quote_area"])
+ self.assertIn("空运费:USD 1,200", quote_text)
+ self.assertIn("总销售价合计:USD 1,280", quote_text)
+ self.assertNotIn("时效:", quote_text)
+ numeric = copy.tms_hit_wecom_payload(
+ work_order_no="WO202609140048",
+ quote={
+ "total": "14669.60",
+ "currency": "CNY",
+ "eta": "-",
+ "valid_until": "2026-09-15",
+ "fee_lines": [{"name": "空运费", "amount": "14669.60", "currency": "CNY"}],
+ },
+ )["template_card"]["quote_area"]["quote_text"]
+ self.assertIn("空运费:CNY 14669.60", numeric)
+ self.assertIn("总销售价合计:CNY 14669.60", numeric)
+ self.assertEqual(copy.money_text("14669.60", ""), "14669.60")
+ self.assertNotIn(copy.collab_hint("WO202609140001"), quote_text)
+ sub = card["sub_title_text"]
+ self.assertIn("时效:3-5 天", sub)
+ self.assertIn("报价有效期:2026-09-20", sub)
+ self.assertIn("相同询价内容检索:首次询价", sub)
+ self.assertNotIn("工单状态", sub)
+ self.assertTrue(sub.endswith(copy.collab_hint("WO202609140001")))
+ self.assertNotIn("内部协同", json.dumps(card, ensure_ascii=False))
+
+ def test_scheme_card_payload_has_quote_and_buttons(self) -> None:
+ quote = {
+ "total": "USD 1,280",
+ "eta": "3-5 天",
+ "valid_until": "2026-09-20",
+ "source_label": "TMS 标准报价",
+ "fee_lines": [
+ {"name": "空运费", "amount": "USD 1,200"},
+ {"name": "其他附加费", "amount": "USD 80"},
+ ],
+ }
+ text = copy.scheme_card(
+ work_order_no="WO202609140001",
+ status="已报价",
+ facts=COMPLETE_AIR,
+ quote=quote,
+ )
+ self.assertIn("【WO202609140001报价方案】", text)
+ self.assertIn("工单号:WO202609140001", text)
+ self.assertIn("起运地:上海", text)
+ self.assertIn("体积:2.5", text)
+ self.assertIn("空运费:USD 1,200", text)
+ self.assertIn("时效:3-5 天", text)
+ self.assertIn("报价有效期:2026-09-20", text)
+ self.assertNotIn("报价日期:", text)
+ self.assertNotIn("工单状态", text)
+ payload = copy.scheme_wecom_payload(
+ work_order_no="WO202609140001", facts=COMPLETE_AIR, quote=quote
+ )
+ card = payload["template_card"]
+ self.assertEqual(card["main_title"]["title"], "WO202609140001报价方案")
+ self.assertTrue(str(card["task_id"]).startswith("scheme-WO202609140001-"))
+ again = copy.scheme_wecom_payload(
+ work_order_no="WO202609140001", facts=COMPLETE_AIR, quote=quote
+ )
+ self.assertNotEqual(card["task_id"], again["template_card"]["task_id"])
+ self.assertEqual(
+ copy.work_order_from_card_meta({"task_id": card["task_id"]}),
+ "WO202609140001",
+ )
+ quote_text = card["quote_area"]["quote_text"]
+ self.assertNotIn("title", card["quote_area"])
+ self.assertIn("空运费:USD 1,200", quote_text)
+ self.assertIn("总销售价合计:USD 1,280", quote_text)
+ self.assertNotIn("sub_title_text", card)
+ rows = {x["keyname"]: x["value"] for x in card["horizontal_content_list"]}
+ self.assertEqual(rows["工单号"], "WO202609140001")
+ self.assertIn("上海", rows["航线"])
+ self.assertIn("洛杉矶", rows["航线"])
+ self.assertIn("普货", rows["货物"])
+ self.assertIn("2.5", rows["货物"])
+ self.assertEqual(rows["时效"], "3-5 天")
+ self.assertEqual(rows["报价有效期"], "2026-09-20")
+ self.assertNotIn("报价日期", rows)
+ empty_eta = copy.scheme_horizontal_rows(
+ work_order_no="WO1", facts=COMPLETE_AIR, quote={"valid_until": "2026-09-20"}
+ )
+ self.assertEqual({x["keyname"]: x["value"] for x in empty_eta}["时效"], "-")
+ dashed = copy.scheme_horizontal_rows(
+ work_order_no="WO1",
+ facts=COMPLETE_AIR,
+ quote={"eta": "-3-5 天", "valid_until": "2026-09-20"},
+ )
+ dashed_rows = {x["keyname"]: x["value"] for x in dashed}
+ self.assertEqual(dashed_rows["时效"], "3-5 天")
+ self.assertEqual(len(card["button_list"]), 3)
+ self.assertEqual(
+ [x["text"] for x in card["button_list"]],
+ ["生成Excel", "生成PDF", "不采用"],
+ )
+ self.assertEqual(
+ copy.work_order_from_card_meta(
+ {
+ "task_id": "scheme-WO202609140001",
+ "event_key": "adopt_excel:WO202609140001",
+ }
+ ),
+ "WO202609140001",
+ )
+ self.assertEqual(copy.canonical_button("adopt_excel:WO202609140001"), copy.BTN_ADOPT_EXCEL)
+ self.assertEqual(copy.canonical_button(copy.BTN_ADOPT_EXCEL_CARD), copy.BTN_ADOPT_EXCEL)
+
+ def test_same_demand_cites_latest_even_if_closed(self) -> None:
+ ledger = MemoryLedger()
+ flow = AirTextInquiryFlow(ledger)
+ replies_a: list[str] = []
+
+ def _keep_a(text: str, extra=None) -> None:
+ replies_a.append(text)
+ _ = extra
+
+ flow.on_text(
+ sender_id="hist-a",
+ text="空运",
+ reply=_keep_a,
+ injected_facts=COMPLETE_AIR,
+ injected_mode="AIR",
+ )
+ wo1 = flow.session_of("hist-a").work_order_no
+ self.assertTrue(wo1)
+ ledger.transition(work_order_no=wo1, to_status="已关闭")
+ replies_b: list[str] = []
+
+ def _keep_b(text: str, extra=None) -> None:
+ replies_b.append(text)
+ _ = extra
+
+ flow.on_text(
+ sender_id="hist-b",
+ text="空运",
+ reply=_keep_b,
+ injected_facts=COMPLETE_AIR,
+ injected_mode="AIR",
+ )
+ wo2 = flow.session_of("hist-b").work_order_no
+ self.assertTrue(wo2)
+ self.assertNotEqual(wo2, wo1)
+ self.assertIn(f"引用历史工单号{wo1}", "\n".join(replies_b))
+ replies_c: list[str] = []
+
+ def _keep_c(text: str, extra=None) -> None:
+ replies_c.append(text)
+ _ = extra
+
+ flow.on_text(
+ sender_id="hist-c",
+ text="空运",
+ reply=_keep_c,
+ injected_facts=COMPLETE_AIR,
+ injected_mode="AIR",
+ )
+ wo3 = flow.session_of("hist-c").work_order_no
+ self.assertNotEqual(wo3, wo2)
+ self.assertIn(f"引用历史工单号{wo2}", "\n".join(replies_c))
+ self.assertNotIn(f"引用历史工单号{wo1}", "\n".join(replies_c))
+
+ def test_card_event_key_matches_skip_button(self) -> None:
+ from agent.channel.wecom.parse import parse_plain_xml
+
+ xml = (
+ "
+ * 职责:建单、重单检索、经本主账查真 TMS、按后台报价模板出单。 + * 鉴权:请求头 X-Agent-Token 或 X-Access-Token 对齐 AGENT_CALLBACK_TOKEN / BIZ_API_TOKEN。 + * 禁止:在本类做自然语言抽取;禁止 Tomcat 线程等 Python Graph。 + */ +@Slf4j +@Api(tags = "询价-智能体服务账号") +@RestController +@RequestMapping("/inquiry/agent") +public class InquiryAgentController { + + @Autowired + private IInquiryWorkOrderService workOrderService; + + @Autowired + private IInquiryQuoteTemplateService quoteTemplateService; + + @Value("${inquiry.agent.callback-token:${AGENT_CALLBACK_TOKEN:}}") + private String agentToken; + + @Value("${inquiry.agent.biz-token:${BIZ_API_TOKEN:}}") + private String bizToken; + + @ApiOperation("建单(每次新开一单,相同需求不复用)") + @PostMapping("/ticket/create") + public Result