mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
knowledge api tests passing
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user