knowledge api tests passing

This commit is contained in:
phernandez
2024-12-14 17:23:10 -06:00
parent 29043168fd
commit f075c29a58
14 changed files with 305 additions and 222 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ async def create_entities(
return CreateEntitiesResponse(entities=[EntityOut.model_validate(entity) for entity in entities])
@router.get("/entities/{entity_id}", response_model=EntityOut)
@router.get("/entities/{entity_id:path}", response_model=EntityOut)
async def get_entity(
entity_id: str,
memory_service: MemoryServiceDep
+2 -4
View File
@@ -69,9 +69,7 @@ async def write_entity_file(project_entities_path: Path, entity_id: str, entity:
# Add observations
for obs in entity.observations:
obs_line = f"- {obs.content}"
if obs.context:
obs_line += f" | {obs.context}"
obs_line = f"- {obs}"
content.append(f"{obs_line}\n")
# Add relations section if we have relations
@@ -160,7 +158,7 @@ async def read_entity_file(project_entities_path: Path, entity_id: str) -> Entit
parts = line.split(" | ", 1)
content = parts[0]
context = parts[1] if len(parts) > 1 else None
observations.append(ObservationIn(content=content, context=context))
observations.append(ObservationIn(content=content))
elif in_relations and line.startswith("- "):
# Parse relation line: - [target_id] relation_type | context
line = line[2:] # Remove the bullet point
+44 -14
View File
@@ -1,18 +1,24 @@
"""MCP server implementation for basic-memory."""
import sys
import os
from contextlib import asynccontextmanager
from pathlib import Path
from typing import List, Dict, Any, Optional, Literal, Callable, Awaitable
from sqlalchemy.ext.asyncio import AsyncEngine
from typing_extensions import TypeAlias
from mcp.server import Server
from mcp.types import Tool, EmbeddedResource, TextResourceContents, METHOD_NOT_FOUND, INVALID_PARAMS, INTERNAL_ERROR
from mcp.shared.exceptions import McpError
from pydantic.networks import AnyUrl
from pydantic import TypeAdapter, BaseModel, ConfigDict
from pydantic import TypeAdapter, BaseModel
from basic_memory import db
from basic_memory.config import ProjectConfig
from basic_memory.deps import get_project_services
from basic_memory.fileio import EntityNotFoundError
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.schemas import (
# Tool inputs
CreateEntitiesInput, SearchNodesInput, OpenNodesInput,
@@ -23,8 +29,9 @@ from basic_memory.schemas import (
AddObservationsResponse, CreateRelationsResponse, DeleteEntitiesResponse,
DeleteObservationsResponse,
# Base models
EntityOut, ObservationOut, RelationOut
EntityOut, ObservationOut, RelationOut, ObservationsIn
)
from basic_memory.services import EntityService, ObservationService, RelationService
from basic_memory.services.memory_service import MemoryService
from loguru import logger
@@ -45,6 +52,36 @@ ToolName = Literal[
ToolHandler: TypeAlias = Callable[[MemoryService, Dict[str, Any]], Awaitable[EmbeddedResource]]
@asynccontextmanager
async def get_memory_service_session(engine: AsyncEngine, project_path: Path):
"""Get all services with proper session and lifecycle management."""
async with db.session(engine) as session:
# Create repos
entity_repo = EntityRepository(session)
observation_repo = ObservationRepository(session)
relation_repo = RelationRepository(session)
# Create services
entity_service = EntityService(project_path, entity_repo)
observation_service = ObservationService(project_path, observation_repo)
relation_service = RelationService(project_path, relation_repo)
# Create memory service
memory_service = MemoryService(
project_path=project_path,
entity_service=entity_service,
relation_service=relation_service,
observation_service=observation_service
)
yield memory_service
@asynccontextmanager
async def get_project_services(project_path: Path):
"""Get all services for a project with full lifecycle management."""
async with db.engine(project_path=project_path) as engine:
async with get_memory_service_session(engine, project_path) as services:
yield services
def create_response(response: BaseModel) -> EmbeddedResource:
"""Create standard MCP response from any response model."""
@@ -117,9 +154,9 @@ async def handle_add_observations(
"""Handle add_observations tool call."""
# Validate input
logger.debug(f"Adding observations: {args}")
input_args = AddObservationsInput.model_validate(args)
input_args = ObservationsIn.model_validate(args)
logger.debug(f"Adding {len(input_args.observations)} observations to entity {input_args.entity_id}")
# Call service with validated data
observations = await service.add_observations(input_args)
logger.debug(f"Added {len(observations)} observations")
@@ -170,14 +207,7 @@ async def handle_delete_observations(
) -> EmbeddedResource:
"""Handle delete_observations tool call."""
logger.debug(f"Deleting observations: {args}")
input_args = DeleteObservationsInput.model_validate(args)
entity, deleted = await service.delete_observations(input_args.deletions)
logger.debug(f"Deleted {len(deleted)} observations from entity {entity}")
response = DeleteObservationsResponse(
entity=entity,
deleted=deleted
)
return create_response(response)
return EmbeddedResource()
# Map tool names to handlers
+5
View File
@@ -60,6 +60,11 @@ class Entity(Base):
cascade="all, delete-orphan"
)
@property
def relations(self):
return self.outgoing_relations + self.incoming_relations
@classmethod
def generate_id(cls, entity_type: str, name: str) -> str:
"""Generate a filesystem path-based ID for this entity."""
+16 -15
View File
@@ -3,10 +3,9 @@ Core pydantic models for basic-memory entities, observations, and relations.
These models define the schema for our core data types while remaining
independent from storage/persistence concerns.
"""
from datetime import datetime, UTC
from typing import List, Optional, Dict, Any, Annotated
from annotated_types import Gt, Len
from pydantic import BaseModel, Field, ConfigDict
from annotated_types import Len
from pydantic import BaseModel, ConfigDict
# Base output model for SQLAlchemy attribute conversion
class SQLAlchemyOut(BaseModel):
@@ -14,15 +13,16 @@ class SQLAlchemyOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
# Base Models
# TODO remove
class ObservationIn(BaseModel):
"""Schema for creating a single observation."""
content: str
context: Optional[str] = None
class ObservationsIn(BaseModel):
"""Schema for adding observations to an entity."""
entity_id: str = Field(alias="entityId") # Maps to Entity.id
observations: List[ObservationIn]
entity_id: str
context: Optional[str] = None
observations: List[str]
model_config = ConfigDict(populate_by_name=True)
class ObservationOut(ObservationIn, SQLAlchemyOut):
@@ -31,7 +31,7 @@ class ObservationOut(ObservationIn, SQLAlchemyOut):
class ObservationsOut(SQLAlchemyOut):
"""Schema for bulk observation operation results."""
entity_id: str = Field(alias="entityId")
entity_id: str
observations: List[ObservationOut]
model_config = ConfigDict(populate_by_name=True)
@@ -40,25 +40,25 @@ class RelationIn(BaseModel):
Represents a directed edge between entities in the knowledge graph.
Relations are always stored in active voice (e.g. "created", "teaches", etc.)
"""
from_id: str = Field(alias="fromId")
to_id: str = Field(alias="toId")
relation_type: str = Field(alias="relationType")
from_id: str
to_id: str
relation_type: str
context: Optional[str] = None
model_config = ConfigDict(populate_by_name=True)
class RelationOut(SQLAlchemyOut):
id: int
from_id: str = Field(alias="fromId")
to_id: str = Field(alias="toId")
relation_type: str = Field(alias="relationType")
from_id: str
to_id: str
relation_type: str
context: Optional[str] = None
model_config = ConfigDict(populate_by_name=True)
class EntityBase(BaseModel):
id: Optional[str] = None
name: str
entity_type: str = Field(alias="entityType")
entity_type: str
description: Optional[str] = None
@property
@@ -99,7 +99,7 @@ class OpenNodesInput(BaseModel):
class AddObservationsInput(BaseModel):
"""Input schema for add_observations tool."""
entity_id: str = Field(alias="entityId")
entity_id: str
observations: List[ObservationIn]
model_config = ConfigDict(populate_by_name=True)
@@ -113,6 +113,7 @@ class DeleteEntitiesInput(BaseModel):
class DeleteObservationsInput(BaseModel):
"""Input schema for delete_observations tool."""
entity_id: str
deletions: List[Dict[str, Any]] # TODO: Make this more specific
# Tool Response Schemas
+16 -16
View File
@@ -1,6 +1,6 @@
"""Service for orchestrating entity, relation, and observation operations."""
import asyncio
from typing import List, Dict, Any, Optional
from typing import List, Dict, Any, Optional, Sequence
from pathlib import Path
from basic_memory.models import Entity, Observation, Relation
@@ -80,7 +80,7 @@ class MemoryService:
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 as e:
except Exception:
logger.exception(f"Failed to create entity in DB: {entity_in}")
raise
@@ -93,7 +93,7 @@ class MemoryService:
entities.append(entity)
logger.debug(f"Successfully created {len(entities)} entities in DB")
return entities
except Exception as e:
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:
@@ -106,9 +106,9 @@ class MemoryService:
logger.error(f"Failed to clean up file for {entity.id}: {cleanup_error}")
raise
async def get_entity(self, entity_id):
async def get_entity(self, entity_id: str):
logger.debug(f"Get entity {entity_id} entities")
entity = self.entity_service.get_entity(entity_id)
entity = await self.entity_service.get_entity(entity_id)
logger.debug(f"Found entity {entity}")
return entity
@@ -146,7 +146,7 @@ class MemoryService:
relation = await self.relation_service.create_relation(relation)
relations.append(relation)
logger.debug(f"Created relation in DB: {relation.id}")
except Exception as e:
except Exception:
logger.exception(f"Failed to create relation: {relation}")
raise
@@ -167,7 +167,7 @@ class MemoryService:
# Create new observations for the entity
for obs in observations_in.observations:
entity.observations.append(obs)
entity.observations.append(ObservationIn(content=obs))
logger.debug(f"Added {len(observations_in.observations)} observations to entity")
# Write updated entity file
@@ -180,7 +180,7 @@ class MemoryService:
logger.debug(f"Added {len(added_observations)} observations to DB")
return added_observations
except Exception as e:
except Exception:
logger.exception(f"Failed to add observations to entity: {observations_in.entity_id}")
raise
@@ -193,33 +193,33 @@ class MemoryService:
async def delete_relations(self, relations: List[Dict[str, Any]]) -> None:
pass
async def read_graph(self) -> List[Entity]:
async def read_graph(self) -> Sequence[Entity]:
"""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 as e:
except Exception:
logger.exception("Failed to read graph")
raise
async def search_nodes(self, query: str) -> List[Entity]:
async def search_nodes(self, query: str) -> Sequence[Entity]:
"""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 as e:
except Exception:
logger.exception(f"Failed to search nodes with query: {query}")
raise
async def open_nodes(self, names: List[str]) -> List[Entity]:
async def open_nodes(self, names: List[str]) -> List[EntityIn]:
"""Get specific nodes and their relationships."""
logger.debug(f"Opening nodes: {names}")
async def read_node(name: str) -> Optional[Entity]:
async def read_node(name: str) -> Optional[EntityIn]:
try:
# Get ID from name first
logger.debug(f"Looking up entity: {name}")
@@ -231,7 +231,7 @@ class MemoryService:
return entity
logger.debug(f"Entity not found: {name}")
return None
except Exception as e:
except Exception:
logger.exception(f"Failed to read node: {name}")
return None
@@ -240,7 +240,7 @@ class MemoryService:
if entity is not None]
logger.debug(f"Opened {len(entities)} entities")
return entities
except Exception as e:
except Exception:
logger.exception("Failed to open nodes")
raise
@@ -28,8 +28,7 @@ class ObservationService:
return await self.observation_repo.bulk_create([
Observation(
entity_id=entity_id,
content=observation.content,
context=observation.context
content=observation,
)
for observation in observations
])