Arag / app /services /session_core /manager.py
AuthorBot
Restructure project for HF Spaces deployment
772f852
Raw
History Blame Contribute Delete
6.63 kB
"""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}"