- Added a new visitor chat API endpoint (`/api/chat/visitor`) to allow unauthenticated users to engage in conversations without requiring customer data. - Introduced a visitor context dependency to manage visitor interactions seamlessly. - Enhanced the chat API to support explicit session termination and improved response handling for customer service interactions. - Updated the database configuration to include Redis client support for caching visitor data. - Added a new customer note repository to persist user notes independently of the L1 profile slots. This update significantly improves the customer service experience by enabling visitor interactions and ensuring efficient data handling for both registered and unregistered users.
109 lines
4.2 KiB
Python
109 lines
4.2 KiB
Python
"""记忆服务(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
|
||
import re
|
||
import time
|
||
|
||
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)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 客服线 · 游客短期记忆(redis-keys §2.2 visitor:{session}:{kind})
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class VisitorMemoryService:
|
||
"""游客短期记忆:闲聊和咨询分库存储。"""
|
||
|
||
KINDS = ("chitchat", "consult")
|
||
|
||
def __init__(self, redis_client=None) -> None:
|
||
self._redis = redis_client or get_gateway()
|
||
self._ttl = settings.visitor_session_ttl
|
||
self._max_rounds = {
|
||
"chitchat": settings.visitor_chitchat_max_rounds,
|
||
"consult": settings.visitor_consult_max_rounds,
|
||
}
|
||
|
||
def key(self, session_id: str, kind: str) -> str:
|
||
safe_session = re.sub(r"[^A-Za-z0-9:_-]", "", session_id)[:64]
|
||
return f"visitor:{safe_session}:{kind}"
|
||
|
||
def recall(self, session_id: str, kind: str) -> list[dict]:
|
||
k = self.key(session_id, kind)
|
||
raw_list = self._redis.lrange(k, 0, -1)
|
||
self._redis.expire(k, self._ttl)
|
||
return [json.loads(raw) for raw in raw_list]
|
||
|
||
def append(self, session_id: str, kind: str, role: str, content: str) -> None:
|
||
k = self.key(session_id, kind)
|
||
msg = json.dumps({"role": role, "content": content, "ts": int(time.time())}, ensure_ascii=False)
|
||
self._redis.rpush(k, msg)
|
||
max_msgs = self._max_rounds[kind] * 2
|
||
self._redis.ltrim(k, -max_msgs, -1)
|
||
self._redis.expire(k, self._ttl)
|
||
|
||
def as_prompt_text(self, session_id: str, kind: str, max_pairs: int = 8) -> str:
|
||
messages = self.recall(session_id, kind)
|
||
recent = messages[-(max_pairs * 2) :]
|
||
lines: list[str] = []
|
||
for msg in recent:
|
||
role = "用户" if msg["role"] == "user" else "客服"
|
||
lines.append(f"{role}: {msg['content']}")
|
||
return "\n".join(lines)
|