mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
fix all tests for observation category
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
"""Writer for knowledge entity markdown files."""
|
||||
|
||||
from datetime import datetime, UTC
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
import yaml
|
||||
@@ -14,12 +13,11 @@ class KnowledgeWriter:
|
||||
|
||||
async def format_frontmatter(self, entity: EntityModel) -> dict:
|
||||
"""Generate frontmatter metadata for entity."""
|
||||
now = datetime.now(UTC).isoformat()
|
||||
return {
|
||||
"type": entity.entity_type,
|
||||
"id": entity.id,
|
||||
"created": now,
|
||||
"modified": now
|
||||
"id": entity.path_id,
|
||||
"created": entity.created_at.isoformat(),
|
||||
"modified": entity.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
async def format_metadata(self, metadata: Optional[Dict[str, Any]] = None) -> str:
|
||||
@@ -40,37 +38,50 @@ class KnowledgeWriter:
|
||||
logger.warning(f"Failed to format metadata YAML: {e}")
|
||||
return "" # Skip metadata on error
|
||||
|
||||
async def format_content(self, entity: EntityModel, metadata: Optional[Dict[str, Any]] = None) -> str:
|
||||
async def format_content(
|
||||
self, entity: EntityModel, metadata: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""Format entity content as markdown."""
|
||||
sections = [
|
||||
f"# {entity.name}\n"
|
||||
f"# {entity.name}\n",
|
||||
"", # Empty line after name
|
||||
]
|
||||
|
||||
if entity.description:
|
||||
sections.extend([
|
||||
entity.description,
|
||||
""
|
||||
])
|
||||
|
||||
sections.extend([entity.description, ""])
|
||||
|
||||
if entity.observations:
|
||||
sections.extend([
|
||||
"## Observations",
|
||||
*[f"- {obs.content}" for obs in entity.observations],
|
||||
""
|
||||
])
|
||||
sections.extend(
|
||||
[
|
||||
"## Observations",
|
||||
"<!-- Format: - [category] Content text #tag1 #tag2 (optional context) -->",
|
||||
"", # Empty line after format comment
|
||||
*[
|
||||
f"- [{obs.category}] {obs.content}"
|
||||
+ (f" ({obs.context})" if obs.context else "")
|
||||
for obs in entity.observations
|
||||
],
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
# Format outgoing and incoming relations separately
|
||||
if entity.to_relations or entity.from_relations:
|
||||
sections.append("## Relations")
|
||||
|
||||
sections.extend(
|
||||
[
|
||||
"## Relations",
|
||||
"", # Empty line after format comment
|
||||
]
|
||||
)
|
||||
|
||||
# Outgoing relations
|
||||
for rel in entity.to_relations:
|
||||
sections.append(f"- [[{rel.from_entity.name}]] {rel.relation_type}")
|
||||
|
||||
|
||||
# Incoming relations
|
||||
for rel in entity.from_relations:
|
||||
sections.append(f"- [[{rel.to_entity.name}]] {rel.relation_type}")
|
||||
|
||||
|
||||
sections.append("")
|
||||
|
||||
if metadata:
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
from basic_memory.models.base import Base
|
||||
from basic_memory.models.documents import Document
|
||||
from basic_memory.models.knowledge import Entity, Observation, Relation
|
||||
from basic_memory.models.knowledge import Entity, Observation, Relation, ObservationCategory
|
||||
|
||||
__all__ = [
|
||||
'Base',
|
||||
'Document',
|
||||
'Entity',
|
||||
'Observation',
|
||||
'ObservationCategory',
|
||||
'Relation'
|
||||
]
|
||||
@@ -26,3 +26,15 @@ class ObservationRepository(Repository[Observation]):
|
||||
query = select(Observation).filter(Observation.context == context)
|
||||
result = await self.execute_query(query)
|
||||
return result.scalars().all()
|
||||
|
||||
async def find_by_category(self, category: str) -> Sequence[Observation]:
|
||||
"""Find observations with a specific context."""
|
||||
query = select(Observation).filter(Observation.category == category)
|
||||
result = await self.execute_query(query)
|
||||
return result.scalars().all()
|
||||
|
||||
async def observation_categories(self) -> Sequence[str]:
|
||||
"""Return a list of all observation categories."""
|
||||
query = select(Observation.category).distinct()
|
||||
result = await self.execute_query(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@@ -127,6 +127,7 @@ class SearchNodesRequest(BaseModel):
|
||||
- Partial word matches
|
||||
- Returns full entity objects with relations
|
||||
- Includes all matching entities
|
||||
- If a category is specified, only entities with that category are returned
|
||||
|
||||
Example Queries:
|
||||
- "memory" - Find entities related to memory systems
|
||||
@@ -143,6 +144,7 @@ class SearchNodesRequest(BaseModel):
|
||||
"""
|
||||
|
||||
query: Annotated[str, MinLen(1), MaxLen(200)]
|
||||
category: Optional[ObservationCategory] = None
|
||||
|
||||
|
||||
class OpenNodesRequest(BaseModel):
|
||||
|
||||
@@ -7,6 +7,7 @@ from loguru import logger
|
||||
from basic_memory.models import Entity as EntityModel
|
||||
from basic_memory.services.exceptions import EntityNotFoundError
|
||||
from basic_memory.services.observation_service import ObservationService
|
||||
from basic_memory.schemas.request import ObservationCreate
|
||||
from .relations import RelationOperations
|
||||
|
||||
|
||||
@@ -18,9 +19,22 @@ class ObservationOperations(RelationOperations):
|
||||
self.observation_service = observation_service
|
||||
|
||||
async def add_observations(
|
||||
self, path_id: str, observations: List[str], context: str | None = None
|
||||
self,
|
||||
path_id: str,
|
||||
observations: List[ObservationCreate],
|
||||
context: str | None = None
|
||||
) -> EntityModel:
|
||||
"""Add observations to entity and update its file."""
|
||||
"""Add observations to entity and update its file.
|
||||
|
||||
Observations are added with their categories and written to both
|
||||
the database and markdown file. The file format is:
|
||||
- [category] Content text #tag1 #tag2 (optional context)
|
||||
|
||||
Args:
|
||||
path_id: Entity path ID
|
||||
observations: List of observations with categories
|
||||
context: Optional shared context for all observations
|
||||
"""
|
||||
logger.debug(f"Adding observations to entity {path_id}")
|
||||
|
||||
try:
|
||||
@@ -33,25 +47,34 @@ class ObservationOperations(RelationOperations):
|
||||
await self.observation_service.add_observations(entity.id, observations, context)
|
||||
|
||||
# Get updated entity
|
||||
updated_entity = await self.entity_service.get_by_path_id(path_id)
|
||||
entity = await self.entity_service.get_by_path_id(path_id)
|
||||
|
||||
# Write updated file and checksum
|
||||
_, checksum = await self.write_entity_file(entity)
|
||||
await self.entity_service.update_entity(path_id, {"checksum": checksum})
|
||||
|
||||
# query to fetch all relations
|
||||
# Return final entity with all updates and relations
|
||||
return await self.entity_service.get_by_path_id(path_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add observations: {e}")
|
||||
raise
|
||||
|
||||
async def delete_observations(self, path_id: str, observations: List[str]) -> EntityModel:
|
||||
"""Delete observations from entity and update its file."""
|
||||
async def delete_observations(
|
||||
self,
|
||||
path_id: str,
|
||||
observations: List[str]
|
||||
) -> EntityModel:
|
||||
"""Delete observations from entity and update its file.
|
||||
|
||||
Args:
|
||||
path_id: Entity path ID
|
||||
observations: List of observation contents to delete
|
||||
"""
|
||||
logger.debug(f"Deleting observations from entity {path_id}")
|
||||
|
||||
try:
|
||||
# Get updated entity
|
||||
# Get entity
|
||||
entity = await self.entity_service.get_by_path_id(path_id)
|
||||
if not entity:
|
||||
raise EntityNotFoundError(f"Entity not found: {path_id}")
|
||||
@@ -63,9 +86,9 @@ class ObservationOperations(RelationOperations):
|
||||
_, checksum = await self.write_entity_file(entity)
|
||||
await self.entity_service.update_entity(path_id, {"checksum": checksum})
|
||||
|
||||
# Get final entity with all updates
|
||||
# Return final entity with all updates
|
||||
return await self.entity_service.get_by_path_id(path_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete observations: {e}")
|
||||
raise
|
||||
raise
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Service for managing observations in the database."""
|
||||
|
||||
from typing import List, Sequence
|
||||
from typing import List, Sequence, Optional
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy import select
|
||||
@@ -8,6 +8,8 @@ from sqlalchemy import select
|
||||
from basic_memory.models import Observation as ObservationModel
|
||||
from basic_memory.repository.observation_repository import ObservationRepository
|
||||
from .service import BaseService
|
||||
from ..schemas.base import ObservationCategory
|
||||
from ..schemas.request import ObservationCreate
|
||||
|
||||
|
||||
class ObservationService(BaseService[ObservationRepository]):
|
||||
@@ -20,13 +22,22 @@ class ObservationService(BaseService[ObservationRepository]):
|
||||
super().__init__(observation_repository)
|
||||
|
||||
async def add_observations(
|
||||
self, entity_id: int, observations: List[str], context: str | None = None
|
||||
self,
|
||||
entity_id: int,
|
||||
observations: List[str | ObservationCreate],
|
||||
context: Optional[str] = None,
|
||||
) -> Sequence[ObservationModel]:
|
||||
"""Add multiple observations to an entity."""
|
||||
logger.debug(f"Adding {len(observations)} observations to entity: {entity_id}")
|
||||
return await self.repository.create_all(
|
||||
[
|
||||
dict(entity_id=entity_id, content=observation, context=context)
|
||||
# unpack the ObservationCreate values if present
|
||||
dict(
|
||||
entity_id=entity_id,
|
||||
content=getattr(observation, "content", observation),
|
||||
context=context,
|
||||
category=getattr(observation, "category", None),
|
||||
)
|
||||
for observation in observations
|
||||
]
|
||||
)
|
||||
@@ -46,14 +57,20 @@ class ObservationService(BaseService[ObservationRepository]):
|
||||
logger.debug(f"Deleting all observations for entity: {entity_id}")
|
||||
return await self.repository.delete_by_fields(entity_id=entity_id)
|
||||
|
||||
async def search_observations(self, query: str) -> List[ObservationModel]:
|
||||
async def search_observations(self, query: str, category: Optional[ObservationCategory] = None) -> List[ObservationModel]:
|
||||
"""Search for observations across all entities."""
|
||||
logger.debug(f"Searching observations with query: {query}")
|
||||
result = await self.repository.execute_query(
|
||||
select(ObservationModel).filter(
|
||||
ObservationModel.content.contains(query) | ObservationModel.context.contains(query)
|
||||
)
|
||||
|
||||
# Build base query
|
||||
statement = select(ObservationModel).filter(
|
||||
ObservationModel.content.contains(query) | ObservationModel.context.contains(query)
|
||||
)
|
||||
|
||||
# Add category filter if specified
|
||||
if category:
|
||||
statement = statement.filter(ObservationModel.category == category)
|
||||
|
||||
result = await self.repository.execute_query(statement)
|
||||
observations = result.scalars().all()
|
||||
return [ObservationModel(content=obs.content) for obs in observations]
|
||||
|
||||
@@ -61,3 +78,13 @@ class ObservationService(BaseService[ObservationRepository]):
|
||||
"""Get all observations with a specific context."""
|
||||
logger.debug(f"Getting observations for context: {context}")
|
||||
return await self.repository.find_by_context(context)
|
||||
|
||||
async def get_observations_by_category(self, category: ObservationCategory) -> Sequence[ObservationModel]:
|
||||
"""Get all observations with a specific context."""
|
||||
logger.debug(f"Getting observations for context: {category}")
|
||||
return await self.repository.find_by_category(category)
|
||||
|
||||
async def observation_categories(self) -> Sequence[str]:
|
||||
"""Get all observation categories."""
|
||||
logger.debug("Getting observations categories")
|
||||
return await self.repository.observation_categories()
|
||||
|
||||
Reference in New Issue
Block a user