Spaces:
Running
Running
| """Author RAG Chatbot SaaS — Session Memory Manager. | |
| Redis-backed conversation memory: stores last N turns per session. | |
| Interest profiler: accumulates topic tags across turns. | |
| RULE: Session TTL resets on every message — 30 min inactivity = expire. | |
| RULE: Interest profile is anonymous — no PII ever stored in Redis. | |
| """ | |
| import json | |
| from dataclasses import dataclass, field | |
| from datetime import timedelta | |
| import structlog | |
| from redis.asyncio import Redis | |
| from app.config import get_settings | |
| from app.services.session_core.context import extract_interest_tags, compute_interest_score | |
| logger = structlog.get_logger(__name__) | |
| cfg = get_settings() | |
| _SESSION_PREFIX = "session:" | |
| _INTEREST_PREFIX = "interest:" | |
| _HISTORY_KEY = "history" | |
| _BOOK_KEY = "selected_book" | |
| _TURN_KEY = "turn_count" | |
| class SessionContext: | |
| """Full context for a chat session turn.""" | |
| session_id: str | |
| author_id: str | |
| history: list[dict] = field(default_factory=list) # Last N turns [{role, content}] | |
| selected_book_id: str | None = None # Currently selected book | |
| is_cross_book: bool = False # Searching all books | |
| turn_count: int = 0 # Total turns this session | |
| interest_tags: list[str] = field(default_factory=list) # Accumulated topic tags | |
| interest_score: float = 0.0 # 0.0 (low) to 1.0 (high) | |
| class SessionManager: | |
| """Manages conversation sessions in Redis.""" | |
| def __init__(self, redis: Redis) -> None: | |
| """Initialize with a Redis connection. | |
| Args: | |
| redis: Async Redis connection. | |
| """ | |
| self._redis = redis | |
| self._ttl = timedelta(minutes=cfg.RAG_SESSION_TTL_MINUTES) | |
| self._max_history = cfg.RAG_SESSION_HISTORY_TURNS | |
| async def load(self, session_id: str, author_id: str) -> SessionContext: | |
| """Load or create a session context. | |
| Args: | |
| session_id: UUID of the chat session. | |
| author_id: UUID of the author (namespace). | |
| Returns: | |
| SessionContext with history and interest data. | |
| """ | |
| key = self._key(author_id, session_id) | |
| data = await self._redis.hgetall(key) | |
| if not data: | |
| return SessionContext(session_id=session_id, author_id=author_id) | |
| history = json.loads(data.get("history", "[]")) | |
| interest_tags = json.loads(data.get("interest_tags", "[]")) | |
| return SessionContext( | |
| session_id=session_id, | |
| author_id=author_id, | |
| history=history, | |
| selected_book_id=data.get("selected_book_id"), | |
| is_cross_book=data.get("is_cross_book", "false") == "true", | |
| turn_count=int(data.get("turn_count", 0)), | |
| interest_tags=interest_tags, | |
| interest_score=float(data.get("interest_score", 0.0)), | |
| ) | |
| async def save( | |
| self, | |
| context: SessionContext, | |
| user_message: str, | |
| assistant_message: str, | |
| new_tags: list[str] | None = None, | |
| ) -> None: | |
| """Persist updated session after a turn. | |
| Appends new messages to history, trims to max_history, | |
| extracts interest tags from the user message, and updates | |
| the interest profile using compute_interest_score. | |
| Args: | |
| context: Current session context. | |
| user_message: The user's message text. | |
| assistant_message: The bot's response text. | |
| new_tags: Optional pre-extracted topic tags (auto-extracted if None). | |
| """ | |
| # Append new turn | |
| context.history.append({"role": "user", "content": user_message}) | |
| context.history.append({"role": "assistant", "content": assistant_message}) | |
| # Trim to max history (keep pairs: user+assistant) | |
| max_messages = self._max_history * 2 | |
| if len(context.history) > max_messages: | |
| context.history = context.history[-max_messages:] | |
| # Auto-extract interest tags from the user message if not provided | |
| detected_tags = new_tags if new_tags is not None else extract_interest_tags(user_message) | |
| # Update interest profile | |
| context.turn_count += 1 | |
| if detected_tags: | |
| context.interest_tags.extend(detected_tags) | |
| context.interest_tags = list(dict.fromkeys(context.interest_tags))[-20:] # Keep last 20 unique | |
| # Recompute interest score using context module | |
| context.interest_score = compute_interest_score(context.turn_count, context.interest_tags) | |
| # Persist to Redis | |
| key = self._key(context.author_id, context.session_id) | |
| mapping = { | |
| "history": json.dumps(context.history), | |
| "selected_book_id": context.selected_book_id or "", | |
| "is_cross_book": "true" if context.is_cross_book else "false", | |
| "turn_count": str(context.turn_count), | |
| "interest_tags": json.dumps(context.interest_tags), | |
| "interest_score": str(context.interest_score), | |
| } | |
| await self._redis.hset(key, mapping=mapping) | |
| await self._redis.expire(key, self._ttl) | |
| logger.debug("Session saved", session_id=context.session_id, turns=context.turn_count) | |
| async def set_selected_book( | |
| self, session_id: str, author_id: str, book_id: str | None, is_cross: bool = False | |
| ) -> None: | |
| """Update the selected book for this session. | |
| Args: | |
| session_id: UUID of the session. | |
| author_id: UUID of the author. | |
| book_id: UUID of the selected book, or None for cross-book. | |
| is_cross: True if searching all books. | |
| """ | |
| key = self._key(author_id, session_id) | |
| await self._redis.hset(key, mapping={ | |
| "selected_book_id": book_id or "", | |
| "is_cross_book": "true" if is_cross else "false", | |
| }) | |
| await self._redis.expire(key, self._ttl) | |
| async def delete(self, session_id: str, author_id: str) -> None: | |
| """Delete a session (on explicit logout or expiry cleanup). | |
| Args: | |
| session_id: UUID of the session. | |
| author_id: UUID of the author. | |
| """ | |
| await self._redis.delete(self._key(author_id, session_id)) | |
| def _key(self, author_id: str, session_id: str) -> str: | |
| """Build the Redis key for a session. | |
| Args: | |
| author_id: UUID of the author. | |
| session_id: UUID of the session. | |
| Returns: | |
| Redis key string. | |
| """ | |
| return f"{_SESSION_PREFIX}{author_id}:{session_id}" | |