落地私聊图片询价:识图当口播复用三线文字流程,图上航线三字码保持原样,避免被改写成中文地名。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-18 15:51:55 +08:00
co-authored by Cursor
parent 982b3a2317
commit 8219dce84b
30 changed files with 2153 additions and 45 deletions
+105 -13
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
import logging
import threading
import uuid
from dataclasses import dataclass, field
from dataclasses import asdict, dataclass, field
from typing import Any, Callable, Optional
from agent.ledger.http_ledger import HttpLedger
@@ -55,6 +55,43 @@ class FlowSession:
pending_product_quote: dict[str, Any] = field(default_factory=dict)
def _session_payload(sess: FlowSession) -> dict[str, Any]:
"""书签转 JSON。tuple 改 list,方便 HTTP/Worker 共用。"""
data = asdict(sess)
data["allowed"] = list(sess.allowed or ())
data["invited_userids"] = list(sess.invited_userids or ())
data["invited_product_names"] = list(sess.invited_product_names or ())
data["facts"] = {str(k): str(v) for k, v in (sess.facts or {}).items()}
return data
def _session_from_payload(raw: dict[str, Any]) -> FlowSession:
"""Redis JSON → 书签。缺字段用默认,避免旧键打崩。"""
return FlowSession(
sender_id=str(raw.get("sender_id") or ""),
thread_id=str(raw.get("thread_id") or raw.get("sender_id") or ""),
wait_version=int(raw.get("wait_version") or 0),
phase=str(raw.get("phase") or "idle"),
business_line=str(raw.get("business_line") or ""),
facts={str(k): str(v) for k, v in dict(raw.get("facts") or {}).items()},
work_order_no=str(raw.get("work_order_no") or ""),
first_or_same=str(raw.get("first_or_same") or "first"),
history_work_order_no=str(raw.get("history_work_order_no") or ""),
quote=dict(raw.get("quote") or {}),
status=str(raw.get("status") or ""),
allowed=tuple(str(x) for x in (raw.get("allowed") or ())),
immutable_text=str(raw.get("immutable_text") or ""),
collab_chat_id=str(raw.get("collab_chat_id") or ""),
collab_facts={str(k): str(v) for k, v in dict(raw.get("collab_facts") or {}).items()},
invited_userids=tuple(str(x) for x in (raw.get("invited_userids") or ())),
invited_sales_name=str(raw.get("invited_sales_name") or ""),
invited_product_names=tuple(str(x) for x in (raw.get("invited_product_names") or ())),
deal_version=int(raw.get("deal_version") or 0),
collab_ended=bool(raw.get("collab_ended")),
pending_product_quote=dict(raw.get("pending_product_quote") or {}),
)
class AirTextInquiryFlow:
"""
空运文字询价 Owner。海运走 SeaTextInquiryFlow,陆运走 LandTextInquiryFlow。
@@ -63,12 +100,15 @@ class AirTextInquiryFlow:
价格卡按工单号另存一份当时会话:点哪张卡就生成哪张卡当时的报价单。
"""
def __init__(self, ledger: Any = None, group_client: Any = None) -> None:
bookmark_kind = "air"
def __init__(self, ledger: Any = None, group_client: Any = None, *, bookmark_store: Any = None) -> None:
self._ledger = ledger or build_ledger()
self._lock = threading.Lock()
self._by_sender: dict[str, FlowSession] = {}
self._by_ticket: dict[str, FlowSession] = {}
self._group_client = group_client
self._bookmark_store = bookmark_store
@property
def ledger(self) -> Any:
@@ -83,8 +123,16 @@ class AirTextInquiryFlow:
return self._group_client
def session_of(self, sender_id: str) -> Optional[FlowSession]:
sid = (sender_id or "").strip()
with self._lock:
return self._by_sender.get(sender_id)
hit = self._by_sender.get(sid)
if hit is not None:
return hit
loaded = self._load_bookmark(sid)
if loaded is None:
return None
self._save(loaded)
return loaded
def session_by_work_order(self, work_order_no: str) -> Optional[FlowSession]:
"""用工单号找回书签,不校验 sender。群成交卡校验用。"""
@@ -126,17 +174,14 @@ class AirTextInquiryFlow:
必须校验 sender_id,禁止用别人的工单号串会话。
"""
no = copy.work_order_from_card_meta(card_meta, text=text)
if not no:
return self.session_of(sender_id)
with self._lock:
if no:
hit = self._by_ticket.get(no)
if hit and hit.sender_id == sender_id:
return hit
elif not no:
return self._by_sender.get(sender_id)
if no:
# 卡上有工单号就只动这一单;进程刚重启时从主账收回,禁止落到别人/最新单。
return self._load_ticket_session(sender_id, no)
return None
hit = self._by_ticket.get(no)
if hit and hit.sender_id == sender_id:
return hit
# 卡上有工单号就只动这一单;进程刚重启时从主账收回,禁止落到别人/最新单。
return self._load_ticket_session(sender_id, no)
def _save(self, sess: FlowSession) -> None:
with self._lock:
@@ -144,6 +189,53 @@ class AirTextInquiryFlow:
no = (sess.work_order_no or "").strip()
if no:
self._by_ticket[no] = sess
self._persist_bookmark(sess)
def drop_current_bookmark(self, sender_id: str) -> Optional[FlowSession]:
"""
发图新开:丢掉当前书签,已出号会话仍留在 _by_ticket。
未出号的补问/核对草稿从此不再被文字当「当前单」。
HTTP 与 Worker 必须同时丢掉 Redis 里那份,否则补「托盘」会问运输方式。
"""
sid = (sender_id or "").strip()
if not sid:
return None
with self._lock:
old = self._by_sender.pop(sid, None)
self._delete_bookmark(sid)
return old
def _persist_bookmark(self, sess: FlowSession) -> None:
from agent.redis_coord.flow_bookmark import save_flow_bookmark
save_flow_bookmark(
self.bookmark_kind,
sess.sender_id,
_session_payload(sess),
store=self._bookmark_store,
)
def _load_bookmark(self, sender_id: str) -> Optional[FlowSession]:
from agent.redis_coord.flow_bookmark import load_flow_bookmark
raw = load_flow_bookmark(
self.bookmark_kind,
sender_id,
store=self._bookmark_store,
)
if not raw:
return None
return _session_from_payload(raw)
def _delete_bookmark(self, sender_id: str) -> None:
from agent.redis_coord.flow_bookmark import delete_flow_bookmark
delete_flow_bookmark(
self.bookmark_kind,
sender_id,
store=self._bookmark_store,
)
def _load_ticket_session(self, sender_id: str, work_order_no: str) -> Optional[FlowSession]:
"""
+386
View File
@@ -0,0 +1,386 @@
"""
私聊图片波次账。
本文件职责:记住「这一波」图和紧挨的字,供图片询价拼口播。
属于 policy 包。生产波次内容落 Redis(HTTP 与 Worker 共用);单测可纯内存。
禁止:调模型、建单、写六态、在这里等几秒;禁止一把大锁串全部销售。
10 秒只约束发图前那一句。发图之后只看业务结果出没出。
"""
from __future__ import annotations
import json
import logging
import threading
import uuid
from dataclasses import asdict, dataclass, field
from typing import Any, Optional, Protocol
# 规格:发图前紧挨的一句字,间隔不超过 10 秒才带到新单。
PRECEDE_SECONDS = 10.0
# 波次短协调:超时后当新开,不当六态。
_WAVE_TTL_SEC = 2 * 3600
logger = logging.getLogger(__name__)
class WaveKV(Protocol):
"""HTTP 与 Worker 共用的短存。单测可注入内存假实现。"""
def get_json(self, kind: str, sender_id: str) -> Optional[dict[str, Any]]: ...
def set_json(self, kind: str, sender_id: str, data: dict[str, Any]) -> None: ...
def delete(self, kind: str, sender_id: str) -> None: ...
@dataclass
class LastPrivateText:
"""同一发送人最近一句私聊文字。"""
sender_id: str
text: str
ts: float
@dataclass
class ImageWave:
"""
一张新询价对应的图片波次。
pending:还在认的图张数;为 0 且未 business_done 时才能出业务结果。
"""
sender_id: str
wave_id: str
ack_sent: bool = False
business_done: bool = False
media_ids: list[str] = field(default_factory=list)
vision_texts: list[str] = field(default_factory=list)
pending: int = 0
precede_text: str = ""
follow_text: str = ""
object_keys: list[str] = field(default_factory=list)
filenames: list[str] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
class ImageWaveBook:
"""
图片波次账。按 sender 一把锁,不同销售互不等。
生产必须挂 Redis:收图在 HTTP 进程、识图在 Worker,两边不能各记一份内存。
单测不传 kv,仍走进程内字典。
"""
def __init__(self, kv: Optional[WaveKV] = None) -> None:
self._lock = threading.Lock()
self._kv = kv
self._last_text: dict[str, LastPrivateText] = {}
self._waves: dict[str, ImageWave] = {}
def _hydrate(self, sender_id: str) -> None:
"""从 Redis 覆盖本进程缓存。无 kv 时不做事。"""
sid = (sender_id or "").strip()
if not sid or self._kv is None:
return
try:
raw_wave = self._kv.get_json("wave", sid)
if raw_wave:
self._waves[sid] = _wave_from_dict(raw_wave)
else:
self._waves.pop(sid, None)
raw_text = self._kv.get_json("text", sid)
if raw_text and raw_text.get("text"):
self._last_text[sid] = LastPrivateText(
sender_id=sid,
text=str(raw_text.get("text") or ""),
ts=float(raw_text.get("ts") or 0.0),
)
else:
self._last_text.pop(sid, None)
except Exception:
logger.exception("读图片波次失败 sender=%s", sid)
def _persist(self, sender_id: str) -> None:
"""把当前 sender 的波次和前一句写回 Redis。"""
sid = (sender_id or "").strip()
if not sid or self._kv is None:
return
try:
wave = self._waves.get(sid)
if wave is None:
self._kv.delete("wave", sid)
else:
self._kv.set_json("wave", sid, asdict(wave))
row = self._last_text.get(sid)
if row is None:
self._kv.delete("text", sid)
else:
self._kv.set_json("text", sid, {"text": row.text, "ts": row.ts})
except Exception:
logger.exception("写图片波次失败 sender=%s", sid)
def note_text(self, sender_id: str, text: str, now: float) -> None:
"""记下最近一句私聊文字,供 10 秒内发图带走。空句不记。"""
body = (text or "").strip()
sid = (sender_id or "").strip()
if not sid or not body:
return
with self._lock:
self._hydrate(sid)
self._last_text[sid] = LastPrivateText(sender_id=sid, text=body, ts=float(now))
self._persist(sid)
def forget_text(self, sender_id: str) -> None:
"""
图片口播不是销售刚打的字,认完后清掉,避免 10 秒内再发图把旧口播带走。
"""
sid = (sender_id or "").strip()
if not sid:
return
with self._lock:
self._hydrate(sid)
self._last_text.pop(sid, None)
self._persist(sid)
def precede_text(self, sender_id: str, now: float) -> str:
"""发图前那一句:超过 10 秒返回空。"""
sid = (sender_id or "").strip()
with self._lock:
self._hydrate(sid)
row = self._last_text.get(sid)
if row is None:
return ""
if float(now) - row.ts > PRECEDE_SECONDS:
return ""
return row.text
def active(self, sender_id: str) -> Optional[ImageWave]:
"""未出业务结果的当前波;没有则 None。"""
sid = (sender_id or "").strip()
with self._lock:
self._hydrate(sid)
wave = self._waves.get(sid)
if wave is None or wave.business_done:
return None
return wave
def open_or_join(
self, sender_id: str, media_id: str, *, now: float
) -> tuple[ImageWave, str]:
"""
第一张图开波;业务发出前的后续图并入;发出后再发图开新波。
返回 how:opened / joined / new_after_done。
副作用:pending +1。不发送企微。
"""
sid = (sender_id or "").strip()
mid = (media_id or "").strip()
precede = self.precede_text(sid, now)
with self._lock:
self._hydrate(sid)
cur = self._waves.get(sid)
if cur is not None and not cur.business_done:
if mid:
cur.media_ids.append(mid)
cur.pending += 1
self._persist(sid)
return cur, "joined"
how = "new_after_done" if cur is not None and cur.business_done else "opened"
wave = ImageWave(
sender_id=sid,
wave_id=uuid.uuid4().hex,
media_ids=[mid] if mid else [],
pending=1,
precede_text=precede,
)
self._waves[sid] = wave
self._persist(sid)
return wave, how
def mark_ack(self, sender_id: str) -> None:
"""已经回过「正在识别图片」,连发第二张不得再 ack。"""
sid = (sender_id or "").strip()
with self._lock:
self._hydrate(sid)
wave = self._waves.get(sid)
if wave is not None:
wave.ack_sent = True
self._persist(sid)
def append_follow_text(self, sender_id: str, text: str) -> bool:
"""业务结果发出前的字并进本波。发出后返回 False,交给文字询价。"""
body = (text or "").strip()
sid = (sender_id or "").strip()
if not sid or not body:
return False
with self._lock:
self._hydrate(sid)
wave = self._waves.get(sid)
if wave is None or wave.business_done:
return False
if wave.follow_text:
wave.follow_text = f"{wave.follow_text}\n{body}"
else:
wave.follow_text = body
self._persist(sid)
return True
def add_vision(
self,
sender_id: str,
text: str,
*,
object_key: str = "",
filename: str = "",
error: str = "",
) -> ImageWave:
"""
一张图认完。pending 减一,可见原文追加。
调用:Worker 识别槽。不得在本方法出发业务回复。
"""
sid = (sender_id or "").strip()
with self._lock:
self._hydrate(sid)
wave = self._waves.get(sid)
if wave is None:
wave = ImageWave(sender_id=sid, wave_id=uuid.uuid4().hex, pending=1)
self._waves[sid] = wave
body = (text or "").strip()
if body:
wave.vision_texts.append(body)
if object_key:
wave.object_keys.append(object_key)
if filename:
wave.filenames.append(filename)
if error:
wave.errors.append(error)
if wave.pending > 0:
wave.pending -= 1
self._persist(sid)
return wave
def compose_oral(self, sender_id: str) -> str:
"""precede + 各张可见原文 + 后到的字,换行拼接。"""
sid = (sender_id or "").strip()
with self._lock:
self._hydrate(sid)
wave = self._waves.get(sid)
if wave is None:
return ""
parts = []
if wave.precede_text.strip():
parts.append(wave.precede_text.strip())
for chunk in wave.vision_texts:
if chunk.strip():
parts.append(chunk.strip())
if wave.follow_text.strip():
parts.append(wave.follow_text.strip())
return "\n".join(parts)
def mark_business_done(self, sender_id: str) -> None:
"""业务补问/卡片已发出。之后再发图必须新开。"""
sid = (sender_id or "").strip()
with self._lock:
self._hydrate(sid)
wave = self._waves.get(sid)
if wave is not None:
wave.business_done = True
wave.pending = 0
self._persist(sid)
_BOOK: Optional[ImageWaveBook] = None
_BOOK_GUARD = threading.Lock()
def _wave_from_dict(raw: dict[str, Any]) -> ImageWave:
"""Redis JSON → 波次对象。缺字段用默认,避免旧键打崩。"""
return ImageWave(
sender_id=str(raw.get("sender_id") or ""),
wave_id=str(raw.get("wave_id") or uuid.uuid4().hex),
ack_sent=bool(raw.get("ack_sent")),
business_done=bool(raw.get("business_done")),
media_ids=[str(x) for x in (raw.get("media_ids") or [])],
vision_texts=[str(x) for x in (raw.get("vision_texts") or [])],
pending=int(raw.get("pending") or 0),
precede_text=str(raw.get("precede_text") or ""),
follow_text=str(raw.get("follow_text") or ""),
object_keys=[str(x) for x in (raw.get("object_keys") or [])],
filenames=[str(x) for x in (raw.get("filenames") or [])],
errors=[str(x) for x in (raw.get("errors") or [])],
)
class RedisWaveKV:
"""
把图片波次落到本前缀 Redis,TTL 2 小时。
HTTP 收图、Worker 认图必须读同一把键,否则第二张图会丢。
"""
def __init__(self, client: Any) -> None:
self._client = client
def get_json(self, kind: str, sender_id: str) -> Optional[dict[str, Any]]:
from agent.redis_coord.keys import IMAGE_WAVE, IMAGE_WAVE_TEXT
suffix = IMAGE_WAVE if kind == "wave" else IMAGE_WAVE_TEXT
raw = self._client.raw.get(self._client.key(suffix, sender_id))
if not raw:
return None
if isinstance(raw, bytes):
raw = raw.decode("utf-8")
data = json.loads(raw)
return dict(data) if isinstance(data, dict) else None
def set_json(self, kind: str, sender_id: str, data: dict[str, Any]) -> None:
from agent.redis_coord.keys import IMAGE_WAVE, IMAGE_WAVE_TEXT
suffix = IMAGE_WAVE if kind == "wave" else IMAGE_WAVE_TEXT
self._client.raw.set(
self._client.key(suffix, sender_id),
json.dumps(data, ensure_ascii=False),
ex=_WAVE_TTL_SEC,
)
def delete(self, kind: str, sender_id: str) -> None:
from agent.redis_coord.keys import IMAGE_WAVE, IMAGE_WAVE_TEXT
suffix = IMAGE_WAVE if kind == "wave" else IMAGE_WAVE_TEXT
self._client.raw.delete(self._client.key(suffix, sender_id))
def _production_kv() -> Optional[WaveKV]:
"""生产挂 Redis;连不上则退回内存并打日志。"""
try:
from agent.redis_coord.runtime import get_redis_runtime
rt = get_redis_runtime()
if rt is None or rt.client is None:
return None
return RedisWaveKV(rt.client)
except Exception:
logger.exception("图片波次 Redis 不可用,本进程退回内存账(双进程会丢第二张图)")
return None
def get_image_wave_book() -> ImageWaveBook:
"""
进程内一份句柄;生产波次内容在 Redis,HTTP 与 Worker 才能对上。
"""
global _BOOK
with _BOOK_GUARD:
if _BOOK is None:
_BOOK = ImageWaveBook(kv=_production_kv())
return _BOOK
def reset_image_wave_book_for_test(kv: Optional[WaveKV] = None) -> ImageWaveBook:
"""单测重置。传入同一 kv 可模拟 HTTP/Worker 两份句柄。"""
global _BOOK
with _BOOK_GUARD:
_BOOK = ImageWaveBook(kv=kv)
return _BOOK
+54 -10
View File
@@ -100,6 +100,24 @@ _CONTINUE_HINT = (
ASK_TRANSPORT = "请补充运输方式:海运 / 空运 / 陆运。"
# 私聊图片询价:先回识别中;看不清/失败与补问拼成一条,销售侧不加 [test]。
IMAGE_RECOGNIZING = "正在识别图片"
IMAGE_UNREADABLE = "图片没看清询价信息,请换一张更清楚的,或直接打字。"
IMAGE_TECH_FAIL = "图片暂时没认出来,请再发一张或直接打字。"
def image_fail_then_clarify(reason: str, clarify: str) -> str:
"""
看不清或技术失败时,说明 + 文字询价补问写在同一条回复。
禁止只回说明就把人丢在那里。两端空白去掉,中间空一行。
"""
head = (reason or "").strip()
body = (clarify or "").strip()
if head and body:
return f"{head}\n\n{body}"
return head or body
# 旧一句只问运输类型;销售要一次看到类型、线路、分类。残留调用也走完整清单。
ASK_LAND_SUBTYPE = _land_option_prompt(current={})
@@ -195,6 +213,24 @@ def land_group_pair_rejected() -> str:
return "拉群失败,请排查填写内容,并重新发起工单。"
def _land_optional_keys(land_type: str) -> tuple[str, ...]:
"""当前类型询价非必填内部键。国内没有通关口岸。"""
if land_type in _LAND_CUSTOMS_TYPES:
return ("通关口岸",)
return ()
def _land_field_line(key: str, value: str, *, required: bool, example: str = "") -> str:
"""销售侧一行:名称(必填/非必填):值或示例。"""
label = LAND_DISPLAY.get(key, key)
mark = "必填" if required else "非必填"
if value:
return f"{label}({mark}):{value}"
if example:
return f"{label}({mark}):(如:{example})"
return f"{label}({mark}):"
def ask_land_fields(
*,
facts: dict[str, str] | None = None,
@@ -204,30 +240,37 @@ def ask_land_fields(
"""
陆运货物缺项补问。不出核对卡、不出现确定句。
已识别和待补充都标明必填/非必填。跨境/中港空着的通关口岸也列进待补充。
报价日期不进待补充。
"""
src = dict(facts or {})
skip = {"报价日期", "运输类型", "线路类别", "运输分类"}
missing = [k for k in missing_keys if k and k not in skip]
land_type = (land_subtype or src.get("运输类型") or "").strip()
optional = _land_optional_keys(land_type)
lines = ["请确认并补充以下信息:", "运输方式:陆运"]
if land_type:
lines.append(f"运输类型:{land_type}")
for key in _land_cargo_keys(land_type, src):
val = (src.get(key) or "").strip()
if val:
lines.append(f"{LAND_DISPLAY.get(key, key)}:{val}")
lines.append(_land_field_line(key, val, required=True))
for key in optional:
val = (src.get(key) or "").strip()
if val:
lines.append(_land_field_line(key, val, required=False))
pending = list(missing)
for key in optional:
if not (src.get(key) or "").strip() and key not in pending:
pending.append(key)
if not pending:
return ""
lines.append("")
lines.append("待补充:")
if not missing:
return ""
for key in missing:
label = display_name(key, "LAND")
for key in pending:
required = key not in optional
example = _LAND_EXAMPLES.get(key, "")
if example:
lines.append(f"{label}:(如:{example})")
else:
lines.append(f"{label}:")
lines.append(_land_field_line(key, "", required=required, example=example))
return "\n".join(lines)
@@ -367,7 +410,8 @@ def inquiry_field_lines(
"""
询价确认字段行(销售可见)。
起运/目的展示销售原文(南京/北京),三字码只给出站 TMS,不对销售展示。
起运/目的展示销售原文:说南京就南京,图上/原话是 SZX 就展示 SZX。
originCode 只给出站 TMS,不拿来改写成城市名再给销售看。
"""
origin = (facts.get("起运港") or facts.get("起运地") or "-").strip()
dest = (facts.get("目的港") or facts.get("目的地") or "-").strip()
@@ -41,6 +41,8 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
前半段与海运不同:核对通过前不建单。后半段套海运建群,匹配改走线路类别。
"""
bookmark_kind = "land"
def on_text(
self,
*,
@@ -40,6 +40,8 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
复用空运后半段(跳过协同 / 出方案卡 / Excel PDF / 成交),只改查价前与卡片、拉群。
"""
bookmark_kind = "sea"
def __init__(self, ledger: Any = None, group_client: Any = None) -> None:
super().__init__(ledger=ledger)
self._group_client = group_client