update main with changes

This commit is contained in:
phernandez
2024-12-19 19:10:29 -06:00
parent 3e43abf8f1
commit e245ed3708
36 changed files with 1715 additions and 2389 deletions
+2 -4
View File
@@ -5,17 +5,15 @@ from loguru import logger
from .routers import knowledge
# Initialize FastAPI app
app = FastAPI(
title="Basic Memory API",
description="Knowledge graph API for basic-memory",
version="0.1.0"
title="Basic Memory API", description="Knowledge graph API for basic-memory", version="0.1.0"
)
# Include routers
app.include_router(knowledge.router)
# Add startup event
@app.on_event("startup")
async def startup_event():
+2 -1
View File
@@ -1,4 +1,5 @@
"""API routers."""
from . import knowledge
__all__ = ["knowledge"]
__all__ = ["knowledge"]
+27 -21
View File
@@ -1,8 +1,9 @@
"""Router for knowledge graph operations."""
from fastapi import APIRouter, HTTPException
from loguru import logger
from basic_memory.deps import MemoryServiceDep
from basic_memory.deps import EntityServiceDep, RelationServiceDep, ObservationServiceDep
from basic_memory.fileio import EntityNotFoundError
from basic_memory.schemas import (
CreateEntityRequest,
@@ -24,7 +25,6 @@ from basic_memory.schemas import (
AddObservationsResponse,
RelationResponse,
DeleteEntityRequest,
Entity,
)
router = APIRouter(prefix="/knowledge", tags=["knowledge"])
@@ -34,10 +34,10 @@ router = APIRouter(prefix="/knowledge", tags=["knowledge"])
@router.post("/entities", response_model=CreateEntityResponse)
async def create_entities(
data: CreateEntityRequest, memory_service: MemoryServiceDep
data: CreateEntityRequest, entity_service: EntityServiceDep
) -> CreateEntityResponse:
"""Create new entities in the knowledge graph."""
entities = await memory_service.create_entities(data.entities)
entities = await entity_service.create_entities(data.entities)
return CreateEntityResponse(
entities=[EntityResponse.model_validate(entity) for entity in entities]
)
@@ -45,10 +45,10 @@ async def create_entities(
@router.post("/relations", response_model=CreateRelationsResponse)
async def create_relations(
data: CreateRelationsRequest, memory_service: MemoryServiceDep
data: CreateRelationsRequest, relation_service: RelationServiceDep
) -> CreateRelationsResponse:
"""Create relations between entities."""
relations = await memory_service.create_relations(data.relations)
relations = await relation_service.create_relations(data.relations)
return CreateRelationsResponse(
relations=[RelationResponse.model_validate(relation) for relation in relations]
)
@@ -56,10 +56,11 @@ async def create_relations(
@router.post("/observations", response_model=AddObservationsResponse)
async def add_observations(
data: AddObservationsRequest, memory_service: MemoryServiceDep
data: AddObservationsRequest, observation_service: ObservationServiceDep
) -> AddObservationsResponse:
"""Add observations to an entity."""
observations = await memory_service.add_observations(data)
logger.debug(f"Adding observations to entity: {data.entity_id}")
observations = await observation_service.add_observations(data.entity_id, data.observations)
return AddObservationsResponse(
entity_id=data.entity_id,
observations=[
@@ -72,10 +73,10 @@ async def add_observations(
@router.get("/entities/{entity_id:path}", response_model=EntityResponse)
async def get_entity(entity_id: str, memory_service: MemoryServiceDep) -> EntityResponse:
async def get_entity(entity_id: str, entity_service: EntityServiceDep) -> EntityResponse:
"""Get a specific entity by ID."""
try:
entity = await memory_service.get_entity(entity_id)
entity = await entity_service.get_entity(entity_id)
return EntityResponse.model_validate(entity)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
@@ -83,20 +84,25 @@ async def get_entity(entity_id: str, memory_service: MemoryServiceDep) -> Entity
@router.post("/search", response_model=SearchNodesResponse)
async def search_nodes(
data: SearchNodesRequest, memory_service: MemoryServiceDep
data: SearchNodesRequest, entity_service: EntityServiceDep
) -> SearchNodesResponse:
"""Search for entities in the knowledge graph."""
matches = await memory_service.search_nodes(data.query)
logger.debug(f"Searching nodes with query: {data.query}")
matches = await entity_service.search(data.query)
logger.debug(f"Found {len(matches)} matches for '{data.query}'")
return SearchNodesResponse(
matches=[EntityResponse.model_validate(entity) for entity in matches], query=data.query
)
@router.post("/nodes", response_model=OpenNodesResponse)
async def open_nodes(data: OpenNodesRequest, memory_service: MemoryServiceDep) -> OpenNodesResponse:
async def open_nodes(data: OpenNodesRequest, entity_service: EntityServiceDep) -> OpenNodesResponse:
"""Open specific nodes by their names."""
entities = await memory_service.open_nodes(data.entity_ids)
return OpenNodesResponse(entities=[Entity.model_validate(entity) for entity in entities])
entities = await entity_service.open_nodes(data.entity_ids)
return OpenNodesResponse(
entities=[EntityResponse.model_validate(entity) for entity in entities]
)
## Delete endpoints
@@ -104,26 +110,26 @@ async def open_nodes(data: OpenNodesRequest, memory_service: MemoryServiceDep) -
@router.post("/entities/delete", response_model=DeleteEntityResponse)
async def delete_entity(
data: DeleteEntityRequest, memory_service: MemoryServiceDep
data: DeleteEntityRequest, entity_service: EntityServiceDep
) -> DeleteEntityResponse:
"""Delete a specific entity by ID."""
deleted = await memory_service.delete_entities(data.entity_ids)
deleted = await entity_service.delete_entities(data.entity_ids)
return DeleteEntityResponse(deleted=deleted)
@router.post("/observations/delete", response_model=DeleteObservationsResponse)
async def delete_observations(
data: DeleteObservationsRequest, memory_service: MemoryServiceDep
data: DeleteObservationsRequest, observation_service: ObservationServiceDep
) -> DeleteObservationsResponse:
"""Delete observations from an entity."""
entity_id = data.entity_id
deleted = await memory_service.delete_observations(entity_id, data.deletions)
deleted = await observation_service.delete_observations(entity_id, data.deletions)
return DeleteObservationsResponse(deleted=deleted)
@router.post("/relations/delete", response_model=DeleteRelationsResponse)
async def delete_relations(
data: DeleteRelationsRequest, memory_service: MemoryServiceDep
data: DeleteRelationsRequest, relation_service: RelationServiceDep
) -> DeleteRelationsResponse:
"""Delete relations between entities."""
to_delete = [
@@ -134,5 +140,5 @@ async def delete_relations(
}
for relation in data.relations
]
deleted = await memory_service.delete_relations(to_delete)
deleted = await relation_service.delete_relations(to_delete)
return DeleteRelationsResponse(deleted=deleted)
+75 -105
View File
@@ -1,128 +1,87 @@
"""Database configuration and initialization for basic-memory."""
import asyncio
from contextlib import asynccontextmanager
from enum import Enum
from enum import Enum, auto
from pathlib import Path
from typing import AsyncGenerator
from loguru import logger
from sqlalchemy import text
from sqlalchemy.ext.asyncio import (
create_async_engine,
async_sessionmaker,
AsyncEngine,
AsyncSession,
AsyncEngine,
async_scoped_session,
)
from sqlalchemy.pool import StaticPool
from basic_memory.models import Base
class DatabaseType(Enum):
"""Types of database configurations."""
"""Types of supported databases."""
MEMORY = "memory" # In-memory SQLite for testing
FILESYSTEM = "file" # File-based SQLite for projects
MEMORY = auto()
FILESYSTEM = auto()
@classmethod
def get_db_path(cls, project_path: Path, db_type: "DatabaseType") -> Path:
"""Get database path based on type."""
if db_type == cls.MEMORY:
return Path(":memory:")
else:
return project_path / "data" / "memory.db"
@classmethod
def get_db_url(cls, db_path: Path) -> str:
"""Get SQLAlchemy URL for database path."""
if str(db_path) == ":memory:":
return "sqlite+aiosqlite://"
return f"sqlite+aiosqlite:///{db_path}"
def get_database_url(
project_path: Path,
db_type: DatabaseType,
) -> str:
"""
Get database URL based on type and optional project path.
Args:
db_type: Type of database to configure
project_path: Project directory for file-based DBs (required if type is FILESYSTEM)
Returns:
Database URL string
Raises:
ValueError: If project_path is required but not provided
"""
match db_type:
case DatabaseType.MEMORY:
return "sqlite+aiosqlite:///:memory:"
case DatabaseType.FILESYSTEM:
if not project_path:
raise ValueError("project_path required for filesystem database")
# Ensure data directory exists
data_dir = project_path / "data"
data_dir.mkdir(parents=True, exist_ok=True)
db_path = data_dir / "memory.db"
return f"sqlite+aiosqlite:///{db_path}"
def get_scoped_session_factory(
session_maker: async_sessionmaker[AsyncSession],
) -> async_scoped_session:
"""Create a scoped session factory scoped to current task."""
return async_scoped_session(session_maker, scopefunc=asyncio.current_task)
async def init_database(url: str, echo: bool = False) -> tuple[AsyncEngine, async_sessionmaker]:
"""
Initialize database with schema.
Args:
url: Database URL
echo: Whether to echo SQL statements
Returns:
Configured async engine and session factory
"""
# Configure engine based on URL
connect_args = {"check_same_thread": False}
if url == "sqlite+aiosqlite:///:memory:":
engine = create_async_engine(
url,
echo=echo,
poolclass=StaticPool, # Single connection for in-memory
connect_args=connect_args,
)
else:
engine = create_async_engine(url, echo=echo, connect_args=connect_args)
# Create tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Create session factory for this engine
session_factory = async_sessionmaker(engine, expire_on_commit=False)
return engine, session_factory
# @asynccontextmanager
# async def session(
# session_factory: async_sessionmaker[AsyncSession],
# ) -> AsyncGenerator[AsyncSession, None]:
# """
# Get database session with proper lifecycle management.
#
# Args:
# session_factory: Async session factory to create session from
#
# Yields:
# AsyncSession configured for engine
# """
# session = session_factory()
# try:
# await session.execute(text("PRAGMA foreign_keys=ON"))
# yield session
# await session.commit()
# except Exception:
# await session.rollback()
# raise
# finally:
# await session.close()
#
@asynccontextmanager
async def engine_session_factory(
project_path: Path, db_type=DatabaseType.FILESYSTEM
) -> AsyncGenerator[tuple[AsyncEngine, async_sessionmaker[AsyncSession]], None]:
"""Get database engine and session factory with proper lifecycle management."""
url = get_database_url(project_path, db_type=db_type)
engine, session_factory = await init_database(url)
logger.debug(f"engine url: {engine.url}")
try:
yield engine, session_factory
finally:
await engine.dispose()
@asynccontextmanager
async def session(
session_factory: async_sessionmaker[AsyncSession],
async def scoped_session(
session_maker: async_sessionmaker[AsyncSession],
) -> AsyncGenerator[AsyncSession, None]:
"""
Get database session with proper lifecycle management.
Get a scoped session with proper lifecycle management.
Args:
session_factory: Async session factory to create session from
Yields:
AsyncSession configured for engine
session_maker: Session maker to create scoped sessions from
"""
# Create and yield session
session = session_factory()
factory = get_scoped_session_factory(session_maker)
session = factory()
try:
# Ensure foreign keys enabled for this session
await session.execute(text("PRAGMA foreign_keys=ON"))
yield session
await session.commit()
@@ -131,13 +90,24 @@ async def session(
raise
finally:
await session.close()
await factory.remove()
async def dispose_database(engine: AsyncEngine):
"""
Clean up database engine.
@asynccontextmanager
async def engine_session_factory(
project_path: Path,
db_type: DatabaseType = DatabaseType.FILESYSTEM,
) -> AsyncGenerator[tuple[AsyncEngine, async_sessionmaker[AsyncSession]], None]:
"""Create engine and session factory."""
db_path = DatabaseType.get_db_path(project_path, db_type)
db_url = DatabaseType.get_db_url(db_path)
engine = create_async_engine(db_url, connect_args={"check_same_thread": False})
try:
factory = async_sessionmaker(engine, expire_on_commit=False)
async with scoped_session(factory) as db_session:
# Initialize database
await db_session.execute(text("PRAGMA foreign_keys=ON"))
Args:
engine: Engine to dispose
"""
await engine.dispose()
yield engine, factory
finally:
await engine.dispose()
-118
View File
@@ -1,118 +0,0 @@
"""Debugging utilities for test suite."""
from sqlalchemy import text, inspect, MetaData, Table
from sqlalchemy.ext.asyncio import AsyncEngine
from loguru import logger
async def dump_sqlite_master(conn) -> None:
"""Dump entire sqlite_master table to see what SQLite thinks exists."""
try:
# Check if sqlite_master exists first
result = await conn.execute(text(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table';"
))
if result.scalar():
result = await conn.execute(text("SELECT * FROM sqlite_master;"))
rows = result.all()
logger.info("SQLITE_MASTER TABLE CONTENTS:")
for row in rows:
logger.info(f"{row}")
else:
logger.info("No tables exist in sqlite_master")
except Exception as e:
logger.warning(f"Could not dump sqlite_master: {e}")
async def dump_table_schema(conn, table_name: str) -> None:
"""Dump detailed schema info for a specific table."""
try:
logger.info(f"\nDETAILED SCHEMA FOR {table_name}:")
# Get CREATE statement
result = await conn.execute(
text("SELECT sql FROM sqlite_master WHERE type='table' AND name=:name"),
{"name": table_name}
)
create_sql = result.scalar()
if create_sql:
logger.info(f"CREATE statement:\n{create_sql}")
else:
logger.info(f"No CREATE statement found for {table_name}")
# Get column info
result = await conn.execute(text(f"PRAGMA table_info('{table_name}');"))
columns = result.all()
if columns:
logger.info("Column definitions:")
for col in columns:
logger.info(f" {col}")
else:
logger.info("No column definitions found")
except Exception as e:
logger.warning(f"Could not dump schema for {table_name}: {e}")
async def dump_sqlalchemy_metadata(engine: AsyncEngine) -> None:
"""Dump SQLAlchemy's view of the table metadata."""
try:
inspector = inspect(engine)
logger.info("\nSQLALCHEMY METADATA:")
# Get all tables
tables = await inspector.get_table_names()
if not tables:
logger.info("No tables found in SQLAlchemy metadata")
return
for table in tables:
logger.info(f"\nTable: {table}")
# Get columns
columns = await inspector.get_columns(table)
if columns:
logger.info("Columns:")
for col in columns:
logger.info(f" {col}")
# Get indexes
indexes = await inspector.get_indexes(table)
if indexes:
logger.info("Indexes:")
for idx in indexes:
logger.info(f" {idx}")
# Get foreign keys
fks = await inspector.get_foreign_keys(table)
if fks:
logger.info("Foreign keys:")
for fk in fks:
logger.info(f" {fk}")
except Exception as e:
logger.warning(f"Could not dump SQLAlchemy metadata: {e}")
async def dump_db_state(engine: AsyncEngine) -> None:
"""Dump complete database state for debugging."""
logger.info("\n=== BEGINNING DATABASE STATE DUMP ===\n")
try:
async with engine.begin() as conn:
# First dump sqlite_master
await dump_sqlite_master(conn)
# Try to get list of tables
try:
result = await conn.execute(text(
"SELECT name FROM sqlite_master WHERE type='table';"
))
tables = [row[0] for row in result.all()]
# Dump each table's schema
for table in tables:
await dump_table_schema(conn, table)
except Exception as e:
logger.warning(f"Could not list tables: {e}")
# Dump SQLAlchemy's view
await dump_sqlalchemy_metadata(engine)
except Exception as e:
logger.warning(f"Error during state dump: {e}")
logger.info("\n=== END DATABASE STATE DUMP ===\n")
+76 -72
View File
@@ -1,109 +1,113 @@
"""Dependency injection functions for basic-memory services."""
from contextlib import asynccontextmanager
from pathlib import Path
from typing import AsyncGenerator, Annotated
from typing import Annotated, AsyncGenerator
from fastapi import Depends
from loguru import logger
from sqlalchemy.ext.asyncio import AsyncSession, AsyncEngine, async_sessionmaker
from sqlalchemy.ext.asyncio import (
AsyncSession,
AsyncEngine,
async_sessionmaker,
)
from basic_memory import db
from basic_memory.config import ProjectConfig, config
from basic_memory.db import DatabaseType
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.repository.observation_repository import ObservationRepository
from basic_memory.repository.relation_repository import RelationRepository
from basic_memory.services import EntityService, ObservationService, RelationService, MemoryService
from basic_memory import db
from basic_memory.services import EntityService, ObservationService, RelationService
def get_project_config() -> ProjectConfig:
return config
ProjectConfigDep = Annotated[ProjectConfig, Depends(get_project_config)]
def get_project_path(project_config: ProjectConfigDep) -> Path:
return Path(project_config.path)
ProjectPathDep = Annotated[Path, Depends(get_project_path)]
async def get_engine_factory(project_path: ProjectPathDep, db_type=db.DatabaseType.FILESYSTEM) -> AsyncGenerator[tuple[AsyncEngine, async_sessionmaker[AsyncSession]], None]:
async with db.engine_session_factory(project_path, db_type) as (engine, factory):
yield engine, factory
EngineFactoryDep = Annotated[tuple[AsyncEngine, async_sessionmaker[AsyncSession]], Depends(get_engine_factory)]
async def get_session(engine_factory: EngineFactoryDep) -> AsyncGenerator[AsyncSession, None]:
_, factory = engine_factory
async with db.session(factory) as session:
yield session
AsyncSessionDep = Annotated[AsyncSession, Depends(get_session)]
async def get_engine_factory(
project_path: ProjectPathDep, db_type=DatabaseType.FILESYSTEM
) -> AsyncGenerator[tuple[AsyncEngine, async_sessionmaker[AsyncSession]], None]:
async with db.engine_session_factory(project_path=project_path, db_type=db_type) as (
engine,
session_maker,
):
yield engine, session_maker
async def get_entity_repo(session: AsyncSessionDep) -> EntityRepository:
"""Get an EntityRepository instance."""
return EntityRepository(session) # Entity type is handled in EntityRepository.__init__
EngineFactoryDep = Annotated[
tuple[AsyncEngine, async_sessionmaker[AsyncSession]], Depends(get_engine_factory)
]
EntityRepositoryDep = Annotated[EntityRepository, Depends(get_entity_repo)]
async def get_observation_repo(session: AsyncSessionDep) -> ObservationRepository:
"""Get an ObservationRepository instance."""
return ObservationRepository(session)
async def get_session_maker(engine_factory: EngineFactoryDep) -> async_sessionmaker[AsyncSession]:
"""Get session maker for tests."""
_, session_maker = engine_factory
return session_maker
ObservationRepositoryDep = Annotated[ObservationRepository, Depends(get_observation_repo)]
async def get_relation_repo(session: AsyncSessionDep) -> RelationRepository:
"""Get a RelationRepository instance."""
return RelationRepository(session)
SessionMakerDep = Annotated[async_sessionmaker, Depends(get_session_maker)]
RelationRepositoryDep = Annotated[RelationRepository, Depends(get_relation_repo)]
async def get_entity_service(
project_path: ProjectPathDep,
entity_repo: EntityRepositoryDep
) -> EntityService:
"""Get an EntityService instance."""
return EntityService(project_path, entity_repo)
async def get_entity_repository(
session_maker: SessionMakerDep,
) -> EntityRepository:
"""Create an EntityRepository instance."""
return EntityRepository(session_maker)
EntityRepositoryDep = Annotated[EntityRepository, Depends(get_entity_repository)]
async def get_observation_repository(
session_maker: SessionMakerDep,
) -> ObservationRepository:
"""Create an ObservationRepository instance."""
return ObservationRepository(session_maker)
ObservationRepositoryDep = Annotated[ObservationRepository, Depends(get_observation_repository)]
async def get_relation_repository(
session_maker: SessionMakerDep,
) -> RelationRepository:
"""Create a RelationRepository instance."""
return RelationRepository(session_maker)
RelationRepositoryDep = Annotated[RelationRepository, Depends(get_relation_repository)]
async def get_entity_service(entity_repository: EntityRepositoryDep) -> EntityService:
"""Create EntityService with repository."""
return EntityService(entity_repository)
EntityServiceDep = Annotated[EntityService, Depends(get_entity_service)]
async def get_observation_service(
project_path: ProjectPathDep,
observation_repo: ObservationRepositoryDep
observation_repository: ObservationRepositoryDep,
) -> ObservationService:
"""Get an ObservationService instance."""
return ObservationService(project_path, observation_repo)
"""Create ObservationService with repository."""
return ObservationService(observation_repository)
ObservationServiceDep = Annotated[ObservationService, Depends(get_observation_service)]
async def get_relation_service(
project_path: ProjectPathDep,
relation_repo: RelationRepositoryDep
) -> RelationService:
"""Get a RelationService instance."""
return RelationService(project_path, relation_repo)
async def get_relation_service(relation_repository: RelationRepositoryDep) -> RelationService:
"""Create RelationService with repository."""
return RelationService(relation_repository)
RelationServiceDep = Annotated[RelationService, Depends(get_relation_service)]
@asynccontextmanager
async def memory_service(
project_path: ProjectPathDep,
entity_service: EntityServiceDep,
relation_service: RelationServiceDep,
observation_service: ObservationServiceDep
) -> AsyncGenerator[MemoryService, None]:
"""Get a fully configured MemoryService instance."""
yield MemoryService(
project_path=project_path,
entity_service=entity_service,
relation_service=relation_service,
observation_service=observation_service
)
async def get_memory_service(
project_path: ProjectPathDep,
entity_service: EntityServiceDep,
relation_service: RelationServiceDep,
observation_service: ObservationServiceDep
) -> AsyncGenerator[MemoryService, None]:
async with memory_service(project_path, entity_service, relation_service, observation_service) as service:
yield service
MemoryServiceDep = Annotated[MemoryService, Depends(get_memory_service)]
-235
View File
@@ -1,235 +0,0 @@
"""Base repository implementation."""
from typing import Type, Optional, Any, Sequence, TypeVar, List, Dict
from sqlalchemy import select, func, Select, Executable, inspect, Result, Column, insert, and_, delete
from sqlalchemy.exc import NoResultFound
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped
from loguru import logger
from basic_memory.models import Base, Entity
T = TypeVar('T', bound=Base)
class Repository[T: Base]:
"""Base repository implementation with generic CRUD operations."""
def __init__(self, session: AsyncSession, Model: Type[T]):
self.session = session
self.Model = Model
self.mapper = inspect(self.Model).mapper
self.primary_key: Column[Any] = self.mapper.primary_key[0]
self.valid_columns = [column.key for column in self.mapper.columns]
def select(self, *entities: Any) -> Select:
"""Create a new SELECT statement.
Returns:
A SQLAlchemy Select object configured with the provided entities
or this repository's model if no entities provided.
"""
if not entities:
entities = (self.Model,)
return select(*entities)
async def refresh(self, instance: T, relationships: list[str] | None = None) -> None:
"""Refresh instance and optionally specified relationships."""
logger.debug(f"Refreshing {self.Model.__name__} instance: {getattr(instance, 'id', None)}")
try:
await self.session.refresh(instance, relationships or [])
logger.debug(f"Refreshed relationships: {relationships}")
except Exception:
logger.exception(f"Failed to refresh {self.Model.__name__} instance")
raise
async def find_all(self, skip: int = 0, limit: int = 100) -> Sequence[T]:
"""Fetch records from the database with pagination."""
logger.debug(f"Finding all {self.Model.__name__} (skip={skip}, limit={limit})")
try:
result = await self.session.execute(
select(self.Model).offset(skip).limit(limit)
)
items = result.scalars().all()
logger.debug(f"Found {len(items)} {self.Model.__name__} records")
return items
except Exception:
logger.exception(f"Failed to find all {self.Model.__name__}")
raise
async def find_by_id(self, entity_id: str) -> Optional[T]:
"""Fetch an entity by its unique identifier."""
logger.debug(f"Finding {self.Model.__name__} by ID: {entity_id}")
try:
result = await self.session.execute(
select(self.Model).filter(self.primary_key == entity_id)
)
entity = result.scalars().one()
logger.debug(f"Found {self.Model.__name__}: {entity_id}")
return entity
except NoResultFound:
logger.debug(f"No {self.Model.__name__} found with ID: {entity_id}")
return None
except Exception:
logger.exception(f"Failed to find {self.Model.__name__} by ID: {entity_id}")
raise
async def create(self, entity_data: dict, model: Type[Base] | None = None) -> T:
"""Create a new entity in the database from the provided data."""
model = model or self.Model
logger.debug(f"Creating {model.__name__} with data: {entity_data}")
try:
# Only include valid columns that are provided in entity_data
model_data = {
k: v for k, v in entity_data.items()
if k in self.valid_columns and v is not None
}
# Generate ID if this is an Entity model and no ID provided
if model is Entity and 'id' not in model_data:
model_data['id'] = Entity.generate_id(
model_data['entity_type'],
model_data['name']
)
logger.debug(f"Filtered data for valid columns: {model_data}")
# Create insert statement with only provided data
stmt = insert(model).values(**model_data).returning(model)
result = await self.session.execute(stmt)
entity: T = result.scalar_one() # pyright: ignore [reportAssignmentType]
logger.debug(f"Created {model.__name__}: {getattr(entity, 'id', None)}")
return entity
except Exception:
logger.exception(f"Failed to create {model.__name__}")
raise
async def instance_create(self, instance: T) -> T:
"""Create a new record from a model instance."""
logger.debug(f"Creating {self.Model.__name__} from instance: {instance}")
try:
self.session.add(instance)
await self.session.flush()
return instance
except Exception:
logger.exception(f"Failed to create {self.Model.__name__}")
raise
async def bulk_create(self, instances: List[T]) -> List[T]:
"""Create multiple records in a single transaction."""
logger.debug(f"Bulk creating {len(instances)} {self.Model.__name__} instances")
try:
for instance in instances:
self.session.add(instance)
await self.session.flush()
return instances
except Exception:
logger.exception(f"Failed to bulk create {self.Model.__name__}")
raise
async def update(self, entity_id: str, entity_data: dict) -> Optional[T]:
"""Update an entity with the given data."""
logger.debug(f"Updating {self.Model.__name__} {entity_id} with data: {entity_data}")
try:
result = await self.session.execute(
select(self.Model).filter(self.primary_key == entity_id)
)
entity = result.scalars().one()
for key, value in entity_data.items():
if key in self.valid_columns:
setattr(entity, key, value)
await self.session.flush()
logger.debug(f"Updated {self.Model.__name__}: {entity_id}")
return entity
except NoResultFound:
logger.debug(f"No {self.Model.__name__} found to update: {entity_id}")
return None
except Exception:
logger.exception(f"Failed to update {self.Model.__name__}: {entity_id}")
raise
async def delete(self, entity_id: str) -> bool:
"""Delete an entity from the database."""
logger.debug(f"Deleting {self.Model.__name__}: {entity_id}")
try:
result = await self.session.execute(
select(self.Model).filter(self.primary_key == entity_id)
)
entity = result.scalars().one()
await self.session.delete(entity)
await self.session.flush()
logger.debug(f"Deleted {self.Model.__name__}: {entity_id}")
return True
except NoResultFound:
logger.debug(f"No {self.Model.__name__} found to delete: {entity_id}")
return False
except Exception:
logger.exception(f"Failed to delete {self.Model.__name__}: {entity_id}")
raise
async def delete_by_fields(self, **filters: Dict[str, Any]) -> bool:
"""
Delete records matching given field values.
Args:
**filters: Field names and values to filter by
Returns:
bool: True if any records were deleted
"""
logger.debug(f"Deleting {self.Model.__name__} by fields: {filters}")
try:
conditions = [getattr(self.Model, field) == value for field, value in filters.items()]
query = delete(self.Model).where(and_(*conditions))
result = await self.execute_query(query)
await self.session.flush()
deleted = result.rowcount > 0 # pyright: ignore [reportAttributeAccessIssue]
logger.debug(f"Deleted {result.rowcount} records") # pyright: ignore [reportAttributeAccessIssue]
return deleted # pyright: ignore [reportAttributeAccessIssue]
except Exception:
logger.exception(f"Failed to delete {self.Model.__name__} by fields")
raise
async def count(self, query: Executable | None = None) -> int:
"""Count entities in the database table."""
try:
if query is None:
query = select(func.count()).select_from(self.Model)
result = await self.session.execute(query)
scalar = result.scalar()
count = scalar if scalar is not None else 0
logger.debug(f"Counted {count} {self.Model.__name__} records")
return count
except Exception:
logger.exception(f"Failed to count {self.Model.__name__}")
raise
async def execute_query(self, query: Executable) -> Result[Any]:
"""Execute a query asynchronously."""
logger.debug(f"Executing query: {query}")
try:
result = await self.session.execute(query)
logger.debug("Query executed successfully")
return result
except Exception:
logger.exception("Failed to execute query")
raise
async def find_one(self, query: Select[tuple[T]]) -> Optional[T]:
"""Execute a query and retrieve a single record."""
logger.debug(f"Finding one {self.Model.__name__} with query: {query}")
try:
result = await self.execute_query(query)
entity = result.scalars().one_or_none()
if entity:
logger.debug(f"Found {self.Model.__name__}: {getattr(entity, 'id', None)}")
else:
logger.debug(f"No {self.Model.__name__} found")
return entity
except Exception:
logger.exception(f"Failed to find one {self.Model.__name__}")
raise
+101 -68
View File
@@ -1,132 +1,165 @@
"""Repository for managing Entity objects."""
from typing import Optional, Sequence, Type
from typing import Optional, Sequence, List
from loguru import logger
from sqlalchemy import select, or_
from sqlalchemy.exc import NoResultFound
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlalchemy.orm import selectinload
from basic_memory import db
from basic_memory.models import Entity, Observation
from basic_memory.repository import Repository
from loguru import logger
from basic_memory.repository.repository import Repository
class EntityRepository(Repository[Entity]):
"""Repository for Entity model with memory-specific operations."""
def __init__(self, session):
super().__init__(session, Entity)
def __init__(self, session_maker: async_sessionmaker):
super().__init__(session_maker, Entity)
logger.debug("Initialized EntityRepository")
async def create(self, entity_data: dict, model: Type[Entity] | None = None) -> Entity:
async def create(self, data: dict) -> Entity: # pyright: ignore [reportIncompatibleMethodOverride]
"""Create a new entity in the database from the provided data."""
entity_id = Entity.generate_id(
entity_data['entity_type'],
entity_data['name']
)
return await super().create({**entity_data, 'id': entity_id})
entity_id = Entity.generate_id(data["entity_type"], data["name"])
await super().create({**data, "id": entity_id})
# we have to find to get relations
created = await self.find_by_id(entity_id)
assert created is not None, f"Created entity {entity_id} should not be None"
return created
async def create_all(self, data_list: List[dict]) -> Sequence[Entity]: # pyright: ignore [reportIncompatibleMethodOverride]
"""Create a new entity in the database from the provided data."""
for data in data_list:
entity_id = Entity.generate_id(data["entity_type"], data["name"])
data["id"] = entity_id
created = await super().create_all(data_list)
# we have to find to get relations
return await self.find_by_ids([e.id for e in created])
async def find_by_id(self, entity_id: str) -> Optional[Entity]:
"""Find entity by ID with all relationships eagerly loaded."""
logger.debug(f"Finding entity by ID: {entity_id}")
try:
# First load base entity
result = await self.session.execute(
select(Entity).filter(Entity.id == entity_id)
async with db.scoped_session(self.session_maker) as session:
try:
result = await session.execute(
select(Entity)
.filter(Entity.id == entity_id)
.options(
selectinload(Entity.observations),
selectinload(Entity.outgoing_relations),
selectinload(Entity.incoming_relations),
)
)
entity = result.scalars().one()
logger.debug(f"Found entity: {entity.id}")
return entity
except NoResultFound:
logger.debug(f"No entity found with ID: {entity_id}")
return None
async def find_by_ids(self, ids: List[str]) -> Sequence[Entity]:
"""Search for entities of a specific type."""
logger.debug(f"Find entities by ids: {ids}")
async with db.scoped_session(self.session_maker) as session:
result = await session.execute(
select(Entity)
.where(self.primary_key.in_(ids))
.options(
selectinload(Entity.observations),
selectinload(Entity.outgoing_relations),
selectinload(Entity.incoming_relations),
)
)
entity = result.scalars().one()
logger.debug(f"Found base entity: {entity.id}")
# Force refresh of all relationships
await self.refresh(entity, ['observations', 'outgoing_relations', 'incoming_relations'])
logger.debug(f"Refreshed entity relationships: {entity.id}")
return entity
except NoResultFound:
logger.debug(f"No entity found with ID: {entity_id}")
return None
except Exception as e:
logger.exception(f"Error finding entity by ID: {entity_id}")
raise
entities = result.scalars().all()
logger.debug(f"Found {len(entities)}")
return entities
async def find_by_name(self, name: str) -> Optional[Entity]:
"""Find an entity by its unique name."""
logger.debug(f"Finding entity by name: {name}")
try:
query = (
async with db.scoped_session(self.session_maker) as session:
result = await session.execute(
select(Entity)
.filter(Entity.name == name)
.options(
selectinload(Entity.observations),
selectinload(Entity.outgoing_relations),
selectinload(Entity.incoming_relations),
)
)
result = await self.session.execute(query)
entity = result.scalars().one_or_none()
if entity:
logger.debug(f"Found entity: {entity.id}")
await self.refresh(entity, ['observations', 'outgoing_relations', 'incoming_relations'])
logger.debug(f"Refreshed entity relationships: {entity.id}")
else:
logger.debug(f"No entity found with name: {name}")
return entity
except Exception as e:
logger.exception(f"Error finding entity by name: {name}")
raise
async def find_by_type_and_name(self, entity_type: str, name: str) -> Optional[Entity]:
"""Find an entity by its type and name combination."""
logger.debug(f"Finding entity by type and name: {entity_type}/{name}")
try:
query = (
async with db.scoped_session(self.session_maker) as session:
result = await session.execute(
select(Entity)
.filter(Entity.entity_type == entity_type)
.filter(Entity.name == name)
.options(
selectinload(Entity.observations),
selectinload(Entity.outgoing_relations),
selectinload(Entity.incoming_relations)
selectinload(Entity.incoming_relations),
)
)
result = await self.session.execute(query)
entity = result.scalars().one_or_none()
if entity:
logger.debug(f"Found entity: {entity.id}")
else:
logger.debug(f"No entity found with type/name: {entity_type}/{name}")
return entity
except Exception as e:
logger.exception(f"Error finding entity by type/name: {entity_type}/{name}")
raise
async def search_by_type(self, entity_type: str, skip: int = 0, limit: int = 100) -> Sequence[Entity]:
async def search_by_type(
self, entity_type: str, skip: int = 0, limit: int = 100
) -> Sequence[Entity]:
"""Search for entities of a specific type."""
logger.debug(f"Searching entities by type: {entity_type} (skip={skip}, limit={limit})")
try:
query = select(Entity).filter(Entity.entity_type == entity_type).offset(skip).limit(limit)
result = await self.execute_query(query)
async with db.scoped_session(self.session_maker) as session:
result = await session.execute(
select(Entity)
.filter(Entity.entity_type == entity_type)
.options(
selectinload(Entity.observations),
selectinload(Entity.outgoing_relations),
selectinload(Entity.incoming_relations),
)
.offset(skip)
.limit(limit)
)
entities = result.scalars().all()
logger.debug(f"Found {len(entities)} entities of type {entity_type}")
return entities
except Exception as e:
logger.exception(f"Error searching entities by type: {entity_type}")
raise
async def search(self, query: str) -> Sequence[Entity]:
"""Search entities using LIKE pattern matching."""
logger.debug(f"Searching entities with query: {query}")
try:
stmt = select(Entity).distinct().where(
or_(
Entity.name.ilike(f"%{query}%"),
Entity.entity_type.ilike(f"%{query}%"),
Entity.observations.any(
Observation.content.ilike(f"%{query}%")
async with db.scoped_session(self.session_maker) as session:
stmt = (
select(Entity)
.distinct()
.where(
or_(
Entity.name.ilike(f"%{query}%"),
Entity.entity_type.ilike(f"%{query}%"),
Entity.observations.any(Observation.content.ilike(f"%{query}%")),
)
)
).options(
selectinload(Entity.observations),
selectinload(Entity.outgoing_relations),
selectinload(Entity.incoming_relations)
.options(
selectinload(Entity.observations),
selectinload(Entity.outgoing_relations),
selectinload(Entity.incoming_relations),
)
)
result = await self.session.execute(stmt)
result = await session.execute(stmt)
entities = list(result.scalars())
logger.debug(f"Found {len(entities)} matching entities")
return entities
except Exception as e:
logger.exception(f"Error searching entities: {query}")
raise
@@ -1,25 +1,30 @@
"""Repository for managing Observation objects."""
from typing import Sequence
from loguru import logger
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker
from basic_memory.models import Observation
from basic_memory.repository import Repository
from basic_memory.repository.repository import Repository
class ObservationRepository(Repository[Observation]):
"""Repository for Observation model with memory-specific operations."""
def __init__(self, session):
super().__init__(session, Observation)
def __init__(self, session_maker: async_sessionmaker):
super().__init__(session_maker, Observation)
logger.debug("Initialized ObservationRepository")
async def find_by_entity(self, entity_id: str) -> Sequence[Observation]:
"""Find all observations for a specific entity."""
query = select(Observation).filter(Observation.entity_id == entity_id)
result = await self.execute_query(query)
return result.scalars().all()
async def find_by_context(self, context: str) -> Sequence[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()
@@ -1,16 +1,19 @@
"""Repository for managing Relation objects."""
from typing import Sequence
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import async_sessionmaker
from basic_memory.models import Relation
from basic_memory.repository import Repository
from basic_memory.repository.repository import Repository
class RelationRepository(Repository[Relation]):
"""Repository for Relation model with memory-specific operations."""
def __init__(self, session):
super().__init__(session, Relation)
def __init__(self, session_maker: async_sessionmaker):
super().__init__(session_maker, Relation)
async def find_by_entity(self, from_entity_id: str) -> Sequence[Relation]:
"""Find all relations from a specific entity."""
@@ -20,17 +23,12 @@ class RelationRepository(Repository[Relation]):
async def find_by_entities(self, from_id: str, to_id: str) -> Sequence[Relation]:
"""Find all relations between two entities."""
query = select(Relation).filter(
and_(
Relation.from_id == from_id,
Relation.to_id == to_id
)
)
query = select(Relation).filter(and_(Relation.from_id == from_id, Relation.to_id == to_id))
result = await self.execute_query(query)
return result.scalars().all()
async def find_by_type(self, relation_type: str) -> Sequence[Relation]:
"""Find all relations of a specific type."""
query = select(Relation).filter(Relation.relation_type == relation_type)
result = await self.execute_query(query)
return result.scalars().all()
return result.scalars().all()
+223
View File
@@ -0,0 +1,223 @@
"""Base repository implementation."""
from typing import Type, Optional, Any, Sequence, TypeVar, List
from loguru import logger
from sqlalchemy import (
select,
func,
Select,
Executable,
inspect,
Result,
Column,
and_,
delete,
)
from sqlalchemy.exc import NoResultFound
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
from basic_memory import db
from basic_memory.models import Base
T = TypeVar("T", bound=Base)
class Repository[T: Base]:
"""Base repository implementation with generic CRUD operations."""
def __init__(self, session_maker: async_sessionmaker[AsyncSession], Model: Type[T]):
self.session_maker = session_maker
self.Model = Model
self.mapper = inspect(self.Model).mapper
self.primary_key: Column[Any] = self.mapper.primary_key[0]
self.valid_columns = [column.key for column in self.mapper.columns]
def get_model_data(self, entity_data):
model_data = {
k: v for k, v in entity_data.items() if k in self.valid_columns and v is not None
}
return model_data
async def add(self, model: T) -> T:
"""
Add a model to the repository. This will also add related objects
:param model: the model to add
:return: the added model instance
"""
async with db.scoped_session(self.session_maker) as session:
session.add(model)
await session.flush()
found = await self.find_by_id(model.id) # pyright: ignore [reportAttributeAccessIssue]
assert found is not None, "can't find model after session.add"
return found
async def add_all(self, models: List[T]) -> Sequence[T]:
"""
Add a list of models to the repository. This will also add related objects
:param model: the models to add
:return: the added models instances
"""
async with db.scoped_session(self.session_maker) as session:
session.add_all(models)
await session.flush()
# we have to find to get relations
return await self.find_by_ids([m.id for m in models]) # pyright: ignore [reportAttributeAccessIssue]
def select(self, *entities: Any) -> Select:
"""Create a new SELECT statement.
Returns:
A SQLAlchemy Select object configured with the provided entities
or this repository's model if no entities provided.
"""
if not entities:
entities = (self.Model,)
return select(*entities)
async def refresh(self, instance: T, relationships: list[str] | None = None) -> None:
"""Refresh instance and optionally specified relationships."""
logger.debug(f"Refreshing {self.Model.__name__} instance: {getattr(instance, 'id', None)}")
async with db.scoped_session(self.session_maker) as session:
await session.refresh(instance, relationships or [])
logger.debug(f"Refreshed relationships: {relationships}")
async def find_all(self, skip: int = 0, limit: int = 100) -> Sequence[T]:
"""Fetch records from the database with pagination."""
logger.debug(f"Finding all {self.Model.__name__} (skip={skip}, limit={limit})")
async with db.scoped_session(self.session_maker) as session:
result = await session.execute(select(self.Model).offset(skip).limit(limit))
items = result.scalars().all()
logger.debug(f"Found {len(items)} {self.Model.__name__} records")
return items
async def find_by_id(self, entity_id: str) -> Optional[T]:
"""Fetch an entity by its unique identifier."""
logger.debug(f"Finding {self.Model.__name__} by ID: {entity_id}")
async with db.scoped_session(self.session_maker) as session:
try:
result = await session.execute(
select(self.Model).filter(self.primary_key == entity_id)
)
entity = result.scalars().one()
logger.debug(f"Found {self.Model.__name__}: {entity_id}")
return entity
except NoResultFound:
logger.debug(f"No {self.Model.__name__} found with ID: {entity_id}")
return None
async def find_one(self, query: Select[tuple[T]]) -> Optional[T]:
"""Execute a query and retrieve a single record."""
logger.debug(f"Finding one {self.Model.__name__} with query: {query}")
result = await self.execute_query(query)
entity = result.scalars().one_or_none()
if entity:
logger.debug(f"Found {self.Model.__name__}: {getattr(entity, 'id', None)}")
else:
logger.debug(f"No {self.Model.__name__} found")
return entity
async def find_by_ids(self, ids: List[str]) -> Sequence[T]:
"""Fetch multiple entities by their identifiers in a single query."""
logger.debug(f"Finding {self.Model.__name__} by IDs: {ids}")
async with db.scoped_session(self.session_maker) as session:
result = await session.execute(select(self.Model).where(self.primary_key.in_(ids)))
entities = result.scalars().all()
logger.debug(f"Found {len(entities)} {self.Model.__name__} records")
return entities
async def create(self, data: dict) -> T:
"""Create a new record from a model instance."""
logger.debug(f"Creating {self.Model.__name__} from entity_data: {data}")
async with db.scoped_session(self.session_maker) as session:
# Only include valid columns that are provided in entity_data
model_data = self.get_model_data(data)
model = self.Model(**model_data)
session.add(model)
await session.flush()
return model
async def create_all(self, data_list: List[dict]) -> List[T]:
"""Create multiple records in a single transaction."""
logger.debug(f"Bulk creating {len(data_list)} {self.Model.__name__} instances")
async with db.scoped_session(self.session_maker) as session:
# Only include valid columns that are provided in entity_data
model_list = [self.Model(**self.get_model_data(d)) for d in data_list]
session.add_all(model_list)
return model_list
async def update(self, entity_id: str, entity_data: dict) -> Optional[T]:
"""Update an entity with the given data."""
logger.debug(f"Updating {self.Model.__name__} {entity_id} with data: {entity_data}")
async with db.scoped_session(self.session_maker) as session:
try:
result = await session.execute(
select(self.Model).filter(self.primary_key == entity_id)
)
entity = result.scalars().one()
for key, value in entity_data.items():
if key in self.valid_columns:
setattr(entity, key, value)
logger.debug(f"Updated {self.Model.__name__}: {entity_id}")
return entity
except NoResultFound:
logger.debug(f"No {self.Model.__name__} found to update: {entity_id}")
return None
async def delete(self, entity_id: str) -> bool:
"""Delete an entity from the database."""
logger.debug(f"Deleting {self.Model.__name__}: {entity_id}")
async with db.scoped_session(self.session_maker) as session:
try:
result = await session.execute(
select(self.Model).filter(self.primary_key == entity_id)
)
entity = result.scalars().one()
await session.delete(entity)
logger.debug(f"Deleted {self.Model.__name__}: {entity_id}")
return True
except NoResultFound:
logger.debug(f"No {self.Model.__name__} found to delete: {entity_id}")
return False
async def delete_by_ids(self, ids: List[str]) -> int:
"""Delete records matching given field values."""
logger.debug(f"Deleting {self.Model.__name__} by ids: {ids}")
async with db.scoped_session(self.session_maker) as session:
query = delete(self.Model).where(self.primary_key.in_(ids))
result = await session.execute(query)
logger.debug(f"Deleted {result.rowcount} records")
return result.rowcount
async def delete_by_fields(self, **filters: Any) -> bool:
"""Delete records matching given field values."""
logger.debug(f"Deleting {self.Model.__name__} by fields: {filters}")
async with db.scoped_session(self.session_maker) as session:
conditions = [getattr(self.Model, field) == value for field, value in filters.items()]
query = delete(self.Model).where(and_(*conditions))
result = await session.execute(query)
deleted = result.rowcount > 0
logger.debug(f"Deleted {result.rowcount} records")
return deleted
async def count(self, query: Executable | None = None) -> int:
"""Count entities in the database table."""
async with db.scoped_session(self.session_maker) as session:
if query is None:
query = select(func.count()).select_from(self.Model)
result = await session.execute(query)
scalar = result.scalar()
count = scalar if scalar is not None else 0
logger.debug(f"Counted {count} {self.Model.__name__} records")
return count
async def execute_query(self, query: Executable) -> Result[Any]:
"""Execute a query asynchronously."""
logger.debug(f"Executing query: {query}")
async with db.scoped_session(self.session_maker) as session:
result = await session.execute(query)
logger.debug("Query executed successfully")
return result
+3 -4
View File
@@ -1,7 +1,7 @@
"""Core pydantic models for basic-memory entities, observations, and relations.
This module defines the foundational data structures for the knowledge graph system.
The graph consists of entities (nodes) connected by relations (edges), where each
The graph consists of entities (nodes) connected by relations (edges), where each
entity can have multiple observations (facts) attached to it.
Key Concepts:
@@ -106,7 +106,7 @@ Names are normalized by:
class Relation(BaseModel):
"""Represents a directed edge between entities in the knowledge graph.
Relations are directed connections stored in active voice (e.g., "created", "depends_on").
The from_id represents the source or actor entity, while to_id represents the target
or recipient entity.
@@ -212,9 +212,8 @@ class Entity(BaseModel):
entity_type: EntityType
description: Optional[str] = None
observations: List[Observation] = []
relations: List[Relation] = []
@property
def file_path(self) -> str:
"""The relative file path for this entity."""
return f"{id}.md"
return f"{id}.md"
+4 -4
View File
@@ -15,12 +15,12 @@ from typing import List, Optional
from pydantic import BaseModel, ConfigDict
from basic_memory.schemas.base import Observation, EntityId, Relation, Entity
from basic_memory.schemas.base import Observation, EntityId, Relation
class SQLAlchemyModel(BaseModel):
"""Base class for models that read from SQLAlchemy attributes.
This base class handles conversion of SQLAlchemy model attributes
to Pydantic model fields. All response models extend this to ensure
proper handling of database results.
@@ -229,7 +229,7 @@ class OpenNodesResponse(SQLAlchemyModel):
}
"""
entities: List[Entity]
entities: List[EntityResponse]
class AddObservationsResponse(SQLAlchemyModel):
@@ -323,4 +323,4 @@ class DeleteObservationsResponse(SQLAlchemyModel):
}
"""
deleted: bool
deleted: bool
+11 -9
View File
@@ -1,31 +1,33 @@
"""Service layer exceptions and imports."""
class ServiceError(Exception):
"""Base exception for service errors"""
pass
class DatabaseSyncError(ServiceError):
"""Raised when database sync fails"""
pass
class RelationError(ServiceError):
"""Base exception for relation-specific errors"""
pass
from .entity_service import EntityService
from .observation_service import ObservationService
from .relation_service import RelationService
from .memory_service import MemoryService
__all__ = [
'ServiceError',
'DatabaseSyncError',
'RelationError',
'EntityService',
'ObservationService',
'RelationService',
'MemoryService',
]
"ServiceError",
"DatabaseSyncError",
"RelationError",
"EntityService",
"ObservationService",
"RelationService",
]
+58 -80
View File
@@ -1,112 +1,90 @@
"""Service for managing entities in the database."""
from pathlib import Path
from typing import Dict, Any, Sequence
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.schemas import Entity
from basic_memory.models import Entity as EntityModel
from basic_memory.fileio import EntityNotFoundError
from typing import Dict, Any, Sequence, List
from loguru import logger
from basic_memory.fileio import EntityNotFoundError
from basic_memory.models import Entity as EntityModel, Observation
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.schemas import Entity as EntitySchema
from .service import BaseService
class EntityService:
"""
Service for managing entities in the database.
File operations are handled by MemoryService.
"""
def __init__(self, project_path: Path, entity_repo: EntityRepository):
self.project_path = project_path
self.entity_repo = entity_repo
logger.debug(f"Initialized EntityService with path: {project_path}")
def entity_model(entity):
model = EntityModel(
id=EntityModel.generate_id(entity.entity_type, entity.name),
name=entity.name,
entity_type=entity.entity_type,
description=entity.description,
observations=[Observation(content=observation) for observation in entity.observations],
)
return model
class EntityService(BaseService[EntityRepository]):
"""Service for managing entities in the database."""
def __init__(self, entity_repository: EntityRepository):
super().__init__(entity_repository)
async def search(self, query: str) -> Sequence[EntityModel]:
"""Search entities using LIKE pattern matching."""
logger.debug(f"Searching entities with query: {query}")
try:
results = await self.entity_repo.search(query)
logger.debug(f"Found {len(results)} matches")
return results
except Exception:
logger.exception(f"Failed to search entities with query: {query}")
raise
return await self.repository.search(query)
async def create_entity(self, entity: Entity) -> EntityModel:
async def create_entity(self, entity: EntitySchema) -> EntityModel:
"""Create a new entity in the database."""
logger.debug(f"Creating entity in DB: {entity}")
try:
created_entity = await self.entity_repo.create(entity.model_dump())
logger.debug(f"Created base entity: {created_entity.id}")
model = entity_model(entity)
return await self.repository.add(model)
await self.entity_repo.refresh(created_entity, ['observations', 'outgoing_relations', 'incoming_relations'])
logger.debug(f"Refreshed entity relationships: {created_entity.id}")
return created_entity
except Exception:
logger.exception(f"Failed to create entity: {entity}")
raise
async def create_entities(self, entities_in: List[EntitySchema]) -> Sequence[EntityModel]:
"""Create multiple entities with their observations."""
logger.debug(f"Creating {len(entities_in)} entities")
created = await self.repository.add_all([entity_model(entity) for entity in entities_in])
return created
async def update_entity(self, entity_id: str, update_data: Dict[str, Any]) -> EntityModel:
"""Update an entity's fields."""
logger.debug(f"Updating entity {entity_id} with data: {update_data}")
try:
updated = await self.entity_repo.update(entity_id, update_data)
if not updated:
raise EntityNotFoundError(f"Entity not found: {entity_id}")
logger.debug(f"Updated entity: {updated.id}")
return updated
except EntityNotFoundError:
raise
except Exception:
logger.exception(f"Failed to update entity: {entity_id}")
raise
updated = await self.repository.update(entity_id, update_data)
if not updated:
raise EntityNotFoundError(f"Entity not found: {entity_id}")
return updated
async def get_entity(self, entity_id: str) -> EntityModel:
"""Get entity by ID."""
logger.debug(f"Getting entity by ID: {entity_id}")
try:
db_entity = await self.entity_repo.find_by_id(entity_id)
if not db_entity:
logger.error(f"Entity not found: {entity_id}")
raise EntityNotFoundError(f"Entity not found: {entity_id}")
logger.debug(f"Found entity: {db_entity.id}")
return db_entity
except EntityNotFoundError:
raise
except Exception:
logger.exception(f"Failed to get entity: {entity_id}")
raise
db_entity = await self.repository.find_by_id(entity_id)
if not db_entity:
raise EntityNotFoundError(f"Entity not found: {entity_id}")
return db_entity
async def get_by_type_and_name(self, entity_type: str, name: str) -> EntityModel:
"""Get entity by type and name combination."""
logger.debug(f"Getting entity by type/name: {entity_type}/{name}")
try:
db_entity = await self.entity_repo.find_by_type_and_name(entity_type, name)
if not db_entity:
logger.error(f"Entity not found: {entity_type}/{name}")
raise EntityNotFoundError(f"Entity not found: {entity_type}/{name}")
logger.debug(f"Found entity: {db_entity.id}")
return db_entity
except EntityNotFoundError:
raise
except Exception:
logger.exception(f"Failed to get entity by type/name: {entity_type}/{name}")
raise
db_entity = await self.repository.find_by_type_and_name(entity_type, name)
if not db_entity:
raise EntityNotFoundError(f"Entity not found: {entity_type}/{name}")
return db_entity
async def get_all(self) -> Sequence[EntityModel]:
return await self.entity_repo.find_all()
"""Get all entities."""
return await self.repository.find_all()
async def delete_entity(self, entity_id: str) -> bool:
"""Delete entity from database."""
logger.debug(f"Deleting entity: {entity_id}")
try:
result = await self.entity_repo.delete(entity_id)
logger.debug(f"Entity deleted: {entity_id}")
return result
except Exception:
logger.exception(f"Failed to delete entity: {entity_id}")
raise
return await self.repository.delete(entity_id)
async def open_nodes(self, entity_ids: List[str]) -> Sequence[EntityModel]:
"""Get specific nodes and their relationships."""
logger.debug(f"Opening nodes entity_ids: {entity_ids}")
return await self.repository.find_by_ids(entity_ids)
async def delete_entities(self, entity_ids: List[str]) -> bool:
"""Delete entities and their files."""
logger.debug(f"Deleting entities: {entity_ids}")
deleted_count = await self.repository.delete_by_ids(entity_ids)
return deleted_count > 0
-371
View File
@@ -1,371 +0,0 @@
"""Service for orchestrating entity, relation, and observation operations."""
import asyncio
from pathlib import Path
from typing import List, Dict, Any, Optional, Sequence
from loguru import logger
from basic_memory.fileio import (
write_entity_file,
read_entity_file,
EntityNotFoundError,
get_entity_path,
)
from basic_memory.models import (
Entity as EntityModel,
Observation as ObservationModel,
Relation as RelationModel,
)
from basic_memory.schemas import AddObservationsRequest, Entity, Relation
from basic_memory.services import EntityService, RelationService, ObservationService
class MemoryService:
"""Orchestrates entity, relation, and observation operations with filesystem handling."""
def __init__(
self,
project_path: Optional[Path],
entity_service: EntityService,
relation_service: RelationService,
observation_service: ObservationService,
):
if project_path:
assert (
project_path.is_dir()
), "Path does not exist or is not a directory: {project_path}"
self.project_path = project_path
self.entities_path = project_path / "entities"
self.entity_service = entity_service
self.relation_service = relation_service
self.observation_service = observation_service
logger.debug(f"Initialized MemoryService with path: {project_path}")
def get_entity_file_path(self, entity_id: str) -> Path:
return get_entity_path(self.entities_path, entity_id)
async def create_entities(self, entities_in: List[Entity]) -> List[EntityModel]:
"""Create multiple entities with their observations."""
logger.debug(f"Creating {len(entities_in)} entities")
# TODO this could be better
for e in entities_in:
# Generate ID and write file
try:
existing = await self.entity_service.get_entity(
EntityModel.generate_id(e.entity_type, e.name)
)
if existing:
raise ValueError(
f"Entity {e.entity_type}/{e.name} already exists, id: {EntityModel.generate_id(e.entity_type, e.name)}"
)
except EntityNotFoundError:
# Good - entity doesn't exist yet
pass
# Write files in parallel (filesystem is source of truth)
async def write_file(entity: Entity):
# Generate ID and write file
entity_id = EntityModel.generate_id(entity.entity_type, entity.name)
await write_entity_file(self.entities_path, entity_id, entity)
file_writes = [write_file(entity) for entity in entities_in]
logger.debug("Starting parallel file writes")
await asyncio.gather(*file_writes)
logger.debug("Completed all file writes")
async def create_entity_in_db(entity_create: Entity):
logger.debug(f"Creating entity in DB: {entity_create}")
try:
# Create base entity
created_entity = await self.entity_service.create_entity(entity_create)
logger.debug(f"Created base entity: {created_entity.id}")
# Add observations
await self.observation_service.add_observations(
created_entity.id, entity_create.observations
)
logger.debug(
f"Added {len(entity_create.observations)} observations to {created_entity.id}"
)
# Add relations
for relation in entity_create.relations:
await self.relation_service.create_relation(relation)
logger.debug(
f"Added {len(entity_create.relations)} relations for {created_entity.id}"
)
# Query final state
final_entity = await self.entity_service.get_entity(created_entity.id)
logger.debug(f"Retrieved final entity state: {final_entity}")
return final_entity
except Exception:
logger.exception(f"Failed to create entity in DB: {entity_create}")
raise
# Update database index sequentially
logger.debug("Starting DB updates")
try:
entities = []
for entity_in in entities_in:
entity = await create_entity_in_db(entity_in)
entities.append(entity)
logger.debug(f"Successfully created {len(entities)} entities in DB")
return entities
except Exception:
# On failure, we should try to clean up any files we wrote
logger.exception("Failed to create entities in DB")
for entity in entities_in:
try:
entity_id = EntityModel.generate_id(entity.entity_type, entity.name)
path = self.entities_path / entity_id
if path.exists():
path.unlink()
except Exception as cleanup_error:
logger.error(f"Failed to clean up file for {entity.id}: {cleanup_error}")
raise
async def get_entity(self, entity_id: str):
logger.debug(f"Get entity {entity_id} entities")
entity = await self.entity_service.get_entity(entity_id)
logger.debug(f"Found entity {entity}")
return entity
async def create_relations(self, relations_data: List[Relation]) -> List[RelationModel]:
"""Create multiple relations between entities."""
logger.debug(f"Creating {len(relations_data)} relations")
relations = []
for relation in relations_data:
logger.debug(f"Processing relation: {relation.from_id} -> {relation.to_id}")
try:
# First read complete entities from filesystem
from_entity = await read_entity_file(self.entities_path, relation.from_id)
to_entity = await read_entity_file(self.entities_path, relation.to_id)
logger.debug(f"Read entities for relation: {from_entity.id}, {to_entity.id}")
# Add the new relation to the source entity
if not hasattr(from_entity, "relations"):
from_entity.relations = []
from_entity.relations.append(relation)
logger.debug(f"Added relation to source entity: {from_entity.id}")
# Write updated entity files (filesystem is source of truth)
logger.debug("Writing updated entity files")
assert from_entity.id is not None
assert to_entity.id is not None
await asyncio.gather(
*[
write_entity_file(self.entities_path, from_entity.id, from_entity),
write_entity_file(self.entities_path, to_entity.id, to_entity),
]
)
logger.debug("Wrote updated entity files")
# Now update the database index
relation = await self.relation_service.create_relation(relation)
relations.append(relation)
logger.debug(f"Created relation in DB: {relation.id}")
except Exception:
logger.exception(f"Failed to create relation: {relation}")
raise
logger.debug(f"Successfully created {len(relations)} relations")
return relations
async def add_observations(
self, observations_in: AddObservationsRequest
) -> List[ObservationModel]:
"""Add observations to an existing entity."""
logger.debug(f"Adding observations to entity: {observations_in.entity_id}")
try:
# First get the entity from DB to get its ID
db_entity = await self.entity_service.get_entity(observations_in.entity_id)
logger.debug(f"Found entity in DB: {db_entity.id}")
# Read entity from filesystem using the ID
entity = await read_entity_file(self.entities_path, db_entity.id)
logger.debug(f"Read entity from filesystem: {db_entity.id}")
# Create new observations for the entity
entity.observations += observations_in.observations
logger.debug(f"Added {len(observations_in.observations)} observations to entity")
# Write updated entity file
logger.debug("Writing updated entity file")
await write_entity_file(self.entities_path, db_entity.id, entity)
logger.debug("Wrote updated entity file")
# Update database index
added_observations = await self.observation_service.add_observations(
db_entity.id, observations_in.observations
)
logger.debug(f"Added {len(added_observations)} observations to DB")
return added_observations
except Exception:
logger.exception(f"Failed to add observations to entity: {observations_in.entity_id}")
raise
async def delete_entities(self, entity_ids: List[str]) -> bool:
"""Delete entities and their files."""
logger.debug(f"Deleting entities: {entity_ids}")
try:
deleted = False
for entity_id in entity_ids:
# First read the entity to make sure it exists
try:
entity = await read_entity_file(self.entities_path, entity_id)
except EntityNotFoundError:
logger.debug(f"Entity file not found: {entity_id}")
continue
# Delete the file first since it's source of truth
file_path = self.get_entity_file_path(entity_id)
if file_path.exists():
file_path.unlink()
logger.debug(f"Deleted entity {entity_id} file: {file_path}")
# Then update database
result = await self.entity_service.delete_entity(entity_id)
if result:
deleted = True
logger.debug(f"Deleted entity from database: {entity_id}")
return deleted
except Exception:
logger.exception("Failed to delete entities")
raise
async def delete_observations(self, entity_id: str, contents: List[str]) -> bool:
"""Delete specific observations from an entity."""
logger.debug(f"Deleting observations from entity: {entity_id}")
try:
# First read the entity
entity = await read_entity_file(self.entities_path, entity_id)
# Remove observations from entity
original_count = len(entity.observations)
entity.observations = [obs for obs in entity.observations if obs not in contents]
# Only write file if we actually removed anything
if len(entity.observations) < original_count:
# Write updated entity file first (source of truth)
await write_entity_file(self.entities_path, entity_id, entity)
logger.debug(f"Updated entity file: {entity_id}")
# Then update database
result = await self.observation_service.delete_observations(entity_id, contents)
logger.debug(f"Deleted observations from database: {result}")
return True
return False
except Exception:
logger.exception("Failed to delete observations")
raise
async def delete_relations(self, relations: List[Dict[str, Any]]) -> bool:
"""Delete relations between entities."""
logger.debug(f"Deleting relations: {relations}")
try:
deleted = False
for relation in relations:
# First read the source entity
try:
from_entity = await read_entity_file(self.entities_path, relation["from_id"])
except EntityNotFoundError:
logger.debug(f"Source entity not found: {relation['from_id']}")
continue
# Update the entity's relations
if hasattr(from_entity, "relations"):
original_count = len(from_entity.relations)
relation_type = relation.get("relation_type")
if relation_type:
from_entity.relations = [
r
for r in from_entity.relations
if not (
r.to_id == relation["to_id"] and r.relation_type == relation_type
)
]
else:
from_entity.relations = [
r for r in from_entity.relations if r.to_id != relation["to_id"]
]
# Only write file if we actually removed any relations
if len(from_entity.relations) < original_count:
# Write updated entity file first (source of truth)
await write_entity_file(self.entities_path, from_entity.id, from_entity) # pyright: ignore [reportArgumentType]
logger.debug(f"Updated source entity file: {from_entity.id}")
# Then update database
result = await self.relation_service.delete_relations([relation])
if result:
deleted = True
logger.debug("Deleted relations from database")
return deleted
except Exception:
logger.exception("Failed to delete relations")
raise
async def read_graph(self) -> Sequence[EntityModel]:
"""Read the entire knowledge graph."""
logger.debug("Reading entire knowledge graph")
try:
entities = await self.entity_service.get_all()
logger.debug(f"Read {len(entities)} entities from graph")
return entities
except Exception:
logger.exception("Failed to read graph")
raise
async def search_nodes(self, query: str) -> Sequence[EntityModel]:
"""Search for nodes in the knowledge graph."""
logger.debug(f"Searching nodes with query: {query}")
try:
results = await self.entity_service.search(query)
logger.debug(f"Found {len(results)} matches for '{query}'")
return results
except Exception:
logger.exception(f"Failed to search nodes with query: {query}")
raise
async def open_nodes(self, entity_ids: List[str]) -> List[Entity]:
"""Get specific nodes and their relationships."""
logger.debug(f"Opening nodes entity_ids: {entity_ids}")
async def read_node(id: str) -> Optional[Entity]:
try:
# Get ID from name first
logger.debug(f"Looking up entity: {id}")
db_entity = await self.entity_service.get_entity(id)
if db_entity:
logger.debug(f"Found entity in DB: {db_entity.id}")
entity = await read_entity_file(self.entities_path, db_entity.id)
logger.debug(f"Read entity from filesystem: {entity.id}")
return entity
logger.debug(f"Entity not found: {id}")
return None
except Exception:
logger.exception(f"Failed to read node: {id}")
return None
try:
entities = [
entity
for entity in await asyncio.gather(*(read_node(id) for id in entity_ids))
if entity is not None
]
logger.debug(f"Opened {len(entities)} entities")
return entities
except Exception:
logger.exception("Failed to open nodes")
raise
@@ -1,99 +1,64 @@
"""Service for managing observations in both filesystem and database."""
from pathlib import Path
from typing import List, Sequence, Dict, Any
"""Service for managing observations in the database."""
from typing import List, Sequence
from loguru import logger
from sqlalchemy import select
from basic_memory.models import Observation as ObservationModel
from basic_memory.repository.observation_repository import ObservationRepository
from . import DatabaseSyncError
from basic_memory.schemas import Observation
from .service import BaseService
class ObservationService:
class ObservationService(BaseService[ObservationRepository]):
"""
Service for managing observations in the database.
File operations are handled by MemoryService.
"""
def __init__(self, project_path: Path, observation_repo: ObservationRepository):
self.project_path = project_path
self.observation_repo = observation_repo
async def add_observations(self, entity_id: str, observations: List[Observation]) -> List[ObservationModel]:
"""
Add multiple observations to an entity.
Returns the created observations with IDs set.
"""
try:
return await self.observation_repo.bulk_create([
ObservationModel(
def __init__(self, observation_repository: ObservationRepository):
super().__init__(observation_repository)
async def add_observations(
self, entity_id: str, observations: List[str]
) -> List[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,
)
for observation in observations
])
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_observations(self, entity_id: str, contents: List[str]) -> bool:
"""Delete specific observations from an entity."""
logger.debug(f"Deleting observations from entity: {entity_id}")
deleted = False
for content in contents:
result = await self.repository.delete_by_fields(entity_id=entity_id, content=content)
if result:
deleted = True
return deleted
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
"""Delete all observations for an entity."""
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]:
"""
Search for observations across all entities.
Args:
query: Text to search for in observation content
Returns:
List of matching observations with their entity contexts
"""
result = await self.observation_repo.execute_query(
select(ObservationModel).filter(
ObservationModel.content.contains(query)
)
"""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))
)
return [
ObservationModel(content=obs.content)
for obs in result.scalars().all()
]
observations = result.scalars().all()
return [ObservationModel(content=obs.content) for obs in observations]
async def get_observations_by_context(self, context: str) -> Sequence[ObservationModel]:
"""Get all observations with a specific context."""
return await self.observation_repo.find_by_context(context)
logger.debug(f"Getting observations for context: {context}")
return await self.repository.find_by_context(context)
+46 -63
View File
@@ -1,78 +1,61 @@
"""Service for managing relations in the database."""
from pathlib import Path
from typing import List, Dict, Any
from basic_memory.repository.relation_repository import RelationRepository
from basic_memory.schemas import Entity, Relation
from . import DatabaseSyncError
from basic_memory.models import Relation as RelationModel
from loguru import logger
class RelationService:
from basic_memory.models import Relation as RelationModel
from basic_memory.repository.relation_repository import RelationRepository
from basic_memory.schemas import Entity as EntitySchema, Relation
from .service import BaseService
class RelationService(BaseService[RelationRepository]):
"""
Service for managing relations in the database.
File operations are handled by MemoryService.
"""
def __init__(self, project_path: Path, relation_repo: RelationRepository):
self.project_path = project_path
self.relation_repo = relation_repo
def __init__(self, relation_repository: RelationRepository):
super().__init__(relation_repository)
async def create_relation(self, relation: Relation) -> RelationModel:
"""Create a new relation in the database."""
try:
return await self.relation_repo.create(relation.model_dump())
except Exception as e:
raise DatabaseSyncError(f"Failed to sync relation to database: {str(e)}") from e
logger.debug(f"Creating relation: {relation}")
return await self.repository.create(relation.model_dump())
async def delete_relation(self, from_entity: Entity, to_entity: Entity, relation_type: str) -> bool:
async def delete_relation(
self, from_entity: EntitySchema, to_entity: EntitySchema, relation_type: str
) -> bool:
"""Delete a specific relation between entities."""
try:
# Use repository to find and delete the relation
filters = {
'from_id': from_entity.id,
'to_id': to_entity.id,
'relation_type': relation_type
}
# Delete in database
result = await self.relation_repo.delete_by_fields(**filters)
# Update in-memory relations if present
if hasattr(from_entity, 'relations'):
from_entity.relations = [
r for r in from_entity.relations
if not (r.to_id == to_entity.id and r.relation_type == relation_type)
]
return result
except Exception as e:
raise DatabaseSyncError(f"Failed to delete relation: {str(e)}") from e
logger.debug(f"Deleting relation between {from_entity.id} and {to_entity.id}")
assert from_entity.id is not None, "from_entity.id must not be None"
result = await self.repository.delete_by_fields(
from_id=from_entity.id,
to_id=to_entity.id,
relation_type=relation_type,
)
return result
async def delete_relations(self, relations: List[Dict[str, Any]]) -> bool:
"""
Delete relations matching specified criteria.
Args:
relations: List of dicts with from_id, to_id, and optional relation_type
Returns:
True if any relations were deleted
"""
try:
deleted = False
for relation in relations:
filters = {
'from_id': relation['from_id'],
'to_id': relation['to_id']
}
if 'relation_type' in relation:
filters['relation_type'] = relation['relation_type']
result = await self.relation_repo.delete_by_fields(**filters)
if result:
deleted = True
return deleted
except Exception as e:
raise DatabaseSyncError(f"Failed to delete relations: {str(e)}") from e
"""Delete relations matching specified criteria."""
logger.debug(f"Deleting {len(relations)} relations")
deleted = False
for relation in relations:
filters = {"from_id": relation["from_id"], "to_id": relation["to_id"]}
if "relation_type" in relation:
filters["relation_type"] = relation["relation_type"]
result = await self.repository.delete_by_fields(**filters)
if result:
deleted = True
return deleted
async def create_relations(self, relations_data: List[Relation]) -> List[RelationModel]:
"""Create multiple relations between entities."""
logger.debug(f"Creating {len(relations_data)} relations")
return await self.repository.create_all(
[Relation.model_dump(relation) for relation in relations_data]
)
+23
View File
@@ -0,0 +1,23 @@
"""Base service class."""
from typing import TypeVar, Generic, List, Sequence
from basic_memory.repository.repository import Repository
T = TypeVar("T", bound=Repository)
class BaseService(Generic[T]):
"""Base service that takes a repository."""
def __init__(self, repository: T):
"""Initialize service with repository."""
self.repository = repository
async def add(self, model: T) -> T:
"""Add model to repository."""
return await self.repository.add(model)
async def add_all(self, models: List[T]) -> Sequence[T]:
"""Add a List of models to repository."""
return await self.repository.add_all(models)