delete observation repository

This commit is contained in:
phernandez
2024-12-15 10:50:25 -06:00
parent b03e7229a9
commit b1ec3bca26
6 changed files with 342 additions and 75 deletions
+18 -11
View File
@@ -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)
return SearchNodesResponse(matches=[EntityResponse.model_validate(entity) for entity in matches], query=data.query)
@@ -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()
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]
@@ -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.
@@ -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"
+127
View File
@@ -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"
-58
View File
@@ -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