service logic wip

This commit is contained in:
phernandez
2024-12-02 22:51:45 -06:00
parent 79e5d19c5a
commit cbf366a7dc
5 changed files with 368 additions and 7 deletions
+25 -7
View File
@@ -1,11 +1,29 @@
from datetime import datetime, UTC
from typing import List
from sqlalchemy import String, DateTime, ForeignKey, Text
from typing import List, Optional
from sqlalchemy import String, DateTime, ForeignKey, Text, TypeDecorator
from sqlalchemy.orm import Mapped, mapped_column, relationship, DeclarativeBase
from sqlalchemy.ext.asyncio import AsyncAttrs
def utc_now():
class UTCDateTime(TypeDecorator):
"""Automatically handle UTC timezone for datetime columns"""
impl = DateTime
cache_ok = True
def process_bind_param(self, value: Optional[datetime], dialect):
if value is not None:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value
return value
def process_result_value(self, value: Optional[datetime], dialect):
if value is not None:
return value.replace(tzinfo=UTC)
return value
def utc_now() -> datetime:
"""Helper to get current UTC time"""
return datetime.now(UTC)
@@ -36,10 +54,10 @@ class Entity(Base):
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
references: Mapped[str] = mapped_column(Text, nullable=False, default="")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now
UTCDateTime, default=utc_now
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
UTCDateTime,
default=utc_now,
onupdate=utc_now
)
@@ -82,7 +100,7 @@ class Observation(Base):
)
content: Mapped[str] = mapped_column(String)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
UTCDateTime,
default=utc_now
)
context: Mapped[str | None] = mapped_column(String, nullable=True)
@@ -114,7 +132,7 @@ class Relation(Base):
)
relation_type: Mapped[str] = mapped_column(String)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
UTCDateTime,
default=utc_now
)
context: Mapped[str | None] = mapped_column(String, nullable=True)
+200
View File
@@ -0,0 +1,200 @@
from datetime import datetime, UTC
from pathlib import Path
from typing import List, Optional, Tuple, Dict
from uuid import uuid4
import shutil
from sqlalchemy.ext.asyncio import AsyncSession
from basic_memory.db import get_sessionmaker
from basic_memory.models import Entity, Observation, Relation
from basic_memory.repository import EntityRepository, ObservationRepository, RelationRepository
class MemoryServiceError(Exception):
"""Base exception for memory service errors"""
pass
class FileOperationError(MemoryServiceError):
"""Raised when file operations fail"""
pass
class DatabaseSyncError(MemoryServiceError):
"""Raised when database sync fails - indicates reindex may be needed"""
pass
class EntityNotFoundError(MemoryServiceError):
"""Raised when an entity cannot be found in the filesystem"""
pass
class MemoryService:
"""
Core service layer for basic-memory.
Implementation follows "filesystem is source of truth" principle:
1. Write to filesystem first
2. Update database indexes second
3. Database is treated as disposable/rebuild-able index
"""
def __init__(self, project_name: str):
self.project_name = project_name
self.session_maker = get_sessionmaker()
self.project_path = Path.home() / ".basic-memory" / "projects" / project_name
async def initialize_project(self):
"""
Initialize project directory structure.
Called when creating a new project or ensuring structure exists.
"""
try:
(self.project_path / "entities").mkdir(parents=True, exist_ok=True)
# Add other directories as needed (e.g., for attachments)
except Exception as e:
raise FileOperationError(f"Failed to initialize project structure: {str(e)}") from e
async def _get_repos(self) -> Tuple[EntityRepository, ObservationRepository, RelationRepository]:
"""Get repository instances with a shared session"""
session = self.session_maker()
try:
return (
EntityRepository(session, Entity),
ObservationRepository(session, Observation),
RelationRepository(session, Relation)
)
except:
await session.close()
raise
@staticmethod
def _generate_id(name: str) -> str:
"""Generate timestamp-based ID for an entity"""
timestamp = datetime.now(UTC).strftime("%Y%m%d")
normalized_name = name.lower().replace(" ", "-")
return f"{timestamp}-{normalized_name}-{uuid4().hex[:8]}"
async def _write_entity_file(self, entity_data: dict) -> bool:
"""Write entity to filesystem in Markdown format."""
try:
entity_path = self.project_path / "entities" / f"{entity_data['id']}.md"
entity_path.parent.mkdir(parents=True, exist_ok=True)
# TODO: Replace with actual markdown formatting
content = f"# {entity_data['name']}\n\nStub content"
# Write to temp file first, then rename for atomic operation
temp_path = entity_path.with_suffix('.tmp')
temp_path.write_text(content)
temp_path.rename(entity_path)
return True
except Exception as e:
raise FileOperationError(f"Failed to write entity file: {str(e)}") from e
async def _read_entity_file(self, entity_id: str) -> Dict:
"""Read entity data from filesystem."""
try:
entity_path = self.project_path / "entities" / f"{entity_id}.md"
if not entity_path.exists():
raise EntityNotFoundError(f"Entity file not found: {entity_id}")
# TODO: Implement actual markdown parsing
content = entity_path.read_text()
# Stub parsing
return {"id": entity_id, "content": content}
except EntityNotFoundError:
raise
except Exception as e:
raise FileOperationError(f"Failed to read entity file: {str(e)}") from e
async def _update_db_index(self, entity_data: dict) -> Entity:
"""Update database index with entity data using upsert pattern."""
entity_repo, _, _ = await self._get_repos()
try:
# TODO: Implement proper upsert logic
try:
return await entity_repo.create(entity_data)
except:
existing = await entity_repo.find_by_id(entity_data['id'])
if existing:
return await entity_repo.update(entity_data['id'], entity_data)
raise
except Exception as e:
raise DatabaseSyncError(f"Failed to update database index: {str(e)}") from e
async def create_entity(self, name: str, type: str, context: Optional[str] = None) -> Entity:
"""Create a new entity."""
entity_id = self._generate_id(name)
entity_data = {
"id": entity_id,
"name": name,
"entity_type": type,
"context": context,
"created_at": datetime.now(UTC)
}
# Step 1: Write to filesystem (source of truth)
await self._write_entity_file(entity_data)
# Step 2: Update database index (can be rebuilt if needed)
try:
return await self._update_db_index(entity_data)
except DatabaseSyncError as e:
print(f"Warning: Database sync failed, reindex may be needed: {str(e)}")
return Entity(**entity_data)
async def get_entity(self, entity_id: str) -> Entity:
"""Get entity by ID, reading from filesystem first."""
# Read from filesystem (source of truth)
entity_data = await self._read_entity_file(entity_id)
# Try to get from database for full object
try:
entity_repo, _, _ = await self._get_repos()
entity = await entity_repo.find_by_id(entity_id)
if entity is None:
# Reindex this entity if not in database
entity = await self._update_db_index(entity_data)
return entity
except DatabaseSyncError:
# Return basic entity from file data if DB fails
return Entity(**entity_data)
async def delete_entity(self, entity_id: str) -> bool:
"""Delete entity from filesystem and database."""
try:
# Delete from filesystem first
entity_path = self.project_path / "entities" / f"{entity_id}.md"
if entity_path.exists():
entity_path.unlink()
# Try to delete from database, but don't error if it fails
try:
entity_repo, _, _ = await self._get_repos()
await entity_repo.delete(entity_id)
except DatabaseSyncError:
pass # Database cleanup can happen during reindex
return True
except Exception as e:
raise FileOperationError(f"Failed to delete entity: {str(e)}") from e
async def rebuild_index(self) -> None:
"""Rebuild database index from filesystem contents."""
try:
entities_dir = self.project_path / "entities"
if not entities_dir.exists():
return
for entity_file in entities_dir.glob("*.md"):
try:
entity_data = await self._read_entity_file(entity_file.stem)
await self._update_db_index(entity_data)
except Exception as e:
print(f"Warning: Failed to reindex {entity_file}: {str(e)}")
except Exception as e:
raise DatabaseSyncError(f"Failed to rebuild index: {str(e)}") from e
async def cleanup(self) -> None:
"""Clean up resources."""
# Implementation depends on what needs cleanup
pass
BIN
View File
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
import pytest
import pytest_asyncio
import tempfile
from pathlib import Path
from basic_memory.service import MemoryService
from basic_memory.db import init_connection
@pytest_asyncio.fixture
async def memory_service():
"""Fixture providing initialized MemoryService with temp directories."""
# Create temp directory that is cleaned up after the test
with tempfile.TemporaryDirectory() as temp_dir:
# Override project path
test_project_path = Path(temp_dir) / "test-project"
# Initialize database connection
init_connection("test-project")
# Initialize service with test configuration
service = MemoryService("test-project")
service.project_path = test_project_path
# Initialize project structure
await service.initialize_project()
yield service
# Cleanup happens automatically when temp directory is removed
@pytest.fixture
def sample_entity_files(memory_service):
"""Fixture providing sample markdown files."""
entity_dir = memory_service.project_path / "entities"
# Create sample files
files = {
"test-entity-1": "# Test Entity 1\n\nContent",
"test-entity-2": "# Test Entity 2\n\nContent"
}
for name, content in files.items():
(entity_dir / f"{name}.md").write_text(content)
return files
+98
View File
@@ -0,0 +1,98 @@
import pytest
from datetime import datetime
from pathlib import Path
from basic_memory.service import MemoryService, FileOperationError, DatabaseSyncError
from basic_memory.models import Entity
pytestmark = pytest.mark.asyncio
# Happy Path Tests
async def test_create_entity_success(memory_service):
"""Test successful entity creation."""
# Act
entity = await memory_service.create_entity(
name="Test Entity",
type="test",
context="test context"
)
# Assert
assert isinstance(entity, Entity)
assert entity.name == "Test Entity"
assert entity.entity_type == "test"
assert entity.context == "test context"
# Verify file was created
entity_file = memory_service.project_path / "entities" / f"{entity.id}.md"
assert entity_file.exists()
content = entity_file.read_text()
assert "Test Entity" in content
# Error Path Tests
async def test_create_entity_file_error(memory_service, monkeypatch):
"""Test handling of file write errors."""
# Arrange - make file write fail
async def mock_write_fail(*args, **kwargs):
raise FileOperationError("Mock file write error")
monkeypatch.setattr(memory_service, "_write_entity_file", mock_write_fail)
# Act & Assert
with pytest.raises(FileOperationError):
await memory_service.create_entity(
name="Test Entity",
type="test"
)
async def test_create_entity_db_error(memory_service, monkeypatch):
"""Test handling of database errors."""
# Arrange - make db update fail but file write succeed
async def mock_db_fail(*args, **kwargs):
raise DatabaseSyncError("Mock DB error")
monkeypatch.setattr(memory_service, "_update_db_index", mock_db_fail)
# Act
entity = await memory_service.create_entity(
name="Test Entity",
type="test"
)
# Assert
assert isinstance(entity, Entity) # Should still return entity from file
entity_file = memory_service.project_path / "entities" / f"{entity.id}.md"
assert entity_file.exists() # File should still be created
# Edge Cases
async def test_create_entity_with_special_chars(memory_service):
"""Test entity creation with special characters in name."""
name = "Test & Entity! With @ Special #Chars"
entity = await memory_service.create_entity(name=name, type="test")
assert entity.name == name
entity_file = memory_service.project_path / "entities" / f"{entity.id}.md"
assert entity_file.exists()
async def test_create_entity_atomic_file_write(memory_service):
"""Test that file writing is atomic (uses temp file)."""
# Act
entity = await memory_service.create_entity(name="Test Entity", type="test")
# Assert
temp_file = memory_service.project_path / "entities" / f"{entity.id}.md.tmp"
assert not temp_file.exists() # Temp file should be cleaned up
entity_file = memory_service.project_path / "entities" / f"{entity.id}.md"
assert entity_file.exists() # Final file should exist
# TODO: Add tests for:
# - Concurrent operations
# - System crash simulation
# - Permission issues
# - File system full scenario
# - Long/unicode entity names
# - Empty/whitespace names
# - Other edge cases