落地海运协同群:测服靠会话存档听群话,报价单能带上协同字段。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-15 18:12:45 +08:00
co-authored by Cursor
parent 235ecd8c83
commit 12219cacb1
39 changed files with 4436 additions and 256 deletions
+161 -3
View File
@@ -68,12 +68,20 @@ class WeComAppClient:
"""
extra = dict(payload or {})
if extra.get("msgtype") == "file":
return self.send_file(
file_result = self.send_file(
touser=touser,
filename=str(extra.get("filename") or "quote.xlsx"),
filepath=str(extra.get("filepath") or ""),
file_b64=str(extra.get("file_b64") or extra.get("fileBase64") or ""),
)
# 成交卡绑在同一条出站:文件 HTTP 成功后再发卡。
# 企微文件气泡要等素材落地,卡片是即时的;同一毫秒连发,销售会先看到成交跟进。
if file_result.ok:
gap = float(getattr(self, "_followup_gap_sec", 1.6) or 0)
if gap > 0:
time.sleep(gap)
self._send_followup_card(touser=touser, extra=extra)
return file_result
if extra.get("msgtype") == "update_template_card":
return self.update_template_card(
touser=touser,
@@ -142,6 +150,36 @@ class WeComAppClient:
logger.info("企微模板卡已置灰 touser=%s", touser)
return WeComSendResult(ok=True, errcode=0, errmsg=errmsg, raw=data)
def _send_followup_card(self, *, touser: str, extra: dict[str, Any]) -> None:
"""
报价文件发出后再发跟进卡。失败不回滚文件、不触发整条重试。
跟进卡单独入队会被另一条出站线程抢走,销售就会先看到成交跟进。
"""
card = extra.get("followup_card")
if not isinstance(card, dict) or not card:
nested = extra.get("followup_payload")
if isinstance(nested, dict):
card = nested.get("template_card")
if not isinstance(card, dict) or not card:
return
result = self._post_message(
{
"touser": touser,
"msgtype": "template_card",
"agentid": self._agent_id,
"template_card": card,
"enable_id_trans": 0,
},
preview="[followup_card]",
)
if not result.ok:
logger.warning(
"报价文件已发出,跟进卡失败 err=%s:%s",
result.errcode,
result.errmsg,
)
def send_file(
self,
*,
@@ -283,12 +321,21 @@ class WeComAppClient:
logger.info("企微群已补人 chat=%s", chat_id)
return {"ok": True, "error": ""}
def send_group(self, *, chat_id: str, content: str) -> dict[str, Any]:
def send_group(
self,
*,
chat_id: str,
content: str,
mention_userids: list[str] | None = None,
) -> dict[str, Any]:
"""
向应用群发文本。必须走 appchat/send,不能当私聊 message/send。
mention_userids 只给调用方记账,不传给企微。
正文开头已经写了 @姓名;再传企微点人名单,结尾会再点一次。
失败回 ok=False,由调用方决定是否重试;不抛给回调线程。
"""
_ = mention_userids
if not chat_id or not content:
return {"ok": False, "error": "chat_or_content"}
if not self._corp_id or not self._secret:
@@ -299,7 +346,7 @@ class WeComAppClient:
logger.exception("群消息取 token 失败")
return {"ok": False, "error": str(exc)}
url = f"{self._api_base}/cgi-bin/appchat/send"
body = {"chatid": chat_id, "msgtype": "text", "text": {"content": content}}
body: dict[str, Any] = {"chatid": chat_id, "msgtype": "text", "text": {"content": content}}
try:
with httpx.Client(timeout=self._timeout) as client:
resp = client.post(url, params={"access_token": token}, json=body)
@@ -313,6 +360,117 @@ class WeComAppClient:
return {"ok": False, "error": str(data.get("errmsg") or f"errcode_{errcode}")}
return {"ok": True, "error": ""}
def send_group_file(
self,
*,
chat_id: str,
filename: str,
filepath: str = "",
file_b64: str = "",
) -> dict[str, Any]:
"""
向应用群发文件:先上传临时素材,再 appchat/send。
与私聊 send_file 相同素材接口,但消息必须走群,不能 message/send。
失败回 ok=False,不抛给回调线程。
"""
if not chat_id:
return {"ok": False, "error": "chat_id"}
raw = b""
if filepath:
try:
from pathlib import Path
raw = Path(filepath).read_bytes()
except OSError as exc:
return {"ok": False, "error": f"read_file:{exc}"}
elif file_b64:
import base64
try:
raw = base64.b64decode(file_b64)
except Exception as exc: # noqa: BLE001
return {"ok": False, "error": f"b64:{exc}"}
if not raw:
return {"ok": False, "error": "file_empty"}
media_id, up = self._upload_media(filename=filename or "quote.xlsx", content=raw)
if not media_id:
return {"ok": False, "error": up.errmsg or "upload_fail"}
return self._post_appchat(
{
"chatid": chat_id,
"msgtype": "file",
"file": {"media_id": media_id},
}
)
def send_group_card(self, *, chat_id: str, template_card: dict[str, Any]) -> dict[str, Any]:
"""
向应用群发卡。企微 appchat 对 template_card 支持不稳定,失败由调用方已发过文本兜底。
"""
if not chat_id or not template_card:
return {"ok": False, "error": "chat_or_card"}
return self._post_appchat(
{
"chatid": chat_id,
"msgtype": "template_card",
"template_card": template_card,
}
)
def dismiss_group(self, *, chat_id: str, userids: list[str]) -> dict[str, Any]:
"""
解散应用群:官方无 dismiss,改为 appchat/update 移出成员。
不抛给回调。移完人不改工单六态。
"""
users = [str(x).strip() for x in (userids or []) if str(x).strip()]
if not chat_id or not users:
return {"ok": False, "error": "chat_or_users"}
try:
token = self._get_token()
except Exception as exc: # noqa: BLE001
logger.exception("解散群取 token 失败")
return {"ok": False, "error": str(exc)}
url = f"{self._api_base}/cgi-bin/appchat/update"
body = {"chatid": chat_id, "del_user_list": users}
try:
with httpx.Client(timeout=self._timeout) as client:
resp = client.post(url, params={"access_token": token}, json=body)
data = resp.json()
except Exception as exc: # noqa: BLE001
logger.exception("企微解散群网络失败")
return {"ok": False, "error": str(exc)}
errcode = int(data.get("errcode") or 0)
if errcode != 0:
logger.warning("企微解散群失败 errcode=%s", errcode)
return {"ok": False, "error": str(data.get("errmsg") or f"errcode_{errcode}")}
logger.info("企微群已移出成员 chat=%s", chat_id)
return {"ok": True, "error": ""}
def _post_appchat(self, body: dict[str, Any]) -> dict[str, Any]:
"""appchat/send 公共发送。失败不抛。"""
if not self._corp_id or not self._secret:
return {"ok": False, "error": "WECOM_CORP_ID/SECRET 未配置"}
try:
token = self._get_token()
except Exception as exc: # noqa: BLE001
logger.exception("群发送取 token 失败")
return {"ok": False, "error": str(exc)}
url = f"{self._api_base}/cgi-bin/appchat/send"
try:
with httpx.Client(timeout=self._timeout) as client:
resp = client.post(url, params={"access_token": token}, json=body)
data = resp.json()
except Exception as exc: # noqa: BLE001
logger.exception("企微 appchat/send 网络失败")
return {"ok": False, "error": str(exc)}
errcode = int(data.get("errcode") or 0)
if errcode != 0:
logger.warning("企微 appchat/send 失败 errcode=%s", errcode)
return {"ok": False, "error": str(data.get("errmsg") or f"errcode_{errcode}")}
return {"ok": True, "error": ""}
def send_text(self, *, touser: str, content: str) -> WeComSendResult:
"""
向指定企微 userid 发送文本应用消息。
+1 -1
View File
@@ -238,7 +238,7 @@ class MemoryMessageStore:
return state == TaskState.DONE
if state == TaskState.CLAIMED:
saw_claim = True
# 单测没有出站线程:一直 PENDING 就不要空等 12 秒。测服有出站线程会立刻认领。
# 没有出站线程时(单测)不要空等;已被认领则等到发完。
if not saw_claim and state == TaskState.PENDING and time.time() - started >= 0.4:
return True
if not state and time.time() - started >= 0.3:
+10 -1
View File
@@ -53,6 +53,15 @@ def parse_plain_xml(xml_text: str, *, default_agent_id: str = "") -> InboundMess
if chat_id:
chat_type = "group"
media: dict[str, object] = {}
if msg_type.lower() in {"file", "image", "video"}:
media = {
"media_id": _text(root, "MediaId") or _text(root, "MediaID"),
"filename": _text(root, "FileName") or _text(root, "Title"),
"file_ext": _text(root, "FileExt"),
"content_type": "",
}
return InboundMessage(
sender_id=sender_id,
message_id=msg_id,
@@ -62,7 +71,7 @@ def parse_plain_xml(xml_text: str, *, default_agent_id: str = "") -> InboundMess
chat_type=chat_type,
agent_id=agent_id,
create_time=create_time,
media={},
media=media,
raw={
"event": event,
"event_key": event_key,
@@ -0,0 +1,6 @@
"""
微盛会话存档通道:把管家查询结果归一成 InboundMessage。
本包只做 HTTP 查询与解析。轮询循环在 Worker HTTP 槽(jobs/archive_poll)。
禁止:推现网官方会话存档 SDK 游标;禁止在本包建工单、调 LLM。
"""
@@ -0,0 +1,197 @@
"""
微盛 OpenAPI 客户端:换票 + 拉员工群消息。
本文件职责:带超时的 HTTPS GET;token 缓存在本前缀 Redis。
禁止:在回调线程调用;禁止把 access_token / secret 打进日志;禁止动现网官方 seq。
"""
from __future__ import annotations
import logging
from typing import Any, Optional
import httpx
from agent.config import Settings, get_settings
from agent.redis_coord import get_redis_runtime
from agent.redis_coord.client import RedisClient
from agent.redis_coord.token_cache import get_wshoto_token, set_wshoto_token
logger = logging.getLogger(__name__)
_TOKEN_PATH = "/openapi/access_token"
_LIST_PATH = "/openapi/chat/message/list"
_HTTP_TIMEOUT = 8.0
class WshotoError(RuntimeError):
"""管家接口业务失败(code!=0)。message 不得含密钥。"""
def __init__(self, code: int, msg: str) -> None:
self.code = int(code)
self.msg = str(msg or "")
super().__init__(f"wshoto code={self.code} msg={self.msg}")
def wshoto_ready(settings: Optional[Settings] = None) -> bool:
"""有 AppId/Secret 且开关未关,才允许 Worker 去拉。"""
cfg = settings or get_settings()
if not bool(getattr(cfg, "wshoto_poll_enabled", True)):
return False
return bool((cfg.wshoto_app_id or "").strip() and (cfg.wshoto_app_secret or "").strip())
def _is_token_error(code: int, msg: str) -> bool:
text = (msg or "").lower()
return code in {40001, 40014, 42001} or "token" in text or "过期" in (msg or "")
class WshotoOpenClient:
"""
微盛查询客户端。
调用时机:Worker HTTP 槽。失败抛 WshotoError 或返回空列表由调用方决定。
"""
def __init__(
self,
settings: Optional[Settings] = None,
*,
redis: Optional[RedisClient] = None,
) -> None:
self.settings = settings or get_settings()
self._redis = redis
def _redis_client(self) -> Optional[RedisClient]:
if self._redis is not None:
return self._redis
try:
return get_redis_runtime().client
except Exception:
logger.exception("微盛 token 缓存:Redis 不可用,本次直打换票")
return None
def _base(self) -> str:
return (self.settings.wshoto_base_url or "https://open.wshoto.com").rstrip("/")
def fetch_token(self, *, force: bool = False) -> str:
"""
取 access_token:先读本前缀缓存,没有再换票。
副作用:可能写 Redis token:wshoto。不会推进任何消息游标。
"""
app_id = (self.settings.wshoto_app_id or "").strip()
secret = (self.settings.wshoto_app_secret or "").strip()
if not app_id or not secret:
raise WshotoError(0, "missing_app_credentials")
cache = self._redis_client()
if cache is not None and not force:
cached = get_wshoto_token(cache, app_id=app_id)
if cached:
return cached
url = f"{self._base()}{_TOKEN_PATH}"
try:
resp = httpx.get(
url,
params={"appid": app_id, "app_secret": secret},
timeout=_HTTP_TIMEOUT,
)
resp.raise_for_status()
body = resp.json()
except Exception as exc: # noqa: BLE001
raise WshotoError(0, f"token_http:{type(exc).__name__}") from exc
code = int(body.get("code") if body.get("code") is not None else -1)
if code != 0:
raise WshotoError(code, str(body.get("msg") or "token_failed"))
data = body.get("data") if isinstance(body.get("data"), dict) else {}
token = str(data.get("access_token") or "").strip()
if not token:
raise WshotoError(code, "empty_access_token")
expires = int(data.get("expires_in") or 7200)
if cache is not None:
set_wshoto_token(cache, app_id=app_id, token=token, ttl_seconds=max(60, expires - 120))
logger.info("微盛换票成功 expires_in=%s", expires)
return token
def list_group_messages(
self,
*,
userid: str,
target_id: str = "",
last_seq: int = 0,
page_size: int = 50,
msg_time_start: int = 0,
msg_time_end: int = 0,
) -> list[dict[str, Any]]:
"""
拉指定员工的群聊消息(type=1)。
last_seq 只是本次查询过滤,不是官方 SDK 消费确认。
首次不要传 last_seq=0 翻全量,应配合时间窗。
"""
token = self.fetch_token()
return self._list_once(
token=token,
userid=userid,
target_id=target_id,
last_seq=last_seq,
page_size=page_size,
msg_time_start=msg_time_start,
msg_time_end=msg_time_end,
retry_token=True,
)
def _list_once(
self,
*,
token: str,
userid: str,
target_id: str,
last_seq: int,
page_size: int,
msg_time_start: int,
msg_time_end: int,
retry_token: bool,
) -> list[dict[str, Any]]:
params: dict[str, Any] = {
"access_token": token,
"type": 1,
"page_size": max(1, min(int(page_size or 50), 100)),
}
if userid:
params["userid"] = userid
if target_id:
params["target_id"] = target_id
if last_seq > 0:
params["last_seq"] = int(last_seq)
if msg_time_start > 0 and msg_time_end > 0:
params["msg_time_start"] = int(msg_time_start)
params["msg_time_end"] = int(msg_time_end)
url = f"{self._base()}{_LIST_PATH}"
try:
resp = httpx.get(url, params=params, timeout=_HTTP_TIMEOUT)
resp.raise_for_status()
body = resp.json()
except Exception as exc: # noqa: BLE001
raise WshotoError(0, f"list_http:{type(exc).__name__}") from exc
code = int(body.get("code") if body.get("code") is not None else -1)
msg = str(body.get("msg") or "")
if code != 0:
if retry_token and _is_token_error(code, msg):
fresh = self.fetch_token(force=True)
return self._list_once(
token=fresh,
userid=userid,
target_id=target_id,
last_seq=last_seq,
page_size=page_size,
msg_time_start=msg_time_start,
msg_time_end=msg_time_end,
retry_token=False,
)
raise WshotoError(code, msg or "list_failed")
data = body.get("data") if isinstance(body.get("data"), dict) else {}
records = data.get("records") if isinstance(data, dict) else None
if not isinstance(records, list):
return []
return [dict(x) for x in records if isinstance(x, dict)]
+113
View File
@@ -0,0 +1,113 @@
"""
微盛群消息 → InboundMessage。
本文件职责:从管家 records 抽出 sender_id / chat_id / 正文 / 附件名。
禁止:把存档席「询价机器人」当成发言人入业务;禁止再向企微换 ID。
"""
from __future__ import annotations
import json
from typing import Any, Mapping, Optional, Sequence
from agent.channel.wecom.models import InboundMessage
# 存档席在群里的显示名;from 偶发不是 userid 时也要丢掉
SEAT_DISPLAY_NAMES = frozenset({"询价机器人"})
_SKIP_TYPES = frozenset({"revoke", "recall", "event", "unknown"})
_FILE_TYPES = frozenset({"file", "image", "video", "emotion", "voice"})
def _as_mapping(raw: Any) -> dict[str, Any]:
if isinstance(raw, Mapping):
return dict(raw)
if isinstance(raw, str) and raw.strip().startswith("{"):
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
return {}
return dict(parsed) if isinstance(parsed, Mapping) else {}
return {}
def _create_time(msgtime: Any) -> int:
"""微盛 msgtime 常是毫秒;InboundMessage.create_time 用秒。"""
try:
n = int(msgtime or 0)
except (TypeError, ValueError):
return 0
if n > 10**12:
return n // 1000
return n
def _filename(msg_type: str, content: str, payload: Mapping[str, Any]) -> str:
for key in ("filename", "file_name", "FileName", "title", "name"):
val = str(payload.get(key) or "").strip()
if val:
return val
if msg_type in _FILE_TYPES:
return str(content or "").strip()
return ""
def archive_record_to_inbound(
record: Mapping[str, Any],
*,
seat_userids: Sequence[str] = (),
) -> Optional[InboundMessage]:
"""
一条管家记录变成群入站。
返回 None:不是群、没有发言人、发言人是存档席、或撤回类消息。
message_id 带 wshoto: 前缀,避免和回调 MsgId 撞车。
"""
room_id = str(record.get("room_id") or record.get("roomid") or "").strip()
sender_id = str(record.get("from") or record.get("from_id") or "").strip()
if not room_id or not sender_id:
return None
seats = {str(x).strip() for x in seat_userids if str(x).strip()}
if sender_id in seats or sender_id in SEAT_DISPLAY_NAMES:
return None
msg_type = str(record.get("msgtype") or record.get("msg_type") or "text").strip().lower()
if msg_type in _SKIP_TYPES:
return None
payload = _as_mapping(record.get("msg_content") or record.get("msgcontent"))
content = str(record.get("content") or payload.get("content") or "").strip()
msgid = str(record.get("msgid") or record.get("msg_id") or "").strip()
seq = record.get("seq")
if not msgid:
msgid = f"{room_id}:{seq or 0}:{sender_id}"
media: dict[str, Any] = {}
inbound_type = "text"
if msg_type in _FILE_TYPES:
inbound_type = "file" if msg_type == "file" else msg_type
media = {
"filename": _filename(msg_type, content, payload),
"media_id": str(payload.get("sdkfileid") or payload.get("sdk_file_id") or ""),
"file_ext": str(payload.get("fileext") or payload.get("file_ext") or ""),
"content_type": "",
"filesize": payload.get("filesize") or payload.get("file_size") or 0,
}
if not content:
content = str(media.get("filename") or "")
return InboundMessage(
sender_id=sender_id,
message_id=f"wshoto:{msgid}",
content=content,
msg_type=inbound_type,
chat_id=room_id,
chat_type="group",
create_time=_create_time(record.get("msgtime") or record.get("msg_time")),
media=media,
raw={
"source": "wshoto_archive",
"seq": seq,
"msgtype": msg_type,
"direction": record.get("direction") or "",
},
)
+246
View File
@@ -0,0 +1,246 @@
"""
微盛群消息一轮:按「询价机器人」席位拉新话,写入 inbox。
本文件职责:换票查询、归一、入 inbox、推进测服 last_seq。
Worker 独立线程轮询,不占 HTTP 槽、不堵回调。入队后由 inbox 线程分发。
禁止:堵回调;禁止推现网官方 SDK 游标;禁止把存档席当发言人。
"""
from __future__ import annotations
import logging
import time
from typing import Any, Callable, Optional, Sequence
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 (
list_watched_rooms,
load_last_seq,
mark_room_hot,
save_last_seq,
)
from agent.config import Settings, get_settings
from agent.redis_coord.client import RedisClient
logger = logging.getLogger(__name__)
# 新群还没有游标时,只回看最近几分钟,避免每轮翻三小时。
_NEW_ROOM_LOOKBACK_SEC = 180
def archive_seat_userids(settings: Optional[Settings] = None) -> list[str]:
"""
会话存档账号 userid。
测服群里的「询价机器人」就是这一席;默认读 WECOM_ARCHIVE_SEAT_USER_IDS(AI001)。
"""
cfg = settings or get_settings()
raw = str(getattr(cfg, "wecom_archive_seat_user_ids", "") or "").strip()
ids: list[str] = []
for part in raw.replace(";", ",").split(","):
uid = part.strip()
if uid and uid not in ids:
ids.append(uid)
return ids
def _record_seq(record: dict[str, Any]) -> int:
try:
return int(record.get("seq") or 0)
except (TypeError, ValueError):
return 0
def ingest_records(
records: Sequence[dict[str, Any]],
*,
seat_userids: Sequence[str],
min_seq: int = 0,
store: Any = None,
) -> tuple[int, int]:
"""
把一页管家记录写入 inbox。
返回:(入队成功数, 跳过数)。无 sender / 存档席 / 重复 message_id 都算跳过。
"""
book = store if store is not None else get_message_store()
enqueued = 0
skipped = 0
for rec in records:
if _record_seq(rec) <= min_seq:
skipped += 1
continue
msg = archive_record_to_inbound(rec, seat_userids=seat_userids)
if msg is None:
skipped += 1
continue
ok, reason = book.enqueue_inbound(msg)
if ok:
enqueued += 1
mark_room_hot(msg.chat_id)
logger.info(
"存档入队 sender=%s chat=%s type=%s reason=%s",
msg.sender_id,
msg.chat_id,
msg.msg_type,
reason,
)
else:
skipped += 1
return enqueued, skipped
def _needs_target(exc: WshotoError) -> bool:
text = (exc.msg or "").lower()
return "target_id" in text or "targetid" in text or "房间" in (exc.msg or "")
def poll_once(
*,
client: Optional[WshotoOpenClient] = None,
settings: Optional[Settings] = None,
redis: Optional[RedisClient] = None,
store: Any = None,
now: Optional[Callable[[], float]] = None,
) -> dict[str, Any]:
"""
拉一页并存入 inbox。
已有 last_seq:按 seq 增量查。没有:只看最近若干小时,避免从 0 翻全公司历史。
已登记协同群按房间号拉(不要带存档席 userid,应用群否则给空页)。
还没有群时才按席位试拉。
"""
cfg = settings or get_settings()
if not wshoto_ready(cfg):
return {"ok": False, "reason": "not_configured"}
seats = archive_seat_userids(cfg)
if not seats:
logger.warning("微盛轮询:未配置 WECOM_ARCHIVE_SEAT_USER_IDS,只能靠显示名丢掉询价机器人自己的话")
api = client or WshotoOpenClient(cfg, redis=redis)
lookback = max(60, int(getattr(cfg, "wshoto_bootstrap_lookback_sec", 10800) or 10800))
page_size = int(getattr(cfg, "wshoto_page_size", 50) or 50)
clock = now or time.time
rooms = list_watched_rooms(redis=redis)
if rooms:
# 应用群必须带房间号、不要带存档席 userid,否则管家返回空页。
records, mode, last = _poll_by_rooms(
api,
redis=redis,
page_size=page_size,
lookback=lookback,
clock=clock,
)
else:
seat = seats[0] if seats else ""
last = load_last_seq(f"seat:{seat or 'default'}", redis=redis)
time_start = 0
time_end = 0
if last <= 0:
time_end = int(clock())
time_start = time_end - lookback
try:
records = api.list_group_messages(
userid=seat,
last_seq=last,
page_size=page_size,
msg_time_start=time_start,
msg_time_end=time_end,
)
mode = "seat"
except WshotoError as exc:
if not _needs_target(exc):
logger.warning("微盛按席拉取失败 code=%s msg=%s", exc.code, exc.msg)
return {"ok": False, "reason": "list_failed", "code": exc.code}
records, mode, last = _poll_by_rooms(
api,
redis=redis,
page_size=page_size,
lookback=lookback,
clock=clock,
)
# 按房间拉时各群自己的 last_seq 已经过滤过,这里不要再用席游标误丢。
ingest_min = 0 if mode == "rooms" else last
enqueued, skipped = ingest_records(
records,
seat_userids=seats,
min_seq=ingest_min,
store=store,
)
max_seq = last
for rec in records:
seq = _record_seq(rec)
if seq > max_seq:
max_seq = seq
if max_seq > last and not rooms:
save_last_seq(f"seat:{(seats[0] if seats else 'default')}", max_seq, redis=redis)
logger.info(
"微盛轮询 mode=%s fetched=%s enqueued=%s skipped=%s last_seq=%s→%s",
mode,
len(records),
enqueued,
skipped,
last,
max_seq,
)
return {
"ok": True,
"mode": mode,
"fetched": len(records),
"enqueued": enqueued,
"skipped": skipped,
"last_seq": max_seq,
}
def _poll_by_rooms(
api: WshotoOpenClient,
*,
redis: Optional[RedisClient],
page_size: int,
lookback: int,
clock: Callable[[], float],
) -> tuple[list[dict[str, Any]], str, int]:
"""只拉已登记协同群。应用群不要带存档席 userid,否则管家给空页。"""
rooms = list_watched_rooms(redis=redis)
if not 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),
)
merged: list[dict[str, Any]] = []
floor = 0
for room in rooms:
scope = f"room:{room}"
last = load_last_seq(scope, redis=redis)
time_start = 0
time_end = 0
if last <= 0:
time_end = int(clock())
time_start = time_end - min(lookback, _NEW_ROOM_LOOKBACK_SEC)
try:
rows = api.list_group_messages(
userid="",
target_id=room,
last_seq=last,
page_size=page_size,
msg_time_start=time_start,
msg_time_end=time_end,
)
except WshotoError as exc:
logger.warning("微盛房间拉取失败 chat=%s code=%s msg=%s", room, exc.code, exc.msg)
continue
max_seq = last
for rec in rows:
seq = _record_seq(rec)
if seq > max_seq:
max_seq = seq
if max_seq > last:
save_last_seq(scope, max_seq, redis=redis)
if last > floor:
floor = last
merged.extend(rows)
return merged, "rooms", floor
+177
View File
@@ -0,0 +1,177 @@
"""
测服自己的微盛监听名单与 last_seq。
本文件职责:把协同群 chat_id、按席/按房间的查询游标写到 REDIS_KEY_PREFIX 下。
禁止:写现网 ytd:prod: 前缀;禁止把游标当六态。
线程:可被建群线程与 Worker 轮询同时调用,只做单 key 读写。
"""
from __future__ import annotations
import logging
import threading
import time
from typing import Optional
from agent.redis_coord import get_redis_runtime
from agent.redis_coord.client import RedisClient
from agent.redis_coord.keys import WSHOTO_LAST_SEQ, WSHOTO_ROOM_TICKET, WSHOTO_ROOMS
logger = logging.getLogger(__name__)
# 建群后或刚听到群话:立刻唤醒轮询,并在热窗内更勤地扫。
_poll_wake = threading.Event()
_hot_until: dict[str, float] = {}
_hot_lock = threading.Lock()
HOT_WINDOW_SEC = 90.0
def wake_wshoto_poll() -> None:
"""打断轮询等待。建群线程可调;真正拉消息仍在 Worker 轮询线程。"""
_poll_wake.set()
def wait_wshoto_poll(timeout: float) -> bool:
"""等下一轮或被唤醒。返回是否被踢醒。"""
fired = _poll_wake.wait(timeout=max(0.1, float(timeout)))
if fired:
_poll_wake.clear()
return fired
def mark_room_hot(chat_id: str, *, ttl_sec: float = HOT_WINDOW_SEC) -> None:
"""这群接下来几十秒更勤扫:刚建群或刚有人说话。"""
raw = (chat_id or "").strip()
if not raw:
return
until = time.time() + max(5.0, float(ttl_sec))
with _hot_lock:
_hot_until[raw] = until
wake_wshoto_poll()
def has_hot_room() -> bool:
"""是否还有热窗内的协同群。"""
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())
def _client(redis: Optional[RedisClient] = None) -> Optional[RedisClient]:
if redis is not None:
return redis
try:
return get_redis_runtime().client
except Exception:
logger.exception("微盛监听名单:Redis 不可用")
return None
def watch_collab_room(
chat_id: str,
*,
work_order_no: str = "",
redis: Optional[RedisClient] = None,
) -> None:
"""
建群成功后登记 chat_id,供按房间补拉。
可同时记下房间→工单号,主账按群查找暂时不可用时也能对上单。
副作用:改 Redis。失败只打日志,不挡销售侧建群。
"""
raw = (chat_id or "").strip()
if not raw:
return
client = _client(redis)
if client is None:
return
key = client.key(WSHOTO_ROOMS)
current = client.raw.get(key) or ""
if isinstance(current, bytes):
current = current.decode("utf-8")
rooms = {x.strip() for x in str(current).split(",") if x.strip()}
rooms.add(raw)
client.raw.set(key, ",".join(sorted(rooms)))
no = (work_order_no or "").strip()
if no:
client.raw.set(client.key(WSHOTO_ROOM_TICKET, raw), no)
logger.info("存档监听已登记群数=%s", len(rooms))
mark_room_hot(raw)
def ticket_no_for_room(chat_id: str, *, redis: Optional[RedisClient] = None) -> str:
"""用协同群 chat_id 找回工单号(测服 Redis 短索引,不是六态)。"""
raw = (chat_id or "").strip()
if not raw:
return ""
client = _client(redis)
if client is None:
return ""
val = client.raw.get(client.key(WSHOTO_ROOM_TICKET, raw))
if val is None:
return ""
if isinstance(val, bytes):
val = val.decode("utf-8")
return str(val).strip()
def unwatch_collab_room(chat_id: str, *, redis: Optional[RedisClient] = None) -> None:
"""协同结束后去掉监听,避免解散群还被当成活群补拉。"""
raw = (chat_id or "").strip()
if not raw:
return
client = _client(redis)
if client is None:
return
key = client.key(WSHOTO_ROOMS)
current = client.raw.get(key) or ""
if isinstance(current, bytes):
current = current.decode("utf-8")
rooms = {x.strip() for x in str(current).split(",") if x.strip() and x.strip() != raw}
client.raw.set(key, ",".join(sorted(rooms)))
client.raw.delete(client.key(WSHOTO_ROOM_TICKET, raw))
def list_watched_rooms(*, redis: Optional[RedisClient] = None) -> list[str]:
"""当前要按房间补拉的协同群。"""
client = _client(redis)
if client is None:
return []
current = client.raw.get(client.key(WSHOTO_ROOMS)) or ""
if isinstance(current, bytes):
current = current.decode("utf-8")
return [x.strip() for x in str(current).split(",") if x.strip()]
def load_last_seq(scope: str, *, redis: Optional[RedisClient] = None) -> int:
"""
读取本前缀下的 last_seq。
scope:seat:AI001 或 room:wrxxx。没有记录返回 0(调用方改走时间窗,避免从 0 翻全量历史)。
"""
client = _client(redis)
if client is None:
return 0
val = client.raw.get(client.key(WSHOTO_LAST_SEQ, scope))
if val is None:
return 0
try:
return int(val)
except (TypeError, ValueError):
return 0
def save_last_seq(scope: str, seq: int, *, redis: Optional[RedisClient] = None) -> None:
"""推进测服自己的查询游标。seq 必须单调不减。"""
if seq <= 0:
return
client = _client(redis)
if client is None:
return
prev = load_last_seq(scope, redis=client)
if seq < prev:
return
client.raw.set(client.key(WSHOTO_LAST_SEQ, scope), str(int(seq)))
+17
View File
@@ -64,6 +64,23 @@ class Settings(BaseSettings):
default="",
alias="WECOM_ARCHIVE_SEAT_USER_IDS",
)
# 微盛企微管家:Worker 轮询群消息。游标只写本进程 Redis 前缀,禁止去推现网官方 seq。
wshoto_base_url: str = Field(default="https://open.wshoto.com", alias="WSHOTO_BASE_URL")
wshoto_app_id: str = Field(default="", alias="WSHOTO_APP_ID")
wshoto_app_secret: str = Field(default="", alias="WSHOTO_APP_SECRET")
wshoto_tenant_id: str = Field(default="", alias="WSHOTO_TENANT_ID")
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")
wshoto_bootstrap_lookback_sec: int = Field(
default=10800,
alias="WSHOTO_BOOTSTRAP_LOOKBACK_SEC",
)
wshoto_page_size: int = Field(default=50, alias="WSHOTO_PAGE_SIZE")
wshoto_attachment_min_interval_ms: int = Field(
default=1050,
alias="WSHOTO_ATTACHMENT_MIN_INTERVAL_MS",
)
wecom_callback_token: str = Field(default="", alias="WECOM_CALLBACK_TOKEN")
wecom_encoding_aes_key: str = Field(default="", alias="WECOM_ENCODING_AES_KEY")
wecom_callback_reply_text: str = Field(
+130 -35
View File
@@ -1,18 +1,27 @@
"""
意图:海运协同群里补协同字段。
意图:海运协同群入站(文字意图 / 附件)。
本文件职责:群聊文字 → 只抽协同六字段 → 静默写主账。
禁止:回「已记下」;把闲聊/报价金额写成报价版本;改工单六态。
本文件职责:对上工单后交给 sea_group_ops;一类动作一条分支。
禁止:改询价七项;终态装没看见;把销售附件当报价。
"""
from __future__ import annotations
import logging
from typing import Any, Optional
from agent.channel.wecom.models import InboundMessage
from agent.llm.extract_text import extract_collab_fields
from agent.policy.sea_group_ops import (
apply_collab_update,
apply_product_quote,
dissolve_group,
group_client_of,
handle_group_file,
send_group_text,
ticket_no,
ticket_view,
)
from agent.policy.sea_text_flow import get_sea_text_flow
from agent.schema.field_validate import SEA_COLLAB_FIELDS
logger = logging.getLogger(__name__)
@@ -22,12 +31,36 @@ def handle_group_collab(
*,
flow=None,
ledger=None,
injected_intent: str = "",
injected_quote: Optional[dict[str, Any]] = None,
) -> str:
"""
处理一条群聊文字。
处理一条群聊入站。兼容旧测试名。
必须能对上已绑定工单,且发言人是被拉进来的销售或产品。
识别到协同字段才写库;否则忽略。不回复。
"""
return handle_group_inbound(
message,
flow=flow,
ledger=ledger,
injected_intent=injected_intent,
injected_quote=injected_quote,
)
def handle_group_inbound(
message: InboundMessage,
*,
flow=None,
ledger=None,
injected_intent: str = "",
injected_quote: Optional[dict[str, Any]] = None,
) -> str:
"""
群里一条文字或附件。
文字走 DeepSeek A(单测可注入);附件只看扩展名。
协同已结束或终态要回话,不能当没看见。
"""
if (message.chat_type or "") != "group":
return "group_ignore"
@@ -41,40 +74,105 @@ def handle_group_collab(
ticket = _find_ticket(book, chat_id)
if ticket is None:
logger.info("group_inbound 对不上工单 chat=%s", chat_id)
return "group_ignore"
if not _sender_allowed(ticket, message.sender_id):
logger.info("group_collab 忽略非成员 sender=%s chat=%s", message.sender_id, chat_id)
logger.info("group_inbound 忽略非成员 sender=%s chat=%s", message.sender_id, chat_id)
return "group_ignore"
facts = extract_collab_fields(message.content or "")
if not facts:
return "group_ignore"
sess = _session_of_ticket(engine, ticket)
view = ticket_view(ticket)
if view.get("collab_ended") or (sess is not None and getattr(sess, "collab_ended", False)):
from agent.policy import inquiry_copy as copy
if isinstance(ticket, dict):
wo = str(ticket.get("work_order_no") or "")
else:
wo = str(getattr(ticket, "work_order_no", "") or "")
if not wo:
return "group_ignore"
send_group_text(
group_client_of(engine),
chat_id,
copy.group_dissolved(work_order_no=ticket_no(ticket)),
)
return "group_already_ended"
engine.apply_collab_facts(
work_order_no=wo,
facts=facts,
sender_id=message.sender_id,
kind = (message.msg_type or "text").lower()
if kind in {"file", "image", "video"}:
media = dict(message.media or {})
filename = str(
media.get("filename") or media.get("FileName") or (message.content or "")
).strip()
return handle_group_file(
flow=engine,
ticket=ticket,
sender_id=message.sender_id,
filename=filename,
media=media,
sess=sess,
)
from agent.routing.group_intent import (
INTENT_DISSOLVE,
INTENT_FIELDS,
INTENT_QUOTE,
classify_group_text,
)
logger.info(
"group_collab 写入 wo=%s keys=%s sender=%s",
wo,
sorted(k for k in facts if k in SEA_COLLAB_FIELDS),
message.sender_id,
)
return "collab_field_saved"
intent = classify_group_text(message.content or "", injected=injected_intent)
if intent == INTENT_FIELDS:
return apply_collab_update(
flow=engine,
ticket=ticket,
text=message.content or "",
sender_id=message.sender_id,
sess=sess,
)
if intent == INTENT_QUOTE:
return apply_product_quote(
flow=engine,
ticket=ticket,
text=message.content or "",
sender_id=message.sender_id,
sess=sess,
injected_quote=injected_quote,
)
if intent == INTENT_DISSOLVE:
return dissolve_group(flow=engine, ticket=ticket, sess=sess)
logger.info("group_inbound 其它意图 chat=%s intent=%s", chat_id, intent)
return "group_ignore"
def _find_ticket(book: object, chat_id: str):
finder = getattr(book, "find_by_collab_chat", None)
if callable(finder):
return finder(chat_id=chat_id)
hit = finder(chat_id=chat_id)
if hit is not None:
return hit
try:
from agent.channel.wshoto.rooms import ticket_no_for_room
no = ticket_no_for_room(chat_id)
except Exception:
logger.exception("存档房间反查工单失败 chat=%s", chat_id)
no = ""
if not no:
return None
getter = getattr(book, "get_ticket", None)
if callable(getter):
hit = getter(work_order_no=no)
if hit is not None:
return hit
getter = getattr(book, "get_for_agent", None)
if callable(getter):
return getter(work_order_no=no, sender_id="")
return None
def _session_of_ticket(engine: object, ticket: object):
"""用工单号找回销售侧书签,方便写 collab_facts / deal_version。"""
no = ticket_no(ticket)
getter = getattr(engine, "session_by_work_order", None)
if callable(getter) and no:
return getter(no)
by_ticket = getattr(engine, "_by_ticket", None)
if isinstance(by_ticket, dict) and no:
return by_ticket.get(no)
return None
@@ -82,12 +180,9 @@ def _sender_allowed(ticket: object, sender_id: str) -> bool:
uid = (sender_id or "").strip()
if not uid:
return False
if isinstance(ticket, dict):
sales = str(ticket.get("sales_wecom_id") or ticket.get("salesWecomId") or "")
members = ticket.get("collab_member_ids") or ticket.get("memberIds") or []
else:
sales = str(getattr(ticket, "sales_wecom_id", "") or "")
members = list(getattr(ticket, "collab_member_ids", []) or [])
view = ticket_view(ticket)
sales = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "")
members = view.get("collab_member_ids") or view.get("memberIds") or []
if uid == sales:
return True
return uid in {str(x) for x in members}
+29
View File
@@ -110,6 +110,7 @@ class WorkerRuntime:
threading.Thread(target=self._llm_loop, name="llm-loop", daemon=True),
threading.Thread(target=self._recognition_loop, name="recog-loop", daemon=True),
threading.Thread(target=self._archive_loop, name="archive-loop", daemon=True),
threading.Thread(target=self._wshoto_poll_loop, name="wshoto-poll", daemon=True),
threading.Thread(target=self._pdf_loop, name="pdf-loop", daemon=True),
]
for t in self._threads:
@@ -241,6 +242,34 @@ class WorkerRuntime:
self.http_jobs.run(_job, label="archive")
time.sleep(0.2)
def _wshoto_poll_loop(self) -> None:
"""
独立线程轮询微盛群消息,写入 inbox。
不占回调、不占 HTTP 槽、不与 soffice 串队。建群或刚有人说话会立刻再扫。
未配钥匙时只空等,避免打无密钥请求。
"""
from agent.channel.wshoto.client import wshoto_ready
from agent.channel.wshoto.poll import poll_once
from agent.channel.wshoto.rooms import has_hot_room, wait_wshoto_poll
interval = float(getattr(self.settings, "wshoto_poll_interval_sec", 2.0) or 2.0)
interval = min(30.0, max(1.0, interval))
logged_skip = False
while not self._stop.is_set():
if not wshoto_ready(self.settings):
if not logged_skip:
logger.info("微盛轮询未开启:缺少 WSHOTO_APP_ID/SECRET 或开关已关")
logged_skip = True
self._stop.wait(15.0)
continue
try:
poll_once(settings=self.settings, redis=self.redis.client if self.redis else None)
except Exception:
logger.exception("微盛轮询失败")
wait = 1.0 if has_hot_room() else interval
wait_wshoto_poll(wait)
def _pdf_loop(self) -> None:
"""仅 LibreOffice 单槽消费 PDF Stream。"""
if not self.redis:
+2 -1
View File
@@ -1,7 +1,8 @@
"""
微盛存档任务壳:独立 Stream,与 LibreOffice 槽隔离。
本文件职责:enqueue / process_stub;正式下载与登记后续接对象存储 + 主账附件元数据。
本文件职责:enqueue / process_stub;附件落对象存储后续再接。
群消息轮询不在本 Stream,见 jobs/archive_poll(Worker HTTP 槽拉微盛)。
禁止:与 soffice 共用阻塞线;禁止把存档成功冒充业务成交成功。
"""
+12
View File
@@ -0,0 +1,12 @@
"""
微盛轮询入口(Worker 槽调用)。
实现在 channel/wshoto/poll.py,避免 jobs 包导入把通道单测拖进 Graph。
"""
from __future__ import annotations
from agent.channel.wshoto.client import wshoto_ready
from agent.channel.wshoto.poll import archive_seat_userids, ingest_records, poll_once
__all__ = ["archive_seat_userids", "ingest_records", "poll_once", "wshoto_ready"]
+14 -12
View File
@@ -136,28 +136,30 @@ def _deliver_quote_file(data: dict[str, Any], result: LibreOfficeResult) -> None
from agent.policy.inquiry_copy import file_ready
store = get_message_store()
quote = data.get("quote") if isinstance(data.get("quote"), dict) else {}
facts = data.get("facts") if isinstance(data.get("facts"), dict) else {}
from agent.policy import inquiry_copy as copy
store.enqueue_outbound(
touser=touser,
content=file_ready(kind="PDF", work_order_no=wo, filename=filename),
content="\n".join(
[
file_ready(kind="PDF", work_order_no=wo, filename=filename),
copy.deal_card(work_order_no=wo, quote=quote, facts=facts),
]
),
dedupe_key=f"pdf-file:{wo}:{filename}",
payload={
"msgtype": "file",
"filepath": result.pdf_path,
"filename": filename,
"followup_card": copy.deal_wecom_payload(
work_order_no=wo, quote=quote, facts=facts
).get("template_card")
or {},
},
)
# 文件先入队,成交卡后入队;出站单线程保证销售先看到文件
quote = data.get("quote") if isinstance(data.get("quote"), dict) else {}
facts = data.get("facts") if isinstance(data.get("facts"), dict) else {}
if wo:
from agent.policy import inquiry_copy as copy
store.enqueue_outbound(
touser=touser,
content=copy.deal_card(work_order_no=wo, quote=quote, facts=facts),
dedupe_key=f"deal-card:{wo}:{filename}",
payload=copy.deal_wecom_payload(work_order_no=wo, quote=quote, facts=facts),
)
try:
from agent.policy.air_text_flow import get_air_text_flow
+58
View File
@@ -102,6 +102,15 @@ class HttpLedger:
return {"ok": False, "found": False, "owned": False, "work_order_no": work_order_no}
return out
def get_ticket(self, *, work_order_no: str) -> dict[str, Any] | None:
"""用工单号读快照。给群入站在按房间反查时用。找不到返回 None。"""
out = self.get_for_agent(work_order_no=work_order_no, sender_id="")
if not out or (not out.get("found") and not out.get("work_order_no") and not out.get("ok")):
return None
if out.get("found") is False and out.get("ok") is False:
return None
return out
def query_tms(
self,
*,
@@ -224,6 +233,7 @@ class HttpLedger:
chat_id: str,
member_ids: list[str],
product_names: list[str] | None = None,
product_ids: list[str] | None = None,
) -> dict[str, Any]:
out = self._post(
"/inquiry/agent/ticket/bindCollabGroup",
@@ -232,6 +242,7 @@ class HttpLedger:
"chatId": chat_id,
"memberIds": member_ids,
"productNames": product_names or [],
"productIds": product_ids or [],
},
idem=f"bind-group:{work_order_no}",
)
@@ -261,3 +272,50 @@ class HttpLedger:
if not out.get("found") and not out.get("work_order_no"):
return None
return out
def end_collab_group(self, *, work_order_no: str) -> dict[str, Any]:
"""协同结束,这单不能再拉群。不改六态。"""
return self._post(
"/inquiry/agent/ticket/endCollabGroup",
{"workOrderNo": work_order_no},
idem=f"end-collab:{work_order_no}",
)
def attach_file(
self,
*,
work_order_no: str,
file_name: str,
object_key: str = "",
content_type: str = "",
sender_id: str = "",
) -> dict[str, Any]:
"""群附件元数据写入主账。"""
return self._post(
"/inquiry/agent/ticket/attachFile",
{
"workOrderNo": work_order_no,
"fileName": file_name,
"objectKey": object_key or file_name,
"contentType": content_type,
"senderId": sender_id,
},
)
def upsert_product_quote(
self,
*,
work_order_no: str,
quote: dict[str, Any],
to_status: str = "已报价",
) -> dict[str, Any]:
"""产品报价写入主账并迁已报价。"""
return self._post(
"/inquiry/agent/ticket/upsertProductQuote",
{
"workOrderNo": work_order_no,
"quote": quote,
"toStatus": to_status,
},
idem=f"product-quote:{work_order_no}:{sorted((quote or {}).keys())}",
)
+53 -2
View File
@@ -36,6 +36,10 @@ class MemoryTicket:
collab_chat_id: str = ""
collab_facts: dict[str, str] = field(default_factory=dict)
collab_member_ids: list[str] = field(default_factory=list)
product_ids: list[str] = field(default_factory=list)
product_names: list[str] = field(default_factory=list)
collab_ended: bool = False
attachments: list[dict[str, Any]] = field(default_factory=list)
quote_file_ready: bool = False
@@ -367,17 +371,64 @@ class MemoryLedger:
chat_id: str,
member_ids: list[str],
product_names: list[str] | None = None,
product_ids: list[str] | None = None,
) -> dict[str, Any]:
"""记下协同群,不改六态。"""
_ = product_names
"""记下协同群和产品姓名,不改六态。重启后群摘要还要靠这些名字 @ 到人。"""
with self._lock:
ticket = self._tickets.get(work_order_no)
if ticket is None:
return {"ok": False, "error": "ticket_not_found"}
ticket.collab_chat_id = (chat_id or "").strip()
ticket.collab_member_ids = [str(x) for x in member_ids]
ticket.product_ids = [str(x) for x in (product_ids or [])]
ticket.product_names = [str(x).strip() for x in (product_names or []) if str(x).strip()]
return {"ok": True, "chat_id": ticket.collab_chat_id}
def end_collab_group(self, *, work_order_no: str) -> dict[str, Any]:
"""协同结束:不改六态,这单不能再拉群。"""
with self._lock:
ticket = self._tickets.get(work_order_no)
if ticket is None:
return {"ok": False, "error": "ticket_not_found"}
ticket.collab_ended = True
ticket.collab_facts["__ended"] = "1"
return {"ok": True, "collab_ended": True}
def attach_file(
self,
*,
work_order_no: str,
file_name: str,
object_key: str = "",
content_type: str = "",
sender_id: str = "",
) -> dict[str, Any]:
"""把群附件记到工单。不改六态。"""
with self._lock:
ticket = self._tickets.get(work_order_no)
if ticket is None:
return {"ok": False, "error": "ticket_not_found"}
row = {
"file_name": file_name,
"object_key": object_key or file_name,
"content_type": content_type,
"sender_id": sender_id,
}
ticket.attachments.append(row)
return {"ok": True, "file_name": file_name}
def upsert_product_quote(
self,
*,
work_order_no: str,
quote: dict[str, Any],
to_status: str = "已报价",
) -> dict[str, Any]:
"""产品报价盖 TMS 价。"""
return self.upsert_quote(
work_order_no=work_order_no, quote=quote, to_status=to_status
)
def patch_collab_facts(
self,
*,
+65 -7
View File
@@ -23,12 +23,12 @@ _MODE_WORDS = (
_LABEL_LINE = re.compile(
r"^(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型数量|"
r"贸易条款|货好时间|HS编码|HS|是否含油|是否含电|是否含磁)\s*[::]\s*(.+)$"
r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁)\s*[::]\s*(.+)$"
)
_LABEL_INLINE = re.compile(
r"(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型数量|"
r"贸易条款|货好时间|HS编码|HS|是否含油|是否含电|是否含磁)\s*[::]\s*(\S+)"
r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁)\s*[::]\s*(\S+)"
)
@@ -118,18 +118,76 @@ def extract_inquiry_snapshot(
}
def extract_collab_fields(text: str) -> dict[str, str]:
def extract_collab_fields(
text: str,
*,
allow_b: bool = False,
injected: Optional[dict[str, str]] = None,
) -> dict[str, str]:
"""
群里协同字段:只收带标签的非空值。
群里协同字段:标签「键:值」+ 口语含油/含电/含磁、货好时间。
闲聊、报价金额、附件本轮不进这里。HS 折成 HS编码。
闲聊、报价金额不进这里。商品海关编码 / HS 折成 HS编码。
allow_b 保留给调用方;协同六项不走询价七项的 B 合同。
"""
from agent.schema.field_validate import SEA_COLLAB_FIELDS, normalize_facts
from agent.schema.field_validate import (
SEA_COLLAB_FIELDS,
harvest_oral_collab,
normalize_facts,
)
del allow_b
if injected:
return {
k: str(v).strip()
for k, v in normalize_facts(dict(injected)).items()
if k in SEA_COLLAB_FIELDS and str(v or "").strip()
}
labeled = normalize_facts(parse_labeled_facts(text))
oral = harvest_oral_collab(text)
merged = dict(oral)
merged.update({k: v for k, v in labeled.items() if v})
out: dict[str, str] = {}
for key in SEA_COLLAB_FIELDS:
val = (labeled.get(key) or "").strip()
val = (merged.get(key) or "").strip()
if val:
out[key] = val
return out
_RE_MONEY = re.compile(
r"(?:USD|CNY|RMB|美金|美元|人民币)?\s*(\d+(?:\.\d+)?)\s*(?:USD|CNY|RMB|美金|美元|人民币|元)?"
r"|(?:USD|CNY|RMB|美金|美元)\s*(\d+(?:\.\d+)?)",
re.I,
)
def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = None) -> dict[str, Any]:
"""
产品文字报价:抽出合计/币别。注入优先。
认不出价格返回空 dict,调用方回「没有识别到报价」。
"""
if isinstance(injected, dict) and (injected.get("total") or injected.get("fee_rows")):
out = dict(injected)
out.setdefault("source_label", "产品报价")
return out
raw = (text or "").strip()
if not raw:
return {}
hit = _RE_MONEY.search(raw)
if not hit:
return {}
amount = (hit.group(1) or hit.group(2) or "").strip()
if not amount:
return {}
ccy = "USD"
if any(x in raw.upper() for x in ("CNY", "RMB", "人民币", "元")):
ccy = "CNY"
return {
"total": amount,
"currency": ccy,
"source_label": "产品报价",
"raw_text": raw,
"fee_rows": [{"name": "海运费", "amount": amount, "currency": ccy}],
}
+36 -8
View File
@@ -50,6 +50,8 @@ class FlowSession:
invited_userids: tuple[str, ...] = ()
invited_sales_name: str = ""
invited_product_names: tuple[str, ...] = ()
deal_version: int = 0
collab_ended: bool = False
class AirTextInquiryFlow:
@@ -74,6 +76,14 @@ class AirTextInquiryFlow:
with self._lock:
return self._by_sender.get(sender_id)
def session_by_work_order(self, work_order_no: str) -> Optional[FlowSession]:
"""用工单号找回书签,不校验 sender。群成交卡校验用。"""
no = (work_order_no or "").strip()
if not no:
return None
with self._lock:
return self._by_ticket.get(no)
def session_by_ticket(self, sender_id: str, work_order_no: str) -> Optional[FlowSession]:
"""
只查进程内该书签,不读主账。
@@ -298,7 +308,8 @@ class AirTextInquiryFlow:
reply("报价单正在生成,请稍候。")
return "wait_file"
if sess.phase == "wait_deal":
self._emit_deal(reply, sess)
# 成交卡已经绑在报价文件那条出站上。这里再发一张独立卡,
# 会在文件还在上传时被另一条出站线程抢走,销售又先看到成交跟进。
return "wait_deal"
if sess.phase == "done":
reply(copy.deal_ack(sess.status or "已关闭", sess.work_order_no))
@@ -814,15 +825,33 @@ class AirTextInquiryFlow:
file_extra: dict[str, Any],
) -> str:
"""
先发报价文件,等这条出站真正发出,再出成交跟进卡。
报价文件与成交跟进走同一条出站:先传文件,成功后再发卡。
避免卡片比文件先到销售手机。单测 reply 不回 id 则不等待。
两张消息分两条出站时,另一条消费线程会在上传期间把成交卡发出去。
单测 reply 不回 id 则不等待。
"""
out_id = reply(
copy.file_ready(kind=kind, work_order_no=sess.work_order_no, filename=filename),
file_extra,
extra = dict(file_extra or {})
extra["followup_card"] = copy.deal_wecom_payload(
work_order_no=sess.work_order_no,
quote=sess.quote,
facts=sess.facts,
).get("template_card") or {}
reply(
"\n".join(
[
copy.file_ready(
kind=kind, work_order_no=sess.work_order_no, filename=filename
),
copy.deal_card(
work_order_no=sess.work_order_no,
status=sess.status or "已报价",
quote=sess.quote,
facts=sess.facts,
),
]
),
extra,
)
self._wait_outbound_sent(str(out_id or ""), work_order_no=sess.work_order_no, what="报价文件")
from agent.redis_coord.quote_file import mark_quote_file_ready
mark_quote_file_ready(sess.work_order_no)
@@ -830,7 +859,6 @@ class AirTextInquiryFlow:
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
sess.wait_version += 1
self._save(sess)
self._emit_deal(reply, sess)
return "wait_deal"
def _emit_deal(self, reply: ReplyFn, sess: FlowSession) -> None:
+100 -18
View File
@@ -13,7 +13,12 @@ import time
import uuid
from typing import Any, Iterable
from agent.schema.field_validate import AIR_REQUIRED_FIELDS, SEA_COLLAB_FIELDS, SEA_REQUIRED_FIELDS
from agent.schema.field_validate import (
AIR_REQUIRED_FIELDS,
SEA_COLLAB_DISPLAY,
SEA_COLLAB_FIELDS,
SEA_REQUIRED_FIELDS,
)
# 空运补问:与合同 8 项一致。报价日期默认当天,不进待补充。
_AIR_EXAMPLES = {
@@ -379,6 +384,20 @@ def work_order_from_card_meta(
return ""
def deal_card_version(card_meta: dict[str, Any] | None = None, *, text: str = "") -> int:
"""从成交卡 EventKey 取出 vN;没有则 0(旧私聊卡)。"""
src = card_meta or {}
for raw in (
str(src.get("event_key") or ""),
str(src.get("task_id") or ""),
text or "",
):
hit = re.search(r":v(\d+)\s*$", raw)
if hit:
return int(hit.group(1))
return 0
def _work_order_in_token(raw: str) -> str:
"""从 TaskId / EventKey 里抽出 WO 工单号;没有则空。"""
text = (raw or "").strip()
@@ -505,19 +524,18 @@ def sea_quote_detail_text(
history_work_order_no: str = "",
status: str = "已报价",
) -> str:
"""海运完整价格明细文本。不受卡片引用条字数限制。"""
"""
海运完整价格明细文本:来源、工单号、费用行、合计。
时效、报价有效期只在卡片上出现,这里不写,避免和卡重复。
不受卡片引用条字数限制。first_or_same 只兼容调用方。
"""
_ = first_or_same, history_work_order_no, status
src = dict(quote or {})
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
lines = [source, f"{work_order_no}价格明细"]
lines.extend(
quote_detail_lines(
src,
first_or_same=first_or_same,
history_work_order_no=history_work_order_no,
status=status,
)
return "\n".join(
[source, f"{work_order_no}价格明细", quote_area_fee_text(src)]
)
return "\n".join(lines)
def collab_hint(work_order_no: str) -> str:
@@ -957,6 +975,11 @@ def sea_group_no_staff() -> str:
return "没有可拉的产品人员。请先在后台「员工管理」维护本线路的海运产品岗后再试。"
def group_sheet_send_fail() -> str:
"""模板已填好但没发进群。"""
return "报价单已生成,发到群里失败,请稍后重试。"
def sea_group_create_fail(*, reason: str = "") -> str:
"""建群失败,按钮保持可点。"""
extra = (reason or "").strip()
@@ -978,7 +1001,8 @@ def sea_group_brief(
"""
群里自动摘要:@销售 @产品 + 工单号 + 询价字段 + 协同字段 + TMS 报价。
协同没填写 -。无价写明暂无匹配报价。
三段之间空一行,方便群里扫。协同没填写 -。
HS编码对外写成商品海关编码。无价只写「TMS 暂无匹配报价」。
"""
no = (work_order_no or "").strip()
sales = (sales_name or "").strip() or "销售"
@@ -988,16 +1012,19 @@ def sea_group_brief(
lines.extend(
inquiry_field_lines(work_order_no=no, facts=facts, transport_mode="海运")[1:]
)
lines.append("")
lines.append("协同补充字段:")
collab = dict(collab_facts or {})
for key in SEA_COLLAB_FIELDS:
val = (collab.get(key) or "").strip() or "-"
lines.append(f"{key}:{val}")
label = SEA_COLLAB_DISPLAY.get(key, key)
val = (collab.get(key) or collab.get(label) or "").strip() or "-"
lines.append(f"{label}:{val}")
lines.append("")
lines.append("TMS报价:")
if has_price and quote:
lines.extend(quote_detail_lines(quote))
else:
lines.append("TMS 暂无匹配报价 / 未查到合适报价")
lines.append("TMS 暂无匹配报价")
return "\n".join(lines)
@@ -1244,6 +1271,7 @@ def deal_wecom_payload(
work_order_no: str,
quote: dict[str, Any] | None = None,
facts: dict[str, str] | None = None,
card_version: int = 0,
) -> dict[str, Any]:
"""
成交跟进卡:对齐现网 button_interaction。
@@ -1254,6 +1282,8 @@ def deal_wecom_payload(
src = dict(quote or {})
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
no = (work_order_no or "").strip()
ver = int(card_version or 0)
suffix = f":v{ver}" if ver > 0 else ""
return {
"msgtype": "template_card",
"template_card": {
@@ -1270,23 +1300,75 @@ def deal_wecom_payload(
{
"text": BTN_DEAL,
"style": 1,
"key": f"{BTN_DEAL_KEY}:{no}",
"key": f"{BTN_DEAL_KEY}:{no}{suffix}",
},
{
"text": BTN_LOST,
"style": 1,
"key": f"{BTN_LOST_KEY}:{no}",
"key": f"{BTN_LOST_KEY}:{no}{suffix}",
},
{
"text": BTN_NEGOTIATE,
"style": 1,
"key": f"{BTN_NEGOTIATE_KEY}:{no}",
"key": f"{BTN_NEGOTIATE_KEY}:{no}{suffix}",
},
],
},
}
def ticket_locked(*, work_order_no: str, status: str) -> str:
"""已成交/未成交/已关闭后,群里改字段或再报价。"""
no = (work_order_no or "").strip() or "该工单"
st = (status or "").strip() or "已关闭"
return f"工单{no}{st},不能修改工单信息"
def ticket_locked_file(*, work_order_no: str, status: str) -> str:
"""终态工单再发 Excel/PDF。"""
no = (work_order_no or "").strip() or "该工单"
st = (status or "").strip() or "已关闭"
return f"工单{no}{st},不能再上传附件"
def group_only_excel_pdf() -> str:
return "群里只收 Excel 和 PDF,其它文件不会存入工单"
def group_quote_not_recognized() -> str:
return "没有识别到报价,请产品再发一次价格或 Excel/PDF"
def group_fields_not_recognized() -> str:
return "听到了。请直接说贸易条款、货好时间、商品海关编码、是否含油/含电/含磁。"
def group_deal_stale(*, work_order_no: str) -> str:
"""点了上一版成交卡。"""
no = (work_order_no or "").strip()
return f"这张成交确认已过期。工单{no}已有更新的报价,请点击群里最新一条成交跟进。"
def group_deal_sales_only() -> str:
return "只有负责该工单的销售可以确认成交状态。"
def group_dissolved(*, work_order_no: str) -> str:
no = (work_order_no or "").strip()
return f"群已解散。工单{no}协同已结束。"
def group_cannot_recreate(*, work_order_no: str) -> str:
"""解散后再点拉群。"""
no = (work_order_no or "").strip() or "该工单"
return f"工单{no}协同已结束,不能再拉群。"
def group_deal_mention(*, sales_name: str, work_order_no: str) -> str:
name = (sales_name or "").strip() or "销售"
return f"@{name}\n{deal_card(work_order_no=work_order_no)}"
def deal_ack(outcome: str, work_order_no: str) -> str:
if outcome == "已成交":
return f"已记录为「已成交」。工单 {work_order_no} 已关闭智能体跟进。"
+125
View File
@@ -0,0 +1,125 @@
"""
按海运询价单标签补写协同字段。
主账填表只认英文码,货好时间 / 海关编码 / 含油电磁常空着。
本文件只改 Excel 字节,不打主账、不占回调线程。
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any, Mapping
logger = logging.getLogger(__name__)
# 长的「含油,电,磁」必须排在「是否含油」前面,避免只写到油。
_COLLAB_LABEL_MARKERS: tuple[tuple[str, tuple[str, ...]], ...] = (
("是否含油,电,磁", ("oil_elec_mag", "是否含油,电,磁")),
("OIL,ELEC,MAG", ("oil_elec_mag", "是否含油,电,磁")),
("贸易条款", ("贸易条款", "trade_terms")),
("货好时间", ("货好时间", "cargo_ready_date")),
("商品海关编码", ("商品海关编码", "HS编码", "海关编码", "hs_code")),
("HS Code", ("商品海关编码", "HS编码", "海关编码", "hs_code")),
("是否含油", ("是否含油", "has_oil")),
("是否含电", ("是否含电", "has_battery")),
("是否含磁", ("是否含磁", "has_magnet")),
)
def _fact_for_markers(facts: Mapping[str, Any], keys: tuple[str, ...]) -> str:
for key in keys:
val = str((facts or {}).get(key) or "").strip()
if val and val != "-":
return val
return ""
def overlay_labeled_facts(
*,
facts: Mapping[str, Any],
filepath: str = "",
file_b64: str = "",
) -> dict[str, str]:
"""
按模板标签再写协同格。失败则原样交回。
副作用:可能写临时 xlsx。
"""
import base64
import tempfile
raw = b""
if filepath:
try:
raw = Path(filepath).read_bytes()
except OSError as exc:
logger.warning("overlay 读文件失败 err=%s", exc)
if not raw and file_b64 and len(file_b64) > 64:
try:
raw = base64.b64decode(file_b64)
except Exception as exc: # noqa: BLE001
logger.warning("overlay 解码失败 err=%s", exc)
if not raw:
return {"filepath": filepath, "file_b64": file_b64}
try:
from openpyxl import load_workbook
except ImportError:
return {"filepath": filepath, "file_b64": file_b64}
tmp = Path(tempfile.mkdtemp(prefix="quote-overlay-")) / "filled.xlsx"
tmp.write_bytes(raw)
written = 0
try:
wb = load_workbook(tmp)
ws = wb.worksheets[0]
pending: list[tuple[int, int, str]] = []
used_rows: set[int] = set()
for row in ws.iter_rows(min_row=1, max_row=ws.max_row or 1, max_col=min(ws.max_column or 1, 16)):
for cell in row:
text = str(cell.value or "").strip()
if not text or cell.row in used_rows:
continue
keys = _match_collab_label(text)
if not keys:
continue
val = _fact_for_markers(facts, keys)
if not val:
continue
dest = _label_value_cell(ws, cell)
pending.append((dest.row, dest.column, val))
used_rows.add(cell.row)
break
for r, c, val in pending:
ws.cell(row=r, column=c, value=val)
written += 1
wb.save(tmp)
wb.close()
except Exception:
logger.exception("overlay 协同字段失败")
return {"filepath": filepath, "file_b64": file_b64}
out_b64 = base64.b64encode(tmp.read_bytes()).decode("ascii")
logger.info("overlay 协同字段 written=%s", written)
return {"filepath": str(tmp), "file_b64": out_b64}
def _match_collab_label(text: str) -> tuple[str, ...] | None:
raw = (text or "").replace(":", ":").strip()
for marker, keys in _COLLAB_LABEL_MARKERS:
if marker in raw:
return keys
return None
def _label_value_cell(ws, label_cell):
"""标签右侧格子;合并标签则从合并区右边开始。"""
row_idx = label_cell.row
start_col = label_cell.column + 1
merged = getattr(ws, "merged_cells", None)
if merged is not None:
for rng in merged.ranges:
if rng.min_row <= row_idx <= rng.max_row and rng.min_col <= label_cell.column <= rng.max_col:
start_col = rng.max_col + 1
break
return ws.cell(row=row_idx, column=start_col)
+702
View File
@@ -0,0 +1,702 @@
"""
海运协同群业务:填表进群、补字段重发、产品报价、附件、解散、成交跟进。
本文件职责:群里副作用(主账+企微群)。私聊按钮仍走 sea_text_flow.on_button。
禁止:改询价七项;终态默默忽略;把销售附件当报价。
"""
from __future__ import annotations
import logging
from typing import Any, Optional
from agent.llm.extract_text import extract_collab_fields, extract_product_quote
from agent.policy.quote_overlay import overlay_labeled_facts
from agent.policy import inquiry_copy as copy
from agent.schema.field_validate import SEA_COLLAB_DISPLAY, SEA_COLLAB_FIELDS
# 主账填表按英文码取格子;中文键必须同时带上,否则货好时间/条款写不进表。
_COLLAB_FILL_ALIASES = {
"贸易条款": ("贸易条款", "trade_terms"),
"货好时间": ("货好时间", "cargo_ready_date"),
"HS编码": ("HS编码", "商品海关编码", "海关编码", "hs_code"),
"是否含油": ("是否含油", "has_oil"),
"是否含电": ("是否含电", "has_battery"),
"是否含磁": ("是否含磁", "has_magnet"),
}
logger = logging.getLogger(__name__)
TERMINAL = {"已成交", "未成交", "已关闭"}
OPEN_EDIT = {"询价中", "已报价", "协商中"}
EXCEL_PDF = {".xlsx", ".xls", ".xlsm", ".pdf"}
def ticket_view(ticket: object) -> dict[str, Any]:
"""主账工单或内存票统一成 dict。"""
if ticket is None:
return {}
if isinstance(ticket, dict):
view = dict(ticket)
view.setdefault("work_order_no", view.get("workOrderNo") or "")
view.setdefault("sales_wecom_id", view.get("salesWecomId") or view.get("sales_wecom_id") or "")
view.setdefault("salesWecomId", view.get("sales_wecom_id") or view.get("salesWecomId") or "")
view.setdefault(
"collab_member_ids",
view.get("memberIds") or view.get("collabMemberIds") or view.get("collab_member_ids") or [],
)
view.setdefault(
"product_ids",
view.get("productIds") or view.get("product_ids") or [],
)
view.setdefault(
"product_names",
view.get("productNames") or view.get("product_names") or [],
)
view.setdefault("sales_name", view.get("salesName") or view.get("sales_name") or "")
facts = dict(view.get("collab_facts") or view.get("collabFacts") or {})
view.setdefault("collab_facts", facts)
flag = str(facts.get("__ended") or view.get("collab_ended") or view.get("collabEnded") or "").strip().lower()
if flag in {"1", "true", "yes"}:
view["collab_ended"] = True
return view
facts = dict(getattr(ticket, "collab_facts", {}) or {})
ended = bool(getattr(ticket, "collab_ended", False)) or str(
facts.get("__ended") or ""
).strip().lower() in {"1", "true", "yes"}
return {
"work_order_no": getattr(ticket, "work_order_no", "") or "",
"status": getattr(ticket, "status", "") or "",
"sales_wecom_id": getattr(ticket, "sales_wecom_id", "") or "",
"salesWecomId": getattr(ticket, "sales_wecom_id", "") or "",
"business_line": getattr(ticket, "business_line", "") or "SEA",
"facts": dict(getattr(ticket, "facts", {}) or {}),
"quote": dict(getattr(ticket, "quote", {}) or {}),
"collab_facts": dict(getattr(ticket, "collab_facts", {}) or {}),
"collab_chat_id": getattr(ticket, "collab_chat_id", "") or "",
"collab_member_ids": list(getattr(ticket, "collab_member_ids", []) or []),
"collab_ended": ended,
"product_ids": list(getattr(ticket, "product_ids", []) or []),
"product_names": list(getattr(ticket, "product_names", []) or []),
"sales_name": str(getattr(ticket, "sales_name", "") or ""),
}
def ticket_status(ticket: object) -> str:
return str(ticket_view(ticket).get("status") or "").strip()
def ticket_no(ticket: object) -> str:
return str(ticket_view(ticket).get("work_order_no") or "").strip()
def is_terminal(ticket: object) -> bool:
return ticket_status(ticket) in TERMINAL
def is_sales(ticket: object, sender_id: str) -> bool:
uid = (sender_id or "").strip()
view = ticket_view(ticket)
sales = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip()
return bool(uid) and uid == sales
def is_product(ticket: object, sender_id: str) -> bool:
"""产品岗:成员里且不是销售。"""
uid = (sender_id or "").strip()
if not uid or is_sales(ticket, uid):
return False
view = ticket_view(ticket)
members = {str(x) for x in (view.get("collab_member_ids") or view.get("memberIds") or [])}
products = {str(x) for x in (view.get("product_ids") or [])}
if products:
return uid in products
return uid in members
def filename_is_excel_pdf(name: str) -> bool:
raw = (name or "").strip().lower()
return any(raw.endswith(ext) for ext in EXCEL_PDF)
def group_client_of(flow: Any) -> Any:
"""正式路径没有注入客户端时,走流程自己的默认企微客户端。"""
getter = getattr(flow, "group_client", None)
if callable(getter):
return getter()
return getattr(flow, "_group_client", None)
def _archive_seat_ids(flow: Any) -> set[str]:
"""会话存档账号(测服 AI001 / 询价机器人)不能出现在群 @ 里。"""
seats: set[str] = {"AI001", "询价机器人"}
getter = getattr(flow, "_archive_seat_userids", None)
if callable(getter):
try:
seats.update(str(x).strip() for x in (getter() or []) if str(x).strip())
except Exception:
logger.warning("读会话存档账号失败,按默认 AI001 排除")
return {x for x in seats if x}
def _staff_display_name(flow: Any, wecom_id: str) -> str:
"""按企微 userid 取后台员工姓名;查不到就退回 userid,仍是具体人。"""
uid = (wecom_id or "").strip()
if not uid:
return ""
book = getattr(flow, "ledger", None) or getattr(flow, "_ledger", None)
finder = getattr(book, "get_staff_by_wecom_id", None) if book is not None else None
if callable(finder):
try:
row = finder(wecom_id=uid) or {}
name = str(row.get("name") or "").strip()
if name:
return name
except Exception:
logger.warning("查群成员姓名失败 uid=%s", uid)
return uid
def resolve_group_mentions(
flow: Any,
ticket: object,
sess: Any = None,
) -> tuple[str, list[str], list[str]]:
"""
群摘要必须 @ 到具体销售和每位产品,禁止只写岗位词「销售」。
进程重启后内存会话没了,主账 byCollabChat 也可能不带姓名:
用销售 userid + 群成员(去掉存档账号)反查员工管理。
返回:(销售展示名, 产品展示名列表, 要点到的 userid)。
"""
view = ticket_view(ticket)
sales_id = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip()
if sess is not None:
sales_id = sales_id or str(getattr(sess, "sales_wecom_id", "") or getattr(sess, "sender_id", "") or "").strip()
members = [
str(x).strip()
for x in (view.get("collab_member_ids") or view.get("memberIds") or [])
if str(x).strip()
]
if sess is not None:
for uid in getattr(sess, "invited_userids", ()) or ():
raw = str(uid).strip()
if raw and raw not in members:
members.append(raw)
seats = _archive_seat_ids(flow)
product_ids = [
str(x).strip()
for x in (view.get("product_ids") or view.get("productIds") or [])
if str(x).strip() and str(x).strip() not in seats
]
if not product_ids:
product_ids = [m for m in members if m and m != sales_id and m not in seats]
sales_name = ""
if sess is not None:
sales_name = str(getattr(sess, "invited_sales_name", "") or "").strip()
sales_name = sales_name or str(view.get("sales_name") or view.get("salesName") or "").strip()
if not sales_name or sales_name == "销售":
looked = _staff_display_name(flow, sales_id) if sales_id else ""
if looked:
sales_name = looked
if not sales_name:
sales_name = sales_id or "销售"
products: list[str] = []
if sess is not None:
products = [
str(n).strip()
for n in (getattr(sess, "invited_product_names", None) or [])
if str(n or "").strip()
]
if not products:
products = [
str(n).strip()
for n in (view.get("product_names") or view.get("productNames") or [])
if str(n or "").strip()
]
if not products:
products = [name for name in (_staff_display_name(flow, pid) for pid in product_ids) if name]
mention_ids: list[str] = []
if sales_id and sales_id not in seats:
mention_ids.append(sales_id)
for pid in product_ids:
if pid and pid not in mention_ids and pid not in seats:
mention_ids.append(pid)
return sales_name, products, mention_ids
def send_group_text(
client: Any,
chat_id: str,
content: str,
mention_userids: Optional[list[str]] = None,
) -> None:
"""群文本可带企微 userid,方便客户端真正点到人。旧客户端无此参数就只发正文。"""
if not client or not chat_id or not content:
return
sender = getattr(client, "send_group", None)
if not callable(sender):
return
mentions = [str(x).strip() for x in (mention_userids or []) if str(x).strip()]
try:
sender(chat_id=chat_id, content=content, mention_userids=mentions)
except TypeError:
sender(chat_id=chat_id, content=content)
def hydrate_ticket(flow: Any, ticket: object) -> object:
"""
按群找回的工单若缺询价七项,用工单号+销售再读主账补全。
测服 byCollabChat 可能只回协同字段;续办 get 空 sender 也不回 facts。
禁止用空 facts 把已确认港口冲成「-」。
"""
view = ticket_view(ticket)
facts = dict(view.get("facts") or {})
if any(str(facts.get(k) or "").strip() for k in ("起运港", "目的港", "品名", "货量")):
return ticket
no = ticket_no(ticket)
sales = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip()
book = getattr(flow, "ledger", None)
if book is None or not no:
return ticket
full: Any = None
getter = getattr(book, "get_for_agent", None)
if callable(getter) and sales:
full = getter(work_order_no=no, sender_id=sales)
if not isinstance(full, dict) or not dict(full.get("facts") or {}):
getter = getattr(book, "get_ticket", None)
if callable(getter):
full = getter(work_order_no=no)
if full is None:
return ticket
merged = dict(view)
full_view = ticket_view(full)
if full_view.get("facts"):
merged["facts"] = dict(full_view.get("facts") or {})
if full_view.get("quote") and not merged.get("quote"):
merged["quote"] = dict(full_view.get("quote") or {})
if full_view.get("status"):
merged.setdefault("status", full_view.get("status"))
collab = dict(full_view.get("collab_facts") or {})
collab.update(dict(view.get("collab_facts") or {}))
merged["collab_facts"] = collab
if view.get("collab_chat_id"):
merged["collab_chat_id"] = view.get("collab_chat_id")
return merged
def send_brief_and_sheet(
*,
flow: Any,
ticket: object,
sess: Any = None,
quote_override: Optional[dict[str, Any]] = None,
) -> str:
"""
群里再发一版摘要 + 填好的海运模板。旧消息不撤。
无价也发表,询价和协同能填的先填。
"""
ticket = hydrate_ticket(flow, ticket)
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 "").strip()
client = group_client_of(flow)
facts = dict(view.get("facts") or {})
if sess is not None and getattr(sess, "facts", None):
facts.update(dict(sess.facts or {}))
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 {}))
quote = dict(quote_override or (sess.quote if sess else None) or view.get("quote") or {})
sales_name, products, mention_ids = resolve_group_mentions(flow, ticket, sess)
has_price = bool(quote.get("total") or quote.get("fee_rows"))
brief = copy.sea_group_brief(
work_order_no=no,
facts=facts,
quote=quote,
collab_facts=collab,
sales_name=sales_name,
product_names=products,
has_price=has_price,
)
send_group_text(client, chat_id, brief, mention_userids=mention_ids)
send_filled_sheet(flow=flow, ticket=ticket, sess=sess, quote_override=quote_override)
return "brief_and_sheet"
def send_filled_sheet(
*,
flow: Any,
ticket: object,
sess: Any = None,
quote_override: Optional[dict[str, Any]] = None,
) -> None:
"""只发填好的模板文件,不再发文字摘要。"""
ticket = hydrate_ticket(flow, ticket)
view = ticket_view(ticket) if ticket is not None else {}
no = (sess.work_order_no if sess else "") or ticket_no(ticket)
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "").strip()
facts = dict(view.get("facts") or {})
if sess is not None and getattr(sess, "facts", None):
facts.update(dict(sess.facts or {}))
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 {}))
quote = dict(quote_override or (sess.quote if sess else None) or view.get("quote") or {})
_render_and_send_sheet(
flow=flow,
chat_id=chat_id,
work_order_no=no,
facts=_facts_for_fill(facts, collab),
quote=quote,
sales_id=str(
(sess.sender_id if sess else "")
or view.get("sales_wecom_id")
or view.get("salesWecomId")
or ""
),
)
def _collab_value(collab: dict[str, str], key: str) -> str:
"""协同六项:内部键、对外展示名都能取到。"""
src = dict(collab or {})
val = str(src.get(key) or "").strip()
if val:
return val
label = SEA_COLLAB_DISPLAY.get(key, "")
if label:
val = str(src.get(label) or "").strip()
return val
def _oil_elec_mag_text(collab: dict[str, str]) -> str:
"""
海运模板是一格「是否含油,电,磁」,三句话拼成一句再写入。
没说的写成 -,不编造。
"""
oil = _collab_value(collab, "是否含油")
elec = _collab_value(collab, "是否含电")
mag = _collab_value(collab, "是否含磁")
if not (oil or elec or mag):
return ""
return f"油:{oil or '-'};电:{elec or '-'};磁:{mag or '-'}"
def _facts_for_fill(facts: dict[str, str], collab: dict[str, str]) -> dict[str, str]:
"""
询价七项 + 协同六项。中文键和填表英文码一起带上。
主账 writeFactsByLabel 只认 cargo_ready_date / trade_terms,只传「货好时间」会空着。
"""
merged = dict(facts or {})
src = dict(collab or {})
for key in SEA_COLLAB_FIELDS:
val = _collab_value(src, key)
if not val:
continue
for alias in _COLLAB_FILL_ALIASES.get(key, (key,)):
merged[alias] = val
oem = _oil_elec_mag_text(src)
if oem:
merged["oil_elec_mag"] = oem
merged["是否含油,电,磁"] = oem
return merged
def _render_and_send_sheet(
*,
flow: Any,
chat_id: str,
work_order_no: str,
facts: dict[str, Any],
quote: dict[str, Any],
sales_id: str,
) -> None:
renderer = getattr(flow.ledger, "render_quote", None)
client = group_client_of(flow)
if not callable(renderer) or not chat_id:
return
result = renderer(
work_order_no=work_order_no,
business_line="SEA",
facts=facts,
quote=quote,
format="xlsx",
sales_wecom_id=sales_id,
) or {}
if not result.get("ok"):
logger.warning("群填表失败 wo=%s err=%s", work_order_no, result.get("error"))
return
filename = str(result.get("fileName") or f"{work_order_no}_quote.xlsx")
sender = getattr(client, "send_group_file", None)
if not callable(sender):
logger.warning("群发文件无客户端 wo=%s chat=%s", work_order_no, chat_id)
return
painted = overlay_labeled_facts(
filepath=str(result.get("filePath") or ""),
file_b64=str(result.get("fileBase64") or ""),
facts=facts,
)
sent = sender(
chat_id=chat_id,
filename=filename,
filepath=str(painted.get("filepath") or ""),
file_b64=str(painted.get("file_b64") or ""),
) or {}
if sent.get("ok") is False:
logger.warning("群发文件失败 wo=%s err=%s", work_order_no, sent.get("error"))
send_group_text(client, chat_id, copy.group_sheet_send_fail())
return
logger.info("群已发模板 wo=%s file=%s", work_order_no, filename)
def apply_collab_update(
*,
flow: Any,
ticket: object,
text: str,
sender_id: str,
sess: Any = None,
) -> str:
"""补协同六项。终态要说明原因。"""
no = ticket_no(ticket)
chat_id = str((sess.collab_chat_id if sess else "") or ticket_view(ticket).get("collab_chat_id") or "")
client = group_client_of(flow)
if is_terminal(ticket):
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
return "ticket_locked"
fields = extract_collab_fields(text, allow_b=True)
if not fields:
send_group_text(client, chat_id, copy.group_fields_not_recognized())
return "collab_not_recognized"
flow.apply_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id)
if sess is not None:
merged = dict(sess.collab_facts)
merged.update(fields)
sess.collab_facts = merged
flow._save(sess)
ticket2 = _reload(flow, chat_id, no) or ticket
view = ticket_view(ticket2)
collab = dict(view.get("collab_facts") or {})
collab.update(fields)
view["collab_facts"] = collab
if chat_id and not view.get("collab_chat_id"):
view["collab_chat_id"] = chat_id
send_brief_and_sheet(flow=flow, ticket=view, sess=sess)
return "collab_field_saved"
def apply_product_quote(
*,
flow: Any,
ticket: object,
text: str,
sender_id: str,
sess: Any = None,
injected_quote: Optional[dict[str, Any]] = None,
) -> str:
"""
产品文字报价:盖 TMS 价、填表发出、群里成交跟进。
一句话里的协同字段一并写入。非产品或终态要回话。
"""
no = ticket_no(ticket)
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)
if is_terminal(ticket):
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
return "ticket_locked"
if not is_product(ticket, sender_id):
return "group_ignore"
fields = extract_collab_fields(text)
if fields:
flow.apply_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id)
if sess is not None:
merged = dict(sess.collab_facts)
merged.update(fields)
sess.collab_facts = merged
quote = extract_product_quote(text, injected=injected_quote)
if not quote:
send_group_text(client, chat_id, copy.group_quote_not_recognized())
return "quote_not_recognized"
_save_product_quote(flow, no, quote, sess)
ticket2 = _reload(flow, chat_id, no) or ticket
send_brief_and_sheet(flow=flow, ticket=ticket2, sess=sess, quote_override=quote)
emit_group_deal(flow=flow, ticket=ticket2, sess=sess, quote=quote)
return "product_quote_sent"
def handle_group_file(
*,
flow: Any,
ticket: object,
sender_id: str,
filename: str,
media: dict[str, Any],
sess: Any = None,
) -> str:
"""
群附件:只收 Excel/PDF。终态不存要提醒。
产品 Excel/PDF 当报价并出成交跟进;别人的只存档。
"""
no = ticket_no(ticket)
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)
if is_terminal(ticket):
send_group_text(client, chat_id, copy.ticket_locked_file(work_order_no=no, status=ticket_status(ticket)))
return "ticket_locked_file"
if not filename_is_excel_pdf(filename):
send_group_text(client, chat_id, copy.group_only_excel_pdf())
return "file_type_rejected"
_archive_file(flow, no, filename, media, sender_id)
if is_product(ticket, sender_id):
quote = {
"source_label": "产品附件报价",
"file_name": filename,
"total": str((view.get("quote") or {}).get("total") or "-"),
}
_save_product_quote(flow, no, quote, sess)
emit_group_deal(flow=flow, ticket=ticket, sess=sess, quote=quote)
return "product_file_quote"
return "file_archived"
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 "")
client = group_client_of(flow)
members = list(view.get("collab_member_ids") or view.get("memberIds") or [])
if sess is not None:
members = list(sess.invited_userids or members)
dismiss = getattr(client, "dismiss_group", None)
if callable(dismiss) and chat_id:
dismiss(chat_id=chat_id, userids=members)
if chat_id:
try:
from agent.channel.wshoto.rooms import unwatch_collab_room
unwatch_collab_room(chat_id)
except Exception:
logger.exception("取消存档监听失败 chat=%s", chat_id)
ender = getattr(flow.ledger, "end_collab_group", None)
if callable(ender):
ender(work_order_no=no)
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"
def emit_group_deal(
*,
flow: Any,
ticket: object,
sess: Any = None,
quote: Optional[dict[str, Any]] = None,
) -> None:
"""群里只发一张成交跟进。版本 +1,旧卡点了要提示。"""
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 "")
client = group_client_of(flow)
q = dict(quote or (sess.quote if sess else None) or view.get("quote") or {})
facts = dict((sess.facts if sess else None) or view.get("facts") or {})
ver = 1
if sess is not None:
sess.deal_version = int(getattr(sess, "deal_version", 0) or 0) + 1
ver = sess.deal_version
sess.phase = "wait_deal"
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
sess.status = "已报价"
sess.quote = q
sess.wait_version += 1
flow._save(sess)
sales_name, _products, mention_ids = resolve_group_mentions(flow, ticket, sess)
text = f"@{sales_name}\n" + copy.deal_card(work_order_no=no, quote=q, facts=facts)
# 成交跟进只点销售:产品不能点这张卡。
sales_mention = mention_ids[:1] if mention_ids else []
send_group_text(client, chat_id, text, mention_userids=sales_mention)
carder = getattr(client, "send_group_card", None)
payload = copy.deal_wecom_payload(work_order_no=no, quote=q, facts=facts, card_version=ver)
if callable(carder):
carder(chat_id=chat_id, template_card=payload.get("template_card") or {})
def explain_deal_click(
*,
flow: Any,
sender_id: str,
action: str,
card_meta: Optional[dict[str, Any]],
sess: Any,
) -> str:
"""
成交卡点错人 / 旧版 / 终态:群里说清楚,不当没反应。
返回空表示可以继续走成交。
"""
if action not in {copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE}:
return ""
if sess is None:
return ""
chat_id = (sess.collab_chat_id or "").strip()
client = group_client_of(flow)
no = sess.work_order_no
if sess.status in TERMINAL:
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=sess.status))
return "ticket_locked"
if sender_id != sess.sender_id:
send_group_text(client, chat_id, copy.group_deal_sales_only())
return "not_sales"
clicked = copy.deal_card_version(card_meta)
current = int(getattr(sess, "deal_version", 0) or 0)
if current and clicked and clicked != current:
send_group_text(client, chat_id, copy.group_deal_stale(work_order_no=no))
return "stale_deal"
return ""
def _save_product_quote(flow: Any, no: str, quote: dict[str, Any], sess: Any) -> None:
writer = getattr(flow.ledger, "upsert_product_quote", None) or getattr(
flow.ledger, "upsert_quote", None
)
if callable(writer):
writer(work_order_no=no, quote=quote, to_status="已报价")
if sess is not None:
sess.quote = dict(quote)
sess.status = "已报价"
flow._save(sess)
def _archive_file(flow: Any, no: str, filename: str, media: dict[str, Any], sender_id: str) -> None:
saver = getattr(flow.ledger, "attach_file", None)
if callable(saver):
saver(
work_order_no=no,
file_name=filename,
object_key=str(media.get("media_id") or media.get("MediaId") or filename),
content_type=str(media.get("content_type") or ""),
sender_id=sender_id,
)
def _reload(flow: Any, chat_id: str, no: str) -> object:
finder = getattr(flow.ledger, "find_by_collab_chat", None)
if callable(finder) and chat_id:
hit = finder(chat_id=chat_id)
if hit is not None:
return hit
getter = getattr(flow.ledger, "get_ticket", None)
if callable(getter) and no:
return getter(work_order_no=no)
return None
+60 -6
View File
@@ -44,6 +44,16 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
super().__init__(ledger=ledger)
self._group_client = group_client
def group_client(self) -> Any:
"""
企微应用群客户端。测服正式不注入,现场再取默认 WeCom 客户端。
单测可注入 FakeGroupClient。取到后挂在流程上,后续补字段/发文件共用。
"""
if self._group_client is None:
self._group_client = _default_group_client()
return self._group_client
def on_text(
self,
*,
@@ -374,6 +384,23 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
sess = self.session_for_action(sender_id, card_meta)
if sess and action == copy.BTN_PULL_COLLAB and action in sess.allowed:
return self._pull_collab_group(sess, reply, card_meta=card_meta)
if action in {copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE}:
from agent.policy.sea_group_ops import explain_deal_click
ticket_sess = sess
if ticket_sess is None:
wo = copy.work_order_from_card_meta(card_meta)
ticket_sess = self.session_by_work_order(wo)
if ticket_sess and (ticket_sess.collab_chat_id or "").strip():
reason = explain_deal_click(
flow=self,
sender_id=sender_id,
action=action,
card_meta=card_meta,
sess=ticket_sess,
)
if reason:
return reason
return super().on_button(
sender_id=sender_id, action=action, reply=reply, card_meta=card_meta
)
@@ -390,10 +417,22 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
没人可拉 / 建群失败:说明原因,按钮保持可点。已有群:不新建,置灰。
"""
no = (sess.work_order_no or "").strip()
if sess.collab_ended:
reply(copy.group_cannot_recreate(work_order_no=no))
return "collab_ended"
getter = getattr(self._ledger, "get_ticket", None)
ticket = getter(work_order_no=no) if callable(getter) else None
if ticket is not None:
facts = dict(getattr(ticket, "collab_facts", {}) or {})
if bool(getattr(ticket, "collab_ended", False)) or str(
facts.get("__ended") or ""
).strip() in {"1", "true"}:
sess.collab_ended = True
self._save(sess)
reply(copy.group_cannot_recreate(work_order_no=no))
return "collab_ended"
existing = (sess.collab_chat_id or "").strip()
if not existing:
getter = getattr(self._ledger, "get_ticket", None)
ticket = getter(work_order_no=no) if callable(getter) else None
if not existing and ticket is not None:
existing = str(getattr(ticket, "collab_chat_id", "") or "")
if existing:
sess.collab_chat_id = existing
@@ -402,6 +441,12 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
self._save(sess)
self._grey_clicked_card(reply, card_meta)
self._ensure_archive_seats_in_group(existing)
try:
from agent.channel.wshoto.rooms import watch_collab_room
watch_collab_room(existing, work_order_no=no)
except Exception:
logger.exception("登记已有存档监听群失败 chat=%s", existing)
reply(copy.sea_group_exists(work_order_no=no))
return "collab_group"
@@ -429,13 +474,19 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
if uid not in userids:
userids.append(uid)
client = self._group_client or _default_group_client()
client = self.group_client()
created = client.create_group(name=no, userids=userids) or {}
if not created.get("ok") or not created.get("chat_id"):
reply(copy.sea_group_create_fail(reason=str(created.get("error") or "")))
return sess.phase or "wait_collab"
chat_id = str(created.get("chat_id") or "").strip()
try:
from agent.channel.wshoto.rooms import watch_collab_room
watch_collab_room(chat_id, work_order_no=no)
except Exception:
logger.exception("登记存档监听群失败 chat=%s", chat_id)
sales_name = self._sales_display_name(sess.sender_id)
binder = getattr(self._ledger, "bind_collab_group", None)
if callable(binder):
@@ -444,6 +495,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
chat_id=chat_id,
member_ids=userids,
product_names=product_names,
product_ids=product_ids,
)
brief = copy.sea_group_brief(
@@ -456,8 +508,10 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
has_price=bool(sess.quote and sess.status == "已报价"),
)
client.send_group(chat_id=chat_id, content=brief)
sess.collab_chat_id = chat_id
from agent.policy.sea_group_ops import send_filled_sheet
send_filled_sheet(flow=self, ticket=None, sess=sess)
sess.invited_userids = tuple(userids)
sess.invited_sales_name = sales_name
sess.invited_product_names = tuple(product_names)
@@ -525,7 +579,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
seats = self._archive_seat_userids()
if not chat_id or not seats:
return
client = self._group_client or _default_group_client()
client = self.group_client()
adder = getattr(client, "add_group_members", None)
if not callable(adder):
return
+5
View File
@@ -44,7 +44,12 @@ STREAM_WAKE = "stream:wake"
IDEMPOTENCY = "idem"
RATE_LIMIT = "ratelimit"
TOKEN_WECOM = "token:wecom"
TOKEN_WSHOTO = "token:wshoto"
TOKEN_COPY_TICKET = "token:copy_ticket"
TOKEN_JSAPI = "token:jsapi"
# 微盛查询游标(本前缀下的 last_seq,不是现网官方 SDK 消费游标)
WSHOTO_LAST_SEQ = "wshoto:last_seq"
WSHOTO_ROOMS = "wshoto:rooms"
WSHOTO_ROOM_TICKET = "wshoto:room_ticket"
# Worker 出完报价文件后写给 HTTP:短协调,不当六态
QUOTE_FILE_READY = "quote_file_ready"
+23 -1
View File
@@ -11,7 +11,7 @@ import logging
from typing import Optional
from agent.redis_coord.client import RedisClient
from agent.redis_coord.keys import TOKEN_WECOM
from agent.redis_coord.keys import TOKEN_WECOM, TOKEN_WSHOTO
logger = logging.getLogger(__name__)
@@ -40,3 +40,25 @@ def set_wecom_token(
key = client.key(TOKEN_WECOM, agent_id)
client.raw.set(key, token, ex=ttl_seconds)
logger.info("企微 token 已缓存 agent_id=%s ttl=%s", agent_id, ttl_seconds)
def get_wshoto_token(client: RedisClient, *, app_id: str) -> Optional[str]:
"""读取微盛 access_token 缓存;未命中返回 None。不把 token 当业务真相。"""
key = client.key(TOKEN_WSHOTO, app_id or "default")
val = client.raw.get(key)
if val is None:
return None
return val if isinstance(val, str) else val.decode("utf-8")
def set_wshoto_token(
client: RedisClient,
*,
app_id: str,
token: str,
ttl_seconds: int = 7000,
) -> None:
"""写入微盛 token;TTL 略短于接口 expires_in,避免用过期票去拉群。"""
key = client.key(TOKEN_WSHOTO, app_id or "default")
client.raw.set(key, token, ex=max(60, int(ttl_seconds)))
logger.info("微盛 token 已缓存 ttl=%s", ttl_seconds)
+17 -1
View File
@@ -68,12 +68,17 @@ def _wrap_card(message: InboundMessage) -> None:
from agent.policy import inquiry_copy as copy
action = copy.canonical_button(message.content or "") or (message.content or "").strip()
payload = dict(message.raw or {})
payload.setdefault("event_key", message.content or "")
if message.chat_id:
payload["chat_id"] = message.chat_id
payload["chat_type"] = message.chat_type
card_action.handle_card_action(
sender_id=message.sender_id,
thread_id="",
wait_version=0,
action=action or "unknown",
payload=dict(message.raw or {}),
payload=payload,
)
@@ -136,6 +141,17 @@ def dispatch_inbound(message: InboundMessage, decision: RouteDecision) -> str:
副作用:仅 handler 内产生;本函数不做业务。
"""
if (message.chat_type or "") == "group":
from agent.policy import inquiry_copy as copy
event = str((message.raw or {}).get("event") or "").lower()
action = copy.canonical_button(message.content or "")
if event in {"template_card_event", "click", "templatecardevent"} or action in {
copy.BTN_DEAL,
copy.BTN_LOST,
copy.BTN_NEGOTIATE,
}:
_wrap_card(message)
return "card_action"
phase = handle_group_collab(message)
logger.info("dispatch group_collab phase=%s chat=%s", phase, message.chat_id)
return "group_collab"
@@ -0,0 +1,91 @@
"""
海运协同群文字意图:DeepSeek A 判断补字段 / 产品报价 / 解散 / 其它。
本文件职责:只分类,不写主账、不发企微。
禁止:把「解散群」写成唯一口令;私聊按钮不走这里。
"""
from __future__ import annotations
import logging
from typing import Optional
from agent.llm.extract_text import extract_collab_fields
logger = logging.getLogger(__name__)
INTENT_FIELDS = "collab_fields"
INTENT_QUOTE = "product_quote"
INTENT_DISSOLVE = "dissolve"
INTENT_OTHER = "other"
_VALID = {INTENT_FIELDS, INTENT_QUOTE, INTENT_DISSOLVE, INTENT_OTHER}
def classify_group_text(
text: str,
*,
injected: str = "",
allow_network: Optional[bool] = None,
) -> str:
"""
群里一句文字的意图。单测可注入;正式走模型。
模型不可用时:只认带标签的协同字段(键:值是合同,不是口令),其余当其它。
"""
hint = (injected or "").strip()
if hint in _VALID:
return hint
raw = (text or "").strip()
if not raw:
return INTENT_OTHER
try:
from agent.config import get_settings
settings = get_settings()
use_net = (
allow_network
if allow_network is not None
else bool(getattr(settings, "llm_allow_network", False))
)
if use_net and getattr(settings, "llm_data_usage_confirmed", False):
got = _classify_with_model(raw, settings)
if got in _VALID:
return got
except Exception:
logger.exception("群意图模型失败,回退标签字段")
if extract_collab_fields(raw):
return INTENT_FIELDS
from agent.llm.extract_text import extract_product_quote
if extract_product_quote(raw):
return INTENT_QUOTE
return INTENT_OTHER
def _classify_with_model(text: str, settings) -> str:
"""DeepSeek 只回一个意图词。失败空串。"""
from agent.llm.http_client import LlmHttpClient
from agent.llm.modes import LlmMode
client = LlmHttpClient.from_settings(settings)
system = (
"你是海运协同群意图分类。只回复一个英文词,不要解释。"
"collab_fields=在补贸易条款/货好时间/商品海关编码/含油含电含磁;"
"product_quote=产品在报价格或费用;"
"dissolve=要把这个协同群解散;"
"other=闲聊、问候、对询价港口的随口更正(不当改单)。"
"一句话里既补字段又报价,回 product_quote。"
)
data = client.chat(
mode=LlmMode.ORDERED_ACTIONS,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": text[:2000]},
],
)
content = str(getattr(data, "content", "") or "").strip().lower()
for token in (INTENT_QUOTE, INTENT_DISSOLVE, INTENT_FIELDS, INTENT_OTHER):
if token in content:
return token
return ""
+33 -1
View File
@@ -44,8 +44,12 @@ SEA_REQUIRED_FIELDS = (
)
SEA_DISPLAY = {key: label for key, label in SEA_REQUIRED_FIELDS}
# 协同补充字段:拉群后可静默写入,不挡首次查价。
# 协同补充字段:拉群后可静默写入,不挡首次查价。内部键仍是 HS编码。
SEA_COLLAB_FIELDS = ("贸易条款", "货好时间", "HS编码", "是否含油", "是否含电", "是否含磁")
# 群摘要对外展示名。内部继续用 HS编码,避免改合同键。
SEA_COLLAB_DISPLAY = {
"HS编码": "商品海关编码",
}
# 口语里已经带上 TMS 文档规定的单位时,补进空字段。
# 只认「数字+单位」,不猜港口,不把长宽高换算成立方。
@@ -80,6 +84,8 @@ _ALIASES = {
"车型/数量": "车型数量",
"HS": "HS编码",
"HS CODE": "HS编码",
"商品海关编码": "HS编码",
"海关编码": "HS编码",
}
@@ -178,6 +184,32 @@ def harvest_oral_measures(text: str, facts: dict[str, str] | None = None) -> dic
return harvest_named_air_route(raw, merged)
def harvest_oral_collab(text: str) -> dict[str, str]:
"""
群里口语协同三项:含油/含电/含磁、货好时间。
只认原话里已经说死的词(不含电、随时可提),不猜贸易条款和海关编码。
"""
raw = text or ""
out: dict[str, str] = {}
yes_no = (
("是否含电", ("不含电", "不带电", "无电"), ("含电", "带电")),
("是否含油", ("不含油", "不带油", "无油"), ("含油", "带油")),
("是否含磁", ("不含磁", "不带磁", "无磁"), ("含磁", "带磁")),
)
for key, no_words, yes_words in yes_no:
if any(w in raw for w in no_words):
out[key] = "否"
elif any(w in raw for w in yes_words):
out[key] = "是"
ready = ("随时可提", "货好随时", "随时货好", "货已好", "已经货好")
for word in ready:
if word in raw:
out["货好时间"] = word
break
return out
def project_sea_cargo_qty(facts: dict[str, str] | None) -> dict[str, str]:
"""
海运货物数量投影:原话「15件」常被抽成空运「件数」。