diff --git a/src/basic_memory/api/routers/knowledge.py b/src/basic_memory/api/routers/knowledge.py index e19f92ab..f346007b 100644 --- a/src/basic_memory/api/routers/knowledge.py +++ b/src/basic_memory/api/routers/knowledge.py @@ -1,7 +1,6 @@ """Router for knowledge graph operations.""" from fastapi import APIRouter - from basic_memory.deps import MemoryServiceDep from basic_memory.schemas import ( CreateEntityRequest, CreateEntityResponse, @@ -10,7 +9,8 @@ from basic_memory.schemas import ( EntityResponse, AddObservationsRequest, ObservationResponse, OpenNodesRequest, OpenNodesResponse, DeleteEntityResponse, - DeleteObservationsRequest, DeleteObservationsResponse, AddObservationsResponse, RelationResponse + DeleteObservationsRequest, DeleteObservationsResponse, AddObservationsResponse, RelationResponse, + DeleteRelationRequest ) router = APIRouter(prefix="/knowledge", tags=["knowledge"]) @@ -66,14 +66,17 @@ async def create_relations( return CreateRelationsResponse(relations=[RelationResponse.model_validate(relation) for relation in relations]) -@router.delete("/relations/{relation_id}", response_model=DeleteEntityResponse) +@router.delete("/relations/{from_id:path}/{to_id:path}", response_model=DeleteEntityResponse) async def delete_relation( - relation_id: int, + from_id: str, + to_id: str, + relation_type: str | None = None, memory_service: MemoryServiceDep ) -> DeleteEntityResponse: - """Delete a specific relation by ID.""" - # TODO: Implement delete_relation in memory service - raise NotImplementedError("Delete relation not implemented yet") + """Delete relations between entities, optionally filtered by type.""" + request = DeleteRelationRequest(from_id=from_id, to_id=to_id, relation_type=relation_type) + deleted = await memory_service.delete_relations([request]) + return DeleteEntityResponse(deleted=deleted) @router.post("/observations", response_model=AddObservationsResponse) @@ -86,14 +89,18 @@ async def add_observations( return AddObservationsResponse(entity_id=data.entity_id, observations=[ObservationResponse.model_validate(observation) for observation in observations]) -@router.delete("/observations", response_model=DeleteObservationsResponse) +@router.delete("/entities/{entity_id:path}/observations", response_model=DeleteObservationsResponse) async def delete_observations( + entity_id: str, data: DeleteObservationsRequest, memory_service: MemoryServiceDep ) -> DeleteObservationsResponse: """Delete observations from an entity.""" - # TODO: Implement delete_observations in memory service - raise NotImplementedError("Delete observations not implemented yet") + # Ensure entity_id matches the request + if data.entity_id != entity_id: + data.entity_id = entity_id + deleted = await memory_service.delete_observations(data) + return DeleteObservationsResponse(deleted=deleted) @router.post("/search", response_model=SearchNodesResponse) @@ -103,4 +110,4 @@ async def search_nodes( ) -> SearchNodesResponse: """Search for entities in the knowledge graph.""" matches = await memory_service.search_nodes(data.query) - return SearchNodesResponse(matches=[EntityResponse.model_validate(entity) for entity in matches], query=data.query) \ No newline at end of file + return SearchNodesResponse(matches=[EntityResponse.model_validate(entity) for entity in matches], query=data.query) diff --git a/src/basic_memory/repository/observation_repository.py b/src/basic_memory/repository/observation_repository.py index 831829d7..25ecd49d 100644 --- a/src/basic_memory/repository/observation_repository.py +++ b/src/basic_memory/repository/observation_repository.py @@ -1,6 +1,6 @@ """Repository for managing Observation objects.""" -from typing import Sequence -from sqlalchemy import select +from typing import Sequence, Dict, Any +from sqlalchemy import select, and_, delete from basic_memory.models import Observation from basic_memory.repository import Repository @@ -22,4 +22,12 @@ class ObservationRepository(Repository[Observation]): """Find observations with a specific context.""" query = select(Observation).filter(Observation.context == context) result = await self.execute_query(query) - return result.scalars().all() \ No newline at end of file + return result.scalars().all() + + async def delete_by_fields(self, **filters: Dict[str, Any]) -> bool: + """Delete observations matching the given field values.""" + conditions = [getattr(Observation, field) == value for field, value in filters.items()] + query = delete(Observation).where(and_(*conditions)) + result = await self.execute_query(query) + await self.session.flush() + return result.rowcount > 0 # pyright: ignore [reportAttributeAccessIssue] \ No newline at end of file diff --git a/src/basic_memory/services/observation_service.py b/src/basic_memory/services/observation_service.py index f868b778..f0a5ca9a 100644 --- a/src/basic_memory/services/observation_service.py +++ b/src/basic_memory/services/observation_service.py @@ -1,6 +1,6 @@ """Service for managing observations in both filesystem and database.""" from pathlib import Path -from typing import List, Sequence +from typing import List, Sequence, Dict, Any from sqlalchemy import select from basic_memory.models import Observation as ObservationModel @@ -9,8 +9,6 @@ from . import DatabaseSyncError from basic_memory.schemas import Observation -#from basic_memory.schemas import Observation - class ObservationService: """ Service for managing observations in the database. @@ -37,6 +35,45 @@ class ObservationService: except Exception as e: raise DatabaseSyncError(f"Failed to add observations to database: {str(e)}") from e + async def delete_observations(self, entity_id: str, contents: List[str]) -> int: + """ + Delete specific observations from an entity. + + Args: + entity_id: ID of the entity + contents: List of observation contents to delete + + Returns: + Number of observations deleted + """ + try: + deleted = False + for content in contents: + result = await self.observation_repo.delete_by_fields( + entity_id=entity_id, + content=content + ) + if result: + deleted = True + return deleted + except Exception as e: + raise DatabaseSyncError(f"Failed to delete observations from database: {str(e)}") from e + + async def delete_by_entity(self, entity_id: str) -> bool: + """ + Delete all observations for an entity. + + Args: + entity_id: ID of the entity + + Returns: + True if any observations were deleted + """ + try: + return await self.observation_repo.delete_by_fields(entity_id=entity_id) + except Exception as e: + raise DatabaseSyncError(f"Failed to delete observations from database: {str(e)}") from e + async def search_observations(self, query: str) -> List[ObservationModel]: """ Search for observations across all entities. diff --git a/tests/repository/test_observation_repository.py b/tests/repository/test_observation_repository.py new file mode 100644 index 00000000..77bfb16a --- /dev/null +++ b/tests/repository/test_observation_repository.py @@ -0,0 +1,146 @@ +"""Tests for the ObservationRepository.""" +import pytest +import pytest_asyncio +from sqlalchemy.ext.asyncio import AsyncSession + +from basic_memory.models import Entity, Observation +from basic_memory.repository.observation_repository import ObservationRepository + + +@pytest_asyncio.fixture(scope="function") +async def repo(observation_repository): + """Create an ObservationRepository instance""" + return observation_repository + +@pytest_asyncio.fixture(scope="function") +async def sample_observation(repo, sample_entity: Entity): + """Create a sample observation for testing""" + observation_data = { + 'entity_id': sample_entity.id, + 'content': 'Test observation', + 'context': 'test-context' + } + return await repo.create(observation_data) + +@pytest.mark.asyncio +async def test_create_observation( + observation_repository: ObservationRepository, + sample_entity: Entity +): + """Test creating a new observation""" + observation_data = { + 'entity_id': sample_entity.id, + 'content': 'Test content', + 'context': 'test-context' + } + observation = await observation_repository.create(observation_data) + + assert observation.entity_id == sample_entity.id + assert observation.content == 'Test content' + assert observation.id is not None # Should be auto-generated + +@pytest.mark.asyncio +async def test_find_by_entity( + observation_repository: ObservationRepository, + sample_observation: Observation, + sample_entity: Entity +): + """Test finding observations by entity""" + observations = await observation_repository.find_by_entity(sample_entity.id) + assert len(observations) == 1 + assert observations[0].id == sample_observation.id + assert observations[0].content == sample_observation.content + +@pytest.mark.asyncio +async def test_find_by_context( + observation_repository: ObservationRepository, + sample_observation: Observation +): + """Test finding observations by context""" + observations = await observation_repository.find_by_context('test-context') + assert len(observations) == 1 + assert observations[0].id == sample_observation.id + assert observations[0].content == sample_observation.content + + +@pytest.mark.asyncio +async def test_delete_observations(session: AsyncSession, repo): + """Test deleting observations by entity_id.""" + # Create test entity + entity = Entity( + id="test/test_entity", + name="test_entity", + entity_type="test", + description="Test entity" + ) + session.add(entity) + await session.flush() + + # Create test observations + obs1 = Observation(entity_id=entity.id, content="Test observation 1") + obs2 = Observation(entity_id=entity.id, content="Test observation 2") + session.add_all([obs1, obs2]) + await session.flush() + + # Test deletion by entity_id + deleted = await repo.delete_by_fields(entity_id=entity.id) + assert deleted is True + + # Verify observations were deleted + remaining = await repo.find_by_entity(entity.id) + assert len(remaining) == 0 + +@pytest.mark.asyncio +async def test_delete_observation_by_id(session: AsyncSession, repo): + """Test deleting a single observation by its ID.""" + # Create test entity + entity = Entity( + id="test/test_entity", + name="test_entity", + entity_type="test", + description="Test entity" + ) + session.add(entity) + await session.flush() + + # Create test observation + obs = Observation(entity_id=entity.id, content="Test observation") + session.add(obs) + await session.flush() + + # Test deletion by ID + deleted = await repo.delete(obs.id) + assert deleted is True + + # Verify observation was deleted + remaining = await repo.find_by_id(obs.id) + assert remaining is None + +@pytest.mark.asyncio +async def test_delete_observation_by_content(session: AsyncSession, repo): + """Test deleting observations by content.""" + # Create test entity + entity = Entity( + id="test/test_entity", + name="test_entity", + entity_type="test", + description="Test entity" + ) + session.add(entity) + await session.flush() + + # Create test observations + obs1 = Observation(entity_id=entity.id, content="Delete this observation") + obs2 = Observation(entity_id=entity.id, content="Keep this observation") + session.add_all([obs1, obs2]) + await session.flush() + + + # Test deletion by content + deleted = await repo.delete_by_fields(content="Delete this observation") + assert deleted is True + + # Verify only matching observation was deleted + remaining = await repo.find_by_entity(entity.id) + assert len(remaining) == 1 + assert remaining[0].content == "Keep this observation" diff --git a/tests/services/test_observation_service.py b/tests/services/test_observation_service.py new file mode 100644 index 00000000..1adbfa4d --- /dev/null +++ b/tests/services/test_observation_service.py @@ -0,0 +1,127 @@ +"""Tests for the ObservationService.""" +import pytest +import pytest_asyncio +from pathlib import Path + +from basic_memory.models import Entity, Observation +from basic_memory.repository.observation_repository import ObservationRepository +from basic_memory.services.observation_service import ObservationService +from basic_memory.services import DatabaseSyncError + + +@pytest_asyncio.fixture +async def observation_service(session): + """Create a test ObservationService.""" + repo = ObservationRepository(session) + return ObservationService(Path("/test"), repo) + + +@pytest_asyncio.fixture +async def test_entity(session): + """Create a test entity.""" + entity = Entity( + id="test/test_entity", + name="test_entity", + entity_type="test", + description="Test entity" + ) + session.add(entity) + await session.flush() + return entity + + +@pytest_asyncio.fixture +async def test_observations(session, test_entity): + """Create test observations.""" + observations = [ + Observation(entity_id=test_entity.id, content="First observation"), + Observation(entity_id=test_entity.id, content="Second observation"), + Observation(entity_id=test_entity.id, content="Third observation") + ] + session.add_all(observations) + await session.flush() + return observations + + +@pytest.mark.asyncio +async def test_add_observations(observation_service, test_entity): + """Test adding observations to an entity.""" + observations = ["Test observation 1", "Test observation 2"] + + result = await observation_service.add_observations(test_entity.id, observations) + + assert len(result) == 2 + assert result[0].content == "Test observation 1" + assert result[1].content == "Test observation 2" + assert all(obs.entity_id == test_entity.id for obs in result) + + +@pytest.mark.asyncio +async def test_delete_observations(observation_service, test_entity, test_observations): + """Test deleting specific observations from an entity.""" + contents_to_delete = ["First observation", "Second observation"] + + result = await observation_service.delete_observations(test_entity.id, contents_to_delete) + + assert result is True + + # Verify observations were deleted + remaining = await observation_service.observation_repo.find_by_entity(test_entity.id) + assert len(remaining) == 1 + assert remaining[0].content == "Third observation" + + +@pytest.mark.asyncio +async def test_delete_by_entity(observation_service, test_entity, test_observations): + """Test deleting all observations for an entity.""" + result = await observation_service.delete_by_entity(test_entity.id) + + assert result is True + + # Verify all observations were deleted + remaining = await observation_service.observation_repo.find_by_entity(test_entity.id) + assert len(remaining) == 0 + + +@pytest.mark.asyncio +async def test_delete_nonexistent_observation(observation_service, test_entity): + """Test deleting observations that don't exist.""" + result = await observation_service.delete_observations(test_entity.id, ["Nonexistent observation"]) + + assert result is False + + +@pytest.mark.asyncio +async def test_delete_observations_invalid_entity(observation_service): + """Test deleting observations for an entity that doesn't exist.""" + result = await observation_service.delete_observations("invalid_entity", ["Test observation"]) + + # Should return False since there were no observations to delete + assert result is False + + +@pytest.mark.asyncio +async def test_search_observations(observation_service, test_observations): + """Test searching observations.""" + results = await observation_service.search_observations("First") + + assert len(results) == 1 + assert results[0].content == "First observation" + + +@pytest.mark.asyncio +async def test_get_observations_by_context(observation_service, session, test_entity): + """Test getting observations by context.""" + obs = Observation( + entity_id=test_entity.id, + content="Contextual observation", + context="test_context" + ) + session.add(obs) + await session.flush() + + results = await observation_service.get_observations_by_context("test_context") + + assert len(results) == 1 + assert results[0].content == "Contextual observation" + assert results[0].context == "test_context" \ No newline at end of file diff --git a/tests/test_observation_repository.py b/tests/test_observation_repository.py deleted file mode 100644 index 68941629..00000000 --- a/tests/test_observation_repository.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Tests for ObservationRepository.""" -import pytest -import pytest_asyncio -from basic_memory.models import Entity, Observation -from basic_memory.repository.observation_repository import ObservationRepository - -pytestmark = pytest.mark.asyncio - - -class TestObservationRepository: - @pytest_asyncio.fixture(scope="function") - async def sample_observation(self, observation_repository: ObservationRepository, sample_entity: Entity): - """Create a sample observation for testing""" - observation_data = { - 'entity_id': sample_entity.id, - 'content': 'Test observation', - 'context': 'test-context' - } - return await observation_repository.create(observation_data) - - async def test_create_observation( - self, - observation_repository: ObservationRepository, - sample_entity: Entity - ): - """Test creating a new observation""" - observation_data = { - 'entity_id': sample_entity.id, - 'content': 'Test content', - 'context': 'test-context' - } - observation = await observation_repository.create(observation_data) - - assert observation.entity_id == sample_entity.id - assert observation.content == 'Test content' - assert observation.id is not None # Should be auto-generated - - async def test_find_by_entity( - self, - observation_repository: ObservationRepository, - sample_observation: Observation, - sample_entity: Entity - ): - """Test finding observations by entity""" - observations = await observation_repository.find_by_entity(sample_entity.id) - assert len(observations) == 1 - assert observations[0].id == sample_observation.id - assert observations[0].content == sample_observation.content - - async def test_find_by_context( - self, - observation_repository: ObservationRepository, - sample_observation: Observation - ): - """Test finding observations by context""" - observations = await observation_repository.find_by_context('test-context') - assert len(observations) == 1 - assert observations[0].id == sample_observation.id \ No newline at end of file