mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
change entity.path_id to entity.permalink
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user