"""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" @dataclass 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}"