落地私聊图片询价:识图当口播复用三线文字流程,图上航线三字码保持原样,避免被改写成中文地名。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user