change entity.path_id to entity.permalink

This commit is contained in:
phernandez
2025-01-12 16:47:21 -06:00
parent 1f374e0133
commit c5d21276de
52 changed files with 714 additions and 687 deletions
@@ -5,7 +5,10 @@ from loguru import logger
from basic_memory.deps import (
EntityServiceDep,
get_search_service, RelationServiceDep, ObservationServiceDep, FileServiceDep,
get_search_service,
RelationServiceDep,
ObservationServiceDep,
FileServiceDep,
)
from basic_memory.schemas import (
CreateEntityRequest,
@@ -47,9 +50,9 @@ async def create_entities(
)
@router.put("/entities/{path_id:path}", response_model=EntityResponse)
@router.put("/entities/{permalink:path}", response_model=EntityResponse)
async def update_entity(
path_id: PathId,
permalink: PathId,
data: UpdateEntityRequest,
background_tasks: BackgroundTasks,
entity_service: EntityServiceDep,
@@ -61,7 +64,7 @@ async def update_entity(
update_data = data.model_dump(exclude_none=True)
# Update the entity
updated_entity = await entity_service.update_entity(path_id, **update_data)
updated_entity = await entity_service.update_entity(permalink, **update_data)
# Reindex since content changed
await search_service.index_entity(updated_entity, background_tasks=background_tasks)
@@ -69,7 +72,7 @@ async def update_entity(
return EntityResponse.model_validate(updated_entity)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Entity with {path_id} not found")
raise HTTPException(status_code=404, detail=f"Entity with {permalink} not found")
@router.post("/relations", response_model=EntityListResponse)
@@ -99,9 +102,9 @@ async def add_observations(
search_service=Depends(get_search_service),
) -> EntityResponse:
"""Add observations to an entity and update search index."""
logger.debug(f"Adding observations to entity: {data.path_id}")
logger.debug(f"Adding observations to entity: {data.permalink}")
updated_entity = await observation_service.add_observations(
data.path_id, data.observations, data.context
data.permalink, data.observations, data.context
)
# Reindex the entity with new observations
@@ -113,22 +116,22 @@ async def add_observations(
## Read endpoints
@router.get("/entities/{path_id:path}", response_model=EntityResponse)
@router.get("/entities/{permalink:path}", response_model=EntityResponse)
async def get_entity(
entity_service: EntityServiceDep,
file_service: FileServiceDep,
path_id: PathId,
content: bool = False, # New parameter
entity_service: EntityServiceDep,
file_service: FileServiceDep,
permalink: PathId,
content: bool = False, # New parameter
) -> EntityResponse:
"""Get a specific entity by ID.
Args:
path_id: Entity path ID
permalink: Entity path ID
content: If True, include full file content
:param entity_service: EntityService
"""
try:
entity = await entity_service.get_by_path_id(path_id)
entity = await entity_service.get_by_permalink(permalink)
entity_response = EntityResponse.model_validate(entity)
if content: # Load content if requested
@@ -137,14 +140,15 @@ async def get_entity(
return entity_response
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Entity with {path_id} not found")
raise HTTPException(status_code=404, detail=f"Entity with {permalink} not found")
@router.post("/nodes", response_model=EntityListResponse)
async def open_nodes(
data: OpenNodesRequest, entity_service: EntityServiceDep
) -> EntityListResponse:
"""Open specific nodes"""
entities = await entity_service.open_nodes(data.path_ids)
entities = await entity_service.open_nodes(data.permalinks)
return EntityListResponse(
entities=[EntityResponse.model_validate(entity) for entity in entities]
)
@@ -161,11 +165,11 @@ async def delete_entities(
search_service=Depends(get_search_service),
) -> DeleteEntitiesResponse:
"""Delete entities and remove from search index."""
deleted = await entity_service.delete_entities(data.path_ids)
deleted = await entity_service.delete_entities(data.permalinks)
# Remove each deleted entity from search index
for path_id in data.path_ids:
background_tasks.add_task(search_service.delete_by_path_id, path_id)
for permalink in data.permalinks:
background_tasks.add_task(search_service.delete_by_permalink, permalink)
return DeleteEntitiesResponse(deleted=deleted)
@@ -178,8 +182,8 @@ async def delete_observations(
search_service=Depends(get_search_service),
) -> EntityResponse:
"""Delete observations and update search index."""
path_id = data.path_id
updated_entity = await observation_service.delete_observations(path_id, data.observations)
permalink = data.permalink
updated_entity = await observation_service.delete_observations(permalink, data.observations)
# Reindex the entity since observations changed
await search_service.index_entity(updated_entity, background_tasks=background_tasks)
+6 -8
View File
@@ -24,14 +24,12 @@ class EntityParser:
"""Parser for markdown files into Entity objects."""
def __init__(self, base_path: Path):
"""Initialize parser with base path for relative path_id generation."""
"""Initialize parser with base path for relative permalink generation."""
self.base_path = base_path.resolve()
self.md = (MarkdownIt()
.use(observation_plugin)
.use(relation_plugin))
self.md = MarkdownIt().use(observation_plugin).use(relation_plugin)
def get_path_id(self, file_path: Path) -> str:
"""Get path_id from file path relative to base_path.
def get_permalink(self, file_path: Path) -> str:
"""Get permalink from file path relative to base_path.
Example:
base_path: /project/root
@@ -61,13 +59,13 @@ class EntityParser:
post = frontmatter.load(str(file_path))
# Extract or generate required fields
path_id = post.metadata.get("id") or self.get_path_id(file_path)
permalink = post.metadata.get("id") or self.get_permalink(file_path)
stats = file_path.stat()
# Parse frontmatter
entity_frontmatter = EntityFrontmatter(
type=str(post.metadata.get("type", "note")),
id=path_id,
id=permalink,
title=str(post.metadata.get("title", file_path.name)),
created=self.parse_date(post.metadata.get("created"))
or datetime.fromtimestamp(stats.st_ctime),
@@ -16,7 +16,7 @@ class KnowledgeWriter:
async def format_frontmatter(self, entity: EntityModel) -> dict:
"""Generate frontmatter metadata for entity."""
frontmatter = {
"id": entity.path_id,
"id": entity.permalink,
"type": entity.entity_type,
"created": entity.created_at.isoformat(),
"modified": entity.updated_at.isoformat(),
+16 -16
View File
@@ -30,7 +30,7 @@ print(f"Relations changed: {activity.summary.relation_changes}")
print("\\nMost active paths:")
for path in activity.summary.most_active_paths:
print(f"- {path}")
"""
""",
},
{
"name": "Document Changes",
@@ -44,9 +44,9 @@ docs = await get_recent_activity(
# Show document evolution chronologically
for change in sorted(docs.changes, key=lambda x: x.timestamp):
print(f"{change.timestamp}: {change.path_id}")
print(f"{change.timestamp}: {change.permalink}")
print(f" {change.change_type}: {change.summary}")
"""
""",
},
{
"name": "Knowledge Evolution",
@@ -69,16 +69,16 @@ for type_, changes in changes_by_type.items():
entity_changes = defaultdict(int)
for change in weekly.changes:
if change.activity_type == "entity":
entity_changes[change.path_id] += 1
entity_changes[change.permalink] += 1
print("\\nMost active entities:")
for path_id, count in sorted(
for permalink, count in sorted(
entity_changes.items(),
key=lambda x: x[1],
reverse=True
)[:5]:
print(f"- {path_id}: {count} changes")
"""
print(f"- {permalink}: {count} changes")
""",
},
{
"name": "Context Building",
@@ -89,14 +89,14 @@ activity = await get_recent_activity(timeframe="1d")
# Extract changed entities for deeper analysis
entity_ids = [
change.path_id for change in activity.changes
change.permalink for change in activity.changes
if change.activity_type == "entity"
]
# Load full entity details
if entity_ids:
entities = await open_nodes(
request=OpenNodesRequest(path_ids=entity_ids)
request=OpenNodesRequest(permalinks=entity_ids)
)
# Analyze recent development focus
@@ -112,27 +112,27 @@ if entity_ids:
print(f"\\n{name}:")
for obs in observations:
print(f"- {obs.content}")
"""
}
""",
},
],
output_model=RecentActivity
output_model=RecentActivity,
)
async def get_recent_activity(
timeframe: str = "1d",
activity_types: Optional[List[ActivityType]] = None,
) -> RecentActivity:
"""Track changes across the knowledge base.
Args:
timeframe: Time window to analyze ("1h", "1d", "1w")
activity_types: Optional list of types to filter by
Returns:
RecentActivity object with changes and summary statistics
"""
logger.debug(f"Getting recent activity (timeframe={timeframe}, types={activity_types})")
# Build params
# Build params
params = {
"timeframe": timeframe,
}
@@ -141,4 +141,4 @@ async def get_recent_activity(
# Get activity
response = await client.get("/activity/recent", params=params)
return RecentActivity.model_validate(response.json())
return RecentActivity.model_validate(response.json())
+28 -28
View File
@@ -42,7 +42,7 @@ await create_entities({
]
}]
})
"""
""",
},
{
"name": "Create Feature",
@@ -61,10 +61,10 @@ await create_entities({
]
}]
})
"""
}
""",
},
],
output_model=EntityListResponse
output_model=EntityListResponse,
)
async def create_entities(request: CreateEntityRequest) -> EntityListResponse:
"""Create new entities in the knowledge graph."""
@@ -90,7 +90,7 @@ await create_relations({
"context": "Needs storage for search indexes"
}]
})
"""
""",
},
{
"name": "Link Implementation",
@@ -105,10 +105,10 @@ await create_relations({
"context": "Primary search implementation"
}]
})
"""
}
""",
},
],
output_model=EntityListResponse
output_model=EntityListResponse,
)
async def create_relations(request: CreateRelationsRequest) -> EntityListResponse:
"""Create relations between existing entities."""
@@ -141,29 +141,29 @@ deps = [rel for rel in component.relations
print("\\nDependencies:")
for dep in deps:
print(f"- {dep.to_id}")
"""
""",
}
],
output_model=EntityResponse
output_model=EntityResponse,
)
async def get_entity(path_id: PathId, content: bool = False) -> EntityResponse:
"""Get a specific entity by its path_id.
async def get_entity(permalink: PathId, content: bool = False) -> EntityResponse:
"""Get a specific entity by its permalink.
Args:
path_id: Path identifier for the entity
permalink: Path identifier for the entity
content: If True, includes the full markdown content of the entity
"""
try:
url = f"/knowledge/entities/{path_id}"
url = f"/knowledge/entities/{permalink}"
params = {"content": "true"} if content else {}
response = await client.get(url, params=params)
if response.status_code == 404:
raise EntityNotFoundError(f"Entity not found: {path_id}")
raise EntityNotFoundError(f"Entity not found: {permalink}")
response.raise_for_status()
return EntityResponse.model_validate(response.json())
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
raise EntityNotFoundError(f"Entity not found: {path_id}")
raise EntityNotFoundError(f"Entity not found: {permalink}")
raise
@@ -177,7 +177,7 @@ async def get_entity(path_id: PathId, content: bool = False) -> EntityResponse:
# Add technical observations
await add_observations(
request=AddObservationsRequest(
path_id="component/search_service",
permalink="component/search_service",
context="Performance optimization",
observations=[
ObservationCreate(
@@ -195,10 +195,10 @@ await add_observations(
]
)
)
"""
""",
}
],
output_model=EntityResponse
output_model=EntityResponse,
)
async def add_observations(request: AddObservationsRequest) -> EntityResponse:
"""Add observations to an existing entity."""
@@ -217,17 +217,17 @@ async def add_observations(request: AddObservationsRequest) -> EntityResponse:
# Remove old implementation notes
await delete_observations(
request=DeleteObservationsRequest(
path_id="component/indexer",
permalink="component/indexer",
observations=[
"Using old indexing algorithm",
"Temporary workaround for issue #123"
]
)
)
"""
""",
}
],
output_model=EntityResponse
output_model=EntityResponse,
)
async def delete_observations(request: DeleteObservationsRequest) -> EntityResponse:
"""Delete specific observations from an entity."""
@@ -253,10 +253,10 @@ await delete_relations(
}]
)
)
"""
""",
}
],
output_model=EntityListResponse
output_model=EntityListResponse,
)
async def delete_relations(request: DeleteRelationsRequest) -> EntityListResponse:
"""Delete relations between entities."""
@@ -275,16 +275,16 @@ async def delete_relations(request: DeleteRelationsRequest) -> EntityListRespons
# Remove deprecated components
await delete_entities(
request=DeleteEntitiesRequest(
path_ids=[
permalinks=[
"component/old_service",
"test/obsolete_test"
]
)
)
"""
""",
}
],
output_model=Dict[str, bool]
output_model=Dict[str, bool],
)
async def delete_entities(request: DeleteEntitiesRequest) -> DeleteEntitiesResponse:
"""Delete entities from the knowledge graph."""
+11 -12
View File
@@ -28,7 +28,7 @@ by_type = defaultdict(list)
for result in results.results:
meta = result.metadata
path = result.path_id
path = result.permalink
# Group by status if available
if "status" in meta:
@@ -78,7 +78,7 @@ sorted_results = sorted(
print("Recent Changes:")
for result in sorted_results:
print(f"\\n{result.path_id}")
print(f"\\n{result.permalink}")
print(f"Type: {result.type}")
print(f"Score: {result.score:.2f}")
if "updated_at" in result.metadata:
@@ -112,7 +112,7 @@ docs.sort(key=lambda x: x.score)
print("Technical Documentation:")
for doc in docs:
print(f"\\n{doc.path_id}")
print(f"\\n{doc.permalink}")
if "title" in doc.metadata:
print(f"Title: {doc.metadata['title']}")
print(f"Score: {doc.score:.2f}")
@@ -125,7 +125,7 @@ for doc in docs:
"description": "Find content related to a specific entity",
"code": """
# First get the entity to extract key terms
entity = await get_entity(path_id="component/memory_service")
entity = await get_entity(permalink="component/memory_service")
if entity:
# Build search terms from entity info
@@ -143,12 +143,12 @@ if entity:
)
# Filter out the original entity and sort by relevance
related = [r for r in results.results if r.path_id != entity["path_id"]]
related = [r for r in results.results if r.permalink != entity["permalink"]]
related.sort(key=lambda x: x.score)
print(f"Content Related to {entity['name']}:")
for result in related[:5]: # Top 5 most relevant
print(f"\\n{result.path_id}")
print(f"\\n{result.permalink}")
print(f"Type: {result.type}")
print(f"Score: {result.score:.2f}")
""",
@@ -156,7 +156,6 @@ if entity:
],
)
async def search(query: SearchQuery) -> SearchResponse:
"""Search across all content in basic-memory.
Args:
@@ -175,7 +174,7 @@ async def search(query: SearchQuery) -> SearchResponse:
@mcp.tool(
category="search",
description="Load multiple entities by their path_ids in a single request",
description="Load multiple entities by their permalinks in a single request",
examples=[
{
"name": "Load and Analyze Entity Context",
@@ -192,9 +191,9 @@ results = await search(
if results.results:
# Load full context for found entities
path_ids = [r.path_id for r in results.results]
permalinks = [r.permalink for r in results.results]
context = await open_nodes(
request=OpenNodesRequest(path_ids=path_ids)
request=OpenNodesRequest(permalinks=permalinks)
)
# Analyze relationships
@@ -219,10 +218,10 @@ if results.results:
],
)
async def open_nodes(request: OpenNodesRequest) -> EntityListResponse:
"""Load multiple entities by their path_ids.
"""Load multiple entities by their permalinks.
Args:
request: OpenNodesRequest containing list of path_ids to load
request: OpenNodesRequest containing list of permalinks to load
Returns:
EntityListResponse containing complete details for each requested entity
+7 -10
View File
@@ -13,7 +13,6 @@ from sqlalchemy import (
DateTime,
Index,
JSON,
CheckConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -27,14 +26,14 @@ class Entity(Base):
Entities represent semantic nodes maintained by the AI layer. Each entity:
- Has a unique numeric ID (database-generated)
- Maps to a file on disk
- Maps to a file on disk
- Maintains a checksum for change detection
- Tracks both source file and semantic properties
"""
__tablename__ = "entity"
__table_args__ = (
UniqueConstraint("path_id", name="uix_entity_path_id"), # Make path_id unique
UniqueConstraint("permalink", name="uix_entity_permalink"), # Make permalink unique
Index("ix_entity_type", "entity_type"),
Index("ix_entity_created_at", "created_at"), # For timeline queries
Index("ix_entity_updated_at", "updated_at"), # For timeline queries
@@ -48,12 +47,12 @@ class Entity(Base):
content_type: Mapped[str] = mapped_column(String)
# Normalized path for URIs
path_id: Mapped[str] = mapped_column(String, unique=True, index=True)
permalink: Mapped[str] = mapped_column(String, unique=True, index=True)
# Actual filesystem relative path
file_path: Mapped[str] = mapped_column(String, unique=True, index=True)
# checksum of file
checksum: Mapped[Optional[str]] = mapped_column(String, nullable=True)
# Content summary
summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
@@ -96,6 +95,7 @@ class ObservationCategory(str, Enum):
ISSUE = "issue"
TODO = "todo"
class Observation(Base):
"""
An observation about an entity.
@@ -122,12 +122,9 @@ class Observation(Base):
)
context: Mapped[str] = mapped_column(Text, nullable=True)
tags: Mapped[Optional[list[str]]] = mapped_column(
JSON,
nullable=True,
default=list,
server_default='[]'
JSON, nullable=True, default=list, server_default="[]"
)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"))
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=text("CURRENT_TIMESTAMP"), onupdate=text("CURRENT_TIMESTAMP")
+2 -2
View File
@@ -6,10 +6,10 @@ from sqlalchemy import DDL
CREATE_SEARCH_INDEX = DDL("""
CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
content, -- Searchable text content
path_id UNINDEXED, -- Link to entity/document (must be unique)
permalink UNINDEXED, -- Link to entity/document (must be unique)
file_path UNINDEXED, -- Filesystem path
type UNINDEXED, -- 'entity' or 'document'
metadata UNINDEXED, -- JSON with timestamps, types, etc.
tokenize='porter unicode61' -- Enable stemming + unicode
);
""")
""")
@@ -2,7 +2,7 @@
from typing import List, Optional, Sequence
from sqlalchemy import select, or_, asc, desc
from sqlalchemy import select, or_, asc
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from sqlalchemy.orm import selectinload
from sqlalchemy.orm.interfaces import LoaderOption
@@ -18,16 +18,16 @@ class EntityRepository(Repository[Entity]):
"""Initialize with session maker."""
super().__init__(session_maker, Entity)
async def get_by_path_id(self, path_id: str) -> Optional[Entity]:
"""Get entity by path_id."""
query = self.select().where(Entity.path_id == path_id).options(*self.get_load_options())
async def get_by_permalink(self, permalink: str) -> Optional[Entity]:
"""Get entity by permalink."""
query = self.select().where(Entity.permalink == permalink).options(*self.get_load_options())
return await self.find_one(query)
async def list_entities(
self,
entity_type: Optional[str] = None,
sort_by: Optional[str] = "updated_at",
include_related: bool = False,
self,
entity_type: Optional[str] = None,
sort_by: Optional[str] = "updated_at",
include_related: bool = False,
) -> Sequence[Entity]:
"""List all entities, optionally filtered by type and sorted."""
query = self.select()
@@ -44,8 +44,12 @@ class EntityRepository(Repository[Entity]):
query = query.where(
or_(
Entity.entity_type == entity_type,
Entity.outgoing_relations.any(Relation.to_entity.has(entity_type=entity_type)),
Entity.incoming_relations.any(Relation.from_entity.has(entity_type=entity_type))
Entity.outgoing_relations.any(
Relation.to_entity.has(entity_type=entity_type)
),
Entity.incoming_relations.any(
Relation.from_entity.has(entity_type=entity_type)
),
)
)
else:
@@ -111,28 +115,30 @@ class EntityRepository(Repository[Entity]):
selectinload(Entity.incoming_relations).selectinload(Relation.to_entity),
]
async def find_by_path_ids(self, path_ids: List[str]) -> Sequence[Entity]:
"""Find multiple entities by their path_id."""
async def find_by_permalinks(self, permalinks: List[str]) -> Sequence[Entity]:
"""Find multiple entities by their permalink."""
# Handle empty input explicitly
if not path_ids:
if not permalinks:
return []
# Use existing select pattern
query = self.select().options(*self.get_load_options()).where(Entity.path_id.in_(path_ids))
query = (
self.select().options(*self.get_load_options()).where(Entity.permalink.in_(permalinks))
)
result = await self.execute_query(query)
return list(result.scalars().all())
async def delete_by_path_ids(self, path_ids: List[str]) -> int:
"""Delete multiple entities by path_id."""
async def delete_by_permalinks(self, permalinks: List[str]) -> int:
"""Delete multiple entities by permalink."""
# Handle empty input explicitly
if not path_ids:
if not permalinks:
return 0
# Find matching entities
entities = await self.find_by_path_ids(path_ids)
entities = await self.find_by_permalinks(permalinks)
if not entities:
return 0
@@ -1,4 +1,5 @@
"""Repository for managing Relation objects."""
from sqlalchemy import and_, delete
from typing import Sequence, List, Optional
@@ -18,7 +19,9 @@ class RelationRepository(Repository[Relation]):
def __init__(self, session_maker: async_sessionmaker):
super().__init__(session_maker, Relation)
async def find_relation(self, from_path_id: str, to_path_id: str, relation_type: str) -> Optional[Relation]:
async def find_relation(
self, from_permalink: str, to_permalink: str, relation_type: str
) -> Optional[Relation]:
"""Find a relation by its from and to path IDs."""
from_entity = aliased(Entity)
to_entity = aliased(Entity)
@@ -29,9 +32,9 @@ class RelationRepository(Repository[Relation]):
.join(to_entity, Relation.to_id == to_entity.id)
.where(
and_(
from_entity.path_id == from_path_id,
to_entity.path_id == to_path_id,
Relation.relation_type == relation_type
from_entity.permalink == from_permalink,
to_entity.permalink == to_permalink,
Relation.relation_type == relation_type,
)
)
)
@@ -62,9 +65,7 @@ class RelationRepository(Repository[Relation]):
as these are the ones owned by this entity's markdown file.
"""
async with db.scoped_session(self.session_maker) as session:
await session.execute(
delete(Relation).where(Relation.from_id == entity_id)
)
await session.execute(delete(Relation).where(Relation.from_id == entity_id))
def get_load_options(self) -> List[LoaderOption]:
return [selectinload(Relation.from_entity), selectinload(Relation.to_entity)]
@@ -2,19 +2,17 @@
import json
from typing import List, Optional
from datetime import datetime
from loguru import logger
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from basic_memory import db
from basic_memory.repository.repository import Repository
from basic_memory.schemas.search import SearchQuery, SearchResult, SearchItemType
from basic_memory.models.search import CREATE_SEARCH_INDEX
class SearchRepository():
class SearchRepository:
"""Repository for search index operations."""
def __init__(self, session_maker: async_sessionmaker[AsyncSession]):
@@ -27,9 +25,7 @@ class SearchRepository():
await session.commit()
async def search(
self,
query: SearchQuery,
context: Optional[List[str]] = None
self, query: SearchQuery, context: Optional[List[str]] = None
) -> List[SearchResult]:
"""Search across all indexed content."""
conditions = []
@@ -48,23 +44,19 @@ class SearchRepository():
# Handle entity type filter
if query.entity_types:
entity_type_list = ", ".join(f"'{t}'" for t in query.entity_types)
conditions.append(
f"json_extract(metadata, '$.entity_type') IN ({entity_type_list})"
)
conditions.append(f"json_extract(metadata, '$.entity_type') IN ({entity_type_list})")
# Handle date filter
if query.after_date:
params["after_date"] = query.after_date
conditions.append(
"json_extract(metadata, '$.created_at') > :after_date"
)
conditions.append("json_extract(metadata, '$.created_at') > :after_date")
# Build WHERE clause
where_clause = " AND ".join(conditions) if conditions else "1=1"
sql = f"""
SELECT
path_id,
permalink,
file_path,
type,
metadata,
@@ -80,11 +72,11 @@ class SearchRepository():
return [
SearchResult(
path_id=row.path_id,
permalink=row.permalink,
file_path=row.file_path,
type=SearchItemType(row.type), # Convert string to enum
score=row.score,
metadata=json.loads(row.metadata)
metadata=json.loads(row.metadata),
)
for row in rows
]
@@ -92,44 +84,44 @@ class SearchRepository():
async def index_item(
self,
content: str,
path_id: str,
permalink: str,
file_path: str,
type: SearchItemType, # Now accepts enum
metadata: dict
metadata: dict,
):
"""Index or update a single item."""
async with db.scoped_session(self.session_maker) as session:
# Delete existing record if any
await session.execute(
text("DELETE FROM search_index WHERE path_id = :path_id"),
{"path_id": path_id}
text("DELETE FROM search_index WHERE permalink = :permalink"),
{"permalink": permalink},
)
# Insert new record
await session.execute(
text("""
INSERT INTO search_index (
content, path_id, file_path, type, metadata
content, permalink, file_path, type, metadata
) VALUES (
:content, :path_id, :file_path, :type, :metadata
:content, :permalink, :file_path, :type, :metadata
)
"""),
{
"content": content,
"path_id": path_id,
"permalink": permalink,
"file_path": file_path,
"type": type.value, # Store the string value
"metadata": json.dumps(metadata)
}
"metadata": json.dumps(metadata),
},
)
logger.debug(f"indexed {path_id}")
logger.debug(f"indexed {permalink}")
await session.commit()
async def delete_by_path_id(self, path_id: str):
async def delete_by_permalink(self, permalink: str):
"""Delete an item from the search index."""
async with db.scoped_session(self.session_maker) as session:
await session.execute(
text("DELETE FROM search_index WHERE path_id = :path_id"),
{"path_id": path_id}
text("DELETE FROM search_index WHERE permalink = :permalink"),
{"permalink": permalink},
)
await session.commit()
await session.commit()
+17 -13
View File
@@ -7,34 +7,34 @@ from pydantic import BaseModel, Field
class TimeFrame:
"""Represents a time period for querying activity."""
def __init__(self, timeframe_str: str):
"""Parse timeframe string (e.g., '1d', '2w', '1m')"""
if not timeframe_str or len(timeframe_str) < 2:
raise ValueError("Invalid timeframe format")
try:
self.value = int(timeframe_str[:-1])
self.unit = timeframe_str[-1]
except ValueError:
raise ValueError("Invalid timeframe format")
if self.unit not in ['h', 'd', 'w', 'm']:
if self.unit not in ["h", "d", "w", "m"]:
raise ValueError("Invalid timeframe unit")
if self.value < 1:
raise ValueError("Timeframe value must be positive")
@property
def to_timedelta(self) -> timedelta:
"""Convert to Python timedelta."""
if self.unit == 'h':
if self.unit == "h":
return timedelta(hours=self.value)
elif self.unit == 'd':
elif self.unit == "d":
return timedelta(days=self.value)
elif self.unit == 'w':
elif self.unit == "w":
return timedelta(weeks=self.value)
elif self.unit == 'm':
elif self.unit == "m":
# Approximate month as 30 days
return timedelta(days=self.value * 30)
else:
@@ -43,12 +43,14 @@ class TimeFrame:
class ActivityType(str, Enum):
"""Types of activities that can be tracked."""
ENTITY = "entity"
RELATION = "relation"
class ChangeType(str, Enum):
"""Types of changes that can occur."""
CREATED = "created"
UPDATED = "updated"
DELETED = "deleted"
@@ -56,26 +58,28 @@ class ChangeType(str, Enum):
class ActivityChange(BaseModel):
"""Represents a single change in the system."""
activity_type: ActivityType
change_type: ChangeType
timestamp: datetime
path_id: str
permalink: str
summary: str
content: Optional[str] = None
class ActivitySummary(BaseModel):
"""Summary statistics about recent activity."""
entity_changes: int = Field(default=0, description="Number of entity changes")
relation_changes: int = Field(default=0, description="Number of relation changes")
most_active_paths: List[str] = Field(
default_factory=list,
description="List of most frequently changed paths"
default_factory=list, description="List of most frequently changed paths"
)
class RecentActivity(BaseModel):
"""Complete activity report."""
timeframe: str
changes: List[ActivityChange] = Field(default_factory=list)
summary: ActivitySummary
+30 -30
View File
@@ -10,6 +10,7 @@ Key Concepts:
3. Observations are atomic facts/notes about an entity
4. Everything is stored in both SQLite and markdown files
"""
import mimetypes
import re
from enum import Enum
@@ -29,7 +30,7 @@ def to_snake_case(name: str) -> str:
Memory_Service -> memory_service
"""
name = name.strip()
# Replace spaces and hyphens and . with underscores
s1 = re.sub(r"[\s\-\\.]", "_", name)
@@ -48,7 +49,6 @@ def validate_path_format(path: str) -> str:
return path
class ObservationCategory(str, Enum):
"""Categories for structuring observations.
@@ -62,6 +62,7 @@ class ObservationCategory(str, Enum):
Categories are case-insensitive for easier use.
"""
TECH = "tech"
DESIGN = "design"
FEATURE = "feature"
@@ -76,16 +77,16 @@ class ObservationCategory(str, Enum):
return cls(value.lower())
except ValueError:
return None
PathId = Annotated[str, BeforeValidator(to_snake_case), BeforeValidator(validate_path_format)]
"""Unique identifier in format '{path}/{normalized_name}'."""
Observation = Annotated[
str,
str,
BeforeValidator(str.strip), # Clean whitespace
MinLen(1), # Ensure non-empty after stripping
MaxLen(1000) # Keep reasonable length
MaxLen(1000), # Keep reasonable length
]
"""A single piece of information about an entity. Must be non-empty and under 1000 characters.
"""
@@ -94,23 +95,22 @@ EntityType = Annotated[str, BeforeValidator(to_snake_case), MinLen(1), MaxLen(20
"""Classification of entity (e.g., 'person', 'project', 'concept'). """
ALLOWED_CONTENT_TYPES = {
'text/markdown',
'text/plain',
'application/pdf',
'image/jpeg',
'image/png',
'image/svg+xml',
"text/markdown",
"text/plain",
"application/pdf",
"image/jpeg",
"image/png",
"image/svg+xml",
}
ContentType = Annotated[
str,
str,
BeforeValidator(str.lower),
Field(pattern=r'^[\w\-\+\.]+/[\w\-\+\.]+$'),
Field(json_schema_extra={"examples": list(ALLOWED_CONTENT_TYPES)})
Field(pattern=r"^[\w\-\+\.]+/[\w\-\+\.]+$"),
Field(json_schema_extra={"examples": list(ALLOWED_CONTENT_TYPES)}),
]
RelationType = Annotated[str, BeforeValidator(to_snake_case), MinLen(1), MaxLen(200)]
"""Type of relationship between entities. Always use active voice present tense."""
@@ -119,7 +119,7 @@ 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_path_id represents the source or actor entity, while to_path_id represents the target
The from_permalink represents the source or actor entity, while to_permalink represents the target
or recipient entity.
"""
@@ -133,7 +133,7 @@ class Entity(BaseModel):
"""Represents a node in our knowledge graph - could be a person, project, concept, etc.
Each entity has:
- A title
- A title
- An entity type (for classification)
- A list of observations (facts/notes about the entity)
- Optional relations to other entities
@@ -147,32 +147,32 @@ class Entity(BaseModel):
summary: Optional[str] = None
content_type: ContentType = Field(
description="MIME type of the content (e.g. text/markdown, image/jpeg)",
examples=["text/markdown", "image/jpeg"]
)
examples=["text/markdown", "image/jpeg"],
)
observations: List[Observation] = []
@property
def path_id(self) -> PathId:
def permalink(self) -> PathId:
"""Get the path ID in format {snake_case_title}."""
normalized_name = to_snake_case(self.title)
return normalized_name
@property
def file_path(self):
"""Get the file path for this entity based on its path_id."""
return f"{self.path_id}.md"
"""Get the file path for this entity based on its permalink."""
return f"{self.permalink}.md"
@model_validator(mode='before')
@model_validator(mode="before")
@classmethod
def infer_content_type(cls, data: Dict) -> Dict:
"""Infer content_type from file_path if not provided."""
if 'content_type' not in data:
# Get path from either file_path or construct from path_id
file_path = data.get('file_path') or f"{data.get('name')}.md"
if "content_type" not in data:
# Get path from either file_path or construct from permalink
file_path = data.get("file_path") or f"{data.get('name')}.md"
if not file_path:
raise ValidationError("Either file_path or name must be provided")
mime_type, _ = mimetypes.guess_type(file_path)
data['content_type'] = mime_type or 'text/plain'
data["content_type"] = mime_type or "text/plain"
return data
return data
+2 -2
View File
@@ -34,7 +34,7 @@ class DeleteEntitiesRequest(BaseModel):
4. Deletes the corresponding markdown file
"""
path_ids: Annotated[List[PathId], MinLen(1)]
permalinks: Annotated[List[PathId], MinLen(1)]
class DeleteRelationsRequest(BaseModel):
@@ -56,5 +56,5 @@ class DeleteObservationsRequest(BaseModel):
match exactly for deletion.
"""
path_id: PathId
permalink: PathId
observations: Annotated[List[Observation], MinLen(1)]
+15 -8
View File
@@ -3,16 +3,23 @@
from typing import List, Optional, Annotated, Dict, Any
from annotated_types import MaxLen, MinLen
from pydantic import BaseModel, StringConstraints
from pydantic import BaseModel
from basic_memory.schemas.base import Observation, Entity, Relation, PathId, ObservationCategory, EntityType
from basic_memory.schemas.base import (
Observation,
Entity,
Relation,
PathId,
ObservationCategory,
EntityType,
)
class ObservationCreate(BaseModel):
"""A single observation with category, content, and optional context."""
category: ObservationCategory = ObservationCategory.NOTE
content: Observation
content: Observation
class AddObservationsRequest(BaseModel):
@@ -23,7 +30,7 @@ class AddObservationsRequest(BaseModel):
to our understanding of the entity.
"""
path_id: PathId
permalink: PathId
context: Optional[str] = None
observations: List[ObservationCreate]
@@ -34,7 +41,7 @@ class CreateEntityRequest(BaseModel):
Entities represent nodes in the knowledge graph. They can be created
with initial observations and optional descriptions. Entity IDs are
automatically generated from the type and name.
Observations will be assigned the default category of 'note'.
"""
@@ -81,21 +88,21 @@ class OpenNodesRequest(BaseModel):
discovered through search.
"""
path_ids: Annotated[List[PathId], MinLen(1)]
permalinks: Annotated[List[PathId], MinLen(1)]
class CreateRelationsRequest(BaseModel):
relations: List[Relation]
## update
class UpdateEntityRequest(BaseModel):
"""Request to update an existing entity."""
title: Optional[str] = None
entity_type: Optional[EntityType] = None
summary: Optional[str] = None
content: Optional[str] = None
entity_metadata: Optional[Dict[str, Any]] = None
+17 -18
View File
@@ -11,12 +11,11 @@ Key Features:
4. Bulk operations return all affected items
"""
import datetime
from typing import List, Optional, Dict, Any
from typing import List, Optional, Dict
from pydantic import BaseModel, ConfigDict, Field, AliasPath, AliasChoices
from basic_memory.schemas.base import Observation, Relation, PathId, Entity, EntityType, ContentType
from basic_memory.schemas.base import Relation, PathId, EntityType, ContentType
from basic_memory.schemas.request import ObservationCreate
@@ -44,6 +43,7 @@ class ObservationResponse(ObservationCreate, SQLAlchemyModel):
"context": "Initial database design meeting"
}
"""
context: Optional[str] = None
@@ -61,20 +61,21 @@ class RelationResponse(Relation, SQLAlchemyModel):
"context": "Comprehensive test suite"
}
"""
from_id: PathId = Field(
# use the path_id from the associated Entity
# use the permalink from the associated Entity
# or the from_id value
validation_alias=AliasChoices(
AliasPath('from_entity', 'path_id'),
'from_id',
AliasPath("from_entity", "permalink"),
"from_id",
)
)
to_id: PathId = Field(
# use the path_id from the associated Entity
# use the permalink from the associated Entity
# or the to_id value
validation_alias=AliasChoices(
AliasPath('to_entity', 'path_id'),
'to_id',
AliasPath("to_entity", "permalink"),
"to_id",
)
)
@@ -90,7 +91,7 @@ class EntityResponse(SQLAlchemyModel):
Example Response:
{
"path_id": "component/memory_service",
"permalink": "component/memory_service",
"title": "MemoryService",
"entity_type": "component",
"description": "Core persistence service",
@@ -117,8 +118,8 @@ class EntityResponse(SQLAlchemyModel):
}
"""
# Note this Class does not inherit form Entity because of the Entity.path_id semantics
path_id: PathId
# Note this Class does not inherit form Entity because of the Entity.permalink semantics
permalink: PathId
title: str
entity_type: EntityType
entity_metadata: Optional[Dict] = None
@@ -133,14 +134,14 @@ class EntityListResponse(SQLAlchemyModel):
"""Response for create_entities operation.
Returns complete information about entities returned from the service,
including their path_ids, observations,
including their permalinks, observations,
and any established relations.
Example Response:
{
"entities": [
{
"path_id": "component/search_service",
"permalink": "component/search_service",
"title": "SearchService",
"entity_type": "component",
"description": "Knowledge graph search",
@@ -152,7 +153,7 @@ class EntityListResponse(SQLAlchemyModel):
"relations": []
},
{
"path_id": "document/api_docs",
"permalink": "document/api_docs",
"title": "API_Documentation",
"entity_type": "document",
"description": "API Reference",
@@ -180,7 +181,7 @@ class SearchNodesResponse(SQLAlchemyModel):
{
"matches": [
{
"path_id": "component/memory_service",
"permalink": "component/memory_service",
"title": "MemoryService",
"entity_type": "component",
"description": "Core service",
@@ -199,7 +200,6 @@ class SearchNodesResponse(SQLAlchemyModel):
query: str
class DeleteEntitiesResponse(SQLAlchemyModel):
"""Response indicating successful entity deletion.
@@ -213,4 +213,3 @@ class DeleteEntitiesResponse(SQLAlchemyModel):
"""
deleted: bool
+7 -3
View File
@@ -8,18 +8,20 @@ from pydantic import BaseModel, field_validator
class SearchItemType(str, Enum):
"""Types of searchable items."""
DOCUMENT = "document"
ENTITY = "entity"
class SearchQuery(BaseModel):
"""Search query parameters."""
text: str
types: Optional[List[SearchItemType]] = None
entity_types: Optional[List[str]] = None
after_date: Optional[Union[datetime, str]] = None
@field_validator('after_date')
@field_validator("after_date")
@classmethod
def validate_date(cls, v: Optional[Union[datetime, str]]) -> Optional[str]:
"""Convert datetime to ISO format if needed."""
@@ -32,7 +34,8 @@ class SearchQuery(BaseModel):
class SearchResult(BaseModel):
"""Search result item."""
path_id: str
permalink: str
file_path: str
type: SearchItemType
score: float
@@ -41,4 +44,5 @@ class SearchResult(BaseModel):
class SearchResponse(BaseModel):
"""Wrapper for search results list."""
results: List[SearchResult]
results: List[SearchResult]
+43 -34
View File
@@ -1,7 +1,7 @@
"""Service for tracking and querying activity across the knowledge base."""
from datetime import datetime, timezone
from typing import List, Optional, Sequence
from typing import List, Optional
from . import EntityService, RelationService
from ..schemas.activity import (
@@ -32,11 +32,11 @@ class ActivityService:
activity_types: Optional[List[str]] = None,
) -> RecentActivity:
"""Get all recent activity in the knowledge base.
Args:
timeframe: Time window to look back (1h, 1d, 1w, 1m)
activity_types: Optional list of types to include
Returns:
RecentActivity object containing changes and summary
"""
@@ -47,9 +47,7 @@ class ActivityService:
# Get changes based on requested types
changes = []
types_to_fetch = (
[ActivityType(t) for t in activity_types]
if activity_types
else list(ActivityType)
[ActivityType(t) for t in activity_types] if activity_types else list(ActivityType)
)
for activity_type in types_to_fetch:
@@ -68,81 +66,92 @@ class ActivityService:
summary = ActivitySummary(
entity_changes=len([c for c in changes if c.activity_type == ActivityType.ENTITY]),
relation_changes=len([c for c in changes if c.activity_type == ActivityType.RELATION]),
most_active_paths=self._get_most_active_paths(changes)
most_active_paths=self._get_most_active_paths(changes),
)
return RecentActivity(
timeframe=timeframe,
changes=changes,
summary=summary
)
return RecentActivity(timeframe=timeframe, changes=changes, summary=summary)
async def _get_entity_changes(self, since: datetime) -> List[ActivityChange]:
"""Get recent entity changes."""
# Query entities updated since the cutoff
entities = await self.entity_service.get_modified_since(since)
changes = []
for entity in entities:
# Ensure timestamps are timezone-aware
created_at = entity.created_at.replace(tzinfo=timezone.utc) if entity.created_at.tzinfo is None else entity.created_at
updated_at = entity.updated_at.replace(tzinfo=timezone.utc) if entity.updated_at.tzinfo is None else entity.updated_at
created_at = (
entity.created_at.replace(tzinfo=timezone.utc)
if entity.created_at.tzinfo is None
else entity.created_at
)
updated_at = (
entity.updated_at.replace(tzinfo=timezone.utc)
if entity.updated_at.tzinfo is None
else entity.updated_at
)
change_type = ChangeType.CREATED if created_at >= since else ChangeType.UPDATED
changes.append(
ActivityChange(
activity_type=ActivityType.ENTITY,
change_type=change_type,
timestamp=updated_at,
path_id=entity.path_id,
permalink=entity.permalink,
summary=f"{change_type.value.title()} entity: {entity.title}",
content=entity.summary
content=entity.summary,
)
)
return changes
return changes
async def _get_relation_changes(self, since: datetime) -> List[ActivityChange]:
"""Get recent relation changes."""
# Query relations updated since the cutoff
relations = await self.relation_service.get_modified_since(since)
changes = []
for relation in relations:
# Ensure timestamps are timezone-aware
created_at = relation.created_at.replace(tzinfo=timezone.utc) if relation.created_at.tzinfo is None else relation.created_at
updated_at = relation.updated_at.replace(tzinfo=timezone.utc) if relation.updated_at.tzinfo is None else relation.updated_at
created_at = (
relation.created_at.replace(tzinfo=timezone.utc)
if relation.created_at.tzinfo is None
else relation.created_at
)
updated_at = (
relation.updated_at.replace(tzinfo=timezone.utc)
if relation.updated_at.tzinfo is None
else relation.updated_at
)
change_type = ChangeType.CREATED if created_at >= since else ChangeType.UPDATED
changes.append(
ActivityChange(
activity_type=ActivityType.RELATION,
change_type=change_type,
timestamp=updated_at,
path_id=f"{relation.from_id}->{relation.to_id}",
permalink=f"{relation.from_id}->{relation.to_id}",
summary=(
f"{change_type.value.title()} relation: "
f"{relation.from_id} {relation.relation_type} {relation.to_id}"
),
content=relation.context
content=relation.context,
)
)
return changes
def _get_most_active_paths(self, changes: List[ActivityChange], limit: int = 5) -> List[str]:
"""Get the most frequently changed paths."""
path_counts = {}
for change in changes:
path_counts[change.path_id] = path_counts.get(change.path_id, 0) + 1
path_counts[change.permalink] = path_counts.get(change.permalink, 0) + 1
# Sort by count descending and take top paths
sorted_paths = sorted(
path_counts.items(),
key=lambda x: (-x[1], x[0]) # Sort by count desc, then path asc
key=lambda x: (-x[1], x[0]), # Sort by count desc, then path asc
)
return [path for path, _ in sorted_paths[:limit]]
return [path for path, _ in sorted_paths[:limit]]
+22 -22
View File
@@ -17,7 +17,7 @@ def entity_model(entity: EntitySchema):
title=entity.title,
entity_type=entity.entity_type,
entity_metadata=entity.entity_metadata,
path_id=entity.path_id,
permalink=entity.permalink,
file_path=entity.file_path,
summary=entity.summary,
content_type=entity.content_type,
@@ -57,7 +57,7 @@ class EntityService(BaseService[EntityModel]):
except Exception as e:
# Clean up on any failure
if db_entity:
await self.delete_entity(db_entity.path_id)
await self.delete_entity(db_entity.permalink)
await self.file_service.delete_entity_file(db_entity)
logger.error(f"Failed to create entity: {e}")
raise
@@ -69,7 +69,7 @@ class EntityService(BaseService[EntityModel]):
async def update_entity(
self,
path_id: str,
permalink: str,
content: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
**update_fields: Any,
@@ -77,7 +77,7 @@ class EntityService(BaseService[EntityModel]):
"""Update an entity's content and metadata.
Args:
path_id: Entity's path ID
permalink: Entity's path ID
content: Optional new content
metadata: Optional metadata updates
**update_fields: Additional entity fields to update
@@ -88,12 +88,12 @@ class EntityService(BaseService[EntityModel]):
Raises:
EntityNotFoundError: If entity doesn't exist
"""
logger.debug(f"Updating entity with path_id: {path_id}")
logger.debug(f"Updating entity with permalink: {permalink}")
# Get existing entity
entity = await self.get_by_path_id(path_id)
entity = await self.get_by_permalink(permalink)
if not entity:
raise EntityNotFoundError(f"Entity not found: {path_id}")
raise EntityNotFoundError(f"Entity not found: {permalink}")
try:
# Build update data
@@ -128,13 +128,13 @@ class EntityService(BaseService[EntityModel]):
logger.error(f"Failed to update entity: {e}")
raise
async def delete_entity(self, path_id: str) -> bool:
async def delete_entity(self, permalink: str) -> bool:
"""Delete entity and its file."""
logger.debug(f"Deleting entity: {path_id}")
logger.debug(f"Deleting entity: {permalink}")
try:
# Get entity first for file deletion
entity = await self.get_by_path_id(path_id)
entity = await self.get_by_permalink(permalink)
# Delete file first (it's source of truth)
await self.file_service.delete_entity_file(entity)
@@ -143,30 +143,30 @@ class EntityService(BaseService[EntityModel]):
return await self.repository.delete(entity.id)
except EntityNotFoundError:
logger.info(f"Entity not found: {path_id}")
logger.info(f"Entity not found: {permalink}")
return True # Already deleted
except Exception as e:
logger.error(f"Failed to delete entity: {e}")
raise
async def delete_entities(self, path_ids: List[str]) -> bool:
async def delete_entities(self, permalinks: List[str]) -> bool:
"""Delete multiple entities and their files."""
logger.debug(f"Deleting entities: {path_ids}")
logger.debug(f"Deleting entities: {permalinks}")
success = True
for path_id in path_ids:
await self.delete_entity(path_id)
for permalink in permalinks:
await self.delete_entity(permalink)
success = True
return success
async def get_by_path_id(self, path_id: str) -> EntityModel:
async def get_by_permalink(self, permalink: str) -> EntityModel:
"""Get entity by type and name combination."""
logger.debug(f"Getting entity by path_id: {path_id}")
db_entity = await self.repository.get_by_path_id(path_id)
logger.debug(f"Getting entity by permalink: {permalink}")
db_entity = await self.repository.get_by_permalink(permalink)
if not db_entity:
raise EntityNotFoundError(f"Entity not found: {path_id}")
raise EntityNotFoundError(f"Entity not found: {permalink}")
return db_entity
async def get_all(self) -> Sequence[EntityModel]:
@@ -188,10 +188,10 @@ class EntityService(BaseService[EntityModel]):
logger.debug(f"Listing entities: type={entity_type} sort={sort_by}")
return await self.repository.list_entities(entity_type=entity_type, sort_by=sort_by)
async def open_nodes(self, path_ids: List[str]) -> Sequence[EntityModel]:
async def open_nodes(self, permalinks: List[str]) -> Sequence[EntityModel]:
"""Get specific nodes and their relationships."""
logger.debug(f"Opening nodes path_ids: {path_ids}")
return await self.repository.find_by_path_ids(path_ids)
logger.debug(f"Opening nodes permalinks: {permalinks}")
return await self.repository.find_by_permalinks(permalinks)
async def delete_entity_by_file_path(self, file_path):
await self.repository.delete_by_file_path(file_path)
+4 -9
View File
@@ -1,6 +1,5 @@
"""Service for file operations with checksum tracking."""
from datetime import datetime, UTC
from pathlib import Path
from typing import Optional, Dict, Any, Tuple
@@ -31,12 +30,11 @@ class FileService:
self.base_path = base_path
self.knowledge_writer = knowledge_writer
def get_entity_path(self, entity: EntityModel) -> Path:
"""Generate filesystem path for entity."""
if entity.file_path:
return self.base_path / entity.file_path
return self.base_path / f"{entity.path_id}.md"
return self.base_path / f"{entity.permalink}.md"
async def write_entity_file(
self,
@@ -62,12 +60,11 @@ class FileService:
logger.error(f"Failed to write entity file: {e}")
raise FileOperationError(f"Failed to write entity file: {e}")
async def read_entity_content(self, entity: EntityModel) -> str:
"""Get entity's content if it's a note.
Args:
path_id: Entity's path ID
permalink: Entity's path ID
Returns:
content without frontmatter
@@ -75,7 +72,7 @@ class FileService:
Raises:
FileOperationError: If entity file doesn't exist
"""
logger.debug(f"Reading entity with path_id: {entity.path_id}")
logger.debug(f"Reading entity with permalink: {entity.permalink}")
# For notes, read the actual file content
file_path = self.get_entity_path(entity)
@@ -86,7 +83,6 @@ class FileService:
content = content.strip()
return content
async def delete_entity_file(self, entity: EntityModel) -> None:
"""Delete entity file from filesystem."""
try:
@@ -96,7 +92,6 @@ class FileService:
logger.error(f"Failed to delete entity file: {e}")
raise FileOperationError(f"Failed to delete entity file: {e}")
async def exists(self, path: Path) -> bool:
"""
Check if file exists.
@@ -265,7 +260,7 @@ class FileService:
) -> str:
"""
Add YAML frontmatter to content.
Args:
content: Content to add frontmatter to
frontmatter: Frontmatter to add
@@ -32,7 +32,7 @@ class ObservationService(BaseService[ObservationRepository]):
self.file_operations = file_service
async def add_observations(
self, path_id: str, observations: List[ObservationCreate], context: str | None = None
self, permalink: str, observations: List[ObservationCreate], context: str | None = None
) -> EntityModel:
"""Add observations to entity and update its file.
@@ -41,17 +41,17 @@ class ObservationService(BaseService[ObservationRepository]):
- [category] Content text #tag1 #tag2 (optional context)
Args:
path_id: Entity path ID
permalink: Entity path ID
observations: List of observations with categories
context: Optional shared context for all observations
"""
logger.debug(f"Adding observations to entity: {path_id}")
logger.debug(f"Adding observations to entity: {permalink}")
try:
# Get entity to update
entity = await self.entity_repository.get_by_path_id(path_id)
entity = await self.entity_repository.get_by_permalink(permalink)
if not entity:
raise EntityNotFoundError(f"Entity not found: {path_id}")
raise EntityNotFoundError(f"Entity not found: {permalink}")
# Add observations to DB
await self.repository.create_all(
@@ -68,33 +68,33 @@ class ObservationService(BaseService[ObservationRepository]):
)
# Get updated entity
entity = await self.entity_repository.get_by_path_id(path_id)
entity = await self.entity_repository.get_by_permalink(permalink)
# Write updated file and checksum
_, checksum = await self.file_operations.write_entity_file(entity)
await self.entity_repository.update(entity.id, {"checksum": checksum})
# Return final entity with all updates and relations
return await self.entity_repository.get_by_path_id(path_id)
return await self.entity_repository.get_by_permalink(permalink)
except Exception as e:
logger.error(f"Failed to add observations: {e}")
raise
async def delete_observations(self, path_id: str, observations: List[str]) -> EntityModel:
async def delete_observations(self, permalink: str, observations: List[str]) -> EntityModel:
"""Delete observations from entity and update its file.
Args:
path_id: Entity path ID
permalink: Entity path ID
observations: List of observation contents to delete
"""
logger.debug(f"Deleting observations from entity {path_id}")
logger.debug(f"Deleting observations from entity {permalink}")
try:
# Get entity
entity = await self.entity_repository.get_by_path_id(path_id)
entity = await self.entity_repository.get_by_permalink(permalink)
if not entity:
raise EntityNotFoundError(f"Entity not found: {path_id}")
raise EntityNotFoundError(f"Entity not found: {permalink}")
# Delete observations from DB by comparing the string value to the Observation content
for observation in observations:
@@ -107,7 +107,7 @@ class ObservationService(BaseService[ObservationRepository]):
await self.entity_repository.update(entity.id, {"checksum": checksum})
# Return final entity with all updates
return await self.entity_repository.get_by_path_id(path_id)
return await self.entity_repository.get_by_permalink(permalink)
except Exception as e:
logger.error(f"Failed to delete observations: {e}")
+14 -12
View File
@@ -37,8 +37,8 @@ class RelationService(BaseService[RelationRepository]):
for rs in relations:
try:
from_entity = await self.entity_repository.get_by_path_id(rs.from_id)
to_entity = await self.entity_repository.get_by_path_id(rs.to_id)
from_entity = await self.entity_repository.get_by_permalink(rs.from_id)
to_entity = await self.entity_repository.get_by_permalink(rs.to_id)
relation = RelationModel(
from_id=from_entity.id,
@@ -58,10 +58,10 @@ class RelationService(BaseService[RelationRepository]):
continue
# Get fresh copies of all updated entities
for path_id in entities_to_update:
for permalink in entities_to_update:
try:
# Get fresh entity
entity = await self.entity_repository.get_by_path_id(path_id)
entity = await self.entity_repository.get_by_permalink(permalink)
# Write updated file
_, checksum = await self.file_service.write_entity_file(entity)
@@ -70,11 +70,13 @@ class RelationService(BaseService[RelationRepository]):
updated_entities.append(updated)
except Exception as e:
logger.error(f"Failed to update entity {path_id}: {e}")
logger.error(f"Failed to update entity {permalink}: {e}")
continue
# select again to eagerly load all relations
return await self.entity_repository.find_by_path_ids([e.path_id for e in updated_entities])
return await self.entity_repository.find_by_permalinks(
[e.permalink for e in updated_entities]
)
async def delete_relations(self, to_delete: List[RelationSchema]) -> Sequence[EntityModel]:
"""Delete relations and return all updated entities."""
@@ -104,12 +106,12 @@ class RelationService(BaseService[RelationRepository]):
logger.warning("No relations were deleted")
# Get fresh copies of all updated entities
for path_id in entities_to_update:
for permalink in entities_to_update:
try:
# Get fresh entity
entity = await self.entity_repository.get_by_path_id(path_id)
entity = await self.entity_repository.get_by_permalink(permalink)
if not entity:
raise EntityNotFoundError(f"Entity not found: {path_id}")
raise EntityNotFoundError(f"Entity not found: {permalink}")
# Write updated file
_, checksum = await self.file_service.write_entity_file(entity)
@@ -118,7 +120,7 @@ class RelationService(BaseService[RelationRepository]):
updated_entities.append(updated)
except Exception as e:
logger.error(f"Failed to update entity {path_id}: {e}")
logger.error(f"Failed to update entity {permalink}: {e}")
continue
return updated_entities
@@ -128,9 +130,9 @@ class RelationService(BaseService[RelationRepository]):
raise
async def find_relation(
self, from_path_id: str, to_path_id: str, relation_type: str
self, from_permalink: str, to_permalink: str, relation_type: str
) -> RelationModel:
return await self.repository.find_relation(from_path_id, to_path_id, relation_type)
return await self.repository.find_relation(from_permalink, to_permalink, relation_type)
async def delete_relation(
self, from_entity: EntityModel, to_entity: EntityModel, relation_type: str
+7 -7
View File
@@ -60,7 +60,7 @@ class SearchService:
*[f"{obs.category}: {obs.content}" for obs in entity.observations],
# Add relations
*[
f"{rel.relation_type} {rel.to_entity.path_id}: {rel.context or ''}"
f"{rel.relation_type} {rel.to_entity.permalink}: {rel.context or ''}"
for rel in entity.relations
],
]
@@ -77,7 +77,7 @@ class SearchService:
background_tasks.add_task(
self._do_index,
content=content,
path_id=entity.path_id,
permalink=entity.permalink,
file_path=entity.file_path,
type=SearchItemType.ENTITY,
metadata=metadata,
@@ -85,20 +85,20 @@ class SearchService:
else:
await self._do_index(
content=content,
path_id=entity.path_id,
permalink=entity.permalink,
file_path=entity.file_path,
type=SearchItemType.ENTITY,
metadata=metadata,
)
async def _do_index(
self, content: str, path_id: str, file_path: str, type: SearchItemType, metadata: dict
self, content: str, permalink: str, file_path: str, type: SearchItemType, metadata: dict
) -> None:
"""Actually perform the indexing."""
await self.repository.index_item(
content=content, path_id=path_id, file_path=file_path, type=type, metadata=metadata
content=content, permalink=permalink, file_path=file_path, type=type, metadata=metadata
)
async def delete_by_path_id(self, path_id: str):
async def delete_by_permalink(self, permalink: str):
"""Delete an item from the search index."""
await self.repository.delete_by_path_id(path_id)
await self.repository.delete_by_permalink(permalink)
+11 -11
View File
@@ -25,7 +25,7 @@ def entity_model_from_markdown(file_path: str, markdown: EntityMarkdown) -> Enti
model = EntityModel(
title=markdown.frontmatter.title,
entity_type=markdown.frontmatter.type,
path_id=markdown.frontmatter.id,
permalink=markdown.frontmatter.id,
file_path=file_path,
content_type="text/markdown",
summary=markdown.content.content,
@@ -65,21 +65,21 @@ class EntitySyncService:
model = entity_model_from_markdown(file_path, markdown)
# Mark as incomplete sync
model.checksum = None
model.checksum = None
return await self.entity_repository.add(model)
async def update_entity_and_observations(
self, path_id: str, markdown: EntityMarkdown
self, permalink: str, markdown: EntityMarkdown
) -> EntityModel:
"""First pass: Update entity fields and observations.
Updates everything except relations and sets null checksum
to indicate sync not complete.
"""
logger.debug(f"Updating entity and observations: {path_id}")
db_entity = await self.entity_repository.get_by_path_id(path_id)
logger.debug(f"Updating entity and observations: {permalink}")
db_entity = await self.entity_repository.get_by_permalink(permalink)
if not db_entity:
raise EntityNotFoundError(f"Entity not found: {path_id}")
raise EntityNotFoundError(f"Entity not found: {permalink}")
# Update fields from markdown
db_entity.title = markdown.frontmatter.title
@@ -88,7 +88,7 @@ class EntitySyncService:
# Clear observations for entity
await self.observation_repository.delete_by_fields(entity_id=db_entity.id)
# add new observations
observations = [
Observation(
@@ -122,14 +122,14 @@ class EntitySyncService:
checksum: Final checksum to set after relations are updated
"""
logger.debug(f"Updating relations for entity: {markdown.frontmatter.id}")
db_entity = await self.entity_repository.get_by_path_id(markdown.frontmatter.id)
db_entity = await self.entity_repository.get_by_permalink(markdown.frontmatter.id)
# get all entities from relations
target_entity_path_ids = [rel.target for rel in markdown.content.relations]
target_entities = await self.entity_repository.find_by_path_ids(target_entity_path_ids)
target_entity_permalinks = [rel.target for rel in markdown.content.relations]
target_entities = await self.entity_repository.find_by_permalinks(target_entity_permalinks)
# dict by path
entity_by_path = {e.path_id: e for e in target_entities}
entity_by_path = {e.permalink: e for e in target_entities}
# Clear and update relations
await self.relation_repository.delete_outgoing_relations_from_entity(db_entity.id)
+24 -18
View File
@@ -1,7 +1,8 @@
"""Service for detecting changes between filesystem and database."""
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, Sequence, Any
from typing import Dict, Sequence
from loguru import logger
@@ -10,22 +11,24 @@ from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.sync.utils import SyncReport
from basic_memory.utils.file_utils import compute_checksum
@dataclass
class FileState:
"""State of a file including file path, path_id and checksum info."""
"""State of a file including file path, permalink and checksum info."""
file_path: str
path_id: str
permalink: str
checksum: str
@dataclass
@dataclass
class ScanResult:
"""Result of scanning a directory."""
# file_path -> checksum
files: Dict[str, str] = field(default_factory=dict)
# file_path -> error message
errors: Dict[str, str] = field(default_factory=dict)
errors: Dict[str, str] = field(default_factory=dict)
class FileChangeScanner:
@@ -34,9 +37,7 @@ class FileChangeScanner:
The filesystem is treated as the source of truth.
"""
def __init__(
self, entity_repository: EntityRepository
):
def __init__(self, entity_repository: EntityRepository):
self.entity_repository = entity_repository
async def scan_directory(self, directory: Path) -> ScanResult:
@@ -70,7 +71,7 @@ class FileChangeScanner:
checksum = await compute_checksum(content)
if checksum: # Only store valid checksums
result.files[rel_path] = checksum
result.files[rel_path] = checksum
else:
result.errors[rel_path] = "Failed to compute checksum"
@@ -85,13 +86,15 @@ class FileChangeScanner:
return result
async def find_changes(self, directory: Path, db_file_state: Dict[str, FileState]) -> SyncReport:
async def find_changes(
self, directory: Path, db_file_state: Dict[str, FileState]
) -> SyncReport:
"""
Find changes between filesystem and database.
Args:
directory: Directory to check
db_file_state: dict mapping file_path to DbState(path_id, checksum)
db_file_state: dict mapping file_path to DbState(permalink, checksum)
Returns:
SyncReport detailing changes
@@ -102,11 +105,11 @@ class FileChangeScanner:
# Build report
report = SyncReport()
# Find new and modified files
for file_path, checksum in current_files.items():
logger.debug(f"{file_path} ({checksum[:8]})")
if file_path not in db_file_state:
report.new.add(file_path)
elif checksum != db_file_state[file_path].checksum:
@@ -134,9 +137,7 @@ class FileChangeScanner:
return report
async def get_db_file_state(
self, db_records: Sequence[Entity]
) -> Dict[str, FileState]:
async def get_db_file_state(self, db_records: Sequence[Entity]) -> Dict[str, FileState]:
"""Get file_path and checksums from database.
Args:
db_records: database records
@@ -144,7 +145,12 @@ class FileChangeScanner:
Dict mapping file paths to FileState
:param db_records: the data from the db
"""
return {r.file_path: FileState(file_path=r.file_path, path_id=r.path_id, checksum=r.checksum) for r in db_records}
return {
r.file_path: FileState(
file_path=r.file_path, permalink=r.permalink, checksum=r.checksum
)
for r in db_records
}
async def find_knowledge_changes(self, directory: Path) -> SyncReport:
"""Find changes in knowledge directory."""
+3 -3
View File
@@ -56,10 +56,10 @@ class SyncService:
file_path, entity_markdown
)
else:
path_id = entity_markdown.frontmatter.id
logger.debug(f"Updating entity_markdown: {path_id}")
permalink = entity_markdown.frontmatter.id
logger.debug(f"Updating entity_markdown: {permalink}")
await self.knowledge_sync_service.update_entity_and_observations(
path_id, entity_markdown
permalink, entity_markdown
)
# Second pass: Process relations
+8 -8
View File
@@ -20,7 +20,7 @@ async def test_entities(entity_repository: EntityRepository) -> list[Entity]:
entity_type="test",
content_type="text/markdown",
summary="Core memory service",
path_id="component/memory_service",
permalink="component/memory_service",
file_path="component/memory_service.md",
observations=[
Observation(category="tech", content="Using SQLite for storage"),
@@ -32,7 +32,7 @@ async def test_entities(entity_repository: EntityRepository) -> list[Entity]:
entity_type="test",
content_type="text/markdown",
summary="File format spec",
path_id="spec/file_format",
permalink="spec/file_format",
file_path="spec/file_format.md",
observations=[
Observation(category="feature", content="Support for frontmatter"),
@@ -44,7 +44,7 @@ async def test_entities(entity_repository: EntityRepository) -> list[Entity]:
entity_type="test",
content_type="text/markdown",
summary="Architecture decision",
path_id="decision/tech_choice",
permalink="decision/tech_choice",
file_path="decision/tech_choice.md",
observations=[
Observation(category="note", content="Team discussed options"),
@@ -57,7 +57,7 @@ async def test_entities(entity_repository: EntityRepository) -> list[Entity]:
entity_type="test",
content_type="text/markdown",
summary="API layer",
path_id="component/api_service",
permalink="component/api_service",
file_path="component/api_service.md",
observations=[
Observation(category="tech", content="FastAPI based"),
@@ -143,12 +143,12 @@ async def test_list_entities_with_sorting(client: AsyncClient, test_entities):
names = [e.name for e in data.entities]
assert names == sorted(names) # Should be alphabetical
# Sort by path_id
response = await client.get("/discovery/entities/technical_component?sort_by=path_id")
# Sort by permalink
response = await client.get("/discovery/entities/technical_component?sort_by=permalink")
assert response.status_code == 200
data = TypedEntityList.model_validate(response.json())
path_ids = [e.path_id for e in data.entities]
assert path_ids == sorted(path_ids)
permalinks = [e.permalink for e in data.entities]
assert permalinks == sorted(permalinks)
async def test_list_entities_empty_type(client: AsyncClient, test_entities):
+65 -58
View File
@@ -39,11 +39,11 @@ async def create_entity(client) -> EntityResponse:
return create_response.entities[0]
async def add_observations(client, path_id: str) -> List[ObservationResponse]:
async def add_observations(client, permalink: str) -> List[ObservationResponse]:
response = await client.post(
"/knowledge/observations",
json={
"path_id": path_id,
"permalink": permalink,
"observations": [
{"content": "First observation", "category": "tech"},
{"content": "Second observation", "category": "note"},
@@ -69,15 +69,19 @@ async def create_related_entities(client) -> List[RelationResponse]: # pyright:
create_response = await client.post("/knowledge/entities", json={"entities": entities})
assert create_response.status_code == 200
created = create_response.json()["entities"]
source_path_id = "source_entity"
target_path_id = "target_entity"
source_permalink = "source_entity"
target_permalink = "target_entity"
# Create relation between them
response = await client.post(
"/knowledge/relations",
json={
"relations": [
{"from_id": source_path_id, "to_id": target_path_id, "relation_type": "related_to"}
{
"from_id": source_permalink,
"to_id": target_permalink,
"relation_type": "related_to",
}
]
},
)
@@ -94,13 +98,13 @@ async def create_related_entities(client) -> List[RelationResponse]: # pyright:
assert len(source_entity.relations) == 1
source_relation = source_entity.relations[0]
assert source_relation.from_id == source_path_id
assert source_relation.to_id == target_path_id
assert source_relation.from_id == source_permalink
assert source_relation.to_id == target_permalink
assert len(target_entity.relations) == 1
target_relation = target_entity.relations[0]
assert target_relation.from_id == source_path_id
assert target_relation.to_id == target_path_id
assert target_relation.from_id == source_permalink
assert target_relation.to_id == target_permalink
return source_entity.relations + target_entity.relations
@@ -121,15 +125,15 @@ async def test_get_entity(client: AsyncClient):
data = response.json()
# Now get it by path
path_id = data["entities"][0]["path_id"]
response = await client.get(f"/knowledge/entities/{path_id}")
permalink = data["entities"][0]["permalink"]
response = await client.get(f"/knowledge/entities/{permalink}")
# Verify retrieval
assert response.status_code == 200
entity = response.json()
assert entity["title"] == "TestEntity"
assert entity["entity_type"] == "test"
assert entity["path_id"] == "test_entity"
assert entity["permalink"] == "test_entity"
@pytest.mark.asyncio
@@ -145,12 +149,12 @@ async def test_add_observations(client: AsyncClient):
data = {"title": "TestEntity", "entity_type": "test"}
response = await client.post("/knowledge/entities", json={"entities": [data]})
path_id = "test_entity"
permalink = "test_entity"
# Add observations
await add_observations(client, path_id)
await add_observations(client, permalink)
# Verify observations appear in entity
entity_response = await client.get(f"/knowledge/entities/{path_id}")
entity_response = await client.get(f"/knowledge/entities/{permalink}")
entity = entity_response.json()
assert len(entity["observations"]) == 2
@@ -168,7 +172,7 @@ async def test_open_nodes(client: AsyncClient):
# Open nodes by path IDs
response = await client.post(
"/knowledge/nodes",
json={"path_ids": ["alpha_test"]},
json={"permalinks": ["alpha_test"]},
)
# Verify results
@@ -178,7 +182,7 @@ async def test_open_nodes(client: AsyncClient):
entity = data["entities"][0]
assert entity["title"] == "AlphaTest"
assert entity["entity_type"] == "test"
assert entity["path_id"] == "alpha_test"
assert entity["permalink"] == "alpha_test"
@pytest.mark.asyncio
@@ -190,14 +194,14 @@ async def test_delete_entity(client: AsyncClient):
# Test deletion
response = await client.post(
"/knowledge/entities/delete", json={"path_ids": ["test/TestEntity"]}
"/knowledge/entities/delete", json={"permalinks": ["test/TestEntity"]}
)
assert response.status_code == 200
assert response.json() == {"deleted": True}
# Verify entity is gone
path_id = quote("test/TestEntity")
response = await client.get(f"/knowledge/entities/{path_id}")
permalink = quote("test/TestEntity")
response = await client.get(f"/knowledge/entities/{permalink}")
assert response.status_code == 404
@@ -213,15 +217,15 @@ async def test_delete_entity_bulk(client: AsyncClient):
# Test deletion
response = await client.post(
"/knowledge/entities/delete", json={"path_ids": ["Entity1", "Entity2"]}
"/knowledge/entities/delete", json={"permalinks": ["Entity1", "Entity2"]}
)
assert response.status_code == 200
assert response.json() == {"deleted": True}
# Verify entities are gone
for name in ["Entity1", "Entity2"]:
path_id = quote(f"{name}")
response = await client.get(f"/knowledge/entities/{path_id}")
permalink = quote(f"{name}")
response = await client.get(f"/knowledge/entities/{permalink}")
assert response.status_code == 404
@@ -234,7 +238,7 @@ async def test_delete_entity_with_observations(client, observation_repository):
await add_observations(client, "TestEntity")
# Delete entity
response = await client.post("/knowledge/entities/delete", json={"path_ids": ["TestEntity"]})
response = await client.post("/knowledge/entities/delete", json={"permalinks": ["TestEntity"]})
assert response.status_code == 200
assert response.json() == {"deleted": True}
@@ -252,7 +256,7 @@ async def test_delete_observations(client, observation_repository):
observations = await add_observations(client, "TestEntity") # adds 2
# Delete specific observations
request_data = {"path_id": "TestEntity", "observations": [observations[0].content]}
request_data = {"permalink": "TestEntity", "observations": [observations[0].content]}
response = await client.post("/knowledge/observations/delete", json=request_data)
assert response.status_code == 200
data = response.json()
@@ -291,7 +295,9 @@ async def test_delete_relations(client, relation_repository):
@pytest.mark.asyncio
async def test_delete_nonexistent_entity(client: AsyncClient):
"""Test deleting a nonexistent entity by path ID."""
response = await client.post("/knowledge/entities/delete", json={"path_ids": ["non_existent"]})
response = await client.post(
"/knowledge/entities/delete", json={"permalinks": ["non_existent"]}
)
assert response.status_code == 200
assert response.json() == {"deleted": True}
@@ -303,7 +309,7 @@ async def test_delete_nonexistent_observations(client: AsyncClient):
entity_data = {"title": "TestEntity", "entity_type": "test"}
await client.post("/knowledge/entities", json={"entities": [entity_data]})
request_data = {"path_id": "TestEntity", "observations": ["Nonexistent observation"]}
request_data = {"permalink": "TestEntity", "observations": ["Nonexistent observation"]}
response = await client.post("/knowledge/observations/delete", json=request_data)
assert response.status_code == 200
@@ -375,7 +381,7 @@ async def test_full_knowledge_flow(client: AsyncClient):
await client.post(
"/knowledge/observations",
json={
"path_id": "main_entity",
"permalink": "main_entity",
"observations": [
{"content": "Connected to first related entity", "category": "tech"},
{"content": "Connected to second related entity", "category": "note"},
@@ -385,8 +391,8 @@ async def test_full_knowledge_flow(client: AsyncClient):
)
# 5. Verify full graph structure
path_id = "MainEntity"
main_get = await client.get(f"/knowledge/entities/{path_id}")
permalink = "MainEntity"
main_get = await client.get(f"/knowledge/entities/{permalink}")
main_entity = main_get.json()
# Check entity structure
@@ -401,14 +407,14 @@ async def test_full_knowledge_flow(client: AsyncClient):
# 7. Delete main entity
response = await client.post(
"/knowledge/entities/delete", json={"path_ids": ["MainEntity", "NonEntity"]}
"/knowledge/entities/delete", json={"permalinks": ["MainEntity", "NonEntity"]}
)
assert response.status_code == 200
assert response.json() == {"deleted": True}
# Verify deletion
path_id = "MainEntity"
response = await client.get(f"/knowledge/entities/{path_id}")
permalink = "MainEntity"
response = await client.get(f"/knowledge/entities/{permalink}")
assert response.status_code == 404
@@ -432,7 +438,7 @@ async def test_entity_indexing(client: AsyncClient):
assert search_response.status_code == 200
search_result = SearchResponse.model_validate(search_response.json())
assert len(search_result.results) == 1
assert search_result.results[0].path_id == "search_test"
assert search_result.results[0].permalink == "search_test"
assert search_result.results[0].type == SearchItemType.ENTITY.value
@@ -453,7 +459,7 @@ async def test_observation_update_indexing(client: AsyncClient):
await client.post(
"/knowledge/observations",
json={
"path_id": entity["path_id"],
"permalink": entity["permalink"],
"observations": [{"content": "Unique sphinx observation", "category": "tech"}],
},
)
@@ -464,7 +470,7 @@ async def test_observation_update_indexing(client: AsyncClient):
)
search_result = SearchResponse.model_validate(search_response.json())
assert len(search_result.results) == 1
assert search_result.results[0].path_id == entity["path_id"]
assert search_result.results[0].permalink == entity["permalink"]
@pytest.mark.asyncio
@@ -490,7 +496,7 @@ async def test_entity_delete_indexing(client: AsyncClient):
# Delete entity
delete_response = await client.post(
"/knowledge/entities/delete", json={"path_ids": [entity["path_id"]]}
"/knowledge/entities/delete", json={"permalinks": [entity["permalink"]]}
)
assert delete_response.status_code == 200
@@ -535,8 +541,8 @@ async def test_relation_indexing(client: AsyncClient):
)
search_result = SearchResponse.model_validate(search_response.json())
assert len(search_result.results) == 2 # Both source and target entities
path_ids = {r.path_id for r in search_result.results}
assert path_ids == {"source_test", "target_test"}
permalinks = {r.permalink for r in search_result.results}
assert permalinks == {"source_test", "target_test"}
@pytest.mark.asyncio
@@ -557,7 +563,7 @@ async def test_update_entity_basic(client: AsyncClient):
"title": "updated-test",
"summary": "Updated description",
}
response = await client.put(f"/knowledge/entities/{entity['path_id']}", json=update_data)
response = await client.put(f"/knowledge/entities/{entity['permalink']}", json=update_data)
assert response.status_code == 200
updated = response.json()
@@ -574,25 +580,26 @@ async def test_get_entity_content_parameter(client: AsyncClient):
data = {
"title": "TestContent",
"entity_type": "test",
"content": "# Test Content\n\nSome test content."
"content": "# Test Content\n\nSome test content.",
}
response = await client.post("/knowledge/entities", json={"entities": [data]})
assert response.status_code == 200
path_id = response.json()["entities"][0]["path_id"]
permalink = response.json()["entities"][0]["permalink"]
# Get without content
response = await client.get(f"/knowledge/entities/{path_id}")
response = await client.get(f"/knowledge/entities/{permalink}")
assert response.status_code == 200
entity = response.json()
assert entity["content"] is None
# Get with content
response = await client.get(f"/knowledge/entities/{path_id}?content=true")
response = await client.get(f"/knowledge/entities/{permalink}?content=true")
assert response.status_code == 200
entity = response.json()
assert "# Test Content" in entity["content"]
assert "Some test content" in entity["content"]
@pytest.mark.asyncio
async def test_update_entity_content(client: AsyncClient):
"""Test updating content for different entity types."""
@@ -604,13 +611,13 @@ async def test_update_entity_content(client: AsyncClient):
# Update note content
new_content = "# Updated Note\n\nNew content."
response = await client.put(
f"/knowledge/entities/{note['path_id']}", json={"content": new_content}
f"/knowledge/entities/{note['permalink']}", json={"content": new_content}
)
assert response.status_code == 200
updated = response.json()
# Verify through get request to check file
response = await client.get(f"/knowledge/entities/{updated['path_id']}?content=true")
response = await client.get(f"/knowledge/entities/{updated['permalink']}?content=true")
fetched = response.json()
assert "# Updated Note" in fetched["content"]
assert "New content" in fetched["content"]
@@ -631,7 +638,7 @@ async def test_update_entity_type_conversion(client: AsyncClient):
# Convert to knowledge type
response = await client.put(
f"/knowledge/entities/{note['path_id']}", json={"entity_type": "test"}
f"/knowledge/entities/{note['permalink']}", json={"entity_type": "test"}
)
assert response.status_code == 200
updated = response.json()
@@ -640,7 +647,7 @@ async def test_update_entity_type_conversion(client: AsyncClient):
assert updated["entity_type"] == "test"
# Get latest to verify file format
response = await client.get(f"/knowledge/entities/{updated['path_id']}")
response = await client.get(f"/knowledge/entities/{updated['permalink']}")
knowledge = response.json()
assert knowledge.get("content") is None
@@ -655,7 +662,7 @@ async def test_update_entity_metadata(client: AsyncClient):
# Update metadata
update_data = {"entity_metadata": {"status": "final", "reviewed": True}}
response = await client.put(f"/knowledge/entities/{entity['path_id']}", json=update_data)
response = await client.put(f"/knowledge/entities/{entity['permalink']}", json=update_data)
assert response.status_code == 200
updated = response.json()
@@ -681,7 +688,7 @@ async def test_update_entity_search_index(client: AsyncClient):
# Update with new searchable content
update_data = {"summary": "Updated with unique sphinx marker"}
response = await client.put(f"/knowledge/entities/{entity['path_id']}", json=update_data)
response = await client.put(f"/knowledge/entities/{entity['permalink']}", json=update_data)
assert response.status_code == 200
# Search should find new content
@@ -690,7 +697,7 @@ async def test_update_entity_search_index(client: AsyncClient):
)
results = search_response.json()["results"]
assert len(results) == 1
assert results[0]["path_id"] == entity["path_id"]
assert results[0]["permalink"] == entity["permalink"]
@pytest.mark.asyncio
@@ -699,9 +706,7 @@ async def test_get_entity_with_relations(client: AsyncClient):
# Create a note and knowledge entity
note = await client.post(
"/knowledge/entities",
json={
"entities": [{"title": "test-note", "entity_type": "note", "summary": "Test note"}]
},
json={"entities": [{"title": "test-note", "entity_type": "note", "summary": "Test note"}]},
)
knowledge = await client.post(
"/knowledge/entities",
@@ -718,8 +723,8 @@ async def test_get_entity_with_relations(client: AsyncClient):
json={
"relations": [
{
"from_id": note.json()["entities"][0]["path_id"],
"to_id": knowledge.json()["entities"][0]["path_id"],
"from_id": note.json()["entities"][0]["permalink"],
"to_id": knowledge.json()["entities"][0]["permalink"],
"relation_type": "references",
}
]
@@ -727,9 +732,11 @@ async def test_get_entity_with_relations(client: AsyncClient):
)
# Verify GET returns relations for both types
note_response = await client.get(f"/knowledge/entities/{note.json()['entities'][0]['path_id']}")
note_response = await client.get(
f"/knowledge/entities/{note.json()['entities'][0]['permalink']}"
)
knowledge_response = await client.get(
f"/knowledge/entities/{knowledge.json()['entities'][0]['path_id']}"
f"/knowledge/entities/{knowledge.json()['entities'][0]['permalink']}"
)
assert len(note_response.json()["relations"]) == 1
+6 -5
View File
@@ -20,7 +20,7 @@ def test_entity():
title = "TestComponent"
entity_type = "test"
entity_metadata = {"test": "test"}
path_id = "component/test_component"
permalink = "component/test_component"
file_path = "entities/component/test_component.md"
summary = "A test component for search testing"
content_type = "text/markdown"
@@ -46,7 +46,7 @@ async def test_search_basic(client, indexed_entity):
assert response.status_code == 200
search_results = SearchResponse.model_validate(response.json())
assert len(search_results.results) == 1
assert search_results.results[0].path_id == indexed_entity.path_id
assert search_results.results[0].permalink == indexed_entity.permalink
@pytest.mark.asyncio
@@ -141,13 +141,14 @@ async def test_search_empty(search_service, client):
async def test_reindex(client, search_service, entity_service, test_entity, session_maker):
"""Test reindex endpoint."""
# Create test entity and document
await entity_service.create_entity( EntitySchema(
await entity_service.create_entity(
EntitySchema(
title="TestEntity1",
entity_type="test",
summary="A test entity description",
observations=["this is a test observation"],
),
)
)
# Clear search index
async with db.scoped_session(session_maker) as session:
@@ -186,6 +187,6 @@ async def test_multiple_filters(client, indexed_entity):
search_result = SearchResponse.model_validate(response.json())
assert len(search_result.results) == 1
result = search_result.results[0]
assert result.path_id == indexed_entity.path_id
assert result.permalink == indexed_entity.permalink
assert result.type == SearchItemType.ENTITY.value
assert result.metadata["entity_type"] == "test"
+5 -5
View File
@@ -37,7 +37,7 @@
# deleted={"old/deleted.md"},
# moved={
# "new/location.md": FileState(
# path_id="new/location.md", checksum="abc123", moved_from="old/location.md"
# permalink="new/location.md", checksum="abc123", moved_from="old/location.md"
# )
# },
# )
@@ -61,7 +61,7 @@
# deleted={"old/deleted.md"},
# moved={
# "new/location.md": FileState(
# path_id="new/location.md",
# permalink="new/location.md",
# checksum="abc123def", # 8 chars for display
# moved_from="old/location.md",
# )
@@ -100,7 +100,7 @@
#
# # Add some files to DB with different paths to test moves
# await entity_repository.create(
# {"path_id": "old/doc.md", "file_path": "old/doc.md", "checksum": "abc123"}
# {"permalink": "old/doc.md", "file_path": "old/doc.md", "checksum": "abc123"}
# )
#
# # Run status check
@@ -133,7 +133,7 @@
#
# # Add to DB
# await entity_repository.create(
# {"path_id": original_path, "file_path": original_path, "checksum": checksum}
# {"permalink": original_path, "file_path": original_path, "checksum": checksum}
# )
#
# # Simulate case change in filesystem
@@ -160,7 +160,7 @@
# checksum = await compute_checksum(content)
#
# # Add to DB with same path
# await entity_repository.create({"path_id": path, "file_path": path, "checksum": checksum})
# await entity_repository.create({"permalink": path, "file_path": path, "checksum": checksum})
#
# # Check changes
# changes = await file_change_scanner.find_knowledge_changes(docs_dir)
+1 -1
View File
@@ -211,7 +211,7 @@ async def sample_entity(entity_repository: EntityRepository) -> Entity:
"title": "Test Entity",
"entity_type": "test",
"summary": "A test entity",
"path_id": "test/test_entity",
"permalink": "test/test_entity",
"file_path": "test/test_entity.md",
"content_type": "text/markdown",
}
+2 -2
View File
@@ -20,7 +20,7 @@ def sample_entity() -> Entity:
id=1,
title="test_entity",
entity_type="test",
path_id="knowledge/test_entity",
permalink="knowledge/test_entity",
file_path="knowledge/test_entity.md",
summary="Test description",
created_at=datetime(2025, 1, 1, tzinfo=UTC),
@@ -44,7 +44,7 @@ def entity_with_observations(sample_entity: Entity) -> Entity:
def entity_with_relations(sample_entity: Entity) -> Entity:
"""Create an entity with relations."""
target = Entity(
id=2, title="target_entity", entity_type="test", path_id="knowledge/target_entity"
id=2, title="target_entity", entity_type="test", permalink="knowledge/target_entity"
)
sample_entity.outgoing_relations = [
Relation(from_id=1, to_id=2, relation_type="connects_to", to_entity=target)
+11 -11
View File
@@ -17,11 +17,11 @@ async def test_add_basic_observation(client):
# First create an entity to add observations to
entity_request = CreateEntityRequest(entities=[Entity(title="TestEntity", entity_type="test")])
result = await create_entities(entity_request)
entity_id = result.entities[0].path_id
entity_id = result.entities[0].permalink
# Add an observation
request = AddObservationsRequest(
path_id=entity_id, observations=[ObservationCreate(content="Test observation")]
permalink=entity_id, observations=[ObservationCreate(content="Test observation")]
)
updated = await add_observations(request)
@@ -38,11 +38,11 @@ async def test_add_categorized_observations(client):
# Create test entity
entity_request = CreateEntityRequest(entities=[Entity(title="TestEntity", entity_type="test")])
result = await create_entities(entity_request)
entity_id = result.entities[0].path_id
entity_id = result.entities[0].permalink
# Add observations with different categories
request = AddObservationsRequest(
path_id=entity_id,
permalink=entity_id,
observations=[
ObservationCreate(
content="Implementation uses SQLite", category=ObservationCategory.TECH
@@ -75,12 +75,12 @@ async def test_add_observations_with_context(client):
# Create test entity
entity_request = CreateEntityRequest(entities=[Entity(title="TestEntity", entity_type="test")])
result = await create_entities(entity_request)
entity_id = result.entities[0].path_id
entity_id = result.entities[0].permalink
# Add observations with context
shared_context = "Design meeting 2024-12-25"
request = AddObservationsRequest(
path_id=entity_id,
permalink=entity_id,
context=shared_context,
observations=[
ObservationCreate(
@@ -108,11 +108,11 @@ async def test_add_observations_preserves_existing(client):
]
)
result = await create_entities(entity_request)
entity_id = result.entities[0].path_id
entity_id = result.entities[0].permalink
# Add new observations
request = AddObservationsRequest(
path_id=entity_id,
permalink=entity_id,
observations=[
ObservationCreate(content="New observation", category=ObservationCategory.TECH)
],
@@ -132,13 +132,13 @@ async def test_add_multiple_observations_same_category(client):
# Create test entity
entity_request = CreateEntityRequest(entities=[Entity(title="TestEntity", entity_type="test")])
result = await create_entities(entity_request)
entity_id = result.entities[0].path_id
entity_id = result.entities[0].permalink
# Add multiple tech observations
tech_observations = ["Uses async/await", "Implements SQLite backend", "Handles UTF-8 encoding"]
request = AddObservationsRequest(
path_id=entity_id,
permalink=entity_id,
observations=[
ObservationCreate(content=obs, category=ObservationCategory.TECH)
for obs in tech_observations
@@ -158,7 +158,7 @@ async def test_add_observation_to_nonexistent_entity(client):
"""Test adding observations to a non-existent entity fails."""
# Create request for non-existent entity
request = AddObservationsRequest(
path_id="test/nonexistent",
permalink="test/nonexistent",
observations=[
ObservationCreate(content="This should fail", category=ObservationCategory.NOTE)
],
+2 -2
View File
@@ -30,7 +30,7 @@ async def test_create_basic_entity(client):
entity = result.entities[0]
assert entity.title == "TestEntity"
assert entity.entity_type == "test"
assert entity.path_id == "test_entity"
assert entity.permalink == "test_entity"
assert entity.summary == "A test entity"
# Check observations
@@ -122,7 +122,7 @@ async def test_create_minimal_entity(client):
entity = result.entities[0]
assert entity.title == "MinimalEntity"
assert entity.entity_type == "test"
assert entity.path_id == "minimal_entity"
assert entity.permalink == "minimal_entity"
assert entity.summary is None
assert len(entity.observations) == 0
assert len(entity.relations) == 0
+9 -9
View File
@@ -30,8 +30,8 @@ async def test_create_basic_relation(client):
assert len(result.entities) == 2
# Find source and target entities
source = next(e for e in result.entities if e.path_id == "source_entity")
target = next(e for e in result.entities if e.path_id == "target_entity")
source = next(e for e in result.entities if e.permalink == "source_entity")
target = next(e for e in result.entities if e.permalink == "target_entity")
# Both entities should have the relation for bi-directional navigation
assert len(source.relations) == 1
@@ -74,8 +74,8 @@ async def test_create_relation_with_context(client):
)
result = await create_relations(relation_request)
source = next(e for e in result.entities if e.path_id == "source")
target = next(e for e in result.entities if e.path_id == "target")
source = next(e for e in result.entities if e.permalink == "source")
target = next(e for e in result.entities if e.permalink == "target")
# Both entities should have the relation with context
assert len(source.relations) == 1
@@ -109,9 +109,9 @@ async def test_create_multiple_relations(client):
assert len(result.entities) == 3
# Get entities
entity1 = next(e for e in result.entities if e.path_id == "entity1")
entity2 = next(e for e in result.entities if e.path_id == "entity2")
entity3 = next(e for e in result.entities if e.path_id == "entity3")
entity1 = next(e for e in result.entities if e.permalink == "entity1")
entity2 = next(e for e in result.entities if e.permalink == "entity2")
entity3 = next(e for e in result.entities if e.permalink == "entity3")
# Entity1 and Entity2 should share the connects_to relation
assert len(entity1.relations) == 1
@@ -146,8 +146,8 @@ async def test_create_bidirectional_relations(client):
)
result = await create_relations(relation_request)
service = next(e for e in result.entities if e.path_id == "service")
database = next(e for e in result.entities if e.path_id == "database")
service = next(e for e in result.entities if e.permalink == "service")
database = next(e for e in result.entities if e.permalink == "database")
# Each entity should have both relations for full navigation
assert len(service.relations) == 2
+1 -1
View File
@@ -35,7 +35,7 @@ async def test_get_observation_categories(client):
]
await add_observations(
AddObservationsRequest(path_id=entity.path_id, observations=observations)
AddObservationsRequest(permalink=entity.permalink, observations=observations)
)
# Get categories
+9 -8
View File
@@ -23,15 +23,15 @@ async def test_get_basic_entity(client):
]
)
create_result = await create_entities(entity_request)
path_id = create_result.entities[0].path_id
permalink = create_result.entities[0].permalink
# Get the entity without content
entity = await get_entity(path_id)
entity = await get_entity(permalink)
# Verify entity details
assert entity.title == "TestEntity"
assert entity.entity_type == "test"
assert entity.path_id == "test_entity"
assert entity.permalink == "test_entity"
assert entity.summary == "A test entity"
# Check observations
@@ -40,6 +40,7 @@ async def test_get_basic_entity(client):
assert obs.content == "First observation"
assert obs.category == ObservationCategory.NOTE
@pytest.mark.asyncio
async def test_get_entity_with_content(client):
"""Test retrieving a basic entity."""
@@ -55,10 +56,10 @@ async def test_get_entity_with_content(client):
]
)
create_result = await create_entities(entity_request)
path_id = create_result.entities[0].path_id
permalink = create_result.entities[0].permalink
# Get entity with content
entity = await get_entity(path_id, content=True)
entity = await get_entity(permalink, content=True)
assert entity.content is not None
# if we passed in content, it should just be the
@@ -113,14 +114,14 @@ async def test_get_entity_with_categorized_observations(client):
]
)
result = await create_entities(entity_request)
path_id = result.entities[0].path_id
permalink = result.entities[0].permalink
# Add observations with different categories
from basic_memory.mcp.tools.knowledge import add_observations
from basic_memory.schemas.request import AddObservationsRequest, ObservationCreate
obs_request = AddObservationsRequest(
path_id=path_id,
permalink=permalink,
observations=[
ObservationCreate(content="Technical detail", category=ObservationCategory.TECH),
ObservationCreate(content="Design decision", category=ObservationCategory.DESIGN),
@@ -130,7 +131,7 @@ async def test_get_entity_with_categorized_observations(client):
await add_observations(obs_request)
# Get and verify entity without content
entity = await get_entity(path_id)
entity = await get_entity(permalink)
assert len(entity.observations) == 3
categories = {obs.category for obs in entity.observations}
assert ObservationCategory.TECH in categories
+18 -16
View File
@@ -20,10 +20,10 @@ async def test_open_multiple_entities(client):
]
)
create_result = await create_entities(entity_request)
path_ids = [e.path_id for e in create_result.entities]
permalinks = [e.permalink for e in create_result.entities]
# Open the nodes
request = OpenNodesRequest(path_ids=path_ids)
request = OpenNodesRequest(permalinks=permalinks)
result = await open_nodes(request)
assert isinstance(result, EntityListResponse)
response = EntityListResponse.model_validate(result)
@@ -32,7 +32,7 @@ async def test_open_multiple_entities(client):
assert len(response.entities) == 2
for response_entity in response.entities:
assert response_entity.path_id in path_ids
assert response_entity.permalink in permalinks
assert response_entity.title in ["Entity1", "Entity2"]
@@ -51,10 +51,10 @@ async def test_open_nodes_with_details(client):
]
)
create_result = await create_entities(entity_request)
path_id = create_result.entities[0].path_id
permalink = create_result.entities[0].permalink
# Open the node
request = OpenNodesRequest(path_ids=[path_id])
request = OpenNodesRequest(permalinks=[permalink])
result = await open_nodes(request)
response = EntityListResponse.model_validate(result)
@@ -77,7 +77,7 @@ async def test_open_nodes_with_relations(client):
]
)
create_result = await create_entities(entity_request)
path_ids = [e.path_id for e in create_result.entities]
permalinks = [e.permalink for e in create_result.entities]
# Add a relation between them
from basic_memory.mcp.tools.knowledge import create_relations
@@ -85,12 +85,12 @@ async def test_open_nodes_with_relations(client):
from basic_memory.schemas.base import Relation
relation_request = CreateRelationsRequest(
relations=[Relation(from_id=path_ids[0], to_id=path_ids[1], relation_type="depends_on")]
relations=[Relation(from_id=permalinks[0], to_id=permalinks[1], relation_type="depends_on")]
)
await create_relations(relation_request)
# Open both nodes
request = OpenNodesRequest(path_ids=path_ids)
request = OpenNodesRequest(permalinks=permalinks)
result = await open_nodes(request)
response = EntityListResponse.model_validate(result)
@@ -105,31 +105,33 @@ async def test_open_nonexistent_nodes(client):
# First create one real entity
entity_request = CreateEntityRequest(entities=[Entity(title="RealEntity", entity_type="test")])
create_result = await create_entities(entity_request)
real_path_id = create_result.entities[0].path_id
real_permalink = create_result.entities[0].permalink
# Try to open both real and non-existent
request = OpenNodesRequest(path_ids=[real_path_id, "nonexistent"])
request = OpenNodesRequest(permalinks=[real_permalink, "nonexistent"])
result = await open_nodes(request)
response = EntityListResponse.model_validate(result)
# Should only get the real entity back
assert len(response.entities) == 1
assert real_path_id in response.entities[0].path_id
assert real_permalink in response.entities[0].permalink
@pytest.mark.asyncio
async def test_open_single_node(client):
"""Test behavior with single path_id."""
"""Test behavior with single permalink."""
# Create an entity
entity_request = CreateEntityRequest(entities=[Entity(title="SingleEntity", entity_type="test")])
entity_request = CreateEntityRequest(
entities=[Entity(title="SingleEntity", entity_type="test")]
)
create_result = await create_entities(entity_request)
path_id = create_result.entities[0].path_id
permalink = create_result.entities[0].permalink
# Open just one node
request = OpenNodesRequest(path_ids=[path_id])
request = OpenNodesRequest(permalinks=[permalink])
result = await open_nodes(request)
response = EntityListResponse.model_validate(result)
# Should get just that entity
assert len(response.entities) == 1
assert path_id in response.entities[0].path_id
assert permalink in response.entities[0].permalink
+30 -32
View File
@@ -30,7 +30,7 @@ async def related_entities(session_maker):
source = Entity(
title="source",
entity_type="test",
path_id="source/source",
permalink="source/source",
file_path="source/source.md",
summary="Source entity",
content_type="text/markdown",
@@ -38,7 +38,7 @@ async def related_entities(session_maker):
target = Entity(
title="target",
entity_type="test",
path_id="target/target",
permalink="target/target",
file_path="target/target.md",
summary="Target entity",
content_type="text/markdown",
@@ -59,7 +59,7 @@ async def test_create_entity(entity_repository: EntityRepository):
entity_data = {
"title": "Test",
"entity_type": "test",
"path_id": "test/test",
"permalink": "test/test",
"file_path": "test/test.md",
"summary": "Test description",
"content_type": "text/markdown",
@@ -93,7 +93,7 @@ async def test_create_all(entity_repository: EntityRepository):
{
"title": "Test_1",
"entity_type": "test",
"path_id": "test/test_1",
"permalink": "test/test_1",
"file_path": "test/test_1.md",
"summary": "Test description",
"content_type": "text/markdown",
@@ -101,7 +101,7 @@ async def test_create_all(entity_repository: EntityRepository):
{
"title": "Test-2",
"entity_type": "test",
"path_id": "test/test_2",
"permalink": "test/test_2",
"file_path": "test/test_2.md",
"summary": "Test description",
"content_type": "text/markdown",
@@ -131,7 +131,7 @@ async def test_create_entity_null_description(session_maker, entity_repository:
entity_data = {
"title": "Test",
"entity_type": "test",
"path_id": "test/test",
"permalink": "test/test",
"file_path": "test/test.md",
"content_type": "text/markdown",
"summary": None,
@@ -167,9 +167,7 @@ async def test_find_by_id(entity_repository: EntityRepository, sample_entity: En
@pytest.mark.asyncio
async def test_update_entity(entity_repository: EntityRepository, sample_entity: Entity):
"""Test updating an entity"""
updated = await entity_repository.update(
sample_entity.id, {"summary": "Updated description"}
)
updated = await entity_repository.update(sample_entity.id, {"summary": "Updated description"})
assert updated is not None
assert updated.summary == "Updated description"
assert updated.title == sample_entity.title # Other fields unchanged
@@ -282,15 +280,15 @@ async def test_entities(session_maker):
title="entity1",
entity_type="test",
summary="First test entity",
path_id="type1/entity1",
permalink="type1/entity1",
file_path="type1/entity1.md",
content_type= "text/markdown",
content_type="text/markdown",
),
Entity(
title="entity2",
entity_type="test",
summary="Second test entity",
path_id="type1/entity2",
permalink="type1/entity2",
file_path="type1/entity2.md",
content_type="text/markdown",
),
@@ -298,7 +296,7 @@ async def test_entities(session_maker):
title="entity3",
entity_type="test",
summary="Third test entity",
path_id="type2/entity3",
permalink="type2/entity3",
file_path="type2/entity3.md",
content_type="text/markdown",
),
@@ -308,39 +306,39 @@ async def test_entities(session_maker):
@pytest.mark.asyncio
async def test_find_by_path_ids(entity_repository: EntityRepository, test_entities):
async def test_find_by_permalinks(entity_repository: EntityRepository, test_entities):
"""Test finding multiple entities by their type/name pairs."""
# Test finding multiple entities
path_ids = [e.path_id for e in test_entities]
found = await entity_repository.find_by_path_ids(path_ids)
permalinks = [e.permalink for e in test_entities]
found = await entity_repository.find_by_permalinks(permalinks)
assert len(found) == 3
names = {e.title for e in found}
assert names == {"entity1", "entity2", "entity3"}
# Test finding subset of entities
path_ids = [e.path_id for e in test_entities if e.title != "entity2"]
found = await entity_repository.find_by_path_ids(path_ids)
permalinks = [e.permalink for e in test_entities if e.title != "entity2"]
found = await entity_repository.find_by_permalinks(permalinks)
assert len(found) == 2
names = {e.title for e in found}
assert names == {"entity1", "entity3"}
# Test with non-existent entities
path_ids = ["type1/entity1", "type3/nonexistent"]
found = await entity_repository.find_by_path_ids(path_ids)
permalinks = ["type1/entity1", "type3/nonexistent"]
found = await entity_repository.find_by_permalinks(permalinks)
assert len(found) == 1
assert found[0].title == "entity1"
# Test empty input
found = await entity_repository.find_by_path_ids([])
found = await entity_repository.find_by_permalinks([])
assert len(found) == 0
@pytest.mark.asyncio
async def test_delete_by_path_ids(entity_repository: EntityRepository, test_entities):
async def test_delete_by_permalinks(entity_repository: EntityRepository, test_entities):
"""Test deleting entities by type/name pairs."""
# Test deleting multiple entities
path_ids = [e.path_id for e in test_entities if e.title != "entity3"]
deleted_count = await entity_repository.delete_by_path_ids(path_ids)
permalinks = [e.permalink for e in test_entities if e.title != "entity3"]
deleted_count = await entity_repository.delete_by_permalinks(permalinks)
assert deleted_count == 2
# Verify deletions
@@ -350,16 +348,16 @@ async def test_delete_by_path_ids(entity_repository: EntityRepository, test_enti
# Test deleting non-existent entities
path__ids = ["type3/nonexistent"]
deleted_count = await entity_repository.delete_by_path_ids(path__ids)
deleted_count = await entity_repository.delete_by_permalinks(path__ids)
assert deleted_count == 0
# Test empty input
deleted_count = await entity_repository.delete_by_path_ids([])
deleted_count = await entity_repository.delete_by_permalinks([])
assert deleted_count == 0
@pytest.mark.asyncio
async def test_delete_by_path_ids_with_observations(
async def test_delete_by_permalinks_with_observations(
entity_repository: EntityRepository, test_entities, session_maker
):
"""Test deleting entities with observations by type/name pairs."""
@@ -372,8 +370,8 @@ async def test_delete_by_path_ids_with_observations(
session.add_all(observations)
# Delete entities
path_ids = [e.path_id for e in test_entities]
deleted_count = await entity_repository.delete_by_path_ids(path_ids)
permalinks = [e.permalink for e in test_entities]
deleted_count = await entity_repository.delete_by_permalinks(permalinks)
assert deleted_count == 3
# Verify observations were cascaded
@@ -396,7 +394,7 @@ async def test_list_entities_with_related(entity_repository: EntityRepository, s
core = Entity(
title="core_service",
entity_type="note",
path_id="service/core",
permalink="service/core",
file_path="service/core.md",
summary="Core service",
content_type="text/markdown",
@@ -404,7 +402,7 @@ async def test_list_entities_with_related(entity_repository: EntityRepository, s
dbe = Entity(
title="db_service",
entity_type="test",
path_id="service/db",
permalink="service/db",
file_path="service/db.md",
summary="Database service",
content_type="text/markdown",
@@ -413,7 +411,7 @@ async def test_list_entities_with_related(entity_repository: EntityRepository, s
config = Entity(
title="service_config",
entity_type="test",
path_id="config/service",
permalink="config/service",
file_path="config/service.md",
summary="Service configuration",
content_type="text/markdown",
@@ -91,7 +91,7 @@ async def test_delete_observations(session_maker: async_sessionmaker, repo):
title="test_entity",
entity_type="test",
summary="Test entity",
path_id="test/test_entity",
permalink="test/test_entity",
file_path="test/test_entity.md",
content_type="text/markdown",
)
@@ -121,7 +121,7 @@ async def test_delete_observation_by_id(session_maker: async_sessionmaker, repo)
title="test_entity",
entity_type="test",
summary="Test entity",
path_id="test/test_entity",
permalink="test/test_entity",
file_path="test/test_entity.md",
content_type="text/markdown",
)
@@ -150,7 +150,7 @@ async def test_delete_observation_by_content(session_maker: async_sessionmaker,
title="test_entity",
entity_type="test",
summary="Test entity",
path_id="test/test_entity",
permalink="test/test_entity",
file_path="test/test_entity.md",
content_type="text/markdown",
)
@@ -181,7 +181,7 @@ async def test_find_by_category(session_maker: async_sessionmaker, repo):
title="test_entity",
entity_type="test",
summary="Test entity",
path_id="test/test_entity",
permalink="test/test_entity",
file_path="test/test_entity.md",
content_type="text/markdown",
)
@@ -223,7 +223,7 @@ async def test_observation_categories(session_maker: async_sessionmaker, repo):
title="test_entity",
entity_type="test",
summary="Test entity",
path_id="test/test_entity",
permalink="test/test_entity",
file_path="test/test_entity.md",
content_type="text/markdown",
)
@@ -271,7 +271,7 @@ async def test_find_by_category_case_sensitivity(session_maker: async_sessionmak
title="test_entity",
entity_type="test",
summary="Test entity",
path_id="test/test_entity",
permalink="test/test_entity",
file_path="test/test_entity.md",
content_type="text/markdown",
)
+5 -5
View File
@@ -15,7 +15,7 @@ async def source_entity(session_maker):
entity = Entity(
title="test_source",
entity_type="test",
path_id="source/test_source",
permalink="source/test_source",
file_path="source/test_source.md",
summary="Source entity",
content_type="text/markdown",
@@ -32,7 +32,7 @@ async def target_entity(session_maker):
entity = Entity(
title="test_target",
entity_type="test",
path_id="target/test_target",
permalink="target/test_target",
file_path="target/test_target.md",
summary="Target entity",
content_type="text/markdown",
@@ -62,7 +62,7 @@ async def related_entity(entity_repository):
entity_data = {
"title": "Related Entity",
"entity_type": "test",
"path_id": "test/related_entity",
"permalink": "test/related_entity",
"file_path": "test/related_entity.md",
"summary": "A related test entity",
"content_type": "text/markdown",
@@ -165,8 +165,8 @@ async def test_find_by_entities(
async def test_find_relation(relation_repository: RelationRepository, sample_relation: Relation):
"""Test finding relations by type"""
relation = await relation_repository.find_relation(
from_path_id=sample_relation.from_entity.path_id,
to_path_id=sample_relation.to_entity.path_id,
from_permalink=sample_relation.from_entity.permalink,
to_permalink=sample_relation.to_entity.permalink,
relation_type=sample_relation.relation_type,
)
assert relation.id == sample_relation.id
+16 -24
View File
@@ -42,7 +42,7 @@ def test_timeframe_to_timedelta():
assert TimeFrame("1w").to_timedelta == timedelta(weeks=1)
assert TimeFrame("1m").to_timedelta == timedelta(days=30) # Approximate
def test_activity_change_model():
"""Test ActivityChange model."""
now = datetime.utcnow()
@@ -50,15 +50,15 @@ def test_activity_change_model():
activity_type=ActivityType.ENTITY,
change_type=ChangeType.CREATED,
timestamp=now,
path_id="test/path",
permalink="test/path",
summary="Created test document",
content="Test content"
content="Test content",
)
assert change.activity_type == ActivityType.ENTITY
assert change.change_type == ChangeType.CREATED
assert change.timestamp == now
assert change.path_id == "test/path"
assert change.permalink == "test/path"
assert change.summary == "Created test document"
assert change.content == "Test content"
@@ -66,11 +66,9 @@ def test_activity_change_model():
def test_activity_summary_model():
"""Test ActivitySummary model."""
summary = ActivitySummary(
entity_changes=3,
relation_changes=2,
most_active_paths=["path1", "path2"]
entity_changes=3, relation_changes=2, most_active_paths=["path1", "path2"]
)
assert summary.entity_changes == 3
assert summary.relation_changes == 2
assert summary.most_active_paths == ["path1", "path2"]
@@ -83,21 +81,15 @@ def test_recent_activity_model():
activity_type=ActivityType.ENTITY,
change_type=ChangeType.CREATED,
timestamp=now,
path_id="test/path",
summary="Created test document"
permalink="test/path",
summary="Created test document",
)
summary = ActivitySummary(
entity_changes=1
)
activity = RecentActivity(
timeframe="1d",
changes=[change],
summary=summary
)
summary = ActivitySummary(entity_changes=1)
activity = RecentActivity(timeframe="1d", changes=[change], summary=summary)
assert activity.timeframe == "1d"
assert len(activity.changes) == 1
assert activity.changes[0].path_id == "test/path"
assert activity.summary.entity_changes == 1
assert activity.changes[0].permalink == "test/path"
assert activity.summary.entity_changes == 1
+1 -1
View File
@@ -41,7 +41,7 @@ def test_observation_response():
"""Test ObservationResponse validation and conversion."""
data = {
"id": 1,
"path_id": 1,
"permalink": 1,
"content": "Test observation",
"category": "tech",
"context": "Test context",
+29 -28
View File
@@ -9,7 +9,8 @@ from basic_memory.schemas import (
Relation,
CreateEntityRequest,
SearchNodesRequest,
OpenNodesRequest, RelationResponse,
OpenNodesRequest,
RelationResponse,
)
from basic_memory.schemas.base import to_snake_case
@@ -70,9 +71,16 @@ def test_relation_in_validation():
with pytest.raises(ValidationError):
Relation.model_validate({"from_id": "123", "to_id": "456"}) # Missing relationType
def test_relation_response():
"""Test RelationResponse validation."""
data = {"from_id": "test/123", "to_id": "test/456", "relation_type": "test", "from_entity":{"path_id": "test/123"}, "to_entity":{"path_id": "test/456"}}
data = {
"from_id": "test/123",
"to_id": "test/456",
"relation_type": "test",
"from_entity": {"permalink": "test/123"},
"to_entity": {"permalink": "test/456"},
}
relation = RelationResponse.model_validate(data)
assert relation.from_id == "test/123"
assert relation.to_id == "test/456"
@@ -101,18 +109,24 @@ def test_entity_out_from_attributes():
"""Test EntityOut creation from database model attributes."""
# Simulate database model attributes
db_data = {
"path_id": "test/test",
"permalink": "test/test",
"title": "test",
"entity_type": "knowledge",
"content_type": "text/markdown",
"summary": "test description",
"observations": [{"id": 1, "content": "test obs", "context": None}],
"relations": [
{"id": 1, "from_id": "test/test", "to_id": "test/test", "relation_type": "test", "context": None}
{
"id": 1,
"from_id": "test/test",
"to_id": "test/test",
"relation_type": "test",
"context": None,
}
],
}
entity = EntityResponse.model_validate(db_data)
assert entity.path_id == "test/test"
assert entity.permalink == "test/test"
assert entity.summary == "test description"
assert len(entity.observations) == 1
assert len(entity.relations) == 1
@@ -156,12 +170,12 @@ def test_search_nodes_input():
def test_open_nodes_input():
"""Test OpenNodesInput validation."""
open_input = OpenNodesRequest.model_validate({"path_ids": ["test/test", "test/test2"]})
assert len(open_input.path_ids) == 2
open_input = OpenNodesRequest.model_validate({"permalinks": ["test/test", "test/test2"]})
assert len(open_input.permalinks) == 2
# Empty names list should fail
with pytest.raises(ValidationError):
OpenNodesRequest.model_validate({"path_ids": []})
OpenNodesRequest.model_validate({"permalinks": []})
def test_path_sanitization():
@@ -184,28 +198,15 @@ def test_path_sanitization():
assert result == expected, f"Failed for input: {input_str}"
def test_path_id_generation():
"""Test path_id property generates correct paths."""
def test_permalink_generation():
"""Test permalink property generates correct paths."""
test_cases = [
(
{"title": "BasicMemory", "entity_type": "knowledge"},
"basic_memory"
),
(
{"title": "Memory Service", "entity_type": "knowledge"},
"memory_service"
),
(
{"title": "API Gateway", "entity_type": "knowledge"},
"api_gateway"
),
(
{"title": "TestCase1", "entity_type": "knowledge"},
"test_case1"
),
({"title": "BasicMemory", "entity_type": "knowledge"}, "basic_memory"),
({"title": "Memory Service", "entity_type": "knowledge"}, "memory_service"),
({"title": "API Gateway", "entity_type": "knowledge"}, "api_gateway"),
({"title": "TestCase1", "entity_type": "knowledge"}, "test_case1"),
]
for input_data, expected_path in test_cases:
entity = Entity.model_validate(input_data)
assert entity.path_id == expected_path, f"Failed for input: {input_data}"
assert entity.permalink == expected_path, f"Failed for input: {input_data}"
+43 -50
View File
@@ -1,13 +1,11 @@
"""Tests for EntityService."""
from pathlib import Path
import pytest
import pytest_asyncio
import yaml
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from basic_memory.models import Entity as EntityModel
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.schemas import Entity as EntitySchema
from basic_memory.services import FileService
from basic_memory.services.entity_service import EntityService
@@ -16,7 +14,6 @@ from basic_memory.services.exceptions import EntityNotFoundError
pytestmark = pytest.mark.asyncio
async def test_create_entity(entity_service: EntityService, file_service: FileService):
"""Test successful entity creation."""
entity_data = EntitySchema(
@@ -32,7 +29,7 @@ async def test_create_entity(entity_service: EntityService, file_service: FileSe
# Assert Entity
assert isinstance(entity, EntityModel)
assert entity.title == "TestEntity"
assert entity.path_id == entity_data.path_id
assert entity.permalink == entity_data.permalink
assert entity.file_path == entity_data.file_path
assert entity.entity_type == "test"
assert entity.summary == "A test entity description"
@@ -40,8 +37,8 @@ async def test_create_entity(entity_service: EntityService, file_service: FileSe
assert entity.observations[0].content == "this is a test observation"
assert len(entity.relations) == 0
# Verify we can retrieve it using path_id
retrieved = await entity_service.get_by_path_id(entity_data.path_id)
# Verify we can retrieve it using permalink
retrieved = await entity_service.get_by_permalink(entity_data.permalink)
assert retrieved.summary == "A test entity description"
assert retrieved.title == "TestEntity"
assert retrieved.entity_type == "test"
@@ -58,7 +55,7 @@ async def test_create_entity(entity_service: EntityService, file_service: FileSe
metadata = yaml.safe_load(frontmatter)
# Verify frontmatter contents
assert metadata["id"] == entity.path_id
assert metadata["id"] == entity.permalink
assert metadata["type"] == entity.entity_type
assert "created" in metadata
assert "modified" in metadata
@@ -103,11 +100,11 @@ async def test_create_entities(entity_service: EntityService, file_service: File
assert entity2.created_at is not None
assert entity2.observations[0].content == "this is a test observation"
# Verify we can retrieve them using path_ids
retrieved1 = await entity_service.get_by_path_id(entity_data[0].path_id)
# Verify we can retrieve them using permalinks
retrieved1 = await entity_service.get_by_permalink(entity_data[0].permalink)
assert retrieved1.summary == "A test entity description"
retrieved2 = await entity_service.get_by_path_id(entity_data[1].path_id)
retrieved2 = await entity_service.get_by_permalink(entity_data[1].permalink)
assert retrieved2.summary == "A test entity description"
# verify files are written
@@ -116,8 +113,7 @@ async def test_create_entities(entity_service: EntityService, file_service: File
assert await file_service.exists(file_path)
async def test_get_by_path_id(entity_service: EntityService):
async def test_get_by_permalink(entity_service: EntityService):
"""Test finding entity by type and name combination."""
entity1_data = EntitySchema(
title="TestEntity1",
@@ -136,14 +132,14 @@ async def test_get_by_path_id(entity_service: EntityService):
entity2 = await entity_service.create_entity(entity2_data)
# Find by type1 and name
found = await entity_service.get_by_path_id(entity1_data.path_id)
found = await entity_service.get_by_permalink(entity1_data.permalink)
assert found is not None
assert found.id == entity1.id
assert found.entity_type == entity1.entity_type
assert found.summary == "First test entity"
# Find by type2 and name
found = await entity_service.get_by_path_id(entity2_data.path_id)
found = await entity_service.get_by_permalink(entity2_data.permalink)
assert found is not None
assert found.id == entity2.id
assert found.entity_type == entity2.entity_type
@@ -151,7 +147,7 @@ async def test_get_by_path_id(entity_service: EntityService):
# Test not found case
with pytest.raises(EntityNotFoundError):
await entity_service.get_by_path_id("nonexistent/test_entity")
await entity_service.get_by_permalink("nonexistent/test_entity")
async def test_create_entity_no_description(entity_service: EntityService):
@@ -162,7 +158,7 @@ async def test_create_entity_no_description(entity_service: EntityService):
assert entity.summary is None
# Verify after retrieval
retrieved = await entity_service.get_by_path_id(entity_data.path_id)
retrieved = await entity_service.get_by_permalink(entity_data.permalink)
assert retrieved.summary is None
@@ -177,7 +173,7 @@ async def test_get_entity_success(entity_service: EntityService):
await entity_service.create_entity(entity_data)
# Get by path ID
retrieved = await entity_service.get_by_path_id(entity_data.path_id)
retrieved = await entity_service.get_by_permalink(entity_data.permalink)
assert isinstance(retrieved, EntityModel)
assert retrieved.title == "TestEntity"
@@ -185,7 +181,6 @@ async def test_get_entity_success(entity_service: EntityService):
assert retrieved.summary == "Test description"
async def test_delete_entity_success(entity_service: EntityService):
"""Test successful entity deletion."""
entity_data = EntitySchema(
@@ -195,19 +190,19 @@ async def test_delete_entity_success(entity_service: EntityService):
)
await entity_service.create_entity(entity_data)
# Act using path_id
result = await entity_service.delete_entity(entity_data.path_id)
# Act using permalink
result = await entity_service.delete_entity(entity_data.permalink)
# Assert
assert result is True
with pytest.raises(EntityNotFoundError):
await entity_service.get_by_path_id(entity_data.path_id)
await entity_service.get_by_permalink(entity_data.permalink)
async def test_get_entity_by_path_id_not_found(entity_service: EntityService):
async def test_get_entity_by_permalink_not_found(entity_service: EntityService):
"""Test handling of non-existent entity retrieval."""
with pytest.raises(EntityNotFoundError):
await entity_service.get_by_path_id("test/non_existent")
await entity_service.get_by_permalink("test/non_existent")
async def test_delete_nonexistent_entity(entity_service: EntityService):
@@ -229,8 +224,8 @@ async def test_create_entity_with_special_chars(entity_service: EntityService):
assert entity.title == name
assert entity.summary == description
# Verify after retrieval using path_id
retrieved = await entity_service.get_by_path_id(entity_data.path_id)
# Verify after retrieval using permalink
retrieved = await entity_service.get_by_permalink(entity_data.permalink)
assert retrieved.summary == description
@@ -247,12 +242,12 @@ async def test_create_entity_long_description(entity_service: EntityService):
entity = await entity_service.create_entity(entity_data)
assert entity.summary == long_description
# Verify after retrieval using path_id
retrieved = await entity_service.get_by_path_id(entity_data.path_id)
# Verify after retrieval using permalink
retrieved = await entity_service.get_by_permalink(entity_data.permalink)
assert retrieved.summary == long_description
async def test_open_nodes_by_path_ids(entity_service: EntityService):
async def test_open_nodes_by_permalinks(entity_service: EntityService):
"""Test opening multiple nodes by path IDs."""
# Create test entities
entity1_data = EntitySchema(
@@ -271,8 +266,8 @@ async def test_open_nodes_by_path_ids(entity_service: EntityService):
await entity_service.create_entity(entity2_data)
# Open nodes by path IDs
path_ids = [entity1_data.path_id, entity2_data.path_id]
found = await entity_service.open_nodes(path_ids)
permalinks = [entity1_data.permalink, entity2_data.permalink]
found = await entity_service.open_nodes(permalinks)
assert len(found) == 2
names = {e.title for e in found}
@@ -297,14 +292,14 @@ async def test_open_nodes_some_not_found(entity_service: EntityService):
await entity_service.create_entity(entity_data)
# Try to open two nodes, one exists, one doesn't
path_ids = [entity_data.path_id, "type1/non_existent"]
found = await entity_service.open_nodes(path_ids)
permalinks = [entity_data.permalink, "type1/non_existent"]
found = await entity_service.open_nodes(permalinks)
assert len(found) == 1
assert found[0].title == "Entity1"
async def test_delete_entities_by_path_ids(entity_service: EntityService):
async def test_delete_entities_by_permalinks(entity_service: EntityService):
"""Test deleting multiple entities by path IDs."""
# Create test entities
entity1_data = EntitySchema(
@@ -323,14 +318,14 @@ async def test_delete_entities_by_path_ids(entity_service: EntityService):
await entity_service.create_entity(entity2_data)
# Delete by path IDs
path_ids = [entity1_data.path_id, entity2_data.path_id]
result = await entity_service.delete_entities(path_ids)
permalinks = [entity1_data.permalink, entity2_data.permalink]
result = await entity_service.delete_entities(permalinks)
assert result is True
# Verify both are deleted
for path_id in path_ids:
for permalink in permalinks:
with pytest.raises(EntityNotFoundError):
await entity_service.get_by_path_id(path_id)
await entity_service.get_by_permalink(permalink)
async def test_delete_entities_empty_input(entity_service: EntityService):
@@ -341,8 +336,8 @@ async def test_delete_entities_empty_input(entity_service: EntityService):
async def test_delete_entities_none_found(entity_service: EntityService):
"""Test deleting non-existent entities by path IDs."""
path_ids = ["type1/NonExistent1", "type2/NonExistent2"]
result = await entity_service.delete_entities(path_ids)
permalinks = ["type1/NonExistent1", "type2/NonExistent2"]
result = await entity_service.delete_entities(permalinks)
assert result is True
@@ -351,7 +346,7 @@ async def test_get_entity_path(entity_service: EntityService):
"""Should generate correct filesystem path for entity."""
entity = EntityModel(
id=1,
path_id="test-entity",
permalink="test-entity",
title="test-entity",
entity_type="test",
summary="Test entity",
@@ -360,9 +355,10 @@ async def test_get_entity_path(entity_service: EntityService):
assert path == Path(entity_service.file_service.base_path / "test-entity.md")
@pytest.mark.asyncio
async def test_update_knowledge_entity_summary(entity_service: EntityService, file_service: FileService):
async def test_update_knowledge_entity_summary(
entity_service: EntityService, file_service: FileService
):
"""Should update knowledge entity description and write to file."""
# Create test entity
entity = await entity_service.create_entity(
@@ -375,9 +371,7 @@ async def test_update_knowledge_entity_summary(entity_service: EntityService, fi
)
# Update description
updated = await entity_service.update_entity(
entity.path_id, summary="Updated description"
)
updated = await entity_service.update_entity(entity.permalink, summary="Updated description")
# Verify file has new description but preserved metadata
file_path = file_service.get_entity_path(updated)
@@ -391,7 +385,6 @@ async def test_update_knowledge_entity_summary(entity_service: EntityService, fi
assert metadata["status"] == "draft"
@pytest.mark.asyncio
async def test_update_note_entity_content(entity_service: EntityService, file_service: FileService):
"""Should update note content directly."""
@@ -407,7 +400,7 @@ async def test_update_note_entity_content(entity_service: EntityService, file_se
# Update content
new_content = "# Updated Content\n\nThis is new content."
updated = await entity_service.update_entity(entity.path_id, content=new_content)
updated = await entity_service.update_entity(entity.permalink, content=new_content)
# Verify file has new content but preserved metadata
file_path = file_service.get_entity_path(updated)
@@ -436,7 +429,7 @@ async def test_update_entity_name(entity_service: EntityService, file_service: F
)
# Update name
updated = await entity_service.update_entity(entity.path_id, title="new-name")
updated = await entity_service.update_entity(entity.permalink, title="new-name")
# Verify name was updated in DB
assert updated.title == "new-name"
@@ -447,7 +440,7 @@ async def test_update_entity_name(entity_service: EntityService, file_service: F
_, frontmatter, _ = content.split("---", 2)
metadata = yaml.safe_load(frontmatter)
assert metadata["id"] == entity.path_id
assert metadata["id"] == entity.permalink
# And verify content uses new name for title
assert "# new-name" in content
+18 -15
View File
@@ -16,7 +16,7 @@ async def test_add_observations(observation_service: ObservationService, sample_
"""Test adding observations to an entity."""
observations = ["Test observation 1", "Test observation 2"]
entity = await observation_service.add_observations(sample_entity.path_id, observations)
entity = await observation_service.add_observations(sample_entity.permalink, observations)
observations = entity.observations
assert len(observations) == 2
@@ -26,19 +26,23 @@ async def test_add_observations(observation_service: ObservationService, sample_
@pytest.mark.asyncio
async def test_delete_observations(observation_service: ObservationService, entity_service: EntityService, sample_entity: Entity):
async def test_delete_observations(
observation_service: ObservationService, entity_service: EntityService, sample_entity: Entity
):
"""Test deleting specific observations from an entity."""
# First add observations
await observation_service.add_observations(
sample_entity.path_id, ["First observation", "Second observation", "Third observation"]
sample_entity.permalink, ["First observation", "Second observation", "Third observation"]
)
# Then delete some
contents_to_delete = ["First observation", "Second observation"]
entity = await observation_service.delete_observations(sample_entity.path_id, contents_to_delete)
entity = await observation_service.delete_observations(
sample_entity.permalink, contents_to_delete
)
# Verify
entity = await entity_service.get_by_path_id(sample_entity.path_id)
entity = await entity_service.get_by_permalink(sample_entity.permalink)
assert len(entity.observations) == 1
assert entity.observations[0].content == "Third observation"
assert entity.observations[0].entity_id == sample_entity.id
@@ -49,7 +53,7 @@ async def test_delete_by_entity(observation_service: ObservationService, sample_
"""Test deleting all observations for an entity."""
# First add observations
await observation_service.add_observations(
sample_entity.path_id, ["First observation", "Second observation"]
sample_entity.permalink, ["First observation", "Second observation"]
)
# Delete all observations for entity
@@ -65,7 +69,7 @@ async def test_delete_nonexistent_observation(
):
"""Test deleting observations that don't exist."""
entity = await observation_service.delete_observations(
sample_entity.path_id, ["Nonexistent observation"]
sample_entity.permalink, ["Nonexistent observation"]
)
assert entity is not None
@@ -73,7 +77,7 @@ async def test_delete_nonexistent_observation(
@pytest.mark.asyncio
async def test_delete_observations_invalid_entity(observation_service: ObservationService):
"""Test deleting observations for an entity that doesn't exist."""
with pytest.raises(EntityNotFoundError):
await observation_service.delete_observations("invalid_entity", ["Test observation"])
@@ -85,7 +89,7 @@ async def test_observation_with_special_characters(
"""Test handling observations with special characters."""
content = "Test & observation with @#$% special chars!"
entity = await observation_service.add_observations(sample_entity.path_id, [content])
entity = await observation_service.add_observations(sample_entity.permalink, [content])
assert len(entity.observations) == 1
assert entity.observations[0].content == content
@@ -97,7 +101,7 @@ async def test_very_long_observation(
"""Test handling very long observation content."""
long_content = "Very long observation " * 100 # ~1800 characters
entity = await observation_service.add_observations(sample_entity.path_id, [long_content])
entity = await observation_service.add_observations(sample_entity.permalink, [long_content])
assert len(entity.observations) == 1
assert entity.observations[0].content == long_content
@@ -137,14 +141,13 @@ async def test_add_observations_with_categories(
ObservationCreate(content="Design observation", category=ObservationCategory.DESIGN),
]
entity = await observation_service.add_observations(sample_entity.path_id, observations)
entity = await observation_service.add_observations(sample_entity.permalink, observations)
assert len(entity.observations) == 2
assert entity.observations[0].category == ObservationCategory.TECH.value
assert entity.observations[1].category == ObservationCategory.DESIGN.value
@pytest.mark.asyncio
async def test_get_observations_by_category(
observation_service: ObservationService, sample_entity: Entity
@@ -156,7 +159,7 @@ async def test_get_observations_by_category(
ObservationCreate(content="Design decision", category=ObservationCategory.DESIGN),
ObservationCreate(content="Second tech note", category=ObservationCategory.TECH),
]
await observation_service.add_observations(sample_entity.path_id, observations)
await observation_service.add_observations(sample_entity.permalink, observations)
# Get tech observations
tech_obs = await observation_service.get_observations_by_category(ObservationCategory.TECH)
@@ -182,7 +185,7 @@ async def test_observation_categories(
ObservationCreate(content="Another tech note", category=ObservationCategory.TECH),
ObservationCreate(content="Feature note", category=ObservationCategory.FEATURE),
]
await observation_service.add_observations(sample_entity.path_id, observations)
await observation_service.add_observations(sample_entity.permalink, observations)
# Get categories
categories = await observation_service.observation_categories()
@@ -202,7 +205,7 @@ async def test_default_category_behavior(
observations = [
ObservationCreate(content="Simple note") # No category specified
]
entity = await observation_service.add_observations(sample_entity.path_id, observations)
entity = await observation_service.add_observations(sample_entity.permalink, observations)
assert len(entity.observations) == 1
assert entity.observations[0].category == ObservationCategory.NOTE.value # Should use default
+16 -10
View File
@@ -19,7 +19,7 @@ async def test_entities(
entity1 = EntityModel(
title="test_entity_1",
entity_type="test",
path_id="test/test_entity_1",
permalink="test/test_entity_1",
file_path="test/test_entity_1.md",
summary="Test entity 1",
content_type="text/markdown",
@@ -27,7 +27,7 @@ async def test_entities(
entity2 = EntityModel(
title="test_entity_2",
entity_type="test",
path_id="test/test_entity_2",
permalink="test/test_entity_2",
file_path="test/test_entity_2.md",
summary="Test entity 2",
content_type="text/markdown",
@@ -49,10 +49,16 @@ async def test_create_relations(
relation_data = [
RelationSchema(
from_id=entity1.path_id, to_id=entity2.path_id, relation_type="type_0", context="context_0"
from_id=entity1.permalink,
to_id=entity2.permalink,
relation_type="type_0",
context="context_0",
),
RelationSchema(
from_id=entity1.path_id, to_id=entity2.path_id, relation_type="type_1", context="context_1"
from_id=entity1.permalink,
to_id=entity2.permalink,
relation_type="type_1",
context="context_1",
),
]
@@ -85,16 +91,16 @@ async def test_create_relations(
assert relations_e1[1].relation_type == "type_1"
# Verify outgoing relation is updated
found = await entity_service.get_by_path_id(entity1.path_id)
found = await entity_service.get_by_permalink(entity1.permalink)
file_path = file_service.get_entity_path(found)
content, _ = await file_service.read_file(file_path)
# verify relation format
assert f"- type_0 [[{entity2.title}]] (context_0)" in content
assert f"- type_1 [[{entity2.title}]] (context_1)" in content
# Verify other entity file is not updated
found = await entity_service.get_by_path_id(entity2.path_id)
found = await entity_service.get_by_permalink(entity2.permalink)
file_path = file_service.get_entity_path(found)
content, _ = await file_service.read_file(file_path)
assert "type_0" not in content
@@ -109,7 +115,7 @@ async def test_delete_relation(
# Create a relation first
relation_data = RelationSchema(
from_id=entity1.path_id, to_id=entity2.path_id, relation_type="test_relation"
from_id=entity1.permalink, to_id=entity2.permalink, relation_type="test_relation"
)
await relation_service.create_relations([relation_data])
@@ -140,10 +146,10 @@ async def test_delete_relations_by_criteria(
# Create test relations
relation1 = RelationSchema(
from_id=entity1.path_id, to_id=entity2.path_id, relation_type="relation1"
from_id=entity1.permalink, to_id=entity2.permalink, relation_type="relation1"
)
relation2 = RelationSchema(
from_id=entity1.path_id, to_id=entity2.path_id, relation_type="relation2"
from_id=entity1.permalink, to_id=entity2.permalink, relation_type="relation2"
)
await relation_service.create_relations([relation1, relation2])
+4 -4
View File
@@ -17,7 +17,7 @@ def test_entity():
title = "TestComponent"
entity_type = "knowledge"
entity_metadata = {"test": "test"}
path_id = "component/test_component"
permalink = "component/test_component"
file_path = "entities/component/test_component.md"
summary = "A test component for search"
content_type = "text/markdown"
@@ -35,7 +35,7 @@ def test_document():
class Document:
id = 1
path_id = "docs/test_doc.md"
permalink = "docs/test_doc.md"
file_path = "docs/test_doc.md"
doc_metadata = {"title": "Test Document", "type": "technical"}
created_at = datetime.now(timezone.utc)
@@ -63,7 +63,7 @@ async def test_index_entity(search_service, test_entity):
# Search for the entity
results = await search_service.search(SearchQuery(text="test component"))
assert len(results) == 1
assert results[0].path_id == test_entity.path_id
assert results[0].permalink == test_entity.permalink
assert results[0].type == SearchItemType.ENTITY
@@ -138,7 +138,7 @@ async def test_reindex_all(search_service, entity_service, session_maker):
SearchQuery(text="TestComponent", types=[SearchItemType.ENTITY])
)
assert len(entity_results) == 1
assert entity_results[0].path_id == test_entity.path_id
assert entity_results[0].permalink == test_entity.permalink
assert entity_results[0].type == SearchItemType.ENTITY
+9 -7
View File
@@ -3,7 +3,6 @@
from pathlib import Path
import pytest
from multipart import file_path
from basic_memory.models import Entity
from basic_memory.sync import FileChangeScanner
@@ -67,7 +66,8 @@ async def test_scan_with_unreadable_file(file_change_scanner: FileChangeScanner,
@pytest.mark.asyncio
async def test_detect_new_files(
file_change_scanner: FileChangeScanner, temp_dir: Path,
file_change_scanner: FileChangeScanner,
temp_dir: Path,
):
"""Test detection of new files."""
# Create new file
@@ -91,7 +91,9 @@ async def test_detect_modified_file(file_change_scanner: FileChangeScanner, temp
# Create DB state with original checksum
original_checksum = await compute_checksum(content)
db_records = {file_path: FileState(file_path=file_path, path_id="test", checksum=original_checksum)}
db_records = {
file_path: FileState(file_path=file_path, permalink="test", checksum=original_checksum)
}
# Modify file
await create_test_file(temp_dir / file_path, "modified")
@@ -108,7 +110,9 @@ async def test_detect_deleted_files(file_change_scanner: FileChangeScanner, temp
file_path = "deleted.md"
# Create DB state with file that doesn't exist
db_records = {file_path: FileState(file_path=file_path, path_id="deleted", checksum="any-checksum")}
db_records = {
file_path: FileState(file_path=file_path, permalink="deleted", checksum="any-checksum")
}
changes = await file_change_scanner.find_changes(directory=temp_dir, db_file_state=db_records)
@@ -116,11 +120,10 @@ async def test_detect_deleted_files(file_change_scanner: FileChangeScanner, temp
assert file_path in changes.deleted
@pytest.mark.asyncio
async def test_get_db_state_entities(file_change_scanner: FileChangeScanner):
"""Test converting entity records to file states."""
entity = Entity(path_id="concept/test", file_path="concept/test.md", checksum="test-checksum")
entity = Entity(permalink="concept/test", file_path="concept/test.md", checksum="test-checksum")
db_records = await file_change_scanner.get_db_file_state([entity])
@@ -129,7 +132,6 @@ async def test_get_db_state_entities(file_change_scanner: FileChangeScanner):
assert db_records["concept/test.md"].checksum == "test-checksum"
@pytest.mark.asyncio
async def test_empty_directory(file_change_scanner: FileChangeScanner, temp_dir: Path):
"""Test handling empty/nonexistent directory."""
+7 -9
View File
@@ -48,9 +48,7 @@ def test_content() -> EntityContent:
@pytest_asyncio.fixture
def test_markdown(test_frontmatter, test_content) -> EntityMarkdown:
"""Create complete test markdown entity."""
return EntityMarkdown(
frontmatter=test_frontmatter, content=test_content
)
return EntityMarkdown(frontmatter=test_frontmatter, content=test_content)
@pytest.mark.asyncio
@@ -64,7 +62,7 @@ async def test_create_entity_without_relations(
# Check basic fields
assert entity.title == "Test Entity"
assert entity.entity_type == "knowledge"
assert entity.path_id == "concept/test_entity"
assert entity.permalink == "concept/test_entity"
assert entity.summary == "A test entity description"
# Check observations
@@ -94,7 +92,7 @@ async def test_update_entity_without_relations(
# Update entity
updated = await knowledge_sync_service.update_entity_and_observations(
entity.path_id, test_markdown
entity.permalink, test_markdown
)
# Check fields updated
@@ -119,14 +117,14 @@ async def test_update_entity_relations(
other_entity = EntityModel(
title="Other Entity",
entity_type="test",
path_id="concept/other_entity",
permalink="concept/other_entity",
file_path="concept/other_entity.md",
content_type="text/markdown",
)
another_entity = EntityModel(
title="Another Entity",
entity_type="test",
path_id="concept/another_entity",
permalink="concept/another_entity",
file_path="concept/another_entity.md",
content_type="text/markdown",
)
@@ -164,14 +162,14 @@ async def test_two_pass_sync_flow(
other_entity = EntityModel(
title="Other Entity",
entity_type="test",
path_id="concept/other_entity",
permalink="concept/other_entity",
file_path="concept/other_entity.md",
content_type="text/markdown",
)
another_entity = EntityModel(
title="Another Entity",
entity_type="test",
path_id="concept/another_entity",
permalink="concept/another_entity",
file_path="concept/another_entity.md",
content_type="text/markdown",
)
+11 -11
View File
@@ -46,7 +46,7 @@ A test concept.
# Create related entity in DB that will be deleted
other = Entity(
path_id="concept/other",
permalink="concept/other",
title="Other",
entity_type="test",
file_path="concept/other.md",
@@ -63,12 +63,12 @@ A test concept.
assert len(entities) == 1
# Find new entity
test_concept = next(e for e in entities if e.path_id == "concept/test_concept")
test_concept = next(e for e in entities if e.permalink == "concept/test_concept")
assert test_concept.entity_type == "knowledge"
# Verify relation was not created
# because file for related entity was not found
entity = await entity_service.get_by_path_id(test_concept.path_id)
entity = await entity_service.get_by_permalink(test_concept.permalink)
relations = entity.relations
assert len(relations) == 0
@@ -103,7 +103,7 @@ modified: 2024-01-01
await sync_service.sync(test_config.home)
# Verify entity created but no relations
entity = await sync_service.knowledge_sync_service.entity_repository.get_by_path_id(
entity = await sync_service.knowledge_sync_service.entity_repository.get_by_permalink(
"concept/depends_on_future"
)
assert entity is not None
@@ -157,10 +157,10 @@ modified: 2024-01-01
await sync_service.sync(test_config.home)
# Verify both entities and their relations
entity_a = await sync_service.knowledge_sync_service.entity_repository.get_by_path_id(
entity_a = await sync_service.knowledge_sync_service.entity_repository.get_by_permalink(
"concept/entity_a"
)
entity_b = await sync_service.knowledge_sync_service.entity_repository.get_by_path_id(
entity_b = await sync_service.knowledge_sync_service.entity_repository.get_by_permalink(
"concept/entity_b"
)
@@ -232,7 +232,7 @@ modified: 2024-01-01
await sync_service.sync(test_config.home)
# Verify duplicates are handled
entity = await sync_service.knowledge_sync_service.entity_repository.get_by_path_id(
entity = await sync_service.knowledge_sync_service.entity_repository.get_by_permalink(
"concept/duplicate_relations"
)
@@ -274,7 +274,7 @@ modified: 2024-01-01
await sync_service.sync(test_config.home)
# Verify observations
entity = await sync_service.knowledge_sync_service.entity_repository.get_by_path_id(
entity = await sync_service.knowledge_sync_service.entity_repository.get_by_permalink(
"concept/invalid_category"
)
@@ -353,13 +353,13 @@ modified: 2024-01-01
await sync_service.sync(test_config.home)
# Verify all relations are created correctly regardless of order
entity_a = await sync_service.knowledge_sync_service.entity_repository.get_by_path_id(
entity_a = await sync_service.knowledge_sync_service.entity_repository.get_by_permalink(
"concept/entity_a"
)
entity_b = await sync_service.knowledge_sync_service.entity_repository.get_by_path_id(
entity_b = await sync_service.knowledge_sync_service.entity_repository.get_by_permalink(
"concept/entity_b"
)
entity_c = await sync_service.knowledge_sync_service.entity_repository.get_by_path_id(
entity_c = await sync_service.knowledge_sync_service.entity_repository.get_by_permalink(
"concept/entity_c"
)
+3 -3
View File
@@ -58,7 +58,7 @@ modified: 2024-01-01
await asyncio.gather(sync_service.sync(test_config.home), modify_file())
# Verify final state
doc = await sync_service.knowledge_sync_service.entity_repository.get_by_path_id("changing")
doc = await sync_service.knowledge_sync_service.entity_repository.get_by_permalink("changing")
assert doc is not None
# File should have a checksum, even if it's from either version
assert doc.checksum is not None
@@ -71,7 +71,7 @@ async def test_sync_null_checksum_cleanup(
"""Test handling of entities with null checksums from incomplete syncs."""
# Create entity with null checksum (simulating incomplete sync)
entity = Entity(
path_id="concept/incomplete",
permalink="concept/incomplete",
title="Incomplete",
entity_type="test",
file_path="concept/incomplete.md",
@@ -99,5 +99,5 @@ modified: 2024-01-01
await sync_service.sync(test_config.home)
# Verify entity was properly synced
updated = await entity_service.get_by_path_id("concept/incomplete")
updated = await entity_service.get_by_permalink("concept/incomplete")
assert updated.checksum is not None