Files
group_xinghuo_jinrong/app/service/memory_service.py
T
zhanghongyu_0626 b841f68295 feat(visitor): Implement visitor chat functionality and enhance customer service interactions
- 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.
2026-09-09 18:32:00 +08:00

109 lines
4.2 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.
"""记忆服务(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)