Files
ai/backend.old/src/agent/memory.py
2026-03-11 18:47:11 -04:00

381 lines
13 KiB
Python

import os
import glob
from typing import List, Dict, Any, Optional
from datetime import datetime
import aiofiles
import chromadb
from chromadb.config import Settings
from sentence_transformers import SentenceTransformer
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
# Prevent ChromaDB from reporting telemetry to the mothership
os.environ["ANONYMIZED_TELEMETRY"] = "False"
class MemoryManager:
"""Manages persistent memory using local tools:
- LangGraph checkpointing (SQLite) for conversation state
- ChromaDB for semantic memory search
- Local sentence-transformers for embeddings
- Memory graph approach for clustering related concepts
"""
def __init__(
self,
checkpoint_db_path: str = "data/checkpoints.db",
chroma_db_path: str = "data/chroma",
embedding_model: str = "all-MiniLM-L6-v2",
context_docs_dir: str = "memory",
base_dir: str = "."
):
"""Initialize memory manager.
Args:
checkpoint_db_path: Path to SQLite checkpoint database
chroma_db_path: Path to ChromaDB directory
embedding_model: Sentence-transformers model name
context_docs_dir: Directory containing markdown context files
base_dir: Base directory for resolving relative paths
"""
self.checkpoint_db_path = checkpoint_db_path
self.chroma_db_path = chroma_db_path
self.embedding_model_name = embedding_model
self.context_docs_dir = os.path.join(base_dir, context_docs_dir)
# Will be initialized on startup
self.checkpointer: Optional[AsyncSqliteSaver] = None
self.checkpointer_context: Optional[Any] = None # Store the context manager
self.chroma_client: Optional[chromadb.Client] = None
self.memory_collection: Optional[Any] = None
self.embedding_model: Optional[SentenceTransformer] = None
self.context_documents: Dict[str, str] = {}
self.initialized = False
async def initialize(self) -> None:
"""Initialize the memory system and load context documents."""
if self.initialized:
return
# Ensure data directories exist
os.makedirs(os.path.dirname(self.checkpoint_db_path), exist_ok=True)
os.makedirs(self.chroma_db_path, exist_ok=True)
# Initialize LangGraph checkpointer (SQLite)
self.checkpointer_context = AsyncSqliteSaver.from_conn_string(
self.checkpoint_db_path
)
self.checkpointer = await self.checkpointer_context.__aenter__()
await self.checkpointer.setup()
# Initialize ChromaDB
self.chroma_client = chromadb.PersistentClient(
path=self.chroma_db_path,
settings=Settings(
anonymized_telemetry=False,
allow_reset=True
)
)
# Get or create memory collection
self.memory_collection = self.chroma_client.get_or_create_collection(
name="conversation_memory",
metadata={"description": "Semantic memory for conversations"}
)
# Initialize local embedding model
print(f"Loading embedding model: {self.embedding_model_name}")
self.embedding_model = SentenceTransformer(self.embedding_model_name)
# Load markdown context documents
await self._load_context_documents()
# Index context documents in ChromaDB
await self._index_context_documents()
self.initialized = True
print("Memory system initialized (LangGraph + ChromaDB + local embeddings)")
async def _load_context_documents(self) -> None:
"""Load all markdown files from context directory."""
if not os.path.exists(self.context_docs_dir):
print(f"Warning: Context directory {self.context_docs_dir} not found")
return
md_files = glob.glob(os.path.join(self.context_docs_dir, "*.md"))
for md_file in md_files:
try:
async with aiofiles.open(md_file, "r", encoding="utf-8") as f:
content = await f.read()
filename = os.path.basename(md_file)
self.context_documents[filename] = content
print(f"Loaded context document: {filename}")
except Exception as e:
print(f"Error loading {md_file}: {e}")
async def _index_context_documents(self) -> None:
"""Index context documents in ChromaDB for semantic search."""
if not self.context_documents or not self.memory_collection:
return
for filename, content in self.context_documents.items():
# Split into sections (by headers)
sections = self._split_document_into_sections(content, filename)
for i, section in enumerate(sections):
doc_id = f"context_{filename}_{i}"
# Generate embedding
embedding = self.embedding_model.encode(section["content"]).tolist()
# Add to ChromaDB
self.memory_collection.add(
ids=[doc_id],
embeddings=[embedding],
documents=[section["content"]],
metadatas=[{
"type": "context",
"source": filename,
"section": section["title"],
"indexed_at": datetime.utcnow().isoformat()
}]
)
print(f"Indexed {len(self.context_documents)} context documents")
def _split_document_into_sections(self, content: str, filename: str) -> List[Dict[str, str]]:
"""Split markdown document into logical sections.
Args:
content: Markdown content
filename: Source filename
Returns:
List of section dicts with title and content
"""
sections = []
current_section = {"title": filename, "content": ""}
for line in content.split("\n"):
if line.startswith("#"):
# New section
if current_section["content"].strip():
sections.append(current_section)
current_section = {
"title": line.strip("#").strip(),
"content": line + "\n"
}
else:
current_section["content"] += line + "\n"
# Add last section
if current_section["content"].strip():
sections.append(current_section)
return sections
def get_context_prompt(self) -> str:
"""Generate a context prompt from loaded documents.
system_prompt.md is ALWAYS included first and prioritized.
Other documents are included after.
Returns:
Formatted string containing all context documents
"""
if not self.context_documents:
return ""
sections = []
# ALWAYS include system_prompt.md first if it exists
system_prompt_key = "system_prompt.md"
if system_prompt_key in self.context_documents:
sections.append(self.context_documents[system_prompt_key])
sections.append("\n---\n")
# Add other context documents
sections.append("# Additional Context\n")
sections.append("The following documents provide additional context about the system:\n")
for filename, content in sorted(self.context_documents.items()):
# Skip system_prompt.md since we already added it
if filename == system_prompt_key:
continue
sections.append(f"\n## {filename}\n")
sections.append(content)
return "\n".join(sections)
async def add_memory(
self,
session_id: str,
role: str,
content: str,
metadata: Optional[Dict[str, Any]] = None
) -> None:
"""Add a message to semantic memory (ChromaDB).
Args:
session_id: Session identifier
role: Message role ("user" or "assistant")
content: Message content
metadata: Optional metadata
"""
if not self.memory_collection or not self.embedding_model:
return
try:
# Generate unique ID
timestamp = datetime.utcnow().isoformat()
doc_id = f"{session_id}_{role}_{timestamp}"
# Generate embedding
embedding = self.embedding_model.encode(content).tolist()
# Prepare metadata
meta = {
"session_id": session_id,
"role": role,
"timestamp": timestamp,
"type": "conversation",
**(metadata or {})
}
# Add to ChromaDB
self.memory_collection.add(
ids=[doc_id],
embeddings=[embedding],
documents=[content],
metadatas=[meta]
)
except Exception as e:
print(f"Error adding to ChromaDB memory: {e}")
async def search_memory(
self,
session_id: str,
query: str,
limit: int = 5,
include_context: bool = True
) -> List[Dict[str, Any]]:
"""Search memory using semantic similarity.
Args:
session_id: Session identifier (filters to this session + context docs)
query: Search query
limit: Maximum results
include_context: Whether to include context documents in search
Returns:
List of relevant memory items with content and metadata
"""
if not self.memory_collection or not self.embedding_model:
return []
try:
# Generate query embedding
query_embedding = self.embedding_model.encode(query).tolist()
# Build where filter
where_filters = []
if include_context:
# Search both session messages and context docs
where_filters = {
"$or": [
{"session_id": session_id},
{"type": "context"}
]
}
else:
where_filters = {"session_id": session_id}
# Query ChromaDB
results = self.memory_collection.query(
query_embeddings=[query_embedding],
n_results=limit,
where=where_filters if where_filters else None
)
# Format results
memories = []
if results and results["documents"]:
for i, doc in enumerate(results["documents"][0]):
memories.append({
"content": doc,
"metadata": results["metadatas"][0][i],
"distance": results["distances"][0][i] if "distances" in results else None
})
return memories
except Exception as e:
print(f"Error searching ChromaDB memory: {e}")
return []
async def get_memory_graph(
self,
session_id: str,
max_depth: int = 2
) -> Dict[str, Any]:
"""Get a graph of related memories using clustering.
This creates a simple memory graph by finding clusters of related concepts.
Args:
session_id: Session identifier
max_depth: Maximum depth for related memory traversal
Returns:
Dict representing memory graph structure
"""
# Simple implementation: get all memories for session and cluster by similarity
if not self.memory_collection:
return {}
try:
# Get all memories for this session
results = self.memory_collection.get(
where={"session_id": session_id},
include=["embeddings", "documents", "metadatas"]
)
if not results or not results["documents"]:
return {"nodes": [], "edges": []}
# Build simple graph structure
nodes = []
edges = []
for i, doc in enumerate(results["documents"]):
nodes.append({
"id": results["ids"][i],
"content": doc,
"metadata": results["metadatas"][i]
})
# TODO: Compute edges based on embedding similarity
# For now, return just nodes
return {"nodes": nodes, "edges": edges}
except Exception as e:
print(f"Error building memory graph: {e}")
return {}
def get_checkpointer(self) -> Optional[AsyncSqliteSaver]:
"""Get the LangGraph checkpointer for conversation state.
Returns:
AsyncSqliteSaver instance for LangGraph persistence
"""
return self.checkpointer
async def close(self) -> None:
"""Close the memory manager and cleanup resources."""
if self.checkpointer_context:
await self.checkpointer_context.__aexit__(None, None, None)
self.checkpointer = None
self.checkpointer_context = None