机器人显示名与站点标题可配;多段联运列表投影;协同群监听对账;工单附件落存;模板与工单后台补强

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-19 09:41:59 +08:00
co-authored by Cursor
parent 277cdc1c28
commit 2b528758b4
119 changed files with 6760 additions and 3516 deletions
+32 -1
View File
@@ -8,10 +8,12 @@
from __future__ import annotations
import json
import logging
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
import httpx
@@ -19,6 +21,7 @@ import httpx
logger = logging.getLogger(__name__)
@dataclass
class WeComSendResult:
"""企微发送结果(通道层)。"""
@@ -496,7 +499,7 @@ class WeComAppClient:
"""
解散应用群:官方无 dismiss,改为 appchat/update 移出成员。
不抛给回调。移完人不改工单六态。
群主会留在空群壳(企微限制,与历史行为一致)。不抛给回调。不改六态。
"""
users = [str(x).strip() for x in (userids or []) if str(x).strip()]
if not chat_id or not users:
@@ -506,6 +509,7 @@ class WeComAppClient:
except Exception as exc: # noqa: BLE001
logger.exception("解散群取 token 失败")
return {"ok": False, "error": str(exc)}
before = self._appchat_info(chat_id=chat_id, token=token)
url = f"{self._api_base}/cgi-bin/appchat/update"
body = {"chatid": chat_id, "del_user_list": users}
try:
@@ -516,12 +520,39 @@ class WeComAppClient:
logger.exception("企微解散群网络失败")
return {"ok": False, "error": str(exc)}
errcode = int(data.get("errcode") or 0)
after = self._appchat_info(chat_id=chat_id, token=token) if errcode == 0 else {}
if errcode != 0:
logger.warning("企微解散群失败 errcode=%s", errcode)
return {"ok": False, "error": str(data.get("errmsg") or f"errcode_{errcode}")}
logger.info("企微群已移出成员 chat=%s", chat_id)
return {"ok": True, "error": ""}
def _appchat_info(self, *, chat_id: str, token: str = "") -> dict[str, Any]:
"""读应用群群主与成员。失败回空字典,不抛。仅调试/核对用。"""
raw_token = (token or "").strip()
if not raw_token:
try:
raw_token = self._get_token()
except Exception: # noqa: BLE001
return {}
url = f"{self._api_base}/cgi-bin/appchat/get"
try:
with httpx.Client(timeout=self._timeout) as client:
resp = client.get(url, params={"access_token": raw_token, "chatid": chat_id})
data = resp.json()
except Exception: # noqa: BLE001
return {}
if int(data.get("errcode") or 0) != 0:
return {}
info = data.get("chat_info") or data.get("chatinfo") or {}
return {
"owner": str(info.get("owner") or "").strip(),
"userlist": [
str(x).strip()
for x in (info.get("userlist") or info.get("user_list") or [])
if str(x).strip()
],
}
def _post_appchat(self, body: dict[str, Any]) -> dict[str, Any]:
"""appchat/send 公共发送。失败不抛。"""
if not self._corp_id or not self._secret:
+57 -11
View File
@@ -16,9 +16,12 @@ from agent.channel.queue import get_message_store
from agent.channel.wshoto.client import WshotoError, WshotoOpenClient, wshoto_ready
from agent.channel.wshoto.parse import archive_record_to_inbound
from agent.channel.wshoto.rooms import (
advance_poll_cursor,
is_room_hot,
list_watched_rooms,
load_last_seq,
mark_room_hot,
plan_rooms_for_poll,
save_last_seq,
)
from agent.config import Settings, get_settings
@@ -26,8 +29,11 @@ from agent.redis_coord.client import RedisClient
logger = logging.getLogger(__name__)
# 新群还没有游标时,只回看最近几分钟,避免每轮翻三小时。
_NEW_ROOM_LOOKBACK_SEC = 180
# 新群还没有游标时,回看窗口必须盖过「存档入库延迟 + 冷群轮转」。
# 以前 180 秒:存档晚几分钟到、或本轮预算扫不到该群时,人话永久漏掉,last_seq 也永远建不起来。
_NEW_ROOM_LOOKBACK_SEC = 3600
# 微盛「请求频率过高」:本轮立刻停,下一轮再扫,避免把活跃群也拖死。
_RATE_LIMIT_CODE = 10061
def archive_seat_userids(settings: Optional[Settings] = None) -> list[str]:
@@ -146,6 +152,7 @@ def poll_once(
page_size=page_size,
lookback=lookback,
clock=clock,
settings=cfg,
)
if extra:
records = list(records) + extra
@@ -176,6 +183,7 @@ def poll_once(
page_size=page_size,
lookback=lookback,
clock=clock,
settings=cfg,
)
if extra:
records = list(records) + extra
@@ -226,9 +234,10 @@ def _poll_by_scan_users(
"""
按人扫新群。空运群是人自建的,存档席没进群时房间名单里没有它。
副作用:看到带 @询价小助手 的新房间会登记监听。
副作用:看到带 @机器人显示名(含旧名)的新房间会登记监听。
"""
from agent.channel.wshoto.rooms import watch_collab_room
from agent.policy.bot_name import text_mentions_bot
merged: list[dict[str, Any]] = []
now = int(clock())
@@ -258,7 +267,7 @@ def _poll_by_scan_users(
max_seq = seq
room = str(rec.get("room_id") or rec.get("roomid") or "").strip()
blob = str(rec.get("content") or "")
if room and "询价小助手" in blob:
if room and text_mentions_bot(blob):
try:
watch_collab_room(room, redis=redis)
except Exception:
@@ -276,19 +285,37 @@ def _poll_by_rooms(
page_size: int,
lookback: int,
clock: Callable[[], float],
settings: Optional[Settings] = None,
) -> tuple[list[dict[str, Any]], str, int]:
"""只拉已登记协同群。应用群不要带存档席 userid,否则管家给空页。"""
rooms = list_watched_rooms(redis=redis)
if not rooms:
"""
只拉已登记协同群。应用群不要带存档席 userid,否则管家给空页。
热群每轮必扫;冷群按游标轮转。遇微盛限频或时间预算用尽则本轮停,下一轮续扫。
禁止把每轮冷群压得太少:销售在冷群发「解散群」时,要等很多轮才会被听见。
"""
cfg = settings or get_settings()
all_rooms = list_watched_rooms(redis=redis)
if not all_rooms:
logger.warning("微盛按房间拉取:还没有登记过的协同群")
return [], "rooms", 0
rooms = sorted(
rooms,
key=lambda r: (0 if load_last_seq(f"room:{r}", redis=redis) <= 0 else 1, r),
)
max_cold = int(getattr(cfg, "wshoto_rooms_per_poll", 120) or 120)
max_cold = min(200, max(1, max_cold))
gap_ms = int(getattr(cfg, "wshoto_room_gap_ms", 100) or 100)
gap_ms = min(1000, max(0, gap_ms))
budget_sec = float(getattr(cfg, "wshoto_poll_budget_sec", 20.0) or 20.0)
budget_sec = min(60.0, max(5.0, budget_sec))
rooms = plan_rooms_for_poll(all_rooms, max_cold=max_cold, redis=redis)
merged: list[dict[str, Any]] = []
floor = 0
rate_limited = False
budget_hit = False
polled = 0
cold_polled = 0
t0 = time.time()
for room in rooms:
if (time.time() - t0) >= budget_sec:
budget_hit = True
break
scope = f"room:{room}"
last = load_last_seq(scope, redis=redis)
time_start = 0
@@ -307,7 +334,13 @@ def _poll_by_rooms(
)
except WshotoError as exc:
logger.warning("微盛房间拉取失败 chat=%s code=%s msg=%s", room, exc.code, exc.msg)
if int(exc.code) == _RATE_LIMIT_CODE:
rate_limited = True
break
continue
polled += 1
if not is_room_hot(room):
cold_polled += 1
max_seq = last
for rec in rows:
seq = _record_seq(rec)
@@ -318,4 +351,17 @@ def _poll_by_rooms(
if last > floor:
floor = last
merged.extend(rows)
if gap_ms > 0:
time.sleep(gap_ms / 1000.0)
# 只推进实际扫过的冷群,限频/超时停住的下轮接着扫。
advance_poll_cursor(cold_polled, redis=redis)
if rate_limited or budget_hit:
logger.warning(
"微盛房间拉取提前结束 rate=%s budget=%s watched=%s planned=%s polled=%s",
rate_limited,
budget_hit,
len(all_rooms),
len(rooms),
polled,
)
return merged, "rooms", floor
+87 -1
View File
@@ -52,12 +52,98 @@ def mark_room_hot(chat_id: str, *, ttl_sec: float = HOT_WINDOW_SEC) -> None:
def has_hot_room() -> bool:
"""是否还有热窗内的协同群。"""
return bool(list_hot_rooms())
def list_hot_rooms() -> list[str]:
"""当前热窗内的协同群(刚建群或刚有人说话)。"""
now = time.time()
with _hot_lock:
stale = [k for k, until in _hot_until.items() if until <= now]
for k in stale:
_hot_until.pop(k, None)
return any(until > now for until in _hot_until.values())
return [k for k, until in _hot_until.items() if until > now]
def is_room_hot(chat_id: str) -> bool:
"""这群是否还在热窗内。"""
raw = (chat_id or "").strip()
if not raw:
return False
now = time.time()
with _hot_lock:
until = _hot_until.get(raw) or 0.0
if until <= now:
_hot_until.pop(raw, None)
return False
return True
def plan_rooms_for_poll(
rooms: list[str],
*,
max_cold: int = 120,
redis: Optional[RedisClient] = None,
) -> list[str]:
"""
本轮要扫的房间:热群全部优先,冷群按游标轮转。
注意:这里只读游标、不推进。真正扫过多少冷群后,由 advance_poll_cursor 推进,
避免限频/超时提前停时把没扫到的群直接跳过。
"""
uniq: list[str] = []
seen: set[str] = set()
for raw in rooms:
room = (raw or "").strip()
if not room or room in seen:
continue
seen.add(room)
uniq.append(room)
hot = [r for r in uniq if is_room_hot(r)]
hot_set = set(hot)
cold = [r for r in uniq if r not in hot_set]
budget = max(0, int(max_cold))
if not cold or budget <= 0:
return hot
start = load_poll_cursor(redis=redis) % len(cold)
rotated = cold[start:] + cold[:start]
take = rotated[:budget]
# 热群在前,保证刚发过话/刚建的群本轮一定扫到。
return hot + [r for r in take if r not in hot_set]
def load_poll_cursor(*, redis: Optional[RedisClient] = None) -> int:
"""冷群轮转游标。"""
client = _client(redis)
if client is None:
return 0
raw_cur = client.raw.get(client.key(WSHOTO_LAST_SEQ, "poll_room_cursor"))
if isinstance(raw_cur, bytes):
raw_cur = raw_cur.decode("utf-8")
try:
return int(raw_cur or 0)
except (TypeError, ValueError):
return 0
def advance_poll_cursor(
cold_polled: int,
*,
redis: Optional[RedisClient] = None,
) -> None:
"""
按本轮实际扫过的冷群数推进游标。
限频或时间预算提前停时,只推进已扫到的,下轮从停住的地方继续。
"""
n = max(0, int(cold_polled))
if n <= 0:
return
client = _client(redis)
if client is None:
return
cur = load_poll_cursor(redis=client)
client.raw.set(client.key(WSHOTO_LAST_SEQ, "poll_room_cursor"), str(int(cur + n)))
def _client(redis: Optional[RedisClient] = None) -> Optional[RedisClient]:
+14 -2
View File
@@ -45,12 +45,12 @@ class Settings(BaseSettings):
default="",
alias="INQUIRY_FORM_PUBLIC_BASE_URL",
)
biz_api_base_url: str = Field(default="http://127.0.0.1:8180/jeecgboot", alias="BIZ_API_BASE_URL")
biz_api_base_url: str = Field(default="http://127.0.0.1:8080/jeecgboot", alias="BIZ_API_BASE_URL")
biz_api_token: str = Field(default="", alias="BIZ_API_TOKEN")
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=单测假账本
# http=打 8080 真主账/真 TMS;memory=单测假账本
ledger_backend: str = Field(default="http", alias="LEDGER_BACKEND")
agent_callback_token: str = Field(default="", alias="AGENT_CALLBACK_TOKEN")
# 兼容现网变量名
@@ -75,6 +75,18 @@ class Settings(BaseSettings):
wshoto_poll_enabled: bool = Field(default=True, alias="WSHOTO_POLL_ENABLED")
# 群消息靠会话存档,扫得越勤,听到得越快。热窗内还会再压到 1 秒。
wshoto_poll_interval_sec: float = Field(default=2.0, alias="WSHOTO_POLL_INTERVAL_SEC")
# 协同群监听对账间隔(秒)。主账绑群 vs Redis 监听,缺则补、结束则清。
collab_watch_reconcile_interval_sec: float = Field(
default=900.0,
alias="COLLAB_WATCH_RECONCILE_INTERVAL_SEC",
)
# 每轮最多扫多少个冷群。热窗内的群每轮必扫。
# 默认尽量一轮扫完;遇限频会提前停并靠游标续扫。不要压太小,否则冷群要等很多轮才听见「解散群」。
wshoto_rooms_per_poll: int = Field(default=120, alias="WSHOTO_ROOMS_PER_POLL")
# 单轮房间拉取时间预算(秒)。超时本轮停,下一轮从游标续,避免卡死 Worker。
wshoto_poll_budget_sec: float = Field(default=20.0, alias="WSHOTO_POLL_BUDGET_SEC")
# 房间之间的最小间隔(毫秒),减轻「请求频率过高」。
wshoto_room_gap_ms: int = Field(default=100, alias="WSHOTO_ROOM_GAP_MS")
wshoto_bootstrap_lookback_sec: int = Field(
default=10800,
alias="WSHOTO_BOOTSTRAP_LOOKBACK_SEC",
+1 -1
View File
@@ -81,7 +81,7 @@ def main() -> int:
logger.info("OK: ordered_actions 解析")
ledger = LedgerClient.from_settings(settings)
assert "8180" in ledger.base_url or ledger.base_url
assert "8080" in ledger.base_url or ledger.base_url
logger.info("OK: ledger client base=%s", ledger.base_url)
ping = ledger.ping()
logger.info("ledger.ping ok=%s status=%s", ping.ok, ping.status_code)
@@ -205,46 +205,6 @@ def handle_air_group(
intent,
wo or "-",
)
# #region agent log
try:
import json
import time
from pathlib import Path
from agent.llm.extract_text import labeled_quote_date
_payload = {
"sessionId": "717349",
"runId": "post-fix",
"hypothesisId": "H1",
"location": "air_group_collab.py:handle_air_group",
"message": "air group inbound classified",
"data": {
"chat_tail": chat_id[-8:],
"sender_tail": (message.sender_id or "")[-8:],
"intent": intent,
"wo": wo or "",
"text_head": (text or "")[:100],
"labeled_qd": labeled_quote_date(text),
"source": str((message.raw or {}).get("source") or ""),
},
"timestamp": int(time.time() * 1000),
}
_line = json.dumps(_payload, ensure_ascii=False) + "\n"
for _p in (
Path(__file__).resolve().parents[3] / "debug-717349.log",
Path(r"C:\Users\ytd_extdev_0903\incoming\debug-717349.log"),
Path(r"E:\wwwroot\agent\current\debug-717349.log"),
):
try:
_p.parent.mkdir(parents=True, exist_ok=True)
with _p.open("a", encoding="utf-8") as _fh:
_fh.write(_line)
except Exception:
pass
except Exception:
pass
# #endregion
if intent in {INTENT_LOCK, INTENT_RELEASE, INTENT_RELEASE_ASK}:
return _handle_cabin(
engine,
@@ -314,17 +274,34 @@ def _handle_file(engine, message: InboundMessage) -> str:
wo = copy.extract_work_order_no(
" ".join([message.content or "", filename])
)
# 多段已激活的附件也跟当前单,不能再要求先发工单号。
followed = _follow_activated_multi(
engine,
chat_id=chat_id,
sender_id=message.sender_id,
text=" ".join(part for part in ((message.content or "").strip(), filename) if part),
injected_quote=None,
named_wo=wo,
)
if followed is not None:
return followed
# 多段附件:落库归段,禁止把文件名当文字报价(会回「没有识别到报价」)。
from agent.policy.multi_group_ops import apply_multi_file, ticket_is_multi
from agent.policy.sea_group_ops import ticket_no as _ticket_no
multi_ticket = None
if wo:
getter = getattr(engine.ledger, "get_ticket", None)
raw = getter(work_order_no=wo) if callable(getter) else None
if raw is not None and ticket_is_multi(raw):
multi_ticket = raw
if multi_ticket is None:
bound = current_ticket(engine, chat_id)
if bound is not None and ticket_is_multi(bound):
multi_ticket = bound
if multi_ticket is not None:
logger.info(
"air_group 多段附件 wo=%s file=%s",
_ticket_no(multi_ticket),
filename[:80],
)
return apply_multi_file(
flow=engine,
ticket=multi_ticket,
chat_id=chat_id,
sender_id=message.sender_id,
filename=filename,
media=media,
)
if wo:
ticket, phase = activate_ticket(
flow=engine, chat_id=chat_id, work_order_no=wo, speak=False
@@ -588,36 +565,6 @@ def _handle_bound(
keep_tms=intent == INTENT_KEEP_TMS,
)
if intent == INTENT_FIELDS:
# #region agent log
try:
import json
import time
from pathlib import Path
_p = Path(__file__).resolve().parents[3] / "debug-866775.log"
with _p.open("a", encoding="utf-8") as _fh:
_fh.write(
json.dumps(
{
"sessionId": "866775",
"hypothesisId": "C",
"location": "air_group_collab.py:_handle_bound",
"message": "INTENT_FIELDS path",
"data": {
"text_head": (message.content or "")[:80],
"has_quote_date_label": (
"报价日期" in (message.content or "")
),
},
"timestamp": int(time.time() * 1000),
},
ensure_ascii=False,
)
+ "\n"
)
except Exception:
pass
# #endregion
return apply_air_fields(
flow=engine,
ticket=ticket,
+45 -5
View File
@@ -1,7 +1,7 @@
"""
意图:询价卡片按钮(复制工单号 / 拉产品进群 / 跳过协同 / 采用 / 不采用 / 成交)。
本文件职责:校验当前会话允许的动作后交给流程执行。
本文件职责:校验当前会话允许的动作后交给流程执行;卡片按钮集中防抖入口。
禁止:不校验 wait/允许集合就改主账;禁止一个函数处理未声明的新按钮。
"""
@@ -15,6 +15,7 @@ from agent.policy import inquiry_copy as copy
from agent.policy.air_text_flow import get_air_text_flow
from agent.policy.land_text_flow import get_land_text_flow
from agent.policy.sea_text_flow import get_sea_text_flow
from agent.redis_coord.card_debounce import debounce_key, try_acquire as try_card_debounce
logger = logging.getLogger(__name__)
@@ -57,6 +58,7 @@ def handle_card_action(
wait_version:会话书签版本;错版本丢弃,不重放副作用。
payload 须带 task_id / event_key,才能找回点卡当时的工单。
连点:同一 task_id+按钮在防抖 TTL 内只跑一次业务,避免多条智能体提示。
"""
_ = thread_id
if not (sender_id or "").strip():
@@ -68,6 +70,41 @@ def handle_card_action(
return "card_discard"
msg_store = store or get_message_store()
wo_hint = ""
if sess is not None:
wo_hint = (sess.work_order_no or "").strip()
if not wo_hint:
wo_hint = copy.work_order_from_card_meta(payload)
# 集中防抖:键用 task_id+动作,不用 ResponseCode(企微每次点击换新码)
dkey = debounce_key(
action=action,
task_id=str((payload or {}).get("task_id") or ""),
work_order_no=wo_hint,
sender_id=sender_id,
event_key=str((payload or {}).get("event_key") or ""),
)
if not try_card_debounce(dkey):
logger.info(
"card 防抖丢弃 sender=%s action=%s key=%s",
sender_id,
action,
dkey,
)
# 仍用新 ResponseCode 置灰,避免企微按钮一直可点;不发业务 ack
code = str((payload or {}).get("response_code") or "").strip()
if code:
msg_store.enqueue_outbound(
touser=sender_id,
content=copy.BTN_SKIP_COLLAB_DONE,
dedupe_key=f"card-grey:{sender_id}:{wo_hint}:{code}",
payload=copy.skip_collab_card_update(
response_code=code,
replace_name=action if action in {
copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE
} else "",
),
)
return "card_debounced"
def reply(text: str, extra: dict | None = None) -> str:
# 销售询价正文不加 [test];[test] 只给运维告警
@@ -79,13 +116,16 @@ def handle_card_action(
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]
msgtype = str(extra.get("msgtype") or "text")
# 文本 ack 按工单+动作去重;置灰更新仍带 code(每次 ResponseCode 只能用一次)
if msgtype == "update_template_card":
dedupe_key = f"card-grey:{sender_id}:{wo}:{code}:{action}"
else:
dedupe_key = f"card-ack:{sender_id}:{wo}:{action}:{body[: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]}"
),
dedupe_key=dedupe_key,
payload=extra,
)
if not ok:
+38 -2
View File
@@ -103,7 +103,12 @@ def handle_group_inbound(
or ""
).upper()
if line == "MULTI":
from agent.policy.multi_group_ops import bot_sender_when_air_multi, handle_multi_inbound, sender_mode
from agent.policy.multi_group_ops import (
apply_multi_file,
bot_sender_when_air_multi,
handle_multi_inbound,
sender_mode,
)
if not (
_sender_allowed(ticket, message.sender_id)
@@ -111,6 +116,22 @@ def handle_group_inbound(
):
logger.info("group_inbound 多段忽略非成员 sender=%s", message.sender_id)
return "group_ignore"
kind_multi = (message.msg_type or "text").lower()
# 附件走归段落库;禁止把文件名塞进文字报价。
if kind_multi in {"file", "image", "video"}:
media = dict(message.media or {})
filename = str(
media.get("filename") or media.get("FileName") or (message.content or "")
).strip()
with bot_sender_when_air_multi(ticket):
return apply_multi_file(
flow=engine,
ticket=ticket,
chat_id=chat_id,
sender_id=message.sender_id,
filename=filename,
media=media,
)
# 会话存档听到的报价也走这里。含空运时群是机器人建的,必须改走桥才能发得出去。
with bot_sender_when_air_multi(ticket):
return handle_multi_inbound(
@@ -142,6 +163,20 @@ def handle_group_inbound(
sess = _session_of_ticket(engine, ticket)
if is_handoff_ticket(ticket, sess):
# 转人工后不再跟报价/成交/补字段,但「解散群」仍要执行,否则协同群清不掉。
from agent.routing.group_intent import INTENT_DISSOLVE, classify_group_text
kind0 = (message.msg_type or "text").lower()
if kind0 in {"", "text"} and (
classify_group_text(message.content or "", injected=injected_intent)
== INTENT_DISSOLVE
):
logger.info(
"group_inbound 转人工仍解散 wo=%s chat=%s",
ticket_no(ticket),
chat_id,
)
return dissolve_group(flow=engine, ticket=ticket, sess=sess)
logger.info(
"group_inbound 转人工不再跟 wo=%s chat=%s",
ticket_no(ticket),
@@ -221,12 +256,13 @@ def handle_group_inbound(
text=message.content or "",
)
if intent == INTENT_CONFIRM_TMS:
return apply_confirm_tms_quote(
phase = apply_confirm_tms_quote(
flow=engine,
ticket=ticket,
sender_id=message.sender_id,
sess=sess,
)
return phase
if intent in {INTENT_KEEP_TMS, INTENT_DROP_TMS}:
return apply_tms_quote_choice(
flow=engine,
+25
View File
@@ -113,6 +113,7 @@ class WorkerRuntime:
threading.Thread(target=self._wshoto_poll_loop, name="wshoto-poll", daemon=True),
threading.Thread(target=self._deal_followup_loop, name="deal-followup", daemon=True),
threading.Thread(target=self._event_exception_loop, name="event-exception", daemon=True),
threading.Thread(target=self._collab_watch_reconcile_loop, name="collab-watch", daemon=True),
threading.Thread(target=self._pdf_loop, name="pdf-loop", daemon=True),
]
for t in self._threads:
@@ -307,6 +308,30 @@ class WorkerRuntime:
logger.exception("事件异常巡检失败")
self._stop.wait(60.0)
def _collab_watch_reconcile_loop(self) -> None:
"""
HTTP 槽对账协同群监听。独立线程,不占回调、不与 soffice 串队。
缺监听补挂;主账已结束或监听孤儿则清人摘监听,避免「解散群」没人听见。
"""
from agent.jobs.collab_watch_reconcile import reconcile_collab_watch
interval = float(
getattr(self.settings, "collab_watch_reconcile_interval_sec", 900.0) or 900.0
)
interval = min(3600.0, max(120.0, interval))
# 启动后先等一截,避开 Worker 冷启动打满企微。
self._stop.wait(min(90.0, interval))
while not self._stop.is_set():
try:
self.http_jobs.run(
lambda: reconcile_collab_watch(settings=self.settings),
label="collab-watch",
)
except Exception:
logger.exception("协同群监听对账失败")
self._stop.wait(interval)
def _pdf_loop(self) -> None:
"""仅 LibreOffice 单槽消费 PDF Stream。"""
if not self.redis:
@@ -0,0 +1,352 @@
"""
协同群监听对账:主账绑群 ↔ Redis 监听名单 ↔ 企微成员。
本文件职责:Worker HTTP 槽定时跑。缺监听则补挂;已结束/无主账则清人并摘监听。
禁止:回调线程调用;占 LibreOffice 槽;改六态;一把大锁串行所有工单。
禁止:listOpenCollab 失败/空列表时把仍有工单映射的活群当孤儿清掉。
"""
from __future__ import annotations
import logging
import time
from typing import Any, Optional
from agent.config import Settings, get_settings
logger = logging.getLogger(__name__)
# 单轮最多清多少个群,避免企微限频拖死 Worker。
_MAX_DISMISS_PER_RUN = 20
# 双 Worker 互斥:同一前缀下 10 分钟内只跑一轮。
_LOCK_TTL_SEC = 600
def reconcile_collab_watch(
*,
ledger: Any = None,
settings: Optional[Settings] = None,
dismiss: bool = True,
) -> dict[str, int]:
"""
对账一轮。返回计数:rewatched / dismissed / skipped / errors。
主账 listOpenCollab 不可用时,仍可按监听名单侧清理(无单/已结束)。
"""
cfg = settings or get_settings()
stats = {
"rewatched": 0,
"dismissed": 0,
"skipped": 0,
"empty_unwatched": 0,
"errors": 0,
"listed": 0,
"watched": 0,
}
if str(getattr(cfg, "ledger_backend", "http") or "http").lower() == "memory":
return stats
if not _try_lock(cfg):
stats["skipped"] = 1
return stats
book = ledger
if book is None:
from agent.ledger.http_ledger import HttpLedger
book = HttpLedger()
from agent.channel.wshoto.rooms import (
list_watched_rooms,
ticket_no_for_room,
unwatch_collab_room,
watch_collab_room,
)
watched = list(list_watched_rooms())
watched_set = set(watched)
stats["watched"] = len(watched)
open_rows: list[dict[str, Any]] = []
list_ok = False
lister = getattr(book, "list_open_collab", None)
if callable(lister):
try:
raw = lister()
# None = 接口失败(与空列表区分);空列表在「仍有监听」时也不能当孤儿清扫依据。
if raw is None:
list_ok = False
open_rows = []
stats["errors"] += 1
logger.error("列出开放协同群失败(返回 None),本轮不做孤儿清群")
else:
open_rows = list(raw or [])
list_ok = True
except Exception:
logger.exception("列出开放协同群失败")
stats["errors"] += 1
open_rows = []
list_ok = False
stats["listed"] = len(open_rows)
# listed=0 且仍有监听:多半是接口挂了或返回形态不对,禁止走孤儿清人。
orphan_dismiss_ok = bool(dismiss) and list_ok and (
stats["listed"] > 0 or stats["watched"] == 0
)
open_by_chat: dict[str, dict[str, Any]] = {}
for row in open_rows:
if not isinstance(row, dict):
continue
chat = str(row.get("collab_chat_id") or row.get("collabChatId") or "").strip()
if not chat:
continue
open_by_chat[chat] = row
ended = _ended(row)
no = str(row.get("work_order_no") or row.get("workOrderNo") or "").strip()
if ended:
continue
if chat not in watched_set:
try:
watch_collab_room(chat, work_order_no=no)
stats["rewatched"] += 1
logger.info("对账补挂监听 wo=%s chat=%s", no or "-", chat)
except Exception:
logger.exception("补挂监听失败 chat=%s", chat)
stats["errors"] += 1
# 待清:主账已结束(不论是否在监听)+ 监听孤儿(监听有、主账无)
client = _group_client()
dismiss_left = _MAX_DISMISS_PER_RUN if dismiss else 0
pending: list[tuple[str, str, list[str]]] = [] # chat, wo, members
for chat, row in open_by_chat.items():
if not _ended(row):
continue
no = str(row.get("work_order_no") or row.get("workOrderNo") or "").strip()
members = [str(x) for x in (row.get("collab_member_ids") or row.get("memberIds") or []) if x]
pending.append((chat, no, members))
for chat in list(watched):
row = open_by_chat.get(chat)
if row is None:
finder = getattr(book, "find_by_collab_chat", None)
if callable(finder):
try:
row = finder(chat_id=chat)
except Exception:
row = None
if isinstance(row, dict) and (row.get("work_order_no") or row.get("workOrderNo") or row.get("found")):
no = str(row.get("work_order_no") or row.get("workOrderNo") or "").strip()
if _ended(row):
# listOpen 未覆盖到的已结束单:补进待清,避免只摘监听不发确认。
if chat not in open_by_chat:
members = [
str(x)
for x in (row.get("collab_member_ids") or row.get("memberIds") or [])
if x
]
pending.append((chat, no, members))
continue
# 活单:确保房间→工单映射在
if no and not ticket_no_for_room(chat):
try:
watch_collab_room(chat, work_order_no=no)
stats["rewatched"] += 1
except Exception:
stats["errors"] += 1
continue
# 监听有、主账 list/byChat 对不上:先用 Redis 工单号回查,活单绝不当地孤儿清。
no = ticket_no_for_room(chat) or ""
live = _lookup_live_ticket(book, work_order_no=no) if no else None
if live is not None:
if _ended(live):
members = [
str(x)
for x in (live.get("collab_member_ids") or live.get("memberIds") or [])
if x
]
pending.append((chat, no, members))
else:
stats["skipped"] += 1
logger.warning(
"对账跳过疑似孤儿(Redis 工单仍存活)wo=%s chat=%s list_ok=%s listed=%s",
no,
chat,
list_ok,
stats["listed"],
)
continue
if not orphan_dismiss_ok:
stats["skipped"] += 1
continue
pending.append((chat, no, []))
seen_chat: set[str] = set()
for chat, no, members in pending:
if chat in seen_chat:
continue
seen_chat.add(chat)
try:
# 已空只摘监听,不占清人配额,避免历史结束单堵死后面仍有人的群。
owner_now, users_now = _room_members(client, chat_id=chat, members=members)
extras_now = [u for u in users_now if u and u != owner_now]
if not extras_now:
unwatch_collab_room(chat)
stats["empty_unwatched"] += 1
continue
if dismiss_left <= 0:
stats["skipped"] += 1
continue
_dismiss_room(client, chat_id=chat, members=members, work_order_no=no)
unwatch_collab_room(chat)
stats["dismissed"] += 1
dismiss_left -= 1
logger.info("对账清群 wo=%s chat=%s", no or "-", chat)
except Exception:
logger.exception("对账清群失败 chat=%s", chat)
stats["errors"] += 1
return stats
def _ended(row: dict[str, Any]) -> bool:
if bool(row.get("collab_ended") or row.get("collabEnded")):
return True
facts = row.get("collab_facts") or row.get("collabFacts") or {}
if isinstance(facts, dict):
flag = str(facts.get("__ended") or "").strip().lower()
if flag in {"1", "true", "yes"}:
return True
return False
def _lookup_live_ticket(book: Any, *, work_order_no: str) -> Optional[dict[str, Any]]:
"""
用 Redis 里的工单号回查主账。查到则返回可判 ended 的 dict;查不到返回 None。
防止 listOpenCollab/byCollabChat 短暂失败时,把仍存活的协同群当孤儿清掉。
"""
no = (work_order_no or "").strip()
if not no or book is None:
return None
getter = getattr(book, "get_ticket", None)
if not callable(getter):
return None
try:
ticket = getter(work_order_no=no)
except Exception:
logger.exception("对账按工单回查失败 wo=%s", no)
return None
if ticket is None:
return None
if isinstance(ticket, dict):
row = dict(ticket)
if not (row.get("work_order_no") or row.get("workOrderNo")):
row["work_order_no"] = no
return row
# MemoryTicket / 其它对象:尽量投影成 dict
row = {
"work_order_no": str(getattr(ticket, "work_order_no", None) or no),
"collab_ended": bool(getattr(ticket, "collab_ended", False)),
"collab_chat_id": str(getattr(ticket, "collab_chat_id", None) or ""),
"collab_member_ids": list(getattr(ticket, "collab_member_ids", None) or []),
}
facts = getattr(ticket, "collab_facts", None) or getattr(ticket, "facts", None)
if isinstance(facts, dict):
row["collab_facts"] = facts
return row
def _try_lock(settings: Settings) -> bool:
from agent.redis_coord import get_redis_runtime
from agent.redis_coord.idempotency import try_acquire
try:
runtime = get_redis_runtime(settings)
client = getattr(runtime, "client", None)
if client is None:
return True
return bool(
try_acquire(
client,
scope="collab-watch-reconcile",
idempotency_key="run",
ttl_seconds=_LOCK_TTL_SEC,
)
)
except Exception:
logger.exception("对账锁获取失败,本轮仍继续")
return True
def _group_client() -> Any:
from agent.policy.sea_group_ops import group_client_of
from agent.policy.sea_text_flow import get_sea_text_flow
return group_client_of(get_sea_text_flow())
def _room_members(client: Any, *, chat_id: str, members: list[str]) -> tuple[str, list[str]]:
"""
读企微实时成员;仅在接口失败/无 userlist 字段时退回主账成员。
注意:userlist=[] 是「已空」,不能用 `or members`(空列表在 Python 为假),
否则会把主账旧成员当成还在群里,白白占满每轮清人配额。
"""
info: dict[str, Any] = {}
try:
info = client._appchat_info(chat_id=chat_id) or {}
except Exception:
info = {}
raw = info.get("userlist") if isinstance(info, dict) else None
if raw is None:
users = [str(u) for u in (members or []) if u]
else:
users = [str(u) for u in (raw or []) if u]
owner = str((info or {}).get("owner") or "")
return owner, users
def _room_already_empty(client: Any, *, chat_id: str, members: list[str]) -> bool:
"""
群里已无其他人(空群或只剩群主)则视为已解散完成。
这类单不占每轮清人配额,只摘监听即可。
"""
owner, users = _room_members(client, chat_id=chat_id, members=members)
extras = [u for u in users if u and u != owner]
return not extras
def _dismiss_room(
client: Any,
*,
chat_id: str,
members: list[str],
work_order_no: str,
) -> bool:
"""
清人。返回 True=执行过移人。
成员列表以企微实时 userlist 为准;主账成员可能过期。
"""
owner, users = _room_members(client, chat_id=chat_id, members=members)
extras = [u for u in users if u and u != owner]
if not extras:
return False
try:
from agent.policy import inquiry_copy as copy
text = copy.group_dissolved(work_order_no=work_order_no or "-")
send = getattr(client, "send_group", None)
if callable(send):
send(chat_id=chat_id, content=text)
except Exception:
logger.exception("对账清群前确认消息失败 chat=%s", chat_id)
dismiss = getattr(client, "dismiss_group", None)
if callable(dismiss):
dismiss(chat_id=chat_id, userids=users)
return True
return False
@@ -7,8 +7,11 @@
from __future__ import annotations
import json
import logging
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Optional
from agent.config import Settings, get_settings
@@ -90,10 +93,9 @@ def poll_event_exceptions(
test_prefix = False
lookup = timeout_lookup or getattr(book, "get_timeout_by_code", None)
ready = notify_app_flag(notify_ready)
rows = [r for r in (lister() or []) if isinstance(r, dict)]
sent = 0
for row in lister() or []:
if not isinstance(row, dict):
continue
for row in rows:
try:
if _handle_one(
row,
@@ -194,7 +196,8 @@ def _handle_one(
if skip_reason:
logger.info("事件异常跳过 wo=%s kind=%s reason=%s", no, kind, skip_reason)
return False
if workday_elapsed_hours(started, now) < hours:
elapsed = workday_elapsed_hours(started, now)
if elapsed < hours:
return False
watchers = [dict(x) for x in (row.get("watchers") or []) if isinstance(x, dict)]
sales = _ticket_sales(row, watchers)
@@ -206,6 +209,7 @@ def _handle_one(
names = [str(p.get("name") or "").strip() for p in people if str(p.get("name") or "").strip()]
chat_id = str(row.get("collab_chat_id") or row.get("collabChatId") or "").strip()
where = conversation_of(wait_kind=kind, collab_chat_id=chat_id)
app_targets = pick_app_targets(people)
# 群里用 @。销售超时即使发到私聊,也写成 @销售姓名,不写「您」或「相关人员」。
mention = where == "group" or kind in _SALES_WAIT_KINDS
text = copy.event_exception_session_text(
@@ -248,6 +252,8 @@ def _handle_one(
test_prefix=test_prefix,
enqueue=enqueue or _default_enqueue,
)
else:
pass
recorder = getattr(book, "record_event_exception", None)
if callable(recorder):
recorder(work_order_no=no, event_exception=exception_type(kind))
+41
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
from agent.jobs.libreoffice import LibreOfficeConverter, LibreOfficeResult
@@ -20,6 +21,8 @@ from agent.redis_coord.stream_queue import StreamQueue
logger = logging.getLogger(__name__)
@dataclass
class PdfEnqueueResult:
accepted: bool
@@ -160,6 +163,8 @@ def _deliver_quote_file(data: dict[str, Any], result: LibreOfficeResult) -> None
or {},
},
)
# PDF 出站同时登记主账附件,后台列表/详情才能看到。
_register_pdf_attachment(wo, filename, result.pdf_path, touser=touser)
if wo:
try:
from agent.policy.air_text_flow import get_air_text_flow
@@ -176,3 +181,39 @@ def _deliver_quote_file(data: dict[str, Any], result: LibreOfficeResult) -> None
logger.exception("PDF 出文件后未能切到成交跟进 inquiry=%s", wo)
except Exception:
logger.exception("PDF 出站入队失败 inquiry=%s", wo)
def _register_pdf_attachment(
work_order_no: str,
filename: str,
pdf_path: str,
*,
touser: str = "",
) -> None:
"""
PDF 转换成功后把文件字节登记到主账附件表。
失败只打日志,不挡企微发文件。LibreOffice 槽内调用,短 HTTP。
"""
wo = (work_order_no or "").strip()
path = Path(str(pdf_path or ""))
if not wo or not path.is_file():
return
try:
from agent.ledger.http_ledger import HttpLedger
raw = path.read_bytes()
if not raw:
return
name = (filename or "").strip() or f"{wo}_quote.pdf"
if not name.lower().endswith(".pdf"):
name = f"{name}.pdf"
out = HttpLedger().attach_file_bytes(
work_order_no=wo,
file_name=name,
file_bytes=raw,
content_type="application/pdf",
sender_id=touser or "",
)
except Exception:
logger.exception("PDF 附件登记失败 inquiry=%s", wo)
+1 -1
View File
@@ -33,7 +33,7 @@ class LedgerClient:
"""
主账客户端。
base_url:BIZ_API_BASE_URL(新系统多为 http://127.0.0.1:8180/jeecgboot)。
base_url:BIZ_API_BASE_URL(新系统为 http://127.0.0.1:8080/jeecgboot)。
超时:默认 10s,禁止无限阻塞 Worker 槽。
禁止:在本类拼 TMS HMAC 或写工单 SQL。
"""
+76 -5
View File
@@ -1,5 +1,5 @@
"""
主账 HTTP 账本:建单 / 查价 / 报价 / 六态只打 8180。
主账 HTTP 账本:建单 / 查价 / 报价 / 六态只打 8080。
本文件职责:把 MemoryLedger 同名方法接到 /inquiry/agent/**;查价走真 TMS(主账出站)。
禁止:直连 TMS / MySQL;把 Jeecg 包装失败当成有价。
@@ -236,13 +236,15 @@ class HttpLedger:
quote: Optional[dict[str, Any]] = None,
format: str = "xlsx",
sales_wecom_id: str = "",
persist_attachment: bool = False,
) -> dict[str, Any]:
"""
打主账按后台报价模板填 Excel。
失败(无关键词且无默认)原样带回销售话术。Java 不转 PDF。
persist_attachment:True 才写主账附件表(确认落库);激活发群默认 False。
"""
from agent.quote_templates.match import FAIL_MESSAGE
from agent.quote_templates.match import FAIL_MESSAGE, normalize_biz, pick_quote_template
from agent.schema.quote_adjust import stamp_prices_for_sheet
# 出单前把当前费用写入线路表、海运分段、空运舱位。否则报价单仍是旧价。
@@ -260,8 +262,9 @@ class HttpLedger:
"facts": facts,
"quote": quote or {},
"salesWecomId": sales_wecom_id,
"persistAttachment": bool(persist_attachment),
},
idem=f"quote-render:{work_order_no}:{format}",
idem=f"quote-render:{work_order_no}:{format}:{int(bool(persist_attachment))}",
)
finally:
self._client.timeout_seconds = prev_timeout
@@ -270,6 +273,7 @@ class HttpLedger:
return {
"ok": False,
"error": out.get("error") or "NO_TEMPLATE",
"failStage": out.get("failStage"),
"message": out.get("message") or FAIL_MESSAGE,
}
from agent.quote_templates.filename import filename_from_render
@@ -465,12 +469,16 @@ class HttpLedger:
flight_no: str = "",
) -> dict[str, Any]:
"""锁舱出站。航班号和航司一并带上,主账缺了这两项不能当锁舱失败。"""
from agent.policy.bot_name import strip_bot_mention
# 群入站常带「@智价通AIQ …」;出站备注只留业务原文,禁止把 @机器人 带给 TMS。
clean_instruction = strip_bot_mention(instruction_text or "")
body: dict[str, Any] = {
"workOrderNo": work_order_no,
"optionNo": option_no,
"operatorUserId": sender_id,
"operatorName": operator_name,
"instructionText": instruction_text,
"instructionText": clean_instruction,
}
if (airline or "").strip():
body["airline"] = airline.strip().upper()
@@ -491,6 +499,9 @@ class HttpLedger:
operator_name: str = "",
instruction_text: str = "",
) -> dict[str, Any]:
from agent.policy.bot_name import strip_bot_mention
clean_instruction = strip_bot_mention(instruction_text or "")
return self._post(
"/inquiry/agent/tms/releaseCabin",
{
@@ -498,7 +509,7 @@ class HttpLedger:
"lockId": lock_id,
"operatorUserId": sender_id,
"operatorName": operator_name,
"instructionText": instruction_text,
"instructionText": clean_instruction,
},
idem=f"air-release:{work_order_no}:{lock_id}",
)
@@ -510,6 +521,38 @@ class HttpLedger:
return None
return out
def list_open_collab(self) -> list[dict[str, Any]] | None:
"""
列出仍绑着协同群的工单(含已结束协同)。
Worker 对账监听用。
返回 None = 接口失败/形态不对(调用方禁止据此做孤儿清群);
返回 [] = 确实没有绑群工单。
"""
try:
out = self._post("/inquiry/agent/ticket/listOpenCollab", {})
except Exception:
logger.exception("listOpenCollab 调用失败")
return None
if not isinstance(out, dict):
logger.error("listOpenCollab 返回非对象")
return None
# 主账失败常见 success=false / code!=200,不能当成「零条活群」。
if out.get("success") is False:
logger.error("listOpenCollab 业务失败 code=%s", out.get("code") or out.get("status"))
return None
rows = out.get("tickets")
if rows is None and "result" in out:
result = out.get("result")
if isinstance(result, dict):
rows = result.get("tickets")
elif isinstance(result, list):
rows = result
if not isinstance(rows, list):
logger.error("listOpenCollab 无 tickets 列表字段 keys=%s", sorted(out.keys())[:20])
return None
return [r for r in rows if isinstance(r, dict)]
def end_collab_group(self, *, work_order_no: str) -> dict[str, Any]:
"""协同结束,这单不能再拉群。不改六态。"""
return self._post(
@@ -539,6 +582,34 @@ class HttpLedger:
},
)
def attach_file_bytes(
self,
*,
work_order_no: str,
file_name: str,
file_bytes: bytes,
content_type: str = "",
sender_id: str = "",
) -> dict[str, Any]:
"""
报价附件字节写入主账(上传对象存储 + inq_attachment)。
用于 PDF 转换完成后落库;Excel 出单由主账 render 内登记。
"""
import base64
return self._post(
"/inquiry/agent/ticket/attachFileBytes",
{
"workOrderNo": work_order_no,
"fileName": file_name,
"fileBase64": base64.b64encode(file_bytes or b"").decode("ascii"),
"contentType": content_type or "application/octet-stream",
"senderId": sender_id,
},
idem=f"attach-bytes:{work_order_no}:{file_name}:{len(file_bytes or b'')}",
)
def upsert_product_quote(
self,
*,
+25 -1
View File
@@ -1,5 +1,5 @@
"""
主账内存假账本(单测 / 无 8180 时)。
主账内存假账本(单测 / 无 8080 时)。
本文件职责:实现建单(每次新开)、相似检索、TMS fixture、报价写入、六态流转,语义对齐 Java 主账。
禁止:冒充已打真实 TMS;禁止被生产 Worker 当默认 Owner(生产必须 HTTP 主账)。
@@ -427,9 +427,11 @@ class MemoryLedger:
quote: Optional[dict[str, Any]] = None,
format: str = "xlsx",
sales_wecom_id: str = "",
persist_attachment: bool = False,
) -> dict[str, Any]:
"""
按询价需求匹配内存模板。不填真实 xlsx,只回假字节供流程/单测。
persist_attachment 与主账同语义:True 表示应登记附件(单测可断言 attachmentSaved)。
"""
from agent.quote_templates.match import FAIL_MESSAGE, pick_quote_template
@@ -459,6 +461,8 @@ class MemoryLedger:
"fileBase64": "UEsDBAoAAAAAA",
"needsPdf": False,
"facts": dict(facts or {}),
"attachmentSaved": bool(persist_attachment),
"persistAttachmentRequested": bool(persist_attachment),
}
with self._lock:
ticket = self._tickets.get((work_order_no or "").strip())
@@ -745,6 +749,26 @@ class MemoryLedger:
ticket.attachments.append(row)
return {"ok": True, "file_name": file_name}
def attach_file_bytes(
self,
*,
work_order_no: str,
file_name: str,
file_bytes: bytes,
content_type: str = "",
sender_id: str = "",
) -> dict[str, Any]:
"""单测:字节附件只记元数据,不写真实对象存储。"""
_ = file_bytes
key = f"memory/{work_order_no}/{file_name or 'file.bin'}"
return self.attach_file(
work_order_no=work_order_no,
file_name=file_name,
object_key=key,
content_type=content_type,
sender_id=sender_id,
)
def upsert_product_quote(
self,
*,
+119 -14
View File
@@ -137,6 +137,7 @@ _SHEET_CLEAN_KEYS = frozenset(
"整柜或拼柜",
"箱型箱量",
"贸易条款",
"增值服务",
"货好时间",
"运输分类",
"报价日期",
@@ -280,7 +281,8 @@ def parse_clarify_paste_facts(text: str) -> dict[str, str]:
continue
if not val or _CLARIFY_EXAMPLE_LINE.match(val):
continue
facts[name] = clip_inquiry_value(val)
clipped = clip_inquiry_value(val)
facts[name] = clipped
return {key: val for key, val in facts.items() if val}
@@ -522,6 +524,14 @@ def extract_inquiry_snapshot(
# 模型常把「郑州-吉隆坡」猜成空运,不能拿来默认。
source = "deepseek_b"
facts = harvest_oral_measures(text, normalize_facts(facts))
if (mode or "").upper() == "LAND":
from agent.schema.field_validate import (
harvest_oral_land_vehicle,
promote_land_vehicle_from_container,
)
facts = promote_land_vehicle_from_container(facts)
facts = harvest_oral_land_vehicle(text, facts)
facts = _merge_spoken_collab(text, facts)
facts = _merge_optional_inquiry_labels(text, facts)
facts = apply_cargo_handover_synonym(text, facts, business_line=mode)
@@ -570,12 +580,35 @@ def harvest_inquiry_optional_labels(text: str) -> dict[str, str]:
return _harvest_labels(text, _OPTIONAL_INQUIRY_LABELS)
def is_clarify_ui_prompt(raw: str | None) -> bool:
"""
补问/核对卡上的系统提示,不是任何询价字段的值。
空「货物品名(必填):」后若没换行,下一句「请确认并补充以下信息」会粘成品名
(多段陆运核对卡运行证据)。报价日期误收「请复制粘贴…」同属此类。
"""
text = str(raw or "").strip().strip("::")
if not text:
return False
if "请复制粘贴" in text or "告知我以下字段" in text:
return True
if "请确认并补充以下信息" in text:
return True
if "请复制以下内容" in text:
return True
if re.match(r"^Step\s*[12]\s*:", text, flags=re.I):
return True
return False
def clip_inquiry_value(raw: str) -> str:
"""
字段值收到下一个编号项为止。
图上常在货好时间后面接着「4. 希望送到…」「6. 送货地址」。
那些不是询价字段,不能进补问。地址里的「602、603」没有点号后空格,会留下。
补问复制清单「10.增值服务」点后可无空格;空贸易条款不能把下一栏标签收成值。
空报价日期不能把下一行「请复制粘贴…」收成日期值。
"""
text = (raw or "").strip()
if not text:
@@ -583,6 +616,20 @@ def clip_inquiry_value(raw: str) -> str:
text = re.sub(r"[((]如\s*[::][^))]*[))]?\s*$", "", text).strip()
if _CLARIFY_EXAMPLE_LINE.match(text):
return ""
# 补问 UI 提示不是任何字段的值(空栏后没换行会粘上标题)。
if is_clarify_ui_prompt(text):
return ""
# 整段就是下一栏「10.增值服务」或「增值服务(非必填):…」→ 本栏未填。
if _starts_with_inquiry_label(text):
return ""
# 值中间换行后出现「10.增值服务」:切到下一栏之前(点后可无空格)。
cut_at = -1
for match in re.finditer(r"(?m)^\s*\d+[\.、.]\s*", text):
if match.start() > 0 and _starts_with_inquiry_label(text[match.start() :]):
cut_at = match.start()
break
if cut_at > 0:
text = text[:cut_at]
hit = _NUMBERED_ITEM.search(text)
if hit and hit.start() > 0:
text = text[: hit.start()]
@@ -594,6 +641,37 @@ def clip_inquiry_value(raw: str) -> str:
return text
def _starts_with_inquiry_label(text: str) -> bool:
"""
值是否以「另一询价栏目标签」开头(可带补问编号、冒号、必填标记)。
空起运地被误收成「目的地:CRK」时走这里丢掉,避免 TMS 按脏起运地查价。
「10.增值服务」「10.增值服务(非必填):卡转」也算下一栏,不能进贸易条款。
「目的地仓库」这种地名不含冒号/必填标记,不算标签,照常保留。
"""
raw = (text or "").lstrip()
if not raw:
return False
# 补问复制清单编号「10.」可无空格,先剥掉再认标签。
raw = _CLARIFY_INDEX.sub("", raw, count=1).lstrip()
if not raw:
return False
labels = sorted(_SHEET_CLEAN_KEYS | _CLARIFY_NAMES, key=len, reverse=True)
for label in labels:
if len(label) < 2 or not raw.startswith(label):
continue
if not _label_is_token(raw, 0, label):
continue
rest = raw[len(label) :]
if re.match(r"^\s*$", rest):
return True
if re.match(r"^\s*[::]", rest):
return True
if re.match(r"^\s*[((][^))]*(?:必填|非必填)", rest):
return True
return False
def clip_sheet_field_value(raw: str) -> str:
"""
报价单格子:去掉 /POL、(必填说明)、Terms 和同一行下一列表头,只留紧挨的值。
@@ -612,6 +690,9 @@ def clip_sheet_field_value(raw: str) -> str:
if "必填" in text:
text = re.sub(r"^[^)\n]*必填[^)\n]*)\s*", "", text)
text = re.sub(r"^[\s::]+", "", text)
# 本栏为空却吞进「目的地:CRK」时,整段丢掉(标签在 pos=0,后面切栏逻辑碰不到)。
if _starts_with_inquiry_label(text):
return ""
for label in _SHEET_CLEAN_KEYS:
idx = 0
while True:
@@ -673,7 +754,11 @@ def harvest_sheet_inquiry_cells(text: str) -> dict[str, str]:
for i, (pos, label) in enumerate(kept):
begin = pos + len(label)
end = kept[i + 1][0] if i + 1 < len(kept) else len(raw)
val = clip_sheet_field_value(raw[begin:end])
raw_chunk = raw[begin:end]
# 报价日期只认同行:空栏后的粘贴提示不能进格子值。
if label in {"报价日期", *QUOTE_DATE_SYNONYMS}:
raw_chunk = raw_chunk.split("\n", 1)[0]
val = clip_sheet_field_value(raw_chunk)
if val:
out[label] = val
return out
@@ -773,18 +858,21 @@ def _harvest_labels(text: str, labels: tuple[str, ...]) -> dict[str, str]:
begin = pos + len(label)
end = kept[i + 1][0] if i + 1 < len(kept) else len(raw)
chunk = raw[begin:end]
# 增值服务只认冒号后同一行。空行不能把下一行「报价日期(非必填):」收成服务。
if label.startswith("增值服务"):
# 增值服务 / 报价日期只认冒号后同一行。空行不能把下一行「请复制粘贴…」收成日期。
if label.startswith("增值服务") or label in {"报价日期", *QUOTE_DATE_SYNONYMS}:
chunk = chunk.split("\n", 1)[0]
same_line = chunk.split("\n", 1)[0]
same_line_body = re.sub(r"^[\s::,,、]+", "", same_line).strip()
# 冒号后本行为空 = 未填。禁止跨行把下一栏「目的地:吉隆坡」收成起运地。
# 核对卡复制粘贴时非必填常整行只有「起运地(非必填):」,下一行才是必填目的地。
if not same_line_body:
# 核对卡无换行粘贴时「起运地(非必填): 目的地(必填):CRK」也视为未填。
if not same_line_body or _starts_with_inquiry_label(same_line_body):
chunk = ""
else:
chunk = re.sub(r"^[\s::,,、]+", "", chunk)
chunk = re.sub(r"[\s,,、]+$", "", chunk)
# 报价日期同行若误收成「请复制粘贴…」补问提示,必须丢掉(WO202610170068)。
if label in {"报价日期", *QUOTE_DATE_SYNONYMS}:
chunk = clip_inquiry_value(chunk)
if chunk:
out[label] = chunk
return out
@@ -899,11 +987,17 @@ _RE_FEE_LINE = re.compile(
r"(?P<name>[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费|[A-Za-z]{2,10})"
r"\s*(?:改为|改成|调整为|调整成)?"
r"\s*[::=]?"
rf"\s*(?:(?P<ccy1>{_CCY_TOKEN})\s+)?"
# 群里常写「陆运费 CNY1000」币种与数字贴在一起;\s+ 会漏匹配再退回把「段1」当金额。
rf"\s*(?:(?P<ccy1>{_CCY_TOKEN})\s*)?"
r"(?P<amount>\d+(?:\.\d+)?)"
rf"\s*(?P<ccy2>{_CCY_TOKEN}|元)?",
re.I,
)
# 多段报价「段1:」「第一段」只是段号,数字不能当费用金额。
_RE_SEGMENT_INDEX_LABEL = re.compile(
r"(?:段\s*\d+\s*[::]?|第\s*[一二三四五六七八九\d]+\s*段\s*[::]?)",
re.I,
)
# 「文件发改为:500」漏了「费」、中间还有冒号,不能丢掉。
_RE_FEE_CHANGE = re.compile(
r"(?P<name>[\u4e00-\u9fff]{2,16}?)"
@@ -998,14 +1092,15 @@ def _normalize_fee_project(raw: str) -> str:
def _strip_group_bot_mentions(text: str) -> str:
"""
去掉群里 @询价小助手 前缀,避免挡住句首「起运港、」分段标签。
去掉群里 @机器人显示名 前缀,避免挡住句首「起运港、」分段标签。
企微协同句常以 @ 开头;不剥的话 project 抽不到,出单只能靠附加费默认「起运港」,
且二次盖价时更容易丢掉分段。
且二次盖价时更容易丢掉分段。显示名以后台为准,过渡期仍剥旧名。
副作用:无。
"""
cleaned = (text or "").replace("@询价小助手", "").replace("询价小助手", "")
return cleaned.strip()
from agent.policy.bot_name import strip_bot_mention
return strip_bot_mention(text or "").strip()
# 报价日期整段:标签+日期。费用抽取前剥掉,避免把年份 2026 当成金额。
@@ -1110,6 +1205,16 @@ def _split_fee_project_prefix(text: str) -> tuple[str, str]:
return project, rest
def _strip_segment_index_labels(text: str) -> str:
"""
去掉「段1:」「第一段」段号标签,避免金额正则吃到段序号。
运行证据:@机器人 段1:陆运费 CNY1000 → 曾抽成 amount=1。
副作用:无。
"""
return _RE_SEGMENT_INDEX_LABEL.sub(" ", text or "").strip()
def _parse_fee_rows(text: str, baseline_currency: str = "") -> list[dict[str, str]]:
"""
按行抽费用:码头操作费100CNY、报关费改为300CNY。
@@ -1233,8 +1338,9 @@ def extract_product_quote(
# 费率句若仍落到普通费用行,会把「燃油附加费」配上句首公斤数。先剥掉再抽。
# 必须用剥掉 @询价小助手 后的正文,否则句首「起运港、」分段标签匹配不到。
# 顺带剥掉报价日期/有效期/时效要求,避免年份、天数再抽成金额。
scrubbed = _strip_rate_and_band_noise(
_strip_non_fee_meta_chunks(stripped or raw)
# 多段「段1:」先去掉,禁止把段序号当成陆运费/空运费金额。
scrubbed = _strip_segment_index_labels(
_strip_rate_and_band_noise(_strip_non_fee_meta_chunks(stripped or raw))
)
rows = _parse_fee_rows(scrubbed, baseline_currency=base_ccy) if scrubbed.strip() else []
if rows:
@@ -1291,7 +1397,6 @@ def extract_product_quote(
return out
def _format_percent_rate(raw: str) -> str:
"""费率展示统一带 %;已有 % 保留。"""
text = (raw or "").strip()
+2 -31
View File
@@ -125,6 +125,8 @@ def build_extract_messages(text: str) -> list[dict[str, str]]:
"你是询价字段抽取器。只根据销售原话抽取字段,调用 extract_inquiry_fields。"
"未出现的字段必须空串,禁止编造港口、重量、日期。"
"空运件数/毛重/体积/包装方式;海运货量与箱型箱量;陆运按二级类型。"
"陆运整车/中港零担的车型写入车型/数量:45HQ(13.5厢车)、9米6/1、3T;"
"陆运不要把 45HQ/厢车 写成箱型箱量。"
"海运时 15件/20吨/3箱 必须写入货量,不要只写到件数把货量留空。"
"原话已带单位必须抽取,不要因为没写「毛重:」就留空:"
"10件/3pcs→空运件数;海运同样数字+件也要写入货量;100KGS/180kg/1.5KG/100公斤→毛重;2CBM/4.2cbm/2.5立方/2方→体积;"
@@ -217,35 +219,4 @@ def invoke_extract_fields(
parsed.get("business_line") or "-",
sorted((parsed.get("facts") or {}).keys()),
)
# #region agent log
try:
import json
import time
from pathlib import Path
_facts = dict(parsed.get("facts") or {})
_log = {
"sessionId": "0593f5",
"runId": "post-fix",
"hypothesisId": "A",
"location": "mode_extract_fields.py:invoke_extract_fields",
"message": "DeepSeek B place fields",
"data": {
"text_head": (text or "")[:80],
"ok": parsed.get("ok"),
"business_line": parsed.get("business_line"),
"land_subtype": parsed.get("land_subtype"),
"起运港": _facts.get("起运港"),
"目的港": _facts.get("目的港"),
"提货地址": _facts.get("提货地址"),
"fact_keys": sorted(_facts.keys()),
},
"timestamp": int(time.time() * 1000),
}
Path(__file__).resolve().parents[3].joinpath("debug-0593f5.log").open(
"a", encoding="utf-8"
).write(json.dumps(_log, ensure_ascii=False) + "\n")
except Exception:
pass
# #endregion
return parsed
+8 -3
View File
@@ -12,6 +12,8 @@ from __future__ import annotations
import json
import logging
import re
import time
from pathlib import Path
from typing import Any, Optional
from agent.llm.extract_text import extract_air_collab_fields, extract_product_quote
@@ -887,16 +889,19 @@ def apply_air_deal(
if not is_sales(ticket, sender_id):
send_group_text(group_client_of(flow), chat_id, copy.air_sales_only_deal())
return "not_sales"
view = ticket_view(ticket)
no = ticket_no(ticket)
status_now = ticket_status(ticket)
# 与海运一致:同一终态再确认不刷「不能修改」。
if status_now in {"已成交", "未成交"} and outcome == status_now:
return "deal_already_recorded"
stopped = halt_closed_group(flow=flow, ticket=ticket, chat_id=chat_id)
if stopped:
return stopped
from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason
from agent.policy.sea_group_ops import _quote_fee_rows, facts_of_group
view = ticket_view(ticket)
no = ticket_no(ticket)
quote = dict(view.get("quote") or {})
status_now = ticket_status(ticket)
usable_total = str(quote.get("total") or "").strip()
if usable_total in {"", "-"}:
usable_total = ""
+8 -19
View File
@@ -72,9 +72,10 @@ def air_cargo_lines(
dest = (src.get("目的港") or src.get("目的地") or "-").strip() or "-"
cargo = (src.get("品名") or "-").strip() or "-"
pack = (src.get("包装方式") or src.get("包装类型") or "-").strip() or "-"
quote_date = (src.get("报价日期") or "").strip()
if quote_date in {"", "-", "—", "-"}:
quote_date = "-"
from agent.schema.field_validate import is_blank_quote_date
quote_date_raw = (src.get("报价日期") or "").strip()
quote_date = "-" if is_blank_quote_date(quote_date_raw) else quote_date_raw
lines = [
f"工单号:{(work_order_no or '').strip()}",
"运输方式:空运",
@@ -218,22 +219,10 @@ def _fee_rows(quote: dict[str, Any] | None, facts: dict[str, str] | None) -> lis
option = resolve_display_air_option(src, facts)
rows: list[dict[str, Any]] = []
if option:
rows.extend(_named_rows(option.get("feeItems") or option.get("fee_items")))
rows.extend(
_named_rows(
option.get("departureCharges")
or option.get("departure_charges")
or option.get("charges")
)
)
rows.extend(
_named_rows(
option.get("destinationCharges")
or option.get("destination_charges")
or option.get("arrivalCharges")
)
)
rows = _dedupe(rows)
from agent.schema.air_route_options import option_fee_dicts
# 与投影同一套:舱位缺 feeItems 时补整单顶层空运费,再拼起运/目的附加费。
rows = option_fee_dicts(option, src)
if not rows and len(parse_air_options(src)) > 1:
return []
if not rows:
+70 -6
View File
@@ -835,9 +835,16 @@ class AirTextInquiryFlow:
facts = dict(view.get("facts") or {})
quote = dict(view.get("quote") or {})
status = str(view.get("status") or "").strip()
lost_reason = str(view.get("lost_reason") or view.get("lostReason") or "").strip()
tms_quote = dict(view.get("tms_quote") or view.get("tmsQuote") or {})
phase = str(view.get("wait_phase") or "").strip() or self._infer_wait_phase(
status=status, quote=quote
status=status, quote=quote, lost_reason=lost_reason
)
# 主账询价中+舱位备选时 wait_phase 常被收成 tms_miss;续办必须回到选舱。
if self._quote_pending_cabin(quote) or self._quote_pending_cabin(tms_quote):
phase = "wait_air_route"
if not quote and tms_quote:
quote = dict(tms_quote)
allowed: tuple[str, ...] = ()
if phase == "wait_collab":
allowed = (copy.BTN_SKIP_COLLAB,)
@@ -855,6 +862,8 @@ class AirTextInquiryFlow:
allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
elif phase == "wait_lost_reason":
allowed = ()
elif phase == "wait_air_route":
allowed = ()
return FlowSession(
sender_id=sender_id,
thread_id=sender_id,
@@ -863,6 +872,7 @@ class AirTextInquiryFlow:
facts=facts,
work_order_no=no,
quote=quote,
tms_quote=tms_quote or dict(quote),
status=status,
allowed=allowed,
wait_version=1,
@@ -870,17 +880,50 @@ class AirTextInquiryFlow:
collab_chat_id=str(view.get("collab_chat_id") or ""),
)
def _infer_wait_phase(self, *, status: str, quote: dict[str, Any]) -> str:
def _infer_wait_phase(
self, *, status: str, quote: dict[str, Any], lost_reason: str = ""
) -> str:
if status in {"已成交", "已关闭"}:
return "done"
if status == "未成交":
return "wait_lost_reason"
# 原因已记则终态回放;缺原因才继续要原因。
return "wait_lost_reason" if not (lost_reason or "").strip() else "done"
if status == "协商中":
return "wait_deal"
if status == "已报价" and quote:
return "wait_adopt"
return "tms_miss"
def _lost_reason_of(self, sess: FlowSession) -> str:
"""从主账读未成交原因;续办终态回放要用,不能靠进程内字段。"""
no = (sess.work_order_no or "").strip()
if not no:
return ""
getter = getattr(self._ledger, "get_for_agent", None)
if not callable(getter):
return ""
try:
view = getter(work_order_no=no, sender_id=sess.sender_id) or {}
except Exception:
logger.warning("读未成交原因失败 wo=%s", no)
return ""
return str(view.get("lost_reason") or view.get("lostReason") or "").strip()
@staticmethod
def _quote_pending_cabin(quote: dict[str, Any] | None) -> bool:
"""报价快照是否仍停在空运选舱(pending_cabin_pick=1 或未选多舱位)。"""
src = dict(quote or {})
flag = str(src.get("pending_cabin_pick") or src.get("pendingCabinPick") or "").strip()
if flag in {"1", "true", "True", "yes"}:
return True
from agent.schema.air_route_options import parse_air_options
if src.get("selected_air_option") or src.get("selectedAirOption"):
return False
return len(parse_air_options(src)) > 0 and not (
str(src.get("total") or "").strip()
)
def _align_resume_phase(self, sess: FlowSession) -> None:
"""
报价文件已经发给销售,续办必须回到成交跟进。
@@ -971,8 +1014,23 @@ class AirTextInquiryFlow:
if sess.phase == "wait_lost_reason":
reply(copy.deal_ack("未成交", sess.work_order_no))
return "wait_lost_reason"
if sess.phase == "wait_air_route":
quote = dict(sess.tms_quote or sess.quote or {})
return self._offer_air_routes(
sess=sess,
work_order_no=sess.work_order_no,
quote=quote,
reply=reply,
cabin_pick=self._quote_pending_cabin(quote),
)
if sess.phase == "done":
reply(copy.deal_ack(sess.status or "已关闭", sess.work_order_no))
status = (sess.status or "").strip() or "已关闭"
lost = self._lost_reason_of(sess) if status == "未成交" else ""
# 未成交且原因已记:续办只重播「原因已记、工单已关」,禁止再要原因。
if status == "未成交" and lost:
reply(copy.lost_reason_ack(lost, work_order_no=sess.work_order_no))
return "done"
reply(copy.deal_ack(status, sess.work_order_no, lost_reason=lost))
return "done"
reply(copy.inquiry_card(work_order_no=sess.work_order_no, facts=sess.facts))
return sess.phase or "replay"
@@ -1393,9 +1451,13 @@ class AirTextInquiryFlow:
)
options = parse_air_options(quote)
from agent.schema.field_validate import is_blank_quote_date
# 没填报价日期:不按起运地收口,TMS 返回的舱位全部给销售点。1 条也要点。
# 未选定前六态保持询价中,禁止把备选舱位费写进后台「已报价」。
if not str((sess.facts or {}).get("报价日期") or "").strip() and options:
# 「(非必填):」占位文案也当没填,必须进选舱。
if is_blank_quote_date((sess.facts or {}).get("报价日期")) and options:
quote = dict(quote)
quote["airOptions"] = sort_cabin_options(options)
ledger_quote = _ledger_quote_pending_cabin(quote)
@@ -1894,6 +1956,7 @@ class AirTextInquiryFlow:
if not callable(renderer):
reply(copy.quote_template_missing())
return "wait_adopt"
# 私聊采用出单:填好的报价单要进后台附件;激活群发模版另走 persist=False。
result = renderer(
work_order_no=sess.work_order_no,
business_line=sess.business_line or "AIR",
@@ -1901,6 +1964,7 @@ class AirTextInquiryFlow:
quote=dict(sess.quote or {}),
format=fmt,
sales_wecom_id=sess.sender_id,
persist_attachment=True,
) or {}
if not result.get("ok"):
reply(str(result.get("message") or copy.quote_template_missing()))
@@ -2298,7 +2362,7 @@ _FLOW_GUARD = threading.Lock()
def build_ledger() -> Any:
"""生产打 8180;单测 LEDGER_BACKEND=memory。"""
"""生产打 8080;单测 LEDGER_BACKEND=memory。"""
from agent.config import get_settings
backend = (get_settings().ledger_backend or "http").strip().lower()
+73
View File
@@ -0,0 +1,73 @@
"""
企微机器人显示名:出站话术与入站 @ 识别共用。
本文件职责:从后台 reply_code=system.bot_display_name 读显示名;
失败回退默认「智报通AIQ」;过渡期识别仍认旧名「询价小助手」。
禁止:每条群消息同步打主账(走 bot_reply 短缓存);禁止当六态。
"""
from __future__ import annotations
from typing import Tuple
# 后台调用编码:inq_bot_reply.reply_text 只写显示名本身,不要整句。
REPLY_CODE = "system.bot_display_name"
# 主账空/失败时的默认显示名(产品已定新名)。
DEFAULT_BOT_DISPLAY_NAME = "智报通AIQ"
# 企微改名过渡:旧习惯 @ 仍要听得见(含「价/报」两版现网名)。
LEGACY_BOT_DISPLAY_NAMES: Tuple[str, ...] = ("询价小助手", "智价通AIQ", "智报通AIQ")
def bot_display_name() -> str:
"""
当前对外显示名(销售话术里的 @ 名)。
读后台短缓存;空串/停用回退默认。只取首行,防止误配整段话术。
"""
from agent.policy.bot_reply import lookup_raw
raw = (lookup_raw(REPLY_CODE) or "").strip()
if not raw:
return DEFAULT_BOT_DISPLAY_NAME
first = raw.splitlines()[0].strip()
return first or DEFAULT_BOT_DISPLAY_NAME
def bot_mention_names() -> Tuple[str, ...]:
"""
入站识别用的全部名字:当前配置名 + 旧名,去重保序。
销售可见话术只用 bot_display_name();识别必须覆盖过渡期旧 @。
"""
names: list[str] = []
for item in (bot_display_name(), *LEGACY_BOT_DISPLAY_NAMES):
name = (item or "").strip()
if name and name not in names:
names.append(name)
return tuple(names)
def text_mentions_bot(text: str) -> bool:
"""原文是否带当前或旧显示名(含不带 @ 的粘贴)。"""
raw = text or ""
return any(name in raw for name in bot_mention_names())
def strip_bot_mention(text: str) -> str:
"""
去掉 @显示名 与裸显示名(含旧名),便于抽字段/判「仅工单号」、出站 instructionText。
先剥带 @ 的,再剥裸名,避免残留 @;末尾压空白,避免「@机器人 舱位」留下双空格。
锁舱/释放出站前必须剥干净,禁止把「@智价通AIQ」原样带给 TMS。
"""
rest = text or ""
# 长名优先,避免短名先剥留下半截。
names = sorted(bot_mention_names(), key=len, reverse=True)
for name in names:
rest = rest.replace(f"@{name}", "").replace(name, "")
return " ".join(rest.split())
def at_bot() -> str:
"""出站示例用的「@显示名」后缀。"""
return f"@{bot_display_name()}"
+20 -1
View File
@@ -58,11 +58,24 @@ def fill(template: str, values: dict[str, Any] | None = None) -> str:
return _PLACEHOLDER.sub(_repl, template or "")
def lookup_raw(code: str) -> str:
"""
按调用编码取原文模板(不填变量)。
供显示名等短配置读取;空串表示停用/未配。走同一份短缓存。
"""
key = (code or "").strip()
if not key:
return ""
return _lookup(key)
def render(code: str, default: str, **kwargs: Any) -> str:
"""
取已启用回复语并填变量。
停用、库空、主账失败、编码对不上:返回 default(已是拼好的默认句)。
自动注入 {{bot_name}}(企微机器人显示名),调用方可不传。
副作用:缓存过期时 GET 主账;超时/失败吞掉,不抛给销售。
"""
key = (code or "").strip()
@@ -72,7 +85,13 @@ def render(code: str, default: str, **kwargs: Any) -> str:
tpl = _lookup(key)
if not (tpl or "").strip():
return fallback
return fill(tpl, kwargs)
values = dict(kwargs)
if "bot_name" not in values:
# 延迟导入,避免与 bot_name → lookup_raw 循环在模块加载期炸。
from agent.policy.bot_name import bot_display_name
values["bot_name"] = bot_display_name()
return fill(tpl, values)
def _lookup(code: str) -> str:
+25 -4
View File
@@ -204,6 +204,22 @@ def _is_waicai(facts: dict[str, str] | None) -> bool:
return "外采" in blob
def _has_all_line(tokens: list[str], *, line: str) -> bool:
"""
该负责人的「负责线路」是否覆盖本业务线全部线路。
成交通知只推负责人(isLeader),不群发。后台负责线路选项里的
「空运全线 / 海运全线 / 陆运全线」表示这位负责人管整条业务线;
旧数据可能仍写「全线」。只认整词,避免误伤「外采-空运」等。
"""
want = {
"SEA": {"海运全线", "全线"},
"LAND": {"陆运全线", "全线"},
"AIR": {"空运全线", "全线"},
}.get((line or "").strip().upper(), set())
return any(t in want for t in tokens)
def leader_matches(
*,
role_code: str,
@@ -215,7 +231,8 @@ def leader_matches(
"""
员工管理「是负责人 + 负责线路对得上」。
海运:海运全线。陆运:线路类别整词。空运:三字码对 / 目的港 / 全线;外采须工单标明。
海运:海运全线。陆运:陆运全线或线路类别整词。
空运:空运全线 / 三字码对 / 目的港;外采须工单标明。
"""
if not is_leader:
return False
@@ -223,14 +240,18 @@ def leader_matches(
code = (role_code or "").strip().lower()
tokens = _split_routes(routes)
if line == "SEA":
return code == "sea" and "海运全线" in tokens
return code == "sea" and _has_all_line(tokens, line="SEA")
if line == "LAND":
if code != "land":
return False
if _has_all_line(tokens, line="LAND"):
return True
want = str((facts or {}).get("线路类别") or "").strip()
return code == "land" and bool(want) and want in tokens
return bool(want) and want in tokens
if line == "AIR":
if code != "air":
return False
if "全线" in tokens:
if _has_all_line(tokens, line="AIR"):
return True
if "外采-空运" in tokens and _is_waicai(facts):
return True
+4 -1
View File
@@ -203,6 +203,7 @@ def finish_deal_outcome(
quote=quote or {},
business_line=business_line,
)
enqueued = 0
for row in leaders:
uid = str(row.get("wecomId") or "").strip()
if not uid:
@@ -214,7 +215,9 @@ def finish_deal_outcome(
dedupe_key=f"deal-notify:{no}:{uid}",
payload=dict(payload),
)
if not ok:
if ok:
enqueued += 1
else:
logger.warning("成交通知入队失败 wo=%s uid=%s info=%s", no, uid, info)
except Exception:
logger.exception("成交通知入队异常 wo=%s uid=%s", no, uid)
+35 -71
View File
@@ -367,7 +367,12 @@ def land_confirm_card(facts: dict[str, str] | None) -> str:
]
for key in _land_cargo_keys(land_type, src):
label = LAND_DISPLAY.get(key, key)
val = (src.get(key) or "").strip()
if key == "品名":
from agent.schema.land_options import land_cargo_name
val = land_cargo_name(src)
else:
val = (src.get(key) or "").strip()
lines.append(f"{label}(必填):{val}")
if land_type in _LAND_CUSTOMS_TYPES:
lines.append(f"通关口岸(非必填):{(src.get('通关口岸') or '').strip()}")
@@ -534,7 +539,10 @@ def _air_known_value(facts: dict[str, str], key: str) -> str:
if key == "包装方式":
return (src.get("包装方式") or src.get("包装类型") or "").strip()
if key == "报价日期":
return (src.get("报价日期") or "").strip()
from agent.schema.field_validate import is_blank_quote_date
val = (src.get("报价日期") or "").strip()
return "" if is_blank_quote_date(val) else val
from agent.llm.extract_text import clip_inquiry_value
return clip_inquiry_value(src.get(key) or "")
@@ -650,42 +658,6 @@ def ask_clarify(
if key == "起运港":
continue
val = known(src, key)
# #region agent log
if key == "added_services":
try:
import json as _dbg_json
import time as _dbg_time
from pathlib import Path as _DbgPath
_payload = {
"sessionId": "5ea5b3",
"runId": "post-fix",
"hypothesisId": "H3",
"location": "inquiry_copy.py:ask_clarify",
"message": "clarify display added_services",
"data": {
"raw_facts": str(src.get("added_services") or ""),
"display_val": val,
"label": label,
},
"timestamp": int(_dbg_time.time() * 1000),
}
for _p in (
_DbgPath(__file__).resolve().parents[3] / "debug-5ea5b3.log",
_DbgPath(r"d:/own/友通达询价智能体(新)/debug-5ea5b3.log"),
_DbgPath(r"C:/Users/ytd_extdev_0903/incoming/debug-5ea5b3.log"),
):
try:
_p.parent.mkdir(parents=True, exist_ok=True)
with _p.open("a", encoding="utf-8") as _f:
_f.write(
_dbg_json.dumps(_payload, ensure_ascii=False) + "\n"
)
except Exception:
pass
except Exception:
pass
# #endregion
if val:
lines.append(f"{label}:{val}")
lines.append("")
@@ -802,10 +774,13 @@ def inquiry_field_lines(
dest = (facts.get("目的港") or facts.get("目的地") or "-").strip()
cargo = (facts.get("品名") or "-").strip() or "-"
pack = (facts.get("包装方式") or facts.get("包装类型") or "-").strip() or "-"
from agent.schema.field_validate import is_blank_quote_date
quote_date = (facts.get("报价日期") or "").strip()
if quote_date in {"-", "—", "-"}:
if is_blank_quote_date(quote_date):
quote_date = ""
if (transport_mode or "").strip() == "海运":
mode = (transport_mode or "").strip()
if mode == "海运":
qty = (facts.get("货量") or facts.get("货物数量") or "-").strip() or "-"
load = (facts.get("整柜或拼柜") or "-").strip() or "-"
box = (facts.get("箱型箱量") or "-").strip() or "-"
@@ -824,7 +799,7 @@ def inquiry_field_lines(
f"运输分类:{klass}",
f"报价日期:{quote_date or '-'}",
]
if (transport_mode or "").strip() == "陆运":
if mode == "陆运":
land_type = (facts.get("运输类型") or "").strip()
lines = [
f"工单号:{work_order_no}",
@@ -854,30 +829,6 @@ def inquiry_field_lines(
]
# 建单确认和空运群:有内容才多一行。没填不出现。核对卡的空行不在这里。
added = (facts.get("added_services") or facts.get("增值服务") or "").strip()
# #region agent log
try:
import json as _dbg_json
import time as _dbg_time
from pathlib import Path as _DbgPath
_payload = {
"sessionId": "5ea5b3",
"runId": "pre-fix",
"hypothesisId": "H3",
"location": "inquiry_copy.py:air_confirm_lines",
"message": "display added_services value",
"data": {
"added": added,
"facts_keys": sorted(str(k) for k in (facts or {}).keys()),
},
"timestamp": int(_dbg_time.time() * 1000),
}
_path = _DbgPath(__file__).resolve().parents[3] / "debug-5ea5b3.log"
with _path.open("a", encoding="utf-8") as _f:
_f.write(_dbg_json.dumps(_payload, ensure_ascii=False) + "\n")
except Exception:
pass
# #endregion
if added and added not in {"-", "—", "-"}:
lines.insert(-1, f"增值服务:{added}")
return lines
@@ -1323,18 +1274,21 @@ def collab_hint(work_order_no: str) -> str:
"""
空运价格卡协同说明:紧挨「不需要协同,直接生成报价单」上方。
后台 inquiring.air_collab_hint;库内模板可用 {{ticket_no}}。
无后台模板时用本默认句(已带工单号)。销售侧示例:
…发送“ WO202610060030@询价小助手”,谢谢。
后台 inquiring.air_collab_hint;库内模板可用 {{ticket_no}}、{{bot_name}}。
无后台模板时用本默认句(已带工单号与当前显示名)。
"""
from agent.policy.bot_name import bot_display_name
no = (work_order_no or "").strip()
name = bot_display_name()
return _r(
"inquiring.air_collab_hint",
(
f"若需要咨询内部航线人员,请手动将相关方拉到空运原有协同群,"
f"并在空运协同群中发送“ {no}@询价小助手”,谢谢。"
f"并在空运协同群中发送“ {no}@{name}”,谢谢。"
),
ticket_no=no,
bot_name=name,
)
@@ -2865,10 +2819,18 @@ def deal_ack(
next_followup_at: str = "",
lost_reason: str = "",
followup_hours: float | None = None,
business_line: str = "",
) -> str:
"""成交三态回执。后台 deal.recorded_won / lost.ask_reason / negotiating.recorded。"""
"""
成交三态回执。后台 deal.recorded_won / lost.ask_reason / negotiating.recorded。
多段联运不锁舱:已成交只回短确认,不要后台模板里「空运可锁舱」括号说明。
"""
no = (work_order_no or "").strip()
line = (business_line or "").strip().upper()
if outcome == "已成交":
if line == "MULTI":
return f"工单 {no} 已记录为「已成交」。"
return _r(
"deal.recorded_won",
f"已记录为「已成交」。工单 {no} 已关闭智能体跟进。",
@@ -3408,8 +3370,10 @@ def air_text_quote_for_lock_example(work_order_no: str = "") -> str:
附件报价不拆金额、常锁不成;产品口径改为引导航线改发文字价。
示例必须带计费单位(空运常见 KG),避免航线漏写单位导致文字价无法落库。
"""
from agent.policy.bot_name import at_bot
no = (work_order_no or "").strip() or "工单号"
return f"{no},空运段,KG,空运费 单价CNY99.00,数量100 ,金额CNY9900.00@询价小助手"
return f"{no},空运段,KG,空运费 单价CNY99.00,数量100 ,金额CNY9900.00{at_bot()}"
def air_attachment_quote_lock_tip(*, work_order_no: str) -> str:
+8 -33
View File
@@ -138,39 +138,6 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
sess.work_order_no or "-",
)
sess = None
# #region agent log
try:
import json as _json
import time as _time
from pathlib import Path as _Path
with _Path(r"d:\own\友通达询价智能体(新)\debug-d331ea.log").open("a", encoding="utf-8") as _dbg:
_dbg.write(
_json.dumps(
{
"sessionId": "d331ea",
"runId": "post-fix",
"hypothesisId": "D",
"location": "land_text_flow.py:on_text:sess_keep",
"message": "land sess keep-or-clear",
"data": {
"phase_in": phase,
"kept_sess": sess is not None,
"in_front": phase in _LAND_FRONT,
"sensitive_hold": (
str(getattr(sess, "sensitive_hold", "") or "")[:120] if sess else ""
),
"text": (text or "")[:120],
},
"timestamp": int(_time.time() * 1000),
},
ensure_ascii=False,
)
+ "\n"
)
except Exception:
pass
# #endregion
snap = extract_inquiry_snapshot(
text,
@@ -206,6 +173,14 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
# 还在问运输方式时,重量体积和陆运选项要对照前面几句,不只看「1」。
source = waiting_mode_source(sess, text)
facts = harvest_oral_measures(source, merged)
# 陆运:箱型误入要改写;口语 45HQ/厢车/米车补进车型/数量。
from agent.schema.field_validate import (
harvest_oral_land_vehicle,
promote_land_vehicle_from_container,
)
facts = promote_land_vehicle_from_container(facts)
facts = harvest_oral_land_vehicle(source, facts)
# 选项只从原话+能对上枚举的模型值收;禁止模型「陆运」盖掉已选的国内运输拼车。
facts = harvest_land_options(source, facts)
b_opt = " ".join(
+206 -23
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import json
import logging
import re
from contextlib import contextmanager
from typing import Any, Callable, Iterator, Optional
@@ -18,10 +19,13 @@ from agent.llm.extract_text import extract_collab_fields, extract_product_quote
from agent.policy import inquiry_copy as copy
from agent.policy.multi_segments import Segment, group_brief, includes_air, segments_from_payload
from agent.policy.sea_group_ops import (
_archive_file,
_quote_currency_hint,
apply_group_deal,
filename_is_excel_pdf,
group_client_of,
is_sales,
notify_quote_attachment_saved,
override_group_client,
send_filled_sheet,
send_group_text,
@@ -109,6 +113,128 @@ def sender_mode(flow: Any, sender_id: str) -> str:
return _ROLE_MODE.get(role, "")
_RE_SEG_INDEX = re.compile(
r"(?:段\s*(\d+)|第\s*([一二三四五六七八九]|\d+)\s*段)",
re.I,
)
_CN_INDEX = {
"一": 1,
"二": 2,
"三": 3,
"四": 4,
"五": 5,
"六": 6,
"七": 7,
"八": 8,
"九": 9,
}
_FEE_MODE = (
("陆运费", "LAND"),
("空运费", "AIR"),
("海运费", "SEA"),
# 报价单文件名常写「空运到港报价单」无「费」字。
("陆运", "LAND"),
("空运", "AIR"),
("海运", "SEA"),
)
def resolve_quote_segment(
text: str,
segments: list[Segment],
role_mode: str,
) -> tuple[Optional[Segment], str]:
"""
多段报价归到哪一段。
1) 正文「段1 / 第一段」;2) 正文写了陆运费/空运费/海运费;3) 岗位。
运行证据:林绵勋岗位是 air,发「段1:陆运费」却出了段2空运单。
返回 (段, 选用原因)。
"""
if not segments:
return None, "no_segments"
raw = text or ""
hit = _RE_SEG_INDEX.search(raw)
if hit:
num = hit.group(1) or hit.group(2) or ""
try:
index = int(num) if str(num).isdigit() else _CN_INDEX.get(str(num), 0)
except ValueError:
index = 0
if index > 0:
for seg in segments:
if seg.index == index:
return seg, f"text_index:{index}"
fee_mode = ""
for word, mode in _FEE_MODE:
if word in raw:
fee_mode = mode
break
role = (role_mode or "").strip().upper()
if fee_mode:
for seg in segments:
if seg.mode == fee_mode:
return seg, f"text_fee:{fee_mode}"
if role:
for seg in segments:
if seg.mode == role:
return seg, f"role:{role}"
return None, "unmatched"
def apply_multi_file(
*,
flow: Any,
ticket: object,
chat_id: str,
sender_id: str,
filename: str,
media: Optional[dict[str, Any]] = None,
) -> str:
"""
多段群附件报价:落 inq_attachment,按文件名/岗位归段,不写正文抽价。
运行证据:石畅畅发「…空运到港报价单.xlsx」时,旧逻辑把文件名当文字报价,
回「没有识别到报价」,附件也没进后台。
副作用:写主账附件+分段报价;可能发群确认/成交跟进。Worker 线程。
"""
room = (chat_id or "").strip()
no = ticket_no(ticket)
client = group_client_of(flow)
name = (filename or "").strip()
if not filename_is_excel_pdf(name):
send_group_text(client, room, copy.group_only_excel_pdf())
return "file_type_rejected"
segments = load_segments(ticket)
if not segments:
send_group_text(client, room, f"工单{no}没有可报价的运输段。")
return "multi_no_segments"
role_mode = sender_mode(flow, sender_id)
target, reason = resolve_quote_segment(name, segments, role_mode)
if target is None:
send_group_text(client, room, "没有对上你负责的运输段,请让对应岗位报价。")
return "quote_wrong_role"
_archive_file(flow, no, name, dict(media or {}), sender_id)
notify_quote_attachment_saved(flow, room, no, count=1)
quote = {
"source_label": "产品附件报价",
"file_name": name,
"total": "-",
"raw_text": name,
}
# 附件本身就是报价单:只落段价,不再重发空白模板。
return _save_segment_quote(
flow=flow,
ticket=ticket,
chat_id=room,
segment=target,
segments=segments,
quote=quote,
sheet_fn=None,
skip_sheet=True,
)
def handle_multi_inbound(
*,
flow: Any,
@@ -142,8 +268,9 @@ def handle_multi_inbound(
if only_no:
_send_activation(flow, ticket, room, segments, sender_id=sender_id, sheet_fn=sheet_fn)
return "multi_activated"
mode = sender_mode(flow, sender_id)
target = next((seg for seg in segments if seg.mode == mode), None)
role_mode = sender_mode(flow, sender_id)
target, resolve_reason = resolve_quote_segment(text or "", segments, role_mode)
mode = (target.mode if target is not None else role_mode) or ""
fields = {}
if target is not None:
keys = collab_fields_for_ticket(target.facts, target.mode)
@@ -152,7 +279,8 @@ def handle_multi_inbound(
_save_collab(flow, ticket, target, fields)
ticket = _reload(flow, no) or ticket
segments = load_segments(ticket)
target = next((seg for seg in segments if seg.mode == mode), target)
target, resolve_reason = resolve_quote_segment(text or "", segments, role_mode)
mode = (target.mode if target is not None else role_mode) or mode
# 段报价币种优先;否则用工单顶层 quote(产品未写币种时继承 TMS)
baseline = ""
if target is not None:
@@ -190,10 +318,21 @@ def handle_multi_inbound(
)
def deal_text(*, work_order_no: str, segments: list[Segment], quotes: dict[str, dict[str, Any]]) -> str:
"""整票成交跟进文字。每段各自的价格、有效期,海运陆运再加时效。不加总。"""
def deal_text(
*,
work_order_no: str,
segments: list[Segment],
quotes: dict[str, dict[str, Any]],
sales_name: str = "",
) -> str:
"""
整票成交跟进文字。开头 @本单销售,与海运 group_deal_followup 一致。
每段各自的价格、有效期,海运陆运再加时效。不加总。
"""
no = (work_order_no or "").strip()
lines = [f"询价工单 {no}", copy.DEAL_SUBTITLE]
name = (sales_name or "").strip() or "销售"
lines = [f"@{name}", f"询价工单 {no}", copy.DEAL_SUBTITLE]
for seg in segments:
quote = quotes.get(seg.mode) or {}
lines.append(f"段{seg.index}:{seg.word()}")
@@ -240,6 +379,7 @@ def _save_segment_quote(
segments: list[Segment],
quote: dict[str, Any],
sheet_fn: Optional[SheetFn],
skip_sheet: bool = False,
) -> str:
"""写入这一段报价并发该段报价单。其他段未齐时不发成交跟进。"""
no = ticket_no(ticket)
@@ -254,28 +394,63 @@ def _save_segment_quote(
"source_label": "分段报价",
"deal_version": version,
}
writer = getattr(flow.ledger, "upsert_quote", None)
if callable(writer):
writer(
# 分段报价是产品手工价,必须走 upsertProductQuote。
# 旧代码误走 replaceTmsQuote,主账 inq_quote 不落、六态也不到「已报价」。
product_writer = getattr(flow.ledger, "upsert_product_quote", None)
if callable(product_writer):
product_writer(
work_order_no=no,
quote=payload,
to_status="已报价" if all_ready else "",
# 未齐段时保持询价中;空串会被主账默认成「已报价」。
to_status="已报价" if all_ready else "询价中",
remark="分段报价",
kind="product",
)
else:
writer = getattr(flow.ledger, "upsert_quote", None)
if callable(writer):
writer(
work_order_no=no,
quote=payload,
to_status="已报价" if all_ready else "",
)
ticket = _reload(flow, no) or ticket
_send_sheet(
flow=flow,
ticket=ticket,
chat_id=chat_id,
segment=segment,
quote=quote,
sheet_fn=sheet_fn,
)
if not skip_sheet:
_send_sheet(
flow=flow,
ticket=ticket,
chat_id=chat_id,
segment=segment,
quote=quote,
sheet_fn=sheet_fn,
)
client = group_client_of(flow)
if not all_ready:
send_group_text(client, chat_id, f"段{segment.index}{segment.word()}报价单已发出。其他段报完后一起跟进成交。")
tip = (
f"段{segment.index}{segment.word()}报价附件已收到。其他段报完后一起跟进成交。"
if skip_sheet
else f"段{segment.index}{segment.word()}报价单已发出。其他段报完后一起跟进成交。"
)
send_group_text(client, chat_id, tip)
return "segment_quoted"
text = deal_text(work_order_no=no, segments=segments, quotes=stored)
send_group_text(client, chat_id, text)
# 与海运一致:正文写 @销售姓名,并带 mention,企微才能真正点到人。
from agent.policy.sea_group_ops import resolve_group_mentions
sales_name, _products, _mention_ids = resolve_group_mentions(flow, ticket, None)
view = ticket_view(ticket)
sales_id = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip()
text = deal_text(
work_order_no=no,
segments=segments,
quotes=stored,
sales_name=sales_name,
)
send_group_text(
client,
chat_id,
text,
mention_userids=[sales_id] if sales_id else None,
)
carder = getattr(client, "send_group_card", None)
if callable(carder):
carder(
@@ -446,15 +621,23 @@ def _send_sheet(
quote: dict[str, Any],
sheet_fn: Optional[SheetFn],
) -> None:
"""按这一段的运输方式匹配模板。失败只记日志,不挡住报价归段。"""
"""
按这一段的运输方式匹配模板。失败只记日志,不挡住报价归段。
有价段报价单:发群同时写后台附件;激活空白模版(quote 无金额)不落库。
"""
sender = sheet_fn or send_filled_sheet
sheet_ticket = _sheet_ticket(ticket, segment)
# 段上已有金额/明细 → 正式出单,要进 inq_attachment;空 quote 是激活模版。
persist = _has_money(dict(quote or {}))
try:
sender(
flow=flow,
ticket=_sheet_ticket(ticket, segment),
ticket=sheet_ticket,
quote_override=dict(quote or {}),
business_line=segment.mode,
chat_id=chat_id,
persist_attachment=persist,
)
except Exception:
logger.exception("多段模板发送失败 wo=%s mode=%s", ticket_no(ticket), segment.mode)
+7 -1
View File
@@ -182,12 +182,16 @@ def _shared_cargo_facts(header: str) -> dict[str, str]:
def _cargo_name_before_segments(raw: str) -> str:
"""段前货物句里,去掉件数重量体积包装后剩下的那一个品名。"""
from agent.llm.extract_text import is_clarify_ui_prompt
cleaned = _SHARED_NOISE.sub(" ", raw or "")
parts: list[str] = []
for part in re.split(r"[,,、;;\n]+", cleaned):
token = part.strip(" ::。.")
if not token or re.fullmatch(r"[\d.\s*xX×]+", token):
continue
if is_clarify_ui_prompt(token):
continue
if any(word in token for word in ("到", "飞", "空运", "海运", "陆运", "段")):
continue
parts.append(token)
@@ -606,12 +610,14 @@ def _lacking_indexes(segments: list[Segment]) -> list[int]:
def _facts_from_body(body: str, mode: str) -> dict[str, str]:
"""一段正文里的「字段名:值」。陆运再收运输类型、线路类别和销售侧叫法。"""
from agent.llm.extract_text import clip_inquiry_value
facts = parse_labeled_facts(body)
for line in (body or "").splitlines():
matched = _EXTRA_LABEL.match(line.strip())
if not matched:
continue
key, val = matched.group(1), matched.group(2).strip()
key, val = matched.group(1), clip_inquiry_value(matched.group(2))
if val and not str(facts.get(key) or "").strip():
facts[key] = val
if mode == "LAND":
@@ -322,6 +322,7 @@ def apply_group_adjust(
collab = dict(view.get("collab_facts") or {})
if sess is not None and getattr(sess, "collab_facts", None):
collab.update(dict(sess.collab_facts or {}))
# 调价后再出一版:正式有价单,登记后台附件。
_render_and_send_sheet(
flow=flow,
chat_id=room,
@@ -335,6 +336,7 @@ def apply_group_adjust(
quote=result.quote,
sales_id=sender_id,
business_line=line,
persist_attachment=True,
)
emit_group_deal(flow=flow, ticket=ticket2, sess=sess, quote=result.quote)
logger.info("group_adjust wo=%s applied=%s", no, [x.get("name") for x in result.applied])
+63 -16
View File
@@ -390,7 +390,11 @@ def send_group_text(
content: str,
mention_userids: Optional[list[str]] = None,
) -> None:
"""群文本可带企微 userid,方便客户端真正点到人。旧客户端无此参数就只发正文。"""
"""
群文本可带企微 userid,方便客户端真正点到人。旧客户端无此参数就只发正文。
发完后把该群标热:销售接着回「解散群」时,存档轮询会优先扫到,少等几分钟。
"""
if not client or not chat_id or not content:
return
sender = getattr(client, "send_group", None)
@@ -401,6 +405,13 @@ def send_group_text(
sender(chat_id=chat_id, content=content, mention_userids=mentions)
except TypeError:
sender(chat_id=chat_id, content=content)
try:
from agent.channel.wshoto.rooms import mark_room_hot, watch_collab_room
watch_collab_room(chat_id)
mark_room_hot(chat_id)
except Exception:
logger.exception("发群后登记热窗失败 chat=%s", chat_id)
def hydrate_ticket(flow: Any, ticket: object) -> object:
@@ -572,11 +583,13 @@ def send_brief_and_sheet(
quote_override: Optional[dict[str, Any]] = None,
tms_quote: Optional[dict[str, Any]] = None,
product_quote: Optional[dict[str, Any]] = None,
persist_attachment: bool = False,
) -> str:
"""
群里再发一版摘要 + 填好的海运模板。旧消息不撤。
TMS 和人工报价分段写,无价也发表。
persist_attachment:正式有价出单传 True,登记后台附件;激活/补字段重发保持 False。
"""
ticket = hydrate_ticket(flow, ticket)
view = ticket_view(ticket)
@@ -638,6 +651,7 @@ def send_brief_and_sheet(
sess=sess,
quote_override=quote,
business_line=line,
persist_attachment=persist_attachment,
)
return "brief_and_sheet"
@@ -672,11 +686,14 @@ def send_filled_sheet(
quote_override: Optional[dict[str, Any]] = None,
business_line: str = "",
chat_id: str = "",
persist_attachment: bool = False,
) -> dict[str, Any]:
"""
只发填好的模板文件,不再发文字摘要。
business_line 决定匹配哪套后台模板;空运必须传 AIR,禁止落到海运默认模板。
persist_attachment:True 时主账登记 inq_attachment(正式有价出单);
激活空白模版保持 False,避免后台附件列堆满空白表。
"""
incoming_view = ticket_view(ticket) if ticket is not None else {}
incoming_facts = dict(incoming_view.get("facts") or {})
@@ -720,6 +737,7 @@ def send_filled_sheet(
or ""
),
business_line=line or "SEA",
persist_attachment=persist_attachment,
)
@@ -795,17 +813,21 @@ def _render_and_send_sheet(
quote: dict[str, Any],
sales_id: str,
business_line: str = "SEA",
persist_attachment: bool = False,
) -> dict[str, Any]:
"""
按业务线匹配后台模板、填表、发群文件。
空运必须 business_line=AIR,否则会套上海运默认模板。
persist_attachment:正式有价出单 True(写 inq_attachment);
激活空白模版 False(只发企微,不堆后台附件列)。
"""
renderer = getattr(flow.ledger, "render_quote", None)
client = group_client_of(flow)
if not callable(renderer) or not chat_id:
return {"ok": False, "error": "no_renderer_or_chat"}
line = (business_line or "SEA").strip() or "SEA"
# 激活空白模版:persist=False;产品/分段有价出单:persist=True 写后台附件。
result = renderer(
work_order_no=work_order_no,
business_line=line,
@@ -813,6 +835,7 @@ def _render_and_send_sheet(
quote=quote,
format="xlsx",
sales_wecom_id=sales_id,
persist_attachment=bool(persist_attachment),
) or {}
if not result.get("ok"):
logger.warning("群填表失败 wo=%s line=%s err=%s", work_order_no, line, result.get("error"))
@@ -955,6 +978,7 @@ def apply_product_quote(
quote = merge_quote_fees(base, quote)
_save_product_quote(flow, no, quote, sess)
ticket2 = _reload(flow, chat_id, no) or ticket
# 正式有价出单:Excel 进群的同时登记后台附件,销售在工单列表能看到。
send_brief_and_sheet(
flow=flow,
ticket=ticket2,
@@ -962,6 +986,7 @@ def apply_product_quote(
quote_override=quote,
tms_quote={},
product_quote=quote,
persist_attachment=True,
)
emit_group_deal(flow=flow, ticket=ticket2, sess=sess, quote=quote)
return "product_quote_sent"
@@ -1005,6 +1030,7 @@ def apply_tms_quote_choice(
_clear_pending_quote(no, sess, flow)
_save_product_quote(flow, no, quote, sess)
ticket2 = _reload(flow, chat_id, no) or ticket
# 正式有价出单:登记后台附件(与产品文字报价同一口径)。
send_brief_and_sheet(
flow=flow,
ticket=ticket2,
@@ -1012,6 +1038,7 @@ def apply_tms_quote_choice(
quote_override=quote,
tms_quote=base if keep_tms else {},
product_quote=overlay if keep_tms else quote,
persist_attachment=True,
)
emit_group_deal(flow=flow, ticket=ticket2, sess=sess, quote=quote)
return "product_quote_sent"
@@ -1104,6 +1131,7 @@ def _persist_quote_sheet(
or "SEA"
).strip() or "SEA"
if callable(renderer) and no:
# 确认落库:需要把填好的报价单登记到后台附件。
result = renderer(
work_order_no=no,
business_line=line,
@@ -1121,10 +1149,15 @@ def _persist_quote_sheet(
or view.get("salesWecomId")
or ""
),
persist_attachment=True,
) or {}
if result.get("ok"):
filename = filename_from_render(no, result)
object_key = str(result.get("objectKey") or filename)
object_key = str(result.get("objectKey") or "").strip()
# 主账 render 已写 inq_attachment 时不再重复挂一条「只有文件名」的空壳。
if object_key or result.get("attachmentSaved"):
return
object_key = filename
_archive_file(flow, no, filename, {"media_id": object_key}, sender_id)
@@ -1168,7 +1201,11 @@ def handle_group_file(
def dissolve_group(*, flow: Any, ticket: object, sess: Any = None) -> str:
"""解散应用群,这单协同结束,不改六态。"""
"""
解散应用群,这单协同结束,不改六态。
先发「群已解散」再移人:移完人销售可能已不在群,收不到确认。
"""
view = ticket_view(ticket)
no = ticket_no(ticket)
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
@@ -1176,6 +1213,8 @@ def dissolve_group(*, flow: Any, ticket: object, sess: Any = None) -> str:
members = list(view.get("collab_member_ids") or view.get("memberIds") or [])
if sess is not None:
members = list(sess.invited_userids or members)
# 先回话再移人,避免销售被移出后看不到确认。
send_group_text(client, chat_id, copy.group_dissolved(work_order_no=no))
dismiss = getattr(client, "dismiss_group", None)
if callable(dismiss) and chat_id:
dismiss(chat_id=chat_id, userids=members)
@@ -1192,7 +1231,6 @@ def dissolve_group(*, flow: Any, ticket: object, sess: Any = None) -> str:
if sess is not None:
sess.collab_ended = True
flow._save(sess)
send_group_text(client, chat_id, copy.group_dissolved(work_order_no=no))
return "group_dissolved"
@@ -1263,6 +1301,18 @@ def apply_group_deal(
view = ticket_view(ticket)
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
client = group_client_of(flow)
line = str(
view.get("business_line")
or view.get("businessLine")
or (sess.business_line if sess else "")
or "SEA"
).strip().upper()
status_now = ticket_status(ticket) or (
str(getattr(sess, "status", "") or "").strip() if sess is not None else ""
)
# 同一终态再确认一次(BOT+存档双通道):只静默,不刷「不能修改工单信息」。
if status_now in {"已成交", "未成交"} and outcome == status_now:
return "deal_already_recorded"
stopped = halt_closed_group(flow=flow, ticket=ticket, sess=sess, chat_id=chat_id)
if stopped:
return stopped
@@ -1270,7 +1320,6 @@ def apply_group_deal(
send_group_text(client, chat_id, copy.group_deal_sales_only())
return "not_sales"
quote = _current_quote(view, sess)
status_now = ticket_status(ticket) or (sess.status if sess else "")
usable_total = str(quote.get("total") or "").strip()
if usable_total in {"", "-"}:
usable_total = ""
@@ -1286,7 +1335,7 @@ def apply_group_deal(
outcome=outcome,
facts=facts_of_group(view, sess),
quote=quote,
business_line=str(view.get("business_line") or (sess.business_line if sess else "SEA") or "SEA"),
business_line=line or "SEA",
sales_name=str(view.get("sales_name") or (getattr(sess, "invited_sales_name", "") if sess else "") or ""),
lost_reason=inline,
)
@@ -1303,17 +1352,15 @@ def apply_group_deal(
sess.allowed = ()
flow._save(sess)
ack_reason = inline if phase == "done" and outcome == "未成交" else ""
send_group_text(
client,
chat_id,
copy.deal_ack(
outcome,
no,
str(result.get("next_followup_at") or ""),
lost_reason=ack_reason,
followup_hours=result.get("negotiate_hours"),
),
ack = copy.deal_ack(
outcome,
no,
str(result.get("next_followup_at") or ""),
lost_reason=ack_reason,
followup_hours=result.get("negotiate_hours"),
business_line=line,
)
send_group_text(client, chat_id, ack)
return phase
+63 -8
View File
@@ -22,12 +22,14 @@ _TTL_SEC = 3 * 60
def _norm_text(text: str) -> str:
body = (text or "").replace("@询价小助手", "").replace("询价小助手", "")
from agent.policy.bot_name import strip_bot_mention
body = strip_bot_mention(text or "")
return "".join(body.split())
def text_fingerprint(chat_id: str, sender_id: str, text: str) -> str:
"""同一群同一人同一句(去掉 @询价小助手)。"""
"""同一群同一人同一句(去掉 @机器人显示名,含旧名)。"""
return f"{(chat_id or '').strip()}|{(sender_id or '').strip()}|{_norm_text(text)}"
@@ -56,6 +58,19 @@ def _redis_mark(fp: str) -> None:
logger.exception("空运群入站去重写 Redis 失败")
def _redis_claim(fp: str) -> Optional[bool]:
"""
Redis SET NX 抢占。True=抢到;False=已被占;None=Redis 不可用。
"""
try:
client = get_redis_runtime().client
ok = client.raw.set(client.key(AIR_INBOUND_TEXT, fp), "1", nx=True, ex=_TTL_SEC)
return bool(ok)
except Exception:
logger.exception("空运群入站去重抢占 Redis 失败")
return None
def _file_fresh(fp: str) -> bool:
path = _file_path(fp)
try:
@@ -74,6 +89,27 @@ def _file_mark(fp: str) -> None:
logger.exception("空运群入站去重写文件失败")
def _file_claim(fp: str) -> bool:
"""文件独占创建;已存在且未过期则失败。"""
path = _file_path(fp)
try:
if path.exists():
if (time.time() - path.stat().st_mtime) < _TTL_SEC:
return False
try:
path.unlink()
except OSError:
pass
with path.open("x", encoding="ascii") as fd:
fd.write("1")
return True
except FileExistsError:
return False
except OSError:
logger.exception("空运群入站去重文件抢占失败")
return True
def already_handled_air_text(*, chat_id: str, sender_id: str, text: str) -> bool:
"""这句是否刚被 BOT 办过。给存档补听用。"""
fp = text_fingerprint(chat_id, sender_id, text)
@@ -91,6 +127,26 @@ def mark_air_text(*, chat_id: str, sender_id: str, text: str) -> None:
_file_mark(fp)
def try_claim_air_text(*, chat_id: str, sender_id: str, text: str) -> bool:
"""
同一群同一人同一句只办一遍(含 BOT 桥重复推送)。
返回 True=本路应处理;False=已有一路在办/办过,直接跳过。
运行证据:多段报价同一句回了两份空运单。
"""
fp = text_fingerprint(chat_id, sender_id, text)
if not _norm_text(text):
return True
redis_ok = _redis_claim(fp)
if redis_ok is True:
_file_mark(fp)
return True
if redis_ok is False:
return False
# Redis 不可用时退回文件抢占。
return _file_claim(fp)
def is_archive_source(raw: Optional[dict[str, Any]]) -> bool:
src = str((raw or {}).get("source") or "").strip().lower()
return src in {"wshoto_archive", "wshoto", "archive"}
@@ -116,13 +172,12 @@ def should_skip_air_text(
raw: Optional[dict[str, Any]] = None,
) -> bool:
"""
存档补听到的 @ 句,若 BOT 已经回过就跳过。
同一句去重:BOT 桥重复推、存档补听都跳过。
BOT 自己再 @ 同一工单号仍要再发一版摘要,不在这里拦。
用 try_claim:第一路处理,后续同文案在 TTL 内不再回。
"""
if not is_archive_source(raw):
return False
if already_handled_air_text(chat_id=chat_id, sender_id=sender_id, text=text):
logger.info("空运群存档补听跳过,BOT 已办 chat=%s", chat_id)
_ = raw
if not try_claim_air_text(chat_id=chat_id, sender_id=sender_id, text=text):
logger.info("空运群入站去重跳过 chat=%s", chat_id)
return True
return False
@@ -0,0 +1,102 @@
"""
卡片按钮集中防抖:同一张卡同一按钮在 TTL 内只处理一次。
本文件职责:按 task_id/工单号 + 按钮动作抢占;HTTP/Worker 共用 Redis,
单测无 Redis 时退回进程内字典。短协调,不当六态、不当 checkpoint。
禁止:用 ResponseCode 当业务键(企微每次点击换新码);禁止全局锁所有工单。
"""
from __future__ import annotations
import logging
import threading
import time
from collections import OrderedDict
from typing import Optional
from agent.redis_coord.keys import IDEMPOTENCY
logger = logging.getLogger(__name__)
# 连点窗口:够挡住企微连点/重试,又不挡稍后改点「成交/未成交」
_TTL_SEC = 20
_SCOPE = "card-btn"
_LOCAL_MAX = 2000
_local_lock = threading.Lock()
_local_seen: OrderedDict[str, float] = OrderedDict()
def debounce_key(
*,
action: str,
task_id: str = "",
work_order_no: str = "",
sender_id: str = "",
event_key: str = "",
) -> str:
"""
拼防抖业务键。优先 task_id(同卡唯一),否则工单号,再退回 event_key/sender。
不含 ResponseCode:每次点击码不同,不能当去重依据。
"""
act = (action or "").strip()
tid = (task_id or "").strip()
if tid and act:
return f"{tid}|{act}"
wo = (work_order_no or "").strip()
if wo and act:
return f"{wo}|{act}|{(sender_id or '').strip()}"
ek = (event_key or "").strip()
if ek:
return f"{ek}|{(sender_id or '').strip()}"
return f"{(sender_id or '').strip()}|{act}"
def try_acquire(key: str, *, ttl_seconds: int = _TTL_SEC) -> bool:
"""
尝试占用卡片按钮防抖键。
返回 True=首次(可继续业务);False=TTL 内重复(应丢弃副作用与 ack)。
Redis 不可用时用进程内字典,保证单测与本机复现脚本也能挡住连点。
"""
token = (key or "").strip()
if not token:
return True
ttl = max(1, int(ttl_seconds or _TTL_SEC))
redis_hit = _try_redis(token, ttl)
if redis_hit is not None:
return redis_hit
return _try_local(token, ttl)
def _try_redis(key: str, ttl: int) -> Optional[bool]:
try:
from agent.redis_coord.runtime import get_redis_runtime
client = get_redis_runtime().client
full = client.key(IDEMPOTENCY, _SCOPE, key)
ok = client.raw.set(full, "1", nx=True, ex=ttl)
acquired = bool(ok)
if not acquired:
logger.info("卡片按钮防抖命中重复 key=%s", key)
return acquired
except Exception:
logger.debug("卡片按钮防抖 Redis 不可用,改用进程内", exc_info=True)
return None
def _try_local(key: str, ttl: int) -> bool:
now = time.time()
with _local_lock:
stale = [k for k, ts in _local_seen.items() if now - ts >= ttl]
for k in stale:
_local_seen.pop(k, None)
prev = _local_seen.get(key)
if prev is not None and now - prev < ttl:
logger.info("卡片按钮防抖(本地)命中重复 key=%s", key)
return False
_local_seen[key] = now
while len(_local_seen) > _LOCAL_MAX:
_local_seen.popitem(last=False)
return True
@@ -71,12 +71,15 @@ _CONFIRM_NO = ("不需要", "不用", "否")
def is_missed_air_bot_at(text: str, *, room_already_air: bool = False) -> bool:
"""
存档里看到用户已经 @ 询价小助手。BOT 长连接没推到时,用这一刀补听。
存档里看到用户已经 @ 机器人显示名。BOT 长连接没推到时,用这一刀补听。
海运拉人卡片也带「询价小助手」,必须排除。闲聊没有 @ 仍不当业务。
显示名以后台 system.bot_display_name 为准,过渡期仍认旧名「询价小助手」。
海运拉人卡片也常带显示名,必须排除。闲聊没有 @ 仍不当业务。
"""
from agent.policy.bot_name import text_mentions_bot
raw = (text or "").strip()
if "询价小助手" not in raw:
if not text_mentions_bot(raw):
return False
if "运输方式:海运" in raw or "运输方式:海运" in raw:
return False
@@ -226,10 +229,12 @@ def cabin_confirm_answer(text: str) -> str:
只认整句(可带工单号和 @)。「不需要」不能被看成「需要」。
返回 yes / no / 空串。空串表示询问作废,调用方按原句继续办。
"""
from agent.policy.bot_name import strip_bot_mention
raw = text or ""
no = copy.extract_work_order_no(raw)
rest = raw.replace(no, "").replace(no.lower(), "")
rest = rest.replace("@询价小助手", "").replace("询价小助手", "")
rest = strip_bot_mention(rest)
rest = "".join(ch for ch in rest if ch.isalnum() or "\u4e00" <= ch <= "\u9fff")
if rest in _CONFIRM_NO:
return "no"
@@ -252,14 +257,16 @@ def _hint_lock_or_release(text: str) -> str:
def _only_work_order(text: str) -> bool:
"""
去掉工单号和 @询价小助手 后几乎没字,当作激活/切单。
去掉工单号和 @机器人显示名 后几乎没字,当作激活/切单。
销售常按话术复制「工单编号WO…@询价小助手」;残留「工单编号」不算业务意图。
销售常按话术复制「工单编号WO…@显示名」;残留「工单编号」不算业务意图。
运行证据:only_wo=false 后走 extract_product_quote→product_quote→航线岗拒绝。
"""
from agent.policy.bot_name import strip_bot_mention
no = copy.extract_work_order_no(text)
rest = (text or "").replace(no, "").replace(no.lower(), "")
rest = rest.replace("@询价小助手", "").replace("询价小助手", "")
rest = strip_bot_mention(rest)
for noise in ("工单编号", "工单号", "编号"):
rest = rest.replace(noise, "")
rest = "".join(ch for ch in rest if ch.isalnum() or "\u4e00" <= ch <= "\u9fff")
+6 -3
View File
@@ -248,9 +248,12 @@ def classify_group_text(
def _norm_intent_text(text: str) -> str:
"""去掉空格和 @询价小助手,方便整句就是「2」时对上选项。"""
blob = (text or "").replace(" ", "").replace(" ", "").lower()
blob = blob.replace("@询价小助手", "").replace("询价小助手", "")
"""去掉空格和 @机器人显示名,方便整句就是「2」时对上选项。"""
from agent.policy.bot_name import strip_bot_mention
# 先剥显示名再 lower:否则「智报通AIQ」会变成「智报通aiq」剥不掉。
blob = strip_bot_mention(text or "")
blob = blob.replace(" ", "").replace(" ", "").lower()
return blob.strip()
@@ -105,50 +105,6 @@ def harvest_added_services(text: str) -> list[str]:
events.append((base + at + max(piece.find(extra), 0), extra))
events.sort(key=lambda item: item[0])
result = _dedupe([word for _pos, word in events])
# #region agent log
try:
import json as _dbg_json
import time as _dbg_time
from pathlib import Path as _DbgPath
_tails: list[dict[str, str]] = []
for _m in list(_TC_RE.finditer(raw)) + list(_CN_RE.finditer(raw)):
if _m.group(0) not in {"TC", "tc", "托盘"} and _m.group(0).casefold() != "tc":
continue
_end = _m.end()
_tails.append(
{
"word": _m.group(0),
"after": raw[_end : _end + 12],
}
)
_payload = {
"sessionId": "5ea5b3",
"runId": "post-fix",
"hypothesisId": "H1",
"location": "added_services.py:harvest_added_services",
"message": "harvest result vs trailing modifiers",
"data": {
"text_preview": (raw or "")[:120],
"found": result,
"match_tails": _tails,
},
"timestamp": int(_dbg_time.time() * 1000),
}
for _path in (
_DbgPath(__file__).resolve().parents[3] / "debug-5ea5b3.log",
_DbgPath(r"C:/Users/ytd_extdev_0903/incoming/debug-5ea5b3.log"),
_DbgPath(r"d:/own/友通达询价智能体(新)/debug-5ea5b3.log"),
):
try:
_path.parent.mkdir(parents=True, exist_ok=True)
with _path.open("a", encoding="utf-8") as _f:
_f.write(_dbg_json.dumps(_payload, ensure_ascii=False) + "\n")
except Exception:
pass
except Exception:
pass
# #endregion
return result
@@ -187,44 +143,6 @@ def apply_air_value_added(
elif not str(merged.get("added_services") or "").strip():
merged.pop("added_services", None)
_keep_stated_package(text, merged)
# #region agent log
try:
import json as _dbg_json
import time as _dbg_time
from pathlib import Path as _DbgPath
_payload = {
"sessionId": "5ea5b3",
"runId": "post-fix",
"hypothesisId": "H2",
"location": "added_services.py:apply_air_value_added",
"message": "apply merge before/after",
"data": {
"text_preview": (text or "")[:120],
"before_added": before_added,
"found": found,
"previous": previous,
"labeled": labeled,
"branch": branch,
"after_added": str(merged.get("added_services") or ""),
"pack": str(merged.get("包装方式") or ""),
},
"timestamp": int(_dbg_time.time() * 1000),
}
for _path in (
_DbgPath(__file__).resolve().parents[3] / "debug-5ea5b3.log",
_DbgPath(r"C:/Users/ytd_extdev_0903/incoming/debug-5ea5b3.log"),
_DbgPath(r"d:/own/友通达询价智能体(新)/debug-5ea5b3.log"),
):
try:
_path.parent.mkdir(parents=True, exist_ok=True)
with _path.open("a", encoding="utf-8") as _f:
_f.write(_dbg_json.dumps(_payload, ensure_ascii=False) + "\n")
except Exception:
pass
except Exception:
pass
# #endregion
return merged
+258 -12
View File
@@ -240,23 +240,221 @@ def _rows_from_list(raw: Any) -> list[tuple[str, str]]:
return out
def option_fee_lines(option: dict[str, Any] | None, quote: dict[str, Any] | None = None) -> list[tuple[str, str]]:
"""
该线路 TMS 回了哪些费用就列哪些。
def _fee_item_dicts(raw: Any) -> list[dict[str, Any]]:
"""TMS 费用数组收成字典;字符串 JSON 也认。无名称的跳过。"""
if isinstance(raw, str) and raw.strip():
try:
raw = json.loads(raw)
except json.JSONDecodeError:
return []
if not isinstance(raw, list):
return []
out: list[dict[str, Any]] = []
for item in raw:
if not isinstance(item, dict):
continue
name = str(
item.get("chargeName") or item.get("feeName") or item.get("name") or item.get("label") or ""
).strip()
if not name:
continue
row = dict(item)
row["name"] = name
out.append(row)
return out
只读本条 departureCharges / feeItems。多条线路时,整单 fee_lines 是最低价方案,
禁止套到没回费用的线路。仅一条线路时,整单 fee_lines 才算这条的明细。
def _top_fee_items_for_option(
quote: dict[str, Any] | None,
option: dict[str, Any] | None,
) -> list[dict[str, Any]]:
"""
舱位缺 feeItems 时补整单顶层(OMS 空运表价常把空运费放在 data.feeItems)。
顶层带 airOptionNo 时优先本舱位;对不上编号时不能退成空列表(否则只剩增点/拆板)。
与主账 InquiryTmsQueryService.extractAirDepartureCharges 口径一致。
"""
items = _fee_item_dicts((quote or {}).get("feeItems") or (quote or {}).get("fee_items"))
if not items:
return []
opt_no = str(
(option or {}).get("optionNo") or (option or {}).get("option_no") or ""
).strip()
tagged = [
row
for row in items
if str(row.get("airOptionNo") or row.get("air_option_no") or "").strip()
]
if opt_no and tagged:
matched = [
row
for row in items
if str(row.get("airOptionNo") or row.get("air_option_no") or "").strip() == opt_no
]
if matched:
return matched
return items
def _has_air_freight_name(rows: list[dict[str, Any]]) -> bool:
"""明细里是否已有空运段运费名。"""
for row in rows:
name = str(row.get("name") or "").strip()
if name == "空运费" or name == "航空运费" or (name.endswith("运费") and "空运" in name):
return True
return False
def _only_origin_surcharges(rows: list[dict[str, Any]]) -> bool:
"""
是否只剩起运附加费(增点/拆板等),没有运输费类分项。
有运输费/提送货费时不注入总价空运费,避免和 ZUH 分项叠两套。
"""
if not rows:
return False
for row in rows:
name = str(row.get("name") or "").strip()
if not name:
continue
if name in {"运输费", "提送货费", "人工费", "空运费", "航空运费"}:
return False
if "运费" in name and "增点" not in name and "拆板" not in name:
return False
return True
def _air_freight_from_quote_fee_lines(
quote: dict[str, Any] | None,
) -> list[dict[str, Any]]:
"""主账 fee_lines 里已有的空运费行(带单价/单位更好)。"""
rows = _fee_item_dicts(
(quote or {}).get("fee_lines") or (quote or {}).get("fee_rows")
)
return [row for row in rows if str(row.get("name") or "").strip() in {"空运费", "航空运费"}]
def _inject_air_freight_from_option(
option: dict[str, Any] | None,
quote: dict[str, Any] | None,
) -> dict[str, Any] | None:
"""
舱位/顶层都没有空运费时,用 salePrice 或 unitPrice×计费重补一行。
对齐主账 injectAirFreightFeeLineIfMissing。无可用金额则不补。
"""
src = dict(option or {})
rows = _rows_from_list(src.get("departureCharges") or src.get("departure_charges"))
rows.extend(_rows_from_list(src.get("feeItems") or src.get("fee_items")))
q = dict(quote or {})
unit_price = src.get("unitPrice")
if unit_price in (None, "", "-"):
unit_price = src.get("unit_price") or src.get("pricePerKg") or src.get("freightRate")
sale = src.get("salePrice")
if sale in (None, "", "-"):
sale = src.get("sale_price") or q.get("total") or q.get("salePrice")
cw = src.get("chargeableWeightKg")
if cw in (None, "", "-"):
cw = src.get("chargeable_weight_kg") or q.get("chargeableWeightKg")
amount = ""
price = str(unit_price or "").strip()
cw_text = str(cw or "").strip()
if price and cw_text:
try:
amount = str(
(float(str(price).replace(",", "")) * float(cw_text.replace(",", "")))
)
# 去掉多余尾零:3334.0
amount = f"{float(amount):.10g}"
except ValueError:
amount = ""
if not amount:
amount = str(sale or "").strip()
if not amount or amount == "-":
return None
if (not price or price == "-") and cw_text:
try:
price = f"{(float(str(amount).replace(',', '')) / float(cw_text.replace(',', ''))):.10g}"
except ValueError:
price = amount
ccy = str(src.get("currency") or q.get("currency") or "").strip()
row: dict[str, Any] = {
"name": "空运费",
"amount": amount,
"project": "空运段",
}
if ccy:
row["currency"] = ccy
if price and price != "-":
row["unit_price"] = price
row["unitPrice"] = price
if cw_text:
row["quantity"] = cw_text
row["unit"] = "KG"
return row
def option_fee_dicts(
option: dict[str, Any] | None,
quote: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
"""
该线路费用明细(保留单价/单位/数量),供群摘要与投影写回 fee_lines。
顺序与主账一致:空运段 feeItems → 起运港 departureCharges → 目的地。
舱位 feeItems 为空时补整单顶层,避免只剩增点费/拆板费丢掉空运费(WO202610150047)。
"""
src = dict(option or {})
q = dict(quote or {})
cabin = _fee_item_dicts(src.get("feeItems") or src.get("fee_items"))
if not cabin:
cabin = _top_fee_items_for_option(q, src)
dep = _fee_item_dicts(
src.get("departureCharges") or src.get("departure_charges") or src.get("charges")
)
dest = _fee_item_dicts(
src.get("destinationCharges")
or src.get("destination_charges")
or src.get("arrivalCharges")
)
rows: list[dict[str, Any]] = []
seen: set[str] = set()
for bucket in (cabin, dep, dest):
for row in bucket:
key = str(row.get("name") or "").strip().lower()
if not key or key in seen:
continue
seen.add(key)
rows.append(row)
# 仍无空运费:先取主账 fee_lines,再按舱位总价注入(OMS 仅增点/拆板时)
if rows and not _has_air_freight_name(rows):
for row in _air_freight_from_quote_fee_lines(q):
key = str(row.get("name") or "").strip().lower()
if key and key not in seen:
seen.add(key)
rows.insert(0, row)
if not _has_air_freight_name(rows) and _only_origin_surcharges(rows):
extra = _inject_air_freight_from_option(src, q)
if extra:
rows.insert(0, extra)
if rows:
return rows
q = dict(quote or {})
options = parse_air_options(q)
if len(options) > 1:
return []
return _rows_from_list(q.get("fee_lines") or q.get("fee_rows") or q.get("feeItems"))
return _fee_item_dicts(q.get("fee_lines") or q.get("fee_rows") or q.get("feeItems"))
def option_fee_lines(option: dict[str, Any] | None, quote: dict[str, Any] | None = None) -> list[tuple[str, str]]:
"""
该线路 TMS 回了哪些费用就列哪些(名称 + 展示金额)。
明细字典见 option_fee_dicts:舱位缺空运段时会补整单顶层 feeItems。
"""
out: list[tuple[str, str]] = []
for row in option_fee_dicts(option, quote):
pair = _fee_row(row)
if pair:
out.append(pair)
return out
def option_total(option: dict[str, Any] | None, quote: dict[str, Any] | None = None) -> str:
@@ -393,10 +591,13 @@ def apply_selected_option(facts: dict[str, str] | None, option: dict[str, Any] |
工单还没有报价日期时,把这条的航班日期写回去,供报价单和锁舱使用。
销售已经说了日期则不覆盖。
"""
from agent.schema.field_validate import is_blank_quote_date
merged = dict(facts or {})
src = dict(option or {})
flight_date = option_flight_date(src)
if flight_date and not str(merged.get("报价日期") or "").strip():
# 占位文案「(非必填):」当没填,必须让航班日期盖上去。
if flight_date and is_blank_quote_date(merged.get("报价日期")):
merged["报价日期"] = flight_date
code = str(src.get("originAirportCode") or src.get("origin") or "").strip().upper()
name = _place_name(code)
@@ -417,7 +618,33 @@ def project_selected_quote(quote: dict[str, Any] | None, option: dict[str, Any]
"""把选中线路投影成价格卡用的 quote。"""
base = dict(quote or {})
src = dict(option or {})
lines = [{"name": n, "amount": a} for n, a in option_fee_lines(src, base)]
# 保留单价/单位/数量,避免后面把「空运费=总价」误判成重复兜底删掉。
lines: list[dict[str, Any]] = []
for row in option_fee_dicts(src, base):
pair = _fee_row(row)
if not pair:
continue
name, amount = pair
line = {
"name": name,
"amount": amount,
"currency": str(row.get("currency") or row.get("currencyCode") or "").strip(),
}
unit = str(row.get("unit") or row.get("unitName") or row.get("chargeUnit") or "").strip()
if unit:
line["unit"] = unit
unit_price = row.get("unitPrice")
if unit_price in (None, ""):
unit_price = row.get("unit_price")
if unit_price not in (None, ""):
line["unit_price"] = unit_price
line["unitPrice"] = unit_price
qty = row.get("quantity")
if qty in (None, ""):
qty = row.get("chargeQuantity") or row.get("charge_quantity")
if qty not in (None, ""):
line["quantity"] = qty
lines.append(line)
# 必须覆盖整单 fee_lines:空列表也要写回去,否则会残留别的线路空运费。
base["fee_lines"] = lines
if not lines:
@@ -515,6 +742,24 @@ def _same_amount(left: Any, right: Any) -> bool:
return a == b
def _rated_air_freight_line(item: dict[str, Any] | None) -> bool:
"""
是否为带计价单位的真实空运费行(单价×计费重),而非「空运费=总价」快照兜底。
有 KG 单位、或有单价/数量,都按真实费率行保留。
"""
src = dict(item or {})
unit = str(src.get("unit") or src.get("unitName") or src.get("chargeUnit") or "").strip().upper()
unit = unit.replace("/", "").replace("每", "").replace(" ", "")
if unit in {"KG", "KGS", "公斤", "千克"}:
return True
if src.get("unitPrice") not in (None, "", "-") or src.get("unit_price") not in (None, "", "-"):
return True
if src.get("quantity") not in (None, "", "-") or src.get("chargeQuantity") not in (None, "", "-"):
return True
return False
def drop_air_freight_total_alias(quote: dict[str, Any] | None) -> dict[str, Any]:
"""
已经有其它费用分项时,去掉「空运费=总价」这一行兜底。
@@ -542,7 +787,8 @@ def drop_air_freight_total_alias(quote: dict[str, Any] | None) -> dict[str, Any]
continue
name = str(item.get("name") or item.get("label") or item.get("feeName") or "").strip()
amount = item.get("amount") or item.get("value")
if name == "空运费" and _same_amount(amount, total):
# OMS 空运费金额常等于顶层 salePrice,但带 KG 单价/数量,不是「总价兜底」假行。
if name == "空运费" and _same_amount(amount, total) and not _rated_air_freight_line(item):
alias.append(item)
continue
others.append(item)
+87 -2
View File
@@ -279,6 +279,15 @@ def normalize_facts(facts: dict[str, Any] | None) -> dict[str, str]:
val = str(raw_val).strip()
if not val or val in _PLACEHOLDER_DASHES:
continue
# 补问提示「请复制粘贴…」等不是日期,入库前丢掉,避免当成已填走出「线路」。
if key == "报价日期" and is_blank_quote_date(val):
continue
# 「请确认并补充以下信息」等标题不是品名(空栏后没换行会粘上)。
if key in {"品名", "货物品名"}:
from agent.llm.extract_text import is_clarify_ui_prompt
if is_clarify_ui_prompt(val):
continue
out[key] = val
_fold_land_vehicle_qty(out)
return out
@@ -319,6 +328,29 @@ def _harvest_packaging(raw: str) -> str:
return last
def is_blank_quote_date(raw: str | None) -> bool:
"""
报价日期是否等于「没填」。
核对卡空行会写成「报价日期(非必填):」。若把「(非必填):」收成字段值,
会挡住选舱写回航班日期(WO202610160033 运行证据)。横杠、装饰词都当空。
补问提示「请复制粘贴…」曾被跨行收成日期(核对卡挤行),一律当空。
"""
text = str(raw or "").strip()
if not text:
return True
if text in {"-", "—", "-", "/", "无", "没有", "暂无"}:
return True
# 补问 UI 文案、下一栏编号,不是日期。
if "请复制粘贴" in text or "告知我以下字段" in text:
return True
if re.match(r"^\d+[\.、.]", text):
return True
cleaned = re.sub(r"[((]\s*非必填\s*[))]", "", text)
cleaned = cleaned.strip(" \t::,,。;;、")
return not cleaned
def _quote_date_labeled_value(raw: str) -> str:
"""
原话里「报价日期」或同义词后面的值。
@@ -327,20 +359,25 @@ def _quote_date_labeled_value(raw: str) -> str:
能写成 yyyy-MM-dd 就规范;「明天」这类留原文。
没有这些标签则空串,交给句子里的裸日期。
同一句出现多次,用最后一次,避免前面的货好时间把日期抢走。
标签后可带「(非必填)」;值为空或只剩装饰词时不当作已填。
只认冒号后同一行:空行后面的「请复制粘贴…」不能跨行收成日期。
"""
text = raw or ""
labels = ("报价日期",) + QUOTE_DATE_SYNONYMS
best_at = -1
best_val = ""
for label in labels:
# 允许「报价日期(非必填):」;禁止把「(非必填):」收成日期值。
# [^\S\n] 只吃同行空白,避免 \s 跨行吞粘贴提示。
pattern = re.compile(
rf"(?<![\u4e00-\u9fffA-Za-z0-9]){re.escape(label)}\s*[::]?\s*(\S+)"
rf"(?<![\u4e00-\u9fffA-Za-z0-9]){re.escape(label)}"
rf"(?:[^\S\n]*[((]\s*非必填\s*[))])?[^\S\n]*[::]?[^\S\n]*([^\s\n]*)"
)
for hit in pattern.finditer(text):
if hit.start() >= best_at:
best_at = hit.start()
best_val = hit.group(1).strip().strip(",,。;;、")
if not best_val:
if is_blank_quote_date(best_val):
return ""
return _harvest_quote_date(best_val) or _excel_serial_date(best_val) or best_val
@@ -603,6 +640,16 @@ def harvest_spoken_trade_term(text: str) -> dict[str, str]:
_CONTAINER_SPEC = re.compile(
r"(?i)(20|40|45)\s*['\"′尺]?\s*(HQ|GP|NOR|RH|HC|高柜|平柜|柜)"
)
# 陆运车型口语:45HQ(13.5厢车)、9米6/1、13.5厢车、3T。不收 9.11 日期、20件。
_RE_LAND_VEHICLE = re.compile(
r"(?i)(?:"
r"(?:20|40|45)\s*(?:HQ|GP|HC|NOR|RH|高柜|平柜)"
r"(?:\s*[((][^))]{1,24}[))])?"
r"|(?:\d+(?:\.\d+)?)\s*米\s*[0-9六]?(?:\s*[//xX×*]\s*\d+)?"
r"|(?:\d+(?:\.\d+)?)\s*厢车"
r"|(?<![\d.])(\d+(?:\.\d+)?)\s*[Tt](?![A-Za-z])"
r")"
)
def looks_like_container_spec(text: str) -> bool:
@@ -610,6 +657,42 @@ def looks_like_container_spec(text: str) -> bool:
return bool(_CONTAINER_SPEC.search(text or ""))
def promote_land_vehicle_from_container(facts: dict[str, str] | None) -> dict[str, str]:
"""
陆运时把误落入「箱型箱量」的车型改写到「车型/数量」。
DeepSeek B 常把 45HQ/厢车当成海运箱型;陆运必填是车型/数量。
已有车型则不覆盖。返回新 dict,不改入参。
"""
merged = dict(facts or {})
vehicle = (merged.get("车型/数量") or merged.get("车型数量") or "").strip()
container = (merged.get("箱型箱量") or "").strip()
if not vehicle and container:
merged["车型/数量"] = container
return merged
def harvest_oral_land_vehicle(text: str, facts: dict[str, str] | None = None) -> dict[str, str]:
"""
从陆运原话机读车型/数量。
已有「车型/数量」不覆盖。命中 45HQ(13.5厢车)、9米6、厢车、3T 等。
只填空字段,不猜港口。返回新 dict。
"""
merged = dict(facts or {})
if (merged.get("车型/数量") or "").strip():
return merged
raw = _without_clarify_examples(text or "")
hit = _RE_LAND_VEHICLE.search(raw)
if not hit:
return merged
token = hit.group(0).strip()
if not token:
return merged
merged["车型/数量"] = token
return merged
def project_sea_cargo_qty(facts: dict[str, str] | None) -> dict[str, str]:
"""
海运货物数量投影。
@@ -655,6 +738,8 @@ def shape_facts_for_chosen_mode(facts: dict[str, str] | None, business_line: str
merged.pop(key, None)
return merged
if line == "LAND":
# 先把误入箱型箱量的车型改写过来,再删海运外键。
merged = promote_land_vehicle_from_container(merged)
for key in _LAND_DROP_FOREIGN:
merged.pop(key, None)
return merged
@@ -97,6 +97,10 @@ _TYPE_ALIASES = (
("国内拼车", "国内运输拼车"),
("国内整车", "国内运输整车"),
("跨境集拼", "跨境集拼"),
# 口语「中越整车」等:先于短词「跨境整车」,避免漏收成国内拼车。
("中越整车", "跨境整车"),
("中缅整车", "跨境整车"),
("中老整车", "跨境整车"),
("跨境整车", "跨境整车"),
)
@@ -108,6 +112,10 @@ _ROUTE_ALIASES = (
("国内长途/零担", "国内长途/零担"),
("国内长途", "国内长途/零担"),
("国内零担", "国内长途/零担"),
# 中越/中缅/中老线路口语 → 东南亚(越南谅山等)。
("中越", "东南亚"),
("中缅", "东南亚"),
("中老", "东南亚"),
("东南亚", "东南亚"),
)
@@ -446,11 +454,16 @@ def land_cargo_name(facts: dict[str, Any] | None) -> str:
陆运货物品名。六个运输类型都要。
示例行「(如:衣服)」原样贴回不算已填。没有则空串。
补问标题「请确认并补充以下信息」不算品名。
"""
from agent.llm.extract_text import is_clarify_ui_prompt
src = facts or {}
name = str(src.get("品名") or src.get("货物品名") or "").strip()
if not name or _CARGO_PLACEHOLDER.match(name):
return ""
if is_clarify_ui_prompt(name):
return ""
return name
+3
View File
@@ -7,9 +7,12 @@
from __future__ import annotations
import json
import logging
import signal
import threading
import time
from pathlib import Path
from agent.config import get_settings
from agent.graph import get_graph_runtime, shutdown_graph_runtime