| """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__) |
|
|
| |
| SOFT_HISTORY_HOURS = 24 |
| RECENT_TURNS_MAX = 6 |
| SUMMARIZE_EVERY_N_MESSAGES = 8 |
|
|
|
|
| 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:] |
| 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 |
|
|