"""记忆服务(T-06 最小闭环):Redis 会话窗口 + MySQL 权威落盘。 口径(redis-keys §2.1):`sess:{agent}:{session_id}:msgs` List,TTL 2h, 最近 N≤20 条 JSON;Redis 只保窗口、丢失可重建(miss/异常回源 MySQL, MySQL 为权威)。L1/L2/L3 画像读写归后续任务(画像不得覆盖 L0 正式测评)。 落盘策略:「每条消息同步或 5s 内异步写 MySQL」——最小闭环同步写(api/chat 双条落库),异步化归后续优化。 """ from __future__ import annotations import json import logging from app.config.settings import settings from app.repository.session_repository import SessionRepository from app.service.risk.redis_gateway import get_gateway logger = logging.getLogger(__name__) SESSION_TTL_SECONDS = 2 * 3600 WINDOW_SIZE = 20 def window_key(agent_type: str, session_id: str) -> str: """redis-keys §2.1:sess:{agent}:{session_id}:msgs。""" return f"sess:{agent_type}:{session_id}:msgs" def _session_repo() -> SessionRepository: """回源仓储入口(测试 monkeypatch 点,与 api 模式一致)。""" return SessionRepository() def get_recent(agent_type: str, session_id: str, limit: int = WINDOW_SIZE) -> list[dict]: """最近 N 轮消息([{role, content}]);Redis miss/异常回源 MySQL。""" try: raw = get_gateway().lrange(window_key(agent_type, session_id), -limit, -1) if raw: return [json.loads(item) for item in raw] except Exception: logger.warning("session window read failed, fallback to MySQL: %s", session_id, exc_info=True) return _session_repo().list_messages(session_id, limit=limit) def append_window( agent_type: str, session_id: str, messages: list[dict], ) -> None: """追加窗口(RPUSH + LTRIM 保留最近 N 条 + TTL 续期);失败降级不阻塞。""" try: key = window_key(agent_type, session_id) client = get_gateway() client.rpush(key, *[json.dumps(m, ensure_ascii=False) for m in messages]) client.ltrim(key, -WINDOW_SIZE, -1) client.expire(key, SESSION_TTL_SECONDS) except Exception: # 窗口丢失可重建(下次读回源 MySQL),不阻塞对话主链路 logger.warning("session window append failed (degrade): %s", session_id, exc_info=True)