Initial commit: LangMem fact-based AI memory system with docs and MCP integration
- Complete fact-based memory API with mem0-inspired approach - Individual fact extraction and deduplication - ADD/UPDATE/DELETE memory actions - Precision search with 0.86+ similarity scores - MCP server for Claude Code integration - Neo4j graph relationships and PostgreSQL vector storage - Comprehensive documentation with architecture and API docs - Matrix communication integration - Production-ready Docker setup with Ollama and Supabase 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
112
tests/conftest.py
Normal file
112
tests/conftest.py
Normal file
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test configuration and fixtures for LangMem API tests
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
import httpx
|
||||
from typing import AsyncGenerator
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
"""Create an instance of the default event loop for the test session."""
|
||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def api_client() -> AsyncGenerator[httpx.AsyncClient, None]:
|
||||
"""Create an async HTTP client for API testing."""
|
||||
async with httpx.AsyncClient(
|
||||
base_url="http://localhost:8765",
|
||||
timeout=30.0
|
||||
) as client:
|
||||
yield client
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers():
|
||||
"""Provide authentication headers for API requests."""
|
||||
return {"Authorization": "Bearer langmem_api_key_2025"}
|
||||
|
||||
@pytest.fixture
|
||||
def test_user_id():
|
||||
"""Generate a unique test user ID."""
|
||||
import uuid
|
||||
return f"test_user_{uuid.uuid4()}"
|
||||
|
||||
@pytest.fixture
|
||||
def test_session_id():
|
||||
"""Generate a unique test session ID."""
|
||||
import uuid
|
||||
return f"test_session_{uuid.uuid4()}"
|
||||
|
||||
@pytest.fixture
|
||||
def sample_memory():
|
||||
"""Provide sample memory data for testing."""
|
||||
return {
|
||||
"content": "This is a sample memory for testing purposes",
|
||||
"metadata": {
|
||||
"category": "test",
|
||||
"importance": "low",
|
||||
"tags": ["sample", "test", "memory"]
|
||||
}
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def sample_conversation():
|
||||
"""Provide sample conversation data for testing."""
|
||||
return [
|
||||
{"role": "user", "content": "Hello, I need help with Python programming"},
|
||||
{"role": "assistant", "content": "I'd be happy to help with Python programming. What specific topic would you like to learn about?"},
|
||||
{"role": "user", "content": "I want to learn about web frameworks"}
|
||||
]
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def wait_for_api():
|
||||
"""Wait for API to be ready before running tests."""
|
||||
import time
|
||||
max_retries = 30
|
||||
retry_delay = 2
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get("http://localhost:8765/health", timeout=5.0)
|
||||
if response.status_code == 200:
|
||||
print("✅ API is ready for testing")
|
||||
return
|
||||
except:
|
||||
pass
|
||||
|
||||
if attempt < max_retries - 1:
|
||||
print(f"⏳ Waiting for API to be ready (attempt {attempt + 1}/{max_retries})")
|
||||
time.sleep(retry_delay)
|
||||
|
||||
raise RuntimeError("API failed to become ready within the timeout period")
|
||||
|
||||
# Configure pytest marks
|
||||
pytest_plugins = []
|
||||
|
||||
def pytest_configure(config):
|
||||
"""Configure pytest with custom markers."""
|
||||
config.addinivalue_line(
|
||||
"markers", "integration: mark test as integration test"
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers", "slow: mark test as slow running"
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers", "unit: mark test as unit test"
|
||||
)
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
"""Modify test collection to add markers automatically."""
|
||||
for item in items:
|
||||
# Add integration marker to integration tests
|
||||
if "integration" in item.nodeid:
|
||||
item.add_marker(pytest.mark.integration)
|
||||
|
||||
# Add slow marker to tests that typically take longer
|
||||
if any(keyword in item.name for keyword in ["full_workflow", "health_monitoring"]):
|
||||
item.add_marker(pytest.mark.slow)
|
||||
5
tests/requirements.txt
Normal file
5
tests/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
pytest==7.4.3
|
||||
pytest-asyncio==0.21.1
|
||||
httpx==0.25.2
|
||||
pytest-mock==3.12.0
|
||||
pytest-cov==4.1.0
|
||||
204
tests/test_api.py
Normal file
204
tests/test_api.py
Normal file
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test suite for LangMem API
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import pytest
|
||||
import httpx
|
||||
from uuid import uuid4
|
||||
|
||||
# Configuration
|
||||
API_BASE_URL = "http://localhost:8765"
|
||||
API_KEY = "langmem_api_key_2025"
|
||||
|
||||
class TestLangMemAPI:
|
||||
"""Test suite for LangMem API endpoints"""
|
||||
|
||||
def setup_method(self):
|
||||
"""Setup test client"""
|
||||
self.client = httpx.AsyncClient(base_url=API_BASE_URL)
|
||||
self.headers = {"Authorization": f"Bearer {API_KEY}"}
|
||||
self.test_user_id = f"test_user_{uuid4()}"
|
||||
|
||||
async def teardown_method(self):
|
||||
"""Cleanup test client"""
|
||||
await self.client.aclose()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_root_endpoint(self):
|
||||
"""Test root endpoint"""
|
||||
response = await self.client.get("/")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["message"] == "LangMem API - Long-term Memory System"
|
||||
assert data["version"] == "1.0.0"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check(self):
|
||||
"""Test health check endpoint"""
|
||||
response = await self.client.get("/health")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "status" in data
|
||||
assert "services" in data
|
||||
assert "timestamp" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_memory(self):
|
||||
"""Test storing a memory"""
|
||||
memory_data = {
|
||||
"content": "This is a test memory about Python programming",
|
||||
"user_id": self.test_user_id,
|
||||
"session_id": "test_session_1",
|
||||
"metadata": {
|
||||
"category": "programming",
|
||||
"language": "python",
|
||||
"importance": "high"
|
||||
}
|
||||
}
|
||||
|
||||
response = await self.client.post(
|
||||
"/v1/memories/store",
|
||||
json=memory_data,
|
||||
headers=self.headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "stored"
|
||||
assert data["user_id"] == self.test_user_id
|
||||
assert "id" in data
|
||||
assert "created_at" in data
|
||||
|
||||
return data["id"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_memories(self):
|
||||
"""Test searching memories"""
|
||||
# First store a memory
|
||||
memory_id = await self.test_store_memory()
|
||||
|
||||
# Wait a moment for indexing
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Search for the memory
|
||||
search_data = {
|
||||
"query": "Python programming",
|
||||
"user_id": self.test_user_id,
|
||||
"limit": 10,
|
||||
"threshold": 0.5,
|
||||
"include_graph": True
|
||||
}
|
||||
|
||||
response = await self.client.post(
|
||||
"/v1/memories/search",
|
||||
json=search_data,
|
||||
headers=self.headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "memories" in data
|
||||
assert "context" in data
|
||||
assert "total_count" in data
|
||||
assert data["total_count"] > 0
|
||||
|
||||
# Check first memory result
|
||||
if data["memories"]:
|
||||
memory = data["memories"][0]
|
||||
assert "id" in memory
|
||||
assert "content" in memory
|
||||
assert "similarity" in memory
|
||||
assert memory["user_id"] == self.test_user_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retrieve_memories(self):
|
||||
"""Test retrieving memories for conversation context"""
|
||||
# Store a memory first
|
||||
await self.test_store_memory()
|
||||
|
||||
# Wait a moment for indexing
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Retrieve memories based on conversation
|
||||
retrieve_data = {
|
||||
"messages": [
|
||||
{"role": "user", "content": "I want to learn about Python"},
|
||||
{"role": "assistant", "content": "Python is a great programming language"},
|
||||
{"role": "user", "content": "Tell me more about Python programming"}
|
||||
],
|
||||
"user_id": self.test_user_id,
|
||||
"session_id": "test_session_1"
|
||||
}
|
||||
|
||||
response = await self.client.post(
|
||||
"/v1/memories/retrieve",
|
||||
json=retrieve_data,
|
||||
headers=self.headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "memories" in data
|
||||
assert "context" in data
|
||||
assert "total_count" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_memories(self):
|
||||
"""Test getting all memories for a user"""
|
||||
# Store a memory first
|
||||
await self.test_store_memory()
|
||||
|
||||
response = await self.client.get(
|
||||
f"/v1/memories/users/{self.test_user_id}",
|
||||
headers=self.headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "memories" in data
|
||||
assert "total_count" in data
|
||||
assert data["total_count"] > 0
|
||||
|
||||
# Check memory structure
|
||||
if data["memories"]:
|
||||
memory = data["memories"][0]
|
||||
assert "id" in memory
|
||||
assert "content" in memory
|
||||
assert "user_id" in memory
|
||||
assert "created_at" in memory
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_memory(self):
|
||||
"""Test deleting a memory"""
|
||||
# Store a memory first
|
||||
memory_id = await self.test_store_memory()
|
||||
|
||||
# Delete the memory
|
||||
response = await self.client.delete(
|
||||
f"/v1/memories/{memory_id}",
|
||||
headers=self.headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "deleted"
|
||||
assert data["id"] == memory_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authentication_required(self):
|
||||
"""Test that authentication is required"""
|
||||
response = await self.client.get("/v1/memories/users/test_user")
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_api_key(self):
|
||||
"""Test invalid API key"""
|
||||
headers = {"Authorization": "Bearer invalid_key"}
|
||||
response = await self.client.get("/v1/memories/users/test_user", headers=headers)
|
||||
assert response.status_code == 401
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
332
tests/test_integration.py
Normal file
332
tests/test_integration.py
Normal file
@@ -0,0 +1,332 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Integration tests for LangMem API with real services
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import pytest
|
||||
import httpx
|
||||
from uuid import uuid4
|
||||
import time
|
||||
|
||||
# Configuration
|
||||
API_BASE_URL = "http://localhost:8765"
|
||||
API_KEY = "langmem_api_key_2025"
|
||||
|
||||
class TestLangMemIntegration:
|
||||
"""Integration test suite for LangMem API"""
|
||||
|
||||
def setup_method(self):
|
||||
"""Setup test client"""
|
||||
self.client = httpx.AsyncClient(base_url=API_BASE_URL, timeout=30.0)
|
||||
self.headers = {"Authorization": f"Bearer {API_KEY}"}
|
||||
self.test_user_id = f"integration_user_{uuid4()}"
|
||||
self.test_session_id = f"integration_session_{uuid4()}"
|
||||
|
||||
async def teardown_method(self):
|
||||
"""Cleanup test client"""
|
||||
await self.client.aclose()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_memory_workflow(self):
|
||||
"""Test complete memory workflow: store -> search -> retrieve -> delete"""
|
||||
|
||||
# Step 1: Store multiple memories
|
||||
memories_data = [
|
||||
{
|
||||
"content": "FastAPI is a modern web framework for building APIs with Python",
|
||||
"user_id": self.test_user_id,
|
||||
"session_id": self.test_session_id,
|
||||
"metadata": {
|
||||
"category": "programming",
|
||||
"framework": "fastapi",
|
||||
"language": "python"
|
||||
}
|
||||
},
|
||||
{
|
||||
"content": "Docker containers provide isolated environments for applications",
|
||||
"user_id": self.test_user_id,
|
||||
"session_id": self.test_session_id,
|
||||
"metadata": {
|
||||
"category": "devops",
|
||||
"technology": "docker"
|
||||
}
|
||||
},
|
||||
{
|
||||
"content": "Vector databases are excellent for similarity search and AI applications",
|
||||
"user_id": self.test_user_id,
|
||||
"session_id": self.test_session_id,
|
||||
"metadata": {
|
||||
"category": "ai",
|
||||
"technology": "vector_database"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
stored_ids = []
|
||||
for memory_data in memories_data:
|
||||
response = await self.client.post(
|
||||
"/v1/memories/store",
|
||||
json=memory_data,
|
||||
headers=self.headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
stored_ids.append(data["id"])
|
||||
print(f"✅ Stored memory: {data['id']}")
|
||||
|
||||
# Wait for indexing
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Step 2: Search for memories
|
||||
search_queries = [
|
||||
"Python web framework",
|
||||
"containerization technology",
|
||||
"AI similarity search"
|
||||
]
|
||||
|
||||
for query in search_queries:
|
||||
search_data = {
|
||||
"query": query,
|
||||
"user_id": self.test_user_id,
|
||||
"limit": 5,
|
||||
"threshold": 0.5,
|
||||
"include_graph": True
|
||||
}
|
||||
|
||||
response = await self.client.post(
|
||||
"/v1/memories/search",
|
||||
json=search_data,
|
||||
headers=self.headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total_count"] > 0
|
||||
print(f"✅ Search '{query}' found {data['total_count']} memories")
|
||||
|
||||
# Step 3: Test conversation-based retrieval
|
||||
retrieve_data = {
|
||||
"messages": [
|
||||
{"role": "user", "content": "I'm working on a Python API project"},
|
||||
{"role": "assistant", "content": "That's great! What framework are you using?"},
|
||||
{"role": "user", "content": "I need something fast and modern for building APIs"}
|
||||
],
|
||||
"user_id": self.test_user_id,
|
||||
"session_id": self.test_session_id
|
||||
}
|
||||
|
||||
response = await self.client.post(
|
||||
"/v1/memories/retrieve",
|
||||
json=retrieve_data,
|
||||
headers=self.headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "memories" in data
|
||||
print(f"✅ Retrieved {data['total_count']} memories for conversation")
|
||||
|
||||
# Step 4: Get all user memories
|
||||
response = await self.client.get(
|
||||
f"/v1/memories/users/{self.test_user_id}",
|
||||
headers=self.headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total_count"] >= 3
|
||||
print(f"✅ User has {data['total_count']} total memories")
|
||||
|
||||
# Step 5: Clean up - delete stored memories
|
||||
for memory_id in stored_ids:
|
||||
response = await self.client.delete(
|
||||
f"/v1/memories/{memory_id}",
|
||||
headers=self.headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
print(f"✅ Deleted memory: {memory_id}")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_similarity_search_accuracy(self):
|
||||
"""Test accuracy of similarity search"""
|
||||
|
||||
# Store memories with different topics
|
||||
test_memories = [
|
||||
{
|
||||
"content": "Machine learning models require large datasets for training",
|
||||
"user_id": self.test_user_id,
|
||||
"metadata": {"topic": "ml_training"}
|
||||
},
|
||||
{
|
||||
"content": "Neural networks use backpropagation for learning",
|
||||
"user_id": self.test_user_id,
|
||||
"metadata": {"topic": "neural_networks"}
|
||||
},
|
||||
{
|
||||
"content": "Database indexing improves query performance",
|
||||
"user_id": self.test_user_id,
|
||||
"metadata": {"topic": "database_performance"}
|
||||
}
|
||||
]
|
||||
|
||||
stored_ids = []
|
||||
for memory in test_memories:
|
||||
response = await self.client.post(
|
||||
"/v1/memories/store",
|
||||
json=memory,
|
||||
headers=self.headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
stored_ids.append(response.json()["id"])
|
||||
|
||||
# Wait for indexing
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Test search with different queries
|
||||
test_cases = [
|
||||
{
|
||||
"query": "deep learning training data",
|
||||
"expected_topic": "ml_training",
|
||||
"min_similarity": 0.6
|
||||
},
|
||||
{
|
||||
"query": "backpropagation algorithm",
|
||||
"expected_topic": "neural_networks",
|
||||
"min_similarity": 0.6
|
||||
},
|
||||
{
|
||||
"query": "database optimization",
|
||||
"expected_topic": "database_performance",
|
||||
"min_similarity": 0.6
|
||||
}
|
||||
]
|
||||
|
||||
for test_case in test_cases:
|
||||
search_data = {
|
||||
"query": test_case["query"],
|
||||
"user_id": self.test_user_id,
|
||||
"limit": 3,
|
||||
"threshold": 0.5
|
||||
}
|
||||
|
||||
response = await self.client.post(
|
||||
"/v1/memories/search",
|
||||
json=search_data,
|
||||
headers=self.headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total_count"] > 0
|
||||
|
||||
# Check that the most similar result matches expected topic
|
||||
top_result = data["memories"][0]
|
||||
assert top_result["similarity"] >= test_case["min_similarity"]
|
||||
assert top_result["metadata"]["topic"] == test_case["expected_topic"]
|
||||
|
||||
print(f"✅ Query '{test_case['query']}' correctly matched topic '{test_case['expected_topic']}' with similarity {top_result['similarity']:.3f}")
|
||||
|
||||
# Cleanup
|
||||
for memory_id in stored_ids:
|
||||
await self.client.delete(f"/v1/memories/{memory_id}", headers=self.headers)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_isolation(self):
|
||||
"""Test that memories are properly isolated between users"""
|
||||
|
||||
user1_id = f"user1_{uuid4()}"
|
||||
user2_id = f"user2_{uuid4()}"
|
||||
|
||||
# Store memory for user1
|
||||
memory1_data = {
|
||||
"content": "User 1 private information about project Alpha",
|
||||
"user_id": user1_id,
|
||||
"metadata": {"privacy": "private"}
|
||||
}
|
||||
|
||||
response = await self.client.post(
|
||||
"/v1/memories/store",
|
||||
json=memory1_data,
|
||||
headers=self.headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
memory1_id = response.json()["id"]
|
||||
|
||||
# Store memory for user2
|
||||
memory2_data = {
|
||||
"content": "User 2 private information about project Beta",
|
||||
"user_id": user2_id,
|
||||
"metadata": {"privacy": "private"}
|
||||
}
|
||||
|
||||
response = await self.client.post(
|
||||
"/v1/memories/store",
|
||||
json=memory2_data,
|
||||
headers=self.headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
memory2_id = response.json()["id"]
|
||||
|
||||
# Wait for indexing
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Search as user1 - should only find user1's memories
|
||||
search_data = {
|
||||
"query": "private information project",
|
||||
"user_id": user1_id,
|
||||
"limit": 10,
|
||||
"threshold": 0.3
|
||||
}
|
||||
|
||||
response = await self.client.post(
|
||||
"/v1/memories/search",
|
||||
json=search_data,
|
||||
headers=self.headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Should only find user1's memory
|
||||
for memory in data["memories"]:
|
||||
assert memory["user_id"] == user1_id
|
||||
assert "Alpha" in memory["content"]
|
||||
assert "Beta" not in memory["content"]
|
||||
|
||||
print(f"✅ User isolation test passed - user1 found {data['total_count']} memories")
|
||||
|
||||
# Cleanup
|
||||
await self.client.delete(f"/v1/memories/{memory1_id}", headers=self.headers)
|
||||
await self.client.delete(f"/v1/memories/{memory2_id}", headers=self.headers)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_health_monitoring(self):
|
||||
"""Test service health monitoring"""
|
||||
|
||||
response = await self.client.get("/health")
|
||||
assert response.status_code == 200
|
||||
|
||||
health_data = response.json()
|
||||
|
||||
# Check overall status
|
||||
assert health_data["status"] in ["healthy", "degraded", "unhealthy"]
|
||||
|
||||
# Check individual services
|
||||
services = health_data["services"]
|
||||
required_services = ["ollama", "supabase", "neo4j", "postgres"]
|
||||
|
||||
for service in required_services:
|
||||
assert service in services
|
||||
service_status = services[service]
|
||||
print(f"Service {service}: {service_status}")
|
||||
|
||||
# For integration tests, we expect core services to be healthy
|
||||
if service in ["ollama", "supabase", "postgres"]:
|
||||
assert service_status == "healthy", f"Required service {service} is not healthy"
|
||||
|
||||
print(f"✅ Health check passed - overall status: {health_data['status']}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "-s"])
|
||||
Reference in New Issue
Block a user