blog2video-test / backend /app /support /memory_manager.py
HumeraR's picture
added support bot
9134973
Raw
History Blame Contribute Delete
7.2 kB
"""Conversation memory: recent turns + rolling summary + structured session_state.
Three layers, all persisted on the conversation row, all rebuilt from Postgres
each request. The LLM is stateless; this module assembles the slice that goes
into the prompt every turn.
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import Optional
from sqlalchemy import desc
from sqlalchemy.orm import Session
from app.models.support_conversation import (
SupportConversation,
SupportMessage,
SupportMessageRole,
)
logger = logging.getLogger(__name__)
# Tunables ---------------------------------------------------------------------
SOFT_HISTORY_HOURS = 24
RECENT_TURNS_MAX = 6 # last 6 messages = ~3 user/assistant turns
SUMMARIZE_EVERY_N_MESSAGES = 8 # roll into summary every 4 turns
def get_or_create_conversation(
db: Session,
*,
user_id: Optional[int],
session_id: str,
conversation_id: Optional[int],
) -> SupportConversation:
"""Load an existing conversation (with auth check) or create a new one.
Authorization rule: a request can read/append iff
- request.user_id == conversation.user_id (both non-null), OR
- request.session_id == conversation.session_id AND conversation.user_id IS NULL
"""
logger.info("[MEMORY] ===== Getting or creating conversation =====")
logger.info("[MEMORY] Identity: user_id=%s, session_id=%s (last 8 chars)", user_id, session_id[-8:] if session_id else None)
if conversation_id is not None:
logger.info("[MEMORY] Loading existing conversation: conv_id=%d", conversation_id)
conv = (
db.query(SupportConversation)
.filter(SupportConversation.id == conversation_id)
.first()
)
if conv is None:
logger.error("[MEMORY] Conversation not found: conv_id=%d", conversation_id)
raise PermissionError("conversation not found")
if conv.user_id is not None and conv.user_id != user_id:
logger.error("[MEMORY] Authorization failed: conv owned by user_id=%d, request has user_id=%s", conv.user_id, user_id)
raise PermissionError("conversation does not belong to this user")
if conv.user_id is None and conv.session_id != session_id:
logger.error("[MEMORY] Authorization failed: conv owned by session_id=%s, request has session_id=%s", conv.session_id[-8:], session_id[-8:])
raise PermissionError("conversation does not belong to this session")
logger.info("[MEMORY] Loaded conversation: conv_id=%d, title=%r, title_len=%d", conv.id, conv.title[:40] if conv.title else "(empty)", len(conv.title or ""))
return conv
logger.info("[MEMORY] Creating new conversation")
conv = SupportConversation(
user_id=user_id,
session_id=session_id,
summary="",
session_state={},
)
db.add(conv)
db.flush()
logger.info("[MEMORY] New conversation created: conv_id=%d", conv.id)
return conv
def load_recent_messages(
db: Session, conversation_id: int
) -> list[SupportMessage]:
logger.info("[MEMORY] Loading recent messages: conv_id=%d, window=%dh, max=%d msgs", conversation_id, SOFT_HISTORY_HOURS, RECENT_TURNS_MAX)
cutoff = datetime.utcnow() - timedelta(hours=SOFT_HISTORY_HOURS)
rows = (
db.query(SupportMessage)
.filter(SupportMessage.conversation_id == conversation_id)
.filter(SupportMessage.created_at >= cutoff)
.order_by(desc(SupportMessage.created_at))
.limit(RECENT_TURNS_MAX)
.all()
)
rows.reverse()
logger.info("[MEMORY] Loaded %d recent messages: %d user, %d assistant", len(rows), sum(1 for m in rows if m.role == SupportMessageRole.USER), sum(1 for m in rows if m.role == SupportMessageRole.ASSISTANT))
for msg in rows:
logger.debug("[MEMORY] - %s: %r (length=%d, cited_docs=%s)", msg.role.value.upper(), msg.content[:60], len(msg.content), msg.cited_docs or [])
return rows
def history_user_messages(messages: list[SupportMessage]) -> list[str]:
user_msgs = [m.content for m in messages if m.role == SupportMessageRole.USER]
logger.debug("[MEMORY] Extracted %d user messages for history expansion", len(user_msgs))
return user_msgs
def last_assistant_cited_doc_ids(messages: list[SupportMessage]) -> list[str]:
for m in reversed(messages):
if m.role == SupportMessageRole.ASSISTANT and m.cited_docs:
doc_list = list(m.cited_docs)
logger.debug("[MEMORY] Last cited doc IDs (for continuity boost): %s", doc_list)
return doc_list
logger.debug("[MEMORY] No prior cited docs found")
return []
def update_session_state(
state: dict,
*,
page_path: Optional[str],
cited_doc_ids: list[str],
shown_action_ids: list[str],
user_authed: bool,
) -> dict:
out = dict(state or {})
out["auth"] = "authed" if user_authed else "anonymous"
if page_path:
out["current_page"] = page_path
visited = list(out.get("visited_pages", []))
if page_path not in visited:
visited.append(page_path)
out["visited_pages"] = visited[-10:] # cap
if cited_doc_ids:
out["recent_cited_doc_ids"] = cited_doc_ids
if shown_action_ids:
prev = list(out.get("shown_action_ids", []))
for a in shown_action_ids:
if a not in prev:
prev.append(a)
out["shown_action_ids"] = prev[-20:]
return out
def session_state_block(state: dict) -> str:
if not state:
logger.debug("[MEMORY] Session state is empty")
return ""
parts = []
for key in (
"current_page",
"visited_pages",
"user_goal",
"shown_action_ids",
"recent_cited_doc_ids",
"auth",
"plan",
):
if key in state and state[key]:
parts.append(f" {key}: {state[key]}")
if not parts:
logger.debug("[MEMORY] Session state has no meaningful keys")
return ""
result = "USER CONTEXT:\n" + "\n".join(parts)
logger.debug("[MEMORY] Built session_state_block: %d keys with values", len(parts))
return result
def total_message_count(db: Session, conversation_id: int) -> int:
return (
db.query(SupportMessage)
.filter(SupportMessage.conversation_id == conversation_id)
.count()
)
def messages_to_fold(
db: Session, conversation_id: int, recent: list[SupportMessage]
) -> list[SupportMessage]:
"""Return older messages (outside the recent window) that haven't been folded into the summary yet.
Heuristic: any message older than the oldest in `recent` is a candidate. We
pick at most 4 at a time to keep summarization cheap.
"""
if not recent:
return []
oldest_kept = min(m.created_at for m in recent)
rows = (
db.query(SupportMessage)
.filter(SupportMessage.conversation_id == conversation_id)
.filter(SupportMessage.created_at < oldest_kept)
.order_by(desc(SupportMessage.created_at))
.limit(4)
.all()
)
rows.reverse()
return rows