mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
update main with changes
This commit is contained in:
@@ -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():
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""API routers."""
|
||||
|
||||
from . import knowledge
|
||||
|
||||
__all__ = ["knowledge"]
|
||||
__all__ = ["knowledge"]
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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)]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user