""" 私聊图片波次账。 本文件职责:记住「这一波」图和紧挨的字,供图片询价拼口播。 属于 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