Files
inquiry_robot/inquiry-agent/agent/policy/image_wave.py
T

387 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
私聊图片波次账。
本文件职责:记住「这一波」图和紧挨的字,供图片询价拼口播。
属于 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