Remove Uptime Kuma integration
- Delete kuma_client.py and all Kuma-related code - Remove /infrastructure/monitors endpoints - Update service start/stop to use Portainer only - Simplify service control widget to show container status - Remove Kuma config and credentials - Delete stale memory tests (moved to core-ai service) - Add test infrastructure with pytest - Add tests for service_groups, config, and health endpoints - Bump version to 1.1.0 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Tests package
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Tests for config module."""
|
||||
import pytest
|
||||
from src.config import (
|
||||
__version__,
|
||||
Settings,
|
||||
get_settings,
|
||||
_get_version_from_pyproject,
|
||||
)
|
||||
|
||||
|
||||
class TestVersion:
|
||||
"""Test version loading from pyproject.toml."""
|
||||
|
||||
def test_version_is_loaded(self):
|
||||
"""Version should be loaded from pyproject.toml."""
|
||||
assert __version__ is not None
|
||||
assert isinstance(__version__, str)
|
||||
|
||||
def test_version_format(self):
|
||||
"""Version should follow semver format."""
|
||||
parts = __version__.split(".")
|
||||
assert len(parts) >= 2, "Version should have at least major.minor"
|
||||
assert all(p.isdigit() for p in parts), "Version parts should be numeric"
|
||||
|
||||
def test_version_matches_settings(self):
|
||||
"""Settings app_version should match module version."""
|
||||
settings = get_settings()
|
||||
assert settings.app_version == __version__
|
||||
|
||||
|
||||
class TestGetVersionFromPyproject:
|
||||
"""Test the version loading function."""
|
||||
|
||||
def test_returns_string(self):
|
||||
"""Should return a string version."""
|
||||
version = _get_version_from_pyproject()
|
||||
assert isinstance(version, str)
|
||||
|
||||
def test_returns_valid_version(self):
|
||||
"""Should return a valid version (not 0.0.0 if file exists)."""
|
||||
version = _get_version_from_pyproject()
|
||||
# Since pyproject.toml exists, version should not be fallback
|
||||
assert version != "0.0.0"
|
||||
|
||||
|
||||
class TestSettings:
|
||||
"""Test Settings configuration class."""
|
||||
|
||||
def test_settings_has_app_name(self):
|
||||
"""Settings should have app_name."""
|
||||
settings = get_settings()
|
||||
assert settings.app_name == "Core Code API"
|
||||
|
||||
def test_settings_has_version(self):
|
||||
"""Settings should have app_version."""
|
||||
settings = get_settings()
|
||||
assert settings.app_version is not None
|
||||
|
||||
def test_settings_default_host(self):
|
||||
"""Settings should have default host."""
|
||||
settings = get_settings()
|
||||
assert settings.host == "0.0.0.0"
|
||||
|
||||
def test_settings_default_port(self):
|
||||
"""Settings should have default port."""
|
||||
settings = get_settings()
|
||||
assert settings.port == 8083
|
||||
|
||||
def test_no_kuma_settings(self):
|
||||
"""Settings should not have Kuma-related attributes."""
|
||||
settings = get_settings()
|
||||
assert not hasattr(settings, "kuma_url")
|
||||
assert not hasattr(settings, "kuma_username")
|
||||
assert not hasattr(settings, "kuma_password")
|
||||
assert not hasattr(settings, "kuma_api_key")
|
||||
|
||||
|
||||
class TestGetSettings:
|
||||
"""Test get_settings function."""
|
||||
|
||||
def test_returns_settings_instance(self):
|
||||
"""Should return a Settings instance."""
|
||||
settings = get_settings()
|
||||
assert isinstance(settings, Settings)
|
||||
|
||||
def test_returns_cached_instance(self):
|
||||
"""Should return the same cached instance."""
|
||||
settings1 = get_settings()
|
||||
settings2 = get_settings()
|
||||
assert settings1 is settings2
|
||||
|
||||
def test_model_aliases_property(self):
|
||||
"""Model aliases property should return dict."""
|
||||
settings = get_settings()
|
||||
aliases = settings.model_aliases
|
||||
assert isinstance(aliases, dict)
|
||||
assert "gpt-3.5-turbo" in aliases
|
||||
assert "gpt-4" in aliases
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Tests for health endpoints."""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from unittest.mock import patch, AsyncMock
|
||||
|
||||
from src.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""Create a test client."""
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
class TestRootEndpoint:
|
||||
"""Test root endpoint."""
|
||||
|
||||
def test_root_returns_200(self, client):
|
||||
"""Root endpoint should return 200."""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_root_returns_service_info(self, client):
|
||||
"""Root endpoint should return service information."""
|
||||
response = client.get("/")
|
||||
data = response.json()
|
||||
|
||||
assert "service" in data
|
||||
assert "version" in data
|
||||
assert "status" in data
|
||||
assert data["service"] == "Core Code API"
|
||||
assert data["status"] == "healthy"
|
||||
|
||||
def test_root_returns_documentation_links(self, client):
|
||||
"""Root endpoint should return documentation links."""
|
||||
response = client.get("/")
|
||||
data = response.json()
|
||||
|
||||
assert "documentation" in data
|
||||
assert "swagger_ui" in data["documentation"]
|
||||
assert "redoc" in data["documentation"]
|
||||
|
||||
def test_root_returns_endpoints(self, client):
|
||||
"""Root endpoint should return available endpoints."""
|
||||
response = client.get("/")
|
||||
data = response.json()
|
||||
|
||||
assert "endpoints" in data
|
||||
assert "health" in data["endpoints"]
|
||||
|
||||
|
||||
class TestHealthEndpoint:
|
||||
"""Test /health endpoint."""
|
||||
|
||||
@patch("src.controllers.health_controller.get_ollama_client")
|
||||
def test_health_returns_200_when_ollama_healthy(self, mock_get_ollama, client):
|
||||
"""Health endpoint should return 200 when Ollama is healthy."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.health_check.return_value = True
|
||||
mock_get_ollama.return_value = mock_client
|
||||
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
|
||||
@patch("src.controllers.health_controller.get_ollama_client")
|
||||
def test_health_returns_status(self, mock_get_ollama, client):
|
||||
"""Health endpoint should return status information."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.health_check.return_value = True
|
||||
mock_get_ollama.return_value = mock_client
|
||||
|
||||
response = client.get("/health")
|
||||
data = response.json()
|
||||
|
||||
assert "status" in data
|
||||
assert "ollama_connected" in data
|
||||
|
||||
@patch("src.controllers.health_controller.get_ollama_client")
|
||||
def test_health_returns_ollama_connected_true(self, mock_get_ollama, client):
|
||||
"""Health should report Ollama connected when healthy."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.health_check.return_value = True
|
||||
mock_get_ollama.return_value = mock_client
|
||||
|
||||
response = client.get("/health")
|
||||
data = response.json()
|
||||
|
||||
assert data["ollama_connected"] is True
|
||||
|
||||
@patch("src.controllers.health_controller.get_ollama_client")
|
||||
def test_health_returns_ollama_connected_false(self, mock_get_ollama, client):
|
||||
"""Health should report Ollama disconnected when unhealthy."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.health_check.return_value = False
|
||||
mock_get_ollama.return_value = mock_client
|
||||
|
||||
response = client.get("/health")
|
||||
data = response.json()
|
||||
|
||||
assert data["ollama_connected"] is False
|
||||
|
||||
|
||||
class TestOpenAPIEndpoint:
|
||||
"""Test OpenAPI documentation endpoints."""
|
||||
|
||||
def test_openapi_spec_available(self, client):
|
||||
"""OpenAPI spec should be available."""
|
||||
response = client.get("/openapi.json")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert "openapi" in data
|
||||
assert "info" in data
|
||||
|
||||
def test_swagger_ui_available(self, client):
|
||||
"""Swagger UI should be available."""
|
||||
response = client.get("/docs")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_redoc_available(self, client):
|
||||
"""ReDoc should be available."""
|
||||
response = client.get("/redoc")
|
||||
assert response.status_code == 200
|
||||
@@ -1,269 +0,0 @@
|
||||
"""
|
||||
Integration tests for Phase 2 Memory System
|
||||
|
||||
Tests the complete memory stack:
|
||||
- Tier 1: ConversationBufferMemory
|
||||
- Tier 2/3: QdrantConversationMemory
|
||||
- Embedding Client
|
||||
"""
|
||||
import asyncio
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
from src.memory import (
|
||||
ConversationBufferMemory,
|
||||
QdrantConversationMemory,
|
||||
ConversationTurn,
|
||||
MessageRole,
|
||||
TokenUsage,
|
||||
get_buffer_memory,
|
||||
get_qdrant_memory
|
||||
)
|
||||
from src.models.embeddings import get_embedding_client
|
||||
|
||||
|
||||
class TestEmbeddingClient:
|
||||
"""Test embedding generation"""
|
||||
|
||||
def test_embedding_client_init(self):
|
||||
"""Test embedding client initialization"""
|
||||
client = get_embedding_client()
|
||||
assert client is not None
|
||||
assert client.dimension == 384
|
||||
print(f"✓ Embedding client initialized: {client.model_name}")
|
||||
|
||||
def test_single_embedding(self):
|
||||
"""Test single text embedding"""
|
||||
client = get_embedding_client()
|
||||
text = "Hello, this is a test message for embedding generation"
|
||||
|
||||
embedding = client.embed_text(text)
|
||||
|
||||
assert isinstance(embedding, list)
|
||||
assert len(embedding) == 384
|
||||
assert all(isinstance(x, float) for x in embedding)
|
||||
print(f"✓ Single embedding generated: {len(embedding)} dimensions")
|
||||
|
||||
def test_batch_embedding(self):
|
||||
"""Test batch text embedding"""
|
||||
client = get_embedding_client()
|
||||
texts = [
|
||||
"First message about Python programming",
|
||||
"Second message about machine learning",
|
||||
"Third message about data science"
|
||||
]
|
||||
|
||||
embeddings = client.embed_batch(texts)
|
||||
|
||||
assert len(embeddings) == 3
|
||||
assert all(len(emb) == 384 for emb in embeddings)
|
||||
print(f"✓ Batch embeddings generated: {len(embeddings)} texts")
|
||||
|
||||
|
||||
class TestQdrantMemory:
|
||||
"""Test Qdrant memory storage and retrieval"""
|
||||
|
||||
@pytest.fixture
|
||||
def qdrant_memory(self):
|
||||
"""Get Qdrant memory instance"""
|
||||
return get_qdrant_memory()
|
||||
|
||||
@pytest.fixture
|
||||
def test_conversation_id(self):
|
||||
"""Generate unique test conversation ID"""
|
||||
return f"test_conv_{int(datetime.utcnow().timestamp())}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qdrant_connection(self, qdrant_memory):
|
||||
"""Test Qdrant connection and collection"""
|
||||
assert qdrant_memory.client is not None
|
||||
assert qdrant_memory.collection_name == "core_api_conversations"
|
||||
print(f"✓ Connected to Qdrant: {qdrant_memory.host}:{qdrant_memory.port}")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_turn(self, qdrant_memory, test_conversation_id):
|
||||
"""Test adding a turn to Qdrant"""
|
||||
turn = ConversationTurn(
|
||||
role=MessageRole.USER,
|
||||
content="What is Python?",
|
||||
turn_number=1,
|
||||
tokens=TokenUsage(prompt=10, completion=0, total=10)
|
||||
)
|
||||
|
||||
await qdrant_memory.add_turn(test_conversation_id, turn)
|
||||
|
||||
# Verify it was stored
|
||||
exists = await qdrant_memory.conversation_exists(test_conversation_id)
|
||||
assert exists is True
|
||||
print(f"✓ Turn stored in Qdrant: {test_conversation_id}")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chronological_retrieval(self, qdrant_memory, test_conversation_id):
|
||||
"""Test Tier 2 mode: chronological retrieval"""
|
||||
# Add multiple turns
|
||||
turns = [
|
||||
ConversationTurn(role=MessageRole.USER, content="What is Python?", turn_number=1),
|
||||
ConversationTurn(role=MessageRole.ASSISTANT, content="Python is a programming language", turn_number=2),
|
||||
ConversationTurn(role=MessageRole.USER, content="How do I learn it?", turn_number=3),
|
||||
]
|
||||
|
||||
for turn in turns:
|
||||
await qdrant_memory.add_turn(test_conversation_id, turn)
|
||||
|
||||
# Retrieve turns chronologically
|
||||
retrieved = await qdrant_memory.get_turns(test_conversation_id)
|
||||
|
||||
assert len(retrieved) == 3
|
||||
assert retrieved[0].turn_number == 1
|
||||
assert retrieved[1].turn_number == 2
|
||||
assert retrieved[2].turn_number == 3
|
||||
assert retrieved[0].content == "What is Python?"
|
||||
print(f"✓ Chronological retrieval works: {len(retrieved)} turns")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_semantic_search(self, qdrant_memory, test_conversation_id):
|
||||
"""Test Tier 3 mode: semantic search"""
|
||||
# Add turns with distinct topics
|
||||
turns = [
|
||||
ConversationTurn(role=MessageRole.USER, content="I love machine learning and neural networks", turn_number=10),
|
||||
ConversationTurn(role=MessageRole.USER, content="Pizza is my favorite food", turn_number=11),
|
||||
ConversationTurn(role=MessageRole.USER, content="Deep learning models are fascinating", turn_number=12),
|
||||
]
|
||||
|
||||
for turn in turns:
|
||||
await qdrant_memory.add_turn(test_conversation_id, turn)
|
||||
|
||||
# Search for AI-related content
|
||||
results = await qdrant_memory.similarity_search(
|
||||
query="artificial intelligence and AI",
|
||||
conversation_id=test_conversation_id,
|
||||
limit=3
|
||||
)
|
||||
|
||||
assert len(results) > 0
|
||||
# Top results should be about ML/AI, not pizza
|
||||
top_result = results[0]
|
||||
assert "machine learning" in top_result["content"] or "Deep learning" in top_result["content"]
|
||||
assert top_result["score"] > 0.5 # Reasonable similarity score
|
||||
print(f"✓ Semantic search works: {len(results)} matches, top score: {results[0]['score']:.3f}")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_conversation_stats(self, qdrant_memory, test_conversation_id):
|
||||
"""Test conversation statistics"""
|
||||
stats = await qdrant_memory.get_conversation_stats(test_conversation_id)
|
||||
|
||||
assert stats["conversation_id"] == test_conversation_id
|
||||
assert stats["total_turns"] >= 0
|
||||
assert "total_tokens" in stats
|
||||
print(f"✓ Stats retrieved: {stats['total_turns']} turns, {stats['total_tokens']} tokens")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_conversation(self, qdrant_memory, test_conversation_id):
|
||||
"""Test clearing a conversation"""
|
||||
# Add a turn
|
||||
turn = ConversationTurn(role=MessageRole.USER, content="Test message", turn_number=99)
|
||||
await qdrant_memory.add_turn(test_conversation_id, turn)
|
||||
|
||||
# Clear it
|
||||
await qdrant_memory.clear_conversation(test_conversation_id)
|
||||
|
||||
# Verify it's gone
|
||||
exists = await qdrant_memory.conversation_exists(test_conversation_id)
|
||||
assert exists is False
|
||||
print(f"✓ Conversation cleared: {test_conversation_id}")
|
||||
|
||||
|
||||
class TestIntegration:
|
||||
"""Test full integration: Tier 1 + Qdrant + Embeddings"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_memory_flow(self):
|
||||
"""Test complete memory flow: Buffer → Qdrant"""
|
||||
conversation_id = f"integration_test_{int(datetime.utcnow().timestamp())}"
|
||||
|
||||
# Initialize both tiers
|
||||
buffer_memory = get_buffer_memory()
|
||||
qdrant_memory = get_qdrant_memory()
|
||||
|
||||
# 1. Add turns to buffer (Tier 1)
|
||||
turns = [
|
||||
ConversationTurn(role=MessageRole.USER, content="Hello!", turn_number=1),
|
||||
ConversationTurn(role=MessageRole.ASSISTANT, content="Hi there!", turn_number=2),
|
||||
ConversationTurn(role=MessageRole.USER, content="How are you?", turn_number=3),
|
||||
]
|
||||
|
||||
for turn in turns:
|
||||
await buffer_memory.add_turn(conversation_id, turn)
|
||||
|
||||
# Verify buffer has them
|
||||
buffer = await buffer_memory.get_buffer(conversation_id)
|
||||
assert len(buffer.turns) == 3
|
||||
print(f"✓ Tier 1 buffer: {len(buffer.turns)} turns")
|
||||
|
||||
# 2. Move to Qdrant (Tier 2/3)
|
||||
for turn in buffer.turns:
|
||||
await qdrant_memory.add_turn(conversation_id, turn)
|
||||
|
||||
# Verify Qdrant has them
|
||||
qdrant_turns = await qdrant_memory.get_turns(conversation_id)
|
||||
assert len(qdrant_turns) == 3
|
||||
print(f"✓ Tier 2/3 Qdrant: {len(qdrant_turns)} turns")
|
||||
|
||||
# 3. Test semantic search across both
|
||||
search_results = await qdrant_memory.similarity_search(
|
||||
query="greeting",
|
||||
conversation_id=conversation_id,
|
||||
limit=2
|
||||
)
|
||||
assert len(search_results) > 0
|
||||
print(f"✓ Semantic search: {len(search_results)} matches")
|
||||
|
||||
# Cleanup
|
||||
await qdrant_memory.clear_conversation(conversation_id)
|
||||
await buffer_memory.clear_conversation(conversation_id)
|
||||
print(f"✓ Full memory flow complete!")
|
||||
|
||||
|
||||
def run_tests():
|
||||
"""Run all tests"""
|
||||
print("\n" + "="*60)
|
||||
print("Phase 2 Memory System Integration Tests")
|
||||
print("="*60 + "\n")
|
||||
|
||||
# Test 1: Embedding Client
|
||||
print("Test 1: Embedding Client")
|
||||
print("-" * 40)
|
||||
test_embed = TestEmbeddingClient()
|
||||
test_embed.test_embedding_client_init()
|
||||
test_embed.test_single_embedding()
|
||||
test_embed.test_batch_embedding()
|
||||
print()
|
||||
|
||||
# Test 2: Qdrant Memory
|
||||
print("Test 2: Qdrant Memory Storage")
|
||||
print("-" * 40)
|
||||
test_qdrant = TestQdrantMemory()
|
||||
qdrant_memory = get_qdrant_memory()
|
||||
test_conv_id = f"test_conv_{int(datetime.utcnow().timestamp())}"
|
||||
|
||||
asyncio.run(test_qdrant.test_qdrant_connection(qdrant_memory))
|
||||
asyncio.run(test_qdrant.test_add_turn(qdrant_memory, test_conv_id))
|
||||
asyncio.run(test_qdrant.test_chronological_retrieval(qdrant_memory, test_conv_id))
|
||||
asyncio.run(test_qdrant.test_semantic_search(qdrant_memory, test_conv_id))
|
||||
asyncio.run(test_qdrant.test_conversation_stats(qdrant_memory, test_conv_id))
|
||||
asyncio.run(test_qdrant.test_clear_conversation(qdrant_memory, test_conv_id))
|
||||
print()
|
||||
|
||||
# Test 3: Full Integration
|
||||
print("Test 3: Full Integration (Tier 1 + Tier 2/3)")
|
||||
print("-" * 40)
|
||||
test_integration = TestIntegration()
|
||||
asyncio.run(test_integration.test_full_memory_flow())
|
||||
print()
|
||||
|
||||
print("="*60)
|
||||
print("✅ All Memory System Tests Passed!")
|
||||
print("="*60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_tests()
|
||||
@@ -1,150 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test MemoryManager orchestration
|
||||
|
||||
Verifies unified memory interface works correctly.
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
sys.path.insert(0, '/app')
|
||||
|
||||
from src.memory import MemoryManager, get_memory_manager, MessageRole, TokenUsage
|
||||
|
||||
|
||||
async def test_memory_manager():
|
||||
"""Test MemoryManager orchestration"""
|
||||
print("\n" + "="*60)
|
||||
print("MEMORY MANAGER TEST")
|
||||
print("="*60)
|
||||
|
||||
test_conv_id = f"manager_test_{int(datetime.utcnow().timestamp())}"
|
||||
|
||||
try:
|
||||
# Initialize manager
|
||||
manager = get_memory_manager()
|
||||
print(f"✓ MemoryManager initialized")
|
||||
|
||||
# Test 1: Add turns through manager
|
||||
print("\n1. Adding turns via MemoryManager...")
|
||||
turn1 = await manager.add_turn(
|
||||
conversation_id=test_conv_id,
|
||||
role=MessageRole.USER,
|
||||
content="Hello, how are you?",
|
||||
tokens=TokenUsage(prompt=5, completion=0, total=5)
|
||||
)
|
||||
assert turn1.turn_number == 1
|
||||
print(f" ✓ Turn 1 added: {turn1.content[:30]}...")
|
||||
|
||||
turn2 = await manager.add_turn(
|
||||
conversation_id=test_conv_id,
|
||||
role=MessageRole.ASSISTANT,
|
||||
content="I'm doing great! How can I help you today?",
|
||||
tokens=TokenUsage(prompt=5, completion=10, total=15)
|
||||
)
|
||||
assert turn2.turn_number == 2
|
||||
print(f" ✓ Turn 2 added: {turn2.content[:30]}...")
|
||||
|
||||
# Test 2: Get recent turns (from buffer)
|
||||
print("\n2. Getting recent turns from buffer...")
|
||||
recent = await manager.get_recent_turns(test_conv_id, limit=10)
|
||||
assert len(recent) == 2
|
||||
assert recent[0].turn_number == 1
|
||||
assert recent[1].turn_number == 2
|
||||
print(f" ✓ Retrieved {len(recent)} recent turns from buffer")
|
||||
|
||||
# Test 3: Add more turns to trigger consolidation (threshold = 10)
|
||||
print("\n3. Adding turns to trigger auto-consolidation...")
|
||||
for i in range(3, 11): # Add turns 3-10
|
||||
await manager.add_turn(
|
||||
conversation_id=test_conv_id,
|
||||
role=MessageRole.USER if i % 2 == 1 else MessageRole.ASSISTANT,
|
||||
content=f"Test message number {i}",
|
||||
tokens=TokenUsage(prompt=5, completion=5, total=10)
|
||||
)
|
||||
print(f" ✓ Added 8 more turns (total: 10)")
|
||||
|
||||
# Check if consolidation happened (turn 10 should trigger it)
|
||||
print("\n4. Verifying auto-consolidation...")
|
||||
stats = await manager.get_conversation_stats(test_conv_id)
|
||||
print(f" Buffer turns: {stats['buffer_turns']}")
|
||||
print(f" Qdrant turns: {stats['qdrant_turns']}")
|
||||
print(f" Exists in buffer: {stats['exists_in_buffer']}")
|
||||
print(f" Exists in Qdrant: {stats['exists_in_qdrant']}")
|
||||
|
||||
if stats['qdrant_turns'] > 0:
|
||||
print(f" ✓ Auto-consolidation triggered! {stats['qdrant_turns']} turns in Qdrant")
|
||||
else:
|
||||
print(f" ⚠ No auto-consolidation yet (threshold may not be reached)")
|
||||
|
||||
# Test 4: Manual consolidation
|
||||
print("\n5. Testing manual consolidation...")
|
||||
consolidated = await manager.consolidate(test_conv_id)
|
||||
print(f" ✓ Manually consolidated {consolidated} turns")
|
||||
|
||||
# Test 5: Get full history (buffer + Qdrant)
|
||||
print("\n6. Getting full conversation history...")
|
||||
full_history = await manager.get_full_history(test_conv_id)
|
||||
print(f" ✓ Retrieved {len(full_history)} total turns")
|
||||
assert len(full_history) == 10, f"Expected 10 turns, got {len(full_history)}"
|
||||
print(f" ✓ Full history verified (10 turns)")
|
||||
|
||||
# Test 6: Semantic search
|
||||
print("\n7. Testing semantic search...")
|
||||
search_results = await manager.search_conversations(
|
||||
query="greeting hello",
|
||||
conversation_id=test_conv_id,
|
||||
limit=3
|
||||
)
|
||||
if len(search_results) > 0:
|
||||
print(f" ✓ Semantic search found {len(search_results)} matches")
|
||||
print(f" Top: '{search_results[0]['content'][:40]}...' (score: {search_results[0]['score']:.3f})")
|
||||
else:
|
||||
print(f" ⚠ No semantic search results (may need more data)")
|
||||
|
||||
# Test 7: Clear conversation
|
||||
print("\n8. Clearing conversation...")
|
||||
await manager.clear_conversation(test_conv_id)
|
||||
stats_after = await manager.get_conversation_stats(test_conv_id)
|
||||
assert stats_after['buffer_turns'] == 0
|
||||
assert stats_after['qdrant_turns'] == 0
|
||||
print(f" ✓ Conversation cleared from all tiers")
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("✅ MEMORY MANAGER TEST: PASSED")
|
||||
print("="*60)
|
||||
print("\nMemoryManager verified:")
|
||||
print(" ✓ Add turns with auto turn numbering")
|
||||
print(" ✓ Get recent turns from buffer")
|
||||
print(" ✓ Auto-consolidation (when threshold reached)")
|
||||
print(" ✓ Manual consolidation")
|
||||
print(" ✓ Get full history (buffer + Qdrant)")
|
||||
print(" ✓ Semantic search")
|
||||
print(" ✓ Clear conversation")
|
||||
print(" ✓ Conversation stats")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ MEMORY MANAGER TEST: FAILED")
|
||||
print(f"Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# Cleanup on error
|
||||
try:
|
||||
await manager.clear_conversation(test_conv_id)
|
||||
except:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the test"""
|
||||
success = asyncio.run(test_memory_manager())
|
||||
return 0 if success else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit(main())
|
||||
@@ -1,330 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple integration tests for Phase 2 Memory System
|
||||
No external dependencies beyond the memory system itself
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
# Add src to path
|
||||
sys.path.insert(0, '/app')
|
||||
|
||||
from src.memory import (
|
||||
ConversationBufferMemory,
|
||||
QdrantConversationMemory,
|
||||
ConversationTurn,
|
||||
MessageRole,
|
||||
TokenUsage,
|
||||
get_buffer_memory,
|
||||
get_qdrant_memory
|
||||
)
|
||||
from src.models.embeddings import get_embedding_client
|
||||
|
||||
|
||||
def test_embedding_client():
|
||||
"""Test 1: Embedding Client"""
|
||||
print("\n" + "="*60)
|
||||
print("Test 1: Embedding Client")
|
||||
print("="*60)
|
||||
|
||||
try:
|
||||
# Initialize
|
||||
client = get_embedding_client()
|
||||
assert client is not None
|
||||
assert client.dimension == 384
|
||||
print(f"✓ Embedding client initialized: {client.model_name}")
|
||||
print(f"✓ Embedding dimension: {client.dimension}")
|
||||
|
||||
# Single embedding
|
||||
text = "Hello, this is a test message for embedding generation"
|
||||
embedding = client.embed_text(text)
|
||||
assert isinstance(embedding, list)
|
||||
assert len(embedding) == 384
|
||||
assert all(isinstance(x, float) for x in embedding)
|
||||
print(f"✓ Single embedding generated: {len(embedding)} dimensions")
|
||||
print(f" Sample values: [{embedding[0]:.4f}, {embedding[1]:.4f}, {embedding[2]:.4f}, ...]")
|
||||
|
||||
# Batch embedding
|
||||
texts = [
|
||||
"First message about Python programming",
|
||||
"Second message about machine learning",
|
||||
"Third message about data science"
|
||||
]
|
||||
embeddings = client.embed_batch(texts)
|
||||
assert len(embeddings) == 3
|
||||
assert all(len(emb) == 384 for emb in embeddings)
|
||||
print(f"✓ Batch embeddings generated: {len(embeddings)} texts")
|
||||
|
||||
print("\n✅ Embedding Client Tests: PASSED")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Embedding Client Tests: FAILED")
|
||||
print(f"Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
async def test_qdrant_memory():
|
||||
"""Test 2: Qdrant Memory Storage"""
|
||||
print("\n" + "="*60)
|
||||
print("Test 2: Qdrant Memory Storage")
|
||||
print("="*60)
|
||||
|
||||
test_conv_id = f"test_conv_{int(datetime.utcnow().timestamp())}"
|
||||
|
||||
try:
|
||||
# Initialize
|
||||
qdrant_memory = get_qdrant_memory()
|
||||
assert qdrant_memory.client is not None
|
||||
assert qdrant_memory.collection_name == "core_api_conversations"
|
||||
print(f"✓ Connected to Qdrant: {qdrant_memory.host}:{qdrant_memory.port}")
|
||||
print(f"✓ Collection: {qdrant_memory.collection_name}")
|
||||
|
||||
# Add single turn
|
||||
turn1 = ConversationTurn(
|
||||
role=MessageRole.USER,
|
||||
content="What is Python?",
|
||||
turn_number=1,
|
||||
tokens=TokenUsage(prompt=10, completion=0, total=10)
|
||||
)
|
||||
await qdrant_memory.add_turn(test_conv_id, turn1)
|
||||
print(f"✓ Turn 1 stored in Qdrant")
|
||||
|
||||
# Verify it exists
|
||||
exists = await qdrant_memory.conversation_exists(test_conv_id)
|
||||
assert exists is True
|
||||
print(f"✓ Conversation exists: {test_conv_id}")
|
||||
|
||||
# Add more turns for chronological test
|
||||
turn2 = ConversationTurn(
|
||||
role=MessageRole.ASSISTANT,
|
||||
content="Python is a high-level programming language known for simplicity and readability",
|
||||
turn_number=2
|
||||
)
|
||||
turn3 = ConversationTurn(
|
||||
role=MessageRole.USER,
|
||||
content="How do I learn Python programming?",
|
||||
turn_number=3
|
||||
)
|
||||
|
||||
await qdrant_memory.add_turn(test_conv_id, turn2)
|
||||
await qdrant_memory.add_turn(test_conv_id, turn3)
|
||||
print(f"✓ Turns 2-3 stored in Qdrant")
|
||||
|
||||
# Test chronological retrieval (Tier 2 mode)
|
||||
retrieved = await qdrant_memory.get_turns(test_conv_id)
|
||||
assert len(retrieved) == 3
|
||||
assert retrieved[0].turn_number == 1
|
||||
assert retrieved[1].turn_number == 2
|
||||
assert retrieved[2].turn_number == 3
|
||||
assert retrieved[0].content == "What is Python?"
|
||||
print(f"✓ Chronological retrieval works: {len(retrieved)} turns")
|
||||
for i, turn in enumerate(retrieved, 1):
|
||||
print(f" Turn {turn.turn_number}: {turn.role.value} - {turn.content[:50]}...")
|
||||
|
||||
# Add turns with distinct topics for semantic search
|
||||
turn10 = ConversationTurn(
|
||||
role=MessageRole.USER,
|
||||
content="I love machine learning and neural networks and artificial intelligence",
|
||||
turn_number=10
|
||||
)
|
||||
turn11 = ConversationTurn(
|
||||
role=MessageRole.USER,
|
||||
content="Pizza is my favorite food and I enjoy eating pasta",
|
||||
turn_number=11
|
||||
)
|
||||
turn12 = ConversationTurn(
|
||||
role=MessageRole.USER,
|
||||
content="Deep learning models and transformers are fascinating AI technologies",
|
||||
turn_number=12
|
||||
)
|
||||
|
||||
await qdrant_memory.add_turn(test_conv_id, turn10)
|
||||
await qdrant_memory.add_turn(test_conv_id, turn11)
|
||||
await qdrant_memory.add_turn(test_conv_id, turn12)
|
||||
print(f"✓ Added 3 more turns for semantic search test")
|
||||
|
||||
# Test semantic search (Tier 3 mode)
|
||||
search_results = await qdrant_memory.similarity_search(
|
||||
query="artificial intelligence and deep learning",
|
||||
conversation_id=test_conv_id,
|
||||
limit=3
|
||||
)
|
||||
assert len(search_results) > 0
|
||||
print(f"✓ Semantic search works: {len(search_results)} matches")
|
||||
|
||||
# Top result should be about AI/ML, not food
|
||||
top_result = search_results[0]
|
||||
print(f" Top match (score: {top_result['score']:.3f}): {top_result['content'][:60]}...")
|
||||
assert top_result["score"] > 0.5, "Semantic similarity score too low"
|
||||
|
||||
# Verify top matches are AI-related
|
||||
ai_keywords = ["machine learning", "neural networks", "Deep learning", "AI", "artificial intelligence"]
|
||||
top_content = search_results[0]["content"]
|
||||
assert any(keyword in top_content for keyword in ai_keywords), "Top result not AI-related"
|
||||
print(f"✓ Semantic relevance verified (AI-related content ranked higher)")
|
||||
|
||||
# Test conversation stats
|
||||
stats = await qdrant_memory.get_conversation_stats(test_conv_id)
|
||||
assert stats["conversation_id"] == test_conv_id
|
||||
assert stats["total_turns"] == 6
|
||||
print(f"✓ Stats retrieved: {stats['total_turns']} turns, {stats['total_tokens']} tokens")
|
||||
|
||||
# Cleanup
|
||||
await qdrant_memory.clear_conversation(test_conv_id)
|
||||
exists_after = await qdrant_memory.conversation_exists(test_conv_id)
|
||||
assert exists_after is False
|
||||
print(f"✓ Conversation cleared successfully")
|
||||
|
||||
print("\n✅ Qdrant Memory Tests: PASSED")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Qdrant Memory Tests: FAILED")
|
||||
print(f"Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# Cleanup on error
|
||||
try:
|
||||
await qdrant_memory.clear_conversation(test_conv_id)
|
||||
except:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def test_full_integration():
|
||||
"""Test 3: Full Integration (Tier 1 + Tier 2/3)"""
|
||||
print("\n" + "="*60)
|
||||
print("Test 3: Full Integration (Tier 1 + Tier 2/3)")
|
||||
print("="*60)
|
||||
|
||||
test_conv_id = f"integration_test_{int(datetime.utcnow().timestamp())}"
|
||||
|
||||
try:
|
||||
# Initialize both tiers
|
||||
buffer_memory = get_buffer_memory()
|
||||
qdrant_memory = get_qdrant_memory()
|
||||
print(f"✓ Initialized Tier 1 (Buffer) and Tier 2/3 (Qdrant)")
|
||||
|
||||
# 1. Add turns to buffer (Tier 1)
|
||||
turns = [
|
||||
ConversationTurn(role=MessageRole.USER, content="Hello!", turn_number=1),
|
||||
ConversationTurn(role=MessageRole.ASSISTANT, content="Hi there! How can I help?", turn_number=2),
|
||||
ConversationTurn(role=MessageRole.USER, content="How are you?", turn_number=3),
|
||||
ConversationTurn(role=MessageRole.ASSISTANT, content="I'm doing great, thanks!", turn_number=4),
|
||||
]
|
||||
|
||||
for turn in turns:
|
||||
await buffer_memory.add_turn(test_conv_id, turn)
|
||||
|
||||
# Verify buffer has them
|
||||
buffer = await buffer_memory.get_buffer(test_conv_id)
|
||||
assert len(buffer.turns) == 4
|
||||
print(f"✓ Tier 1 buffer: {len(buffer.turns)} turns stored")
|
||||
|
||||
# 2. Move to Qdrant (Tier 2/3) - simulating consolidation
|
||||
for turn in buffer.turns:
|
||||
await qdrant_memory.add_turn(test_conv_id, turn)
|
||||
|
||||
# Verify Qdrant has them
|
||||
qdrant_turns = await qdrant_memory.get_turns(test_conv_id)
|
||||
assert len(qdrant_turns) == 4
|
||||
print(f"✓ Tier 2/3 Qdrant: {len(qdrant_turns)} turns stored")
|
||||
|
||||
# 3. Test semantic search across consolidated data
|
||||
search_results = await qdrant_memory.similarity_search(
|
||||
query="greeting hello",
|
||||
conversation_id=test_conv_id,
|
||||
limit=2
|
||||
)
|
||||
assert len(search_results) > 0
|
||||
print(f"✓ Semantic search: {len(search_results)} matches found")
|
||||
print(f" Best match: '{search_results[0]['content']}' (score: {search_results[0]['score']:.3f})")
|
||||
|
||||
# 4. Test data consistency
|
||||
buffer_content = [t.content for t in buffer.turns]
|
||||
qdrant_content = [t.content for t in qdrant_turns]
|
||||
assert buffer_content == qdrant_content
|
||||
print(f"✓ Data consistency verified (Buffer ↔ Qdrant)")
|
||||
|
||||
# Cleanup
|
||||
await qdrant_memory.clear_conversation(test_conv_id)
|
||||
await buffer_memory.clear_conversation(test_conv_id)
|
||||
print(f"✓ Cleanup complete")
|
||||
|
||||
print("\n✅ Full Integration Tests: PASSED")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Full Integration Tests: FAILED")
|
||||
print(f"Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# Cleanup on error
|
||||
try:
|
||||
await qdrant_memory.clear_conversation(test_conv_id)
|
||||
await buffer_memory.clear_conversation(test_conv_id)
|
||||
except:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Run all tests"""
|
||||
print("\n" + "="*60)
|
||||
print("PHASE 2 MEMORY SYSTEM - INTEGRATION TESTS")
|
||||
print("="*60)
|
||||
print(f"Start time: {datetime.utcnow().isoformat()}")
|
||||
|
||||
results = []
|
||||
|
||||
# Test 1: Embedding Client
|
||||
results.append(("Embedding Client", test_embedding_client()))
|
||||
|
||||
# Test 2: Qdrant Memory
|
||||
results.append(("Qdrant Memory", asyncio.run(test_qdrant_memory())))
|
||||
|
||||
# Test 3: Full Integration
|
||||
results.append(("Full Integration", asyncio.run(test_full_integration())))
|
||||
|
||||
# Summary
|
||||
print("\n" + "="*60)
|
||||
print("TEST SUMMARY")
|
||||
print("="*60)
|
||||
|
||||
for test_name, passed in results:
|
||||
status = "✅ PASSED" if passed else "❌ FAILED"
|
||||
print(f"{test_name:.<40} {status}")
|
||||
|
||||
total = len(results)
|
||||
passed = sum(1 for _, p in results if p)
|
||||
failed = total - passed
|
||||
|
||||
print(f"\nTotal: {total} | Passed: {passed} | Failed: {failed}")
|
||||
print(f"Success rate: {(passed/total)*100:.1f}%")
|
||||
|
||||
if all(p for _, p in results):
|
||||
print("\n" + "="*60)
|
||||
print("🎉 ALL TESTS PASSED!")
|
||||
print("="*60)
|
||||
print("\nPhase 2 Memory System Status: ✅ FUNCTIONAL")
|
||||
print("- Embedding client working (384d vectors)")
|
||||
print("- Qdrant storage working (chronological + semantic)")
|
||||
print("- Full integration working (Tier 1 ↔ Tier 2/3)")
|
||||
return 0
|
||||
else:
|
||||
print("\n" + "="*60)
|
||||
print("❌ SOME TESTS FAILED")
|
||||
print("="*60)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit(main())
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Tests for service_groups module."""
|
||||
import pytest
|
||||
from src.service_groups import (
|
||||
ALWAYS_ON_SERVICES,
|
||||
SERVICE_GROUPS,
|
||||
is_always_on,
|
||||
get_service_group,
|
||||
get_group_name,
|
||||
list_service_groups,
|
||||
list_stoppable_services,
|
||||
validate_stop_request,
|
||||
)
|
||||
|
||||
|
||||
class TestAlwaysOnServices:
|
||||
"""Test always-on service configuration."""
|
||||
|
||||
def test_always_on_services_includes_infrastructure(self):
|
||||
"""Critical infrastructure services should be in always-on list."""
|
||||
assert "portainer" in ALWAYS_ON_SERVICES
|
||||
assert "nginx-proxy-manager" in ALWAYS_ON_SERVICES
|
||||
assert "core-api" in ALWAYS_ON_SERVICES
|
||||
|
||||
def test_uptime_kuma_not_in_always_on(self):
|
||||
"""Uptime Kuma was removed from always-on list."""
|
||||
assert "uptime-kuma" not in ALWAYS_ON_SERVICES
|
||||
|
||||
|
||||
class TestIsAlwaysOn:
|
||||
"""Test is_always_on function."""
|
||||
|
||||
def test_is_always_on_returns_true_for_infrastructure(self):
|
||||
"""Infrastructure services should return True."""
|
||||
assert is_always_on("portainer") is True
|
||||
assert is_always_on("nginx-proxy-manager") is True
|
||||
assert is_always_on("core-api") is True
|
||||
|
||||
def test_is_always_on_case_insensitive(self):
|
||||
"""Function should be case-insensitive."""
|
||||
assert is_always_on("PORTAINER") is True
|
||||
assert is_always_on("Portainer") is True
|
||||
assert is_always_on("PoRtAiNeR") is True
|
||||
|
||||
def test_is_always_on_returns_false_for_stoppable(self):
|
||||
"""Stoppable services should return False."""
|
||||
assert is_always_on("jellyfin") is False
|
||||
assert is_always_on("nextcloud") is False
|
||||
assert is_always_on("unknown-service") is False
|
||||
|
||||
|
||||
class TestGetServiceGroup:
|
||||
"""Test get_service_group function."""
|
||||
|
||||
def test_returns_group_members_for_grouped_service(self):
|
||||
"""Should return all services in the group."""
|
||||
# Assuming jellyfin is defined in SERVICE_GROUPS
|
||||
if "jellyfin" in SERVICE_GROUPS:
|
||||
result = get_service_group("jellyfin")
|
||||
assert "jellyfin" in result
|
||||
|
||||
def test_returns_single_item_for_ungrouped_service(self):
|
||||
"""Ungrouped services should return themselves."""
|
||||
result = get_service_group("some-random-service")
|
||||
assert result == ["some-random-service"]
|
||||
|
||||
def test_returns_copy_not_reference(self):
|
||||
"""Should return a copy to prevent modification."""
|
||||
if SERVICE_GROUPS:
|
||||
group_name = list(SERVICE_GROUPS.keys())[0]
|
||||
result1 = get_service_group(group_name)
|
||||
result2 = get_service_group(group_name)
|
||||
assert result1 is not result2
|
||||
|
||||
|
||||
class TestGetGroupName:
|
||||
"""Test get_group_name function."""
|
||||
|
||||
def test_returns_group_name_for_grouped_service(self):
|
||||
"""Should return group name for services in groups."""
|
||||
# If ai-stack group exists with ollama
|
||||
if "ai-stack" in SERVICE_GROUPS and "ollama" in SERVICE_GROUPS["ai-stack"]:
|
||||
assert get_group_name("ollama") == "ai-stack"
|
||||
|
||||
def test_returns_service_name_for_ungrouped(self):
|
||||
"""Ungrouped services should return their own name."""
|
||||
assert get_group_name("random-service") == "random-service"
|
||||
|
||||
|
||||
class TestListServiceGroups:
|
||||
"""Test list_service_groups function."""
|
||||
|
||||
def test_returns_all_groups(self):
|
||||
"""Should return all defined service groups."""
|
||||
result = list_service_groups()
|
||||
assert isinstance(result, dict)
|
||||
assert result == SERVICE_GROUPS
|
||||
|
||||
def test_returns_copy(self):
|
||||
"""Should return a copy to prevent modification."""
|
||||
result = list_service_groups()
|
||||
assert result is not SERVICE_GROUPS
|
||||
|
||||
|
||||
class TestListStoppableServices:
|
||||
"""Test list_stoppable_services function."""
|
||||
|
||||
def test_returns_list(self):
|
||||
"""Should return a list."""
|
||||
result = list_stoppable_services()
|
||||
assert isinstance(result, list)
|
||||
|
||||
def test_excludes_always_on_services(self):
|
||||
"""Should not include always-on services."""
|
||||
result = list_stoppable_services()
|
||||
for service in result:
|
||||
assert not is_always_on(service), f"{service} is always-on but in stoppable list"
|
||||
|
||||
|
||||
class TestValidateStopRequest:
|
||||
"""Test validate_stop_request function."""
|
||||
|
||||
def test_valid_for_stoppable_services(self):
|
||||
"""Should return valid for stoppable services."""
|
||||
stoppable = list_stoppable_services()
|
||||
if stoppable:
|
||||
is_valid, error = validate_stop_request([stoppable[0]])
|
||||
assert is_valid is True
|
||||
assert error == ""
|
||||
|
||||
def test_invalid_for_always_on_services(self):
|
||||
"""Should return invalid for always-on services."""
|
||||
is_valid, error = validate_stop_request(["portainer"])
|
||||
assert is_valid is False
|
||||
assert "always-on" in error.lower()
|
||||
assert "portainer" in error
|
||||
|
||||
def test_invalid_if_any_service_is_always_on(self):
|
||||
"""Should fail if any service in list is always-on."""
|
||||
is_valid, error = validate_stop_request(["jellyfin", "portainer"])
|
||||
assert is_valid is False
|
||||
|
||||
def test_valid_for_empty_list(self):
|
||||
"""Empty list should be valid."""
|
||||
is_valid, error = validate_stop_request([])
|
||||
assert is_valid is True
|
||||
assert error == ""
|
||||
Reference in New Issue
Block a user