mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
fix updates and save content. remove entity.summary
This commit is contained in:
@@ -1076,7 +1076,6 @@ Context(
|
||||
primary=entity, # The main entity
|
||||
related_results=[...], # Related entities
|
||||
discussions=[...], # Relevant chats/discussions
|
||||
summary="..." # AI-friendly summary
|
||||
)
|
||||
```
|
||||
|
||||
@@ -1171,7 +1170,6 @@ class ContextBuilder:
|
||||
primary=entity,
|
||||
related_results=related,
|
||||
discussions=discussions,
|
||||
summary=summary
|
||||
)
|
||||
self.cache.set(f"{uri}:{depth}", context)
|
||||
return context
|
||||
@@ -1283,7 +1281,6 @@ async def merge_contexts(
|
||||
return Context(
|
||||
entities=list(all_entities),
|
||||
discussions=list(all_discussions),
|
||||
summary=self.summarize_merged(contexts)
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ async def create_or_update_entity(
|
||||
entity, created = await entity_service.create_or_update_entity(data)
|
||||
response.status_code = 201 if created else 200
|
||||
|
||||
# Always reindex since content has changed
|
||||
# reindex
|
||||
await search_service.index_entity(entity, background_tasks=background_tasks)
|
||||
|
||||
return EntityResponse.model_validate(entity)
|
||||
|
||||
+34
-16
@@ -11,7 +11,8 @@ from sqlalchemy.ext.asyncio import (
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.config import ProjectConfig, config
|
||||
from basic_memory.markdown.knowledge_writer import KnowledgeWriter
|
||||
from basic_memory.markdown import EntityParser
|
||||
from basic_memory.markdown.markdown_processor import MarkdownProcessor
|
||||
from basic_memory.repository.entity_repository import EntityRepository
|
||||
from basic_memory.repository.observation_repository import ObservationRepository
|
||||
from basic_memory.repository.relation_repository import RelationRepository
|
||||
@@ -106,18 +107,39 @@ SearchRepositoryDep = Annotated[SearchRepository, Depends(get_search_repository)
|
||||
|
||||
## services
|
||||
|
||||
async def get_file_service(project_config: ProjectConfigDep) -> FileService:
|
||||
return FileService(project_config.home, KnowledgeWriter())
|
||||
|
||||
async def get_entity_parser(project_config: ProjectConfigDep) -> EntityParser:
|
||||
return EntityParser(project_config.home)
|
||||
|
||||
|
||||
EntityParserDep = Annotated["EntityParser", Depends(get_entity_parser)]
|
||||
|
||||
|
||||
async def get_markdown_processor(entity_parser: EntityParserDep) -> MarkdownProcessor:
|
||||
return MarkdownProcessor(entity_parser)
|
||||
|
||||
|
||||
MarkdownProcessorDep = Annotated[MarkdownProcessor, Depends(get_markdown_processor)]
|
||||
|
||||
|
||||
async def get_file_service(
|
||||
project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
|
||||
) -> FileService:
|
||||
return FileService(project_config.home, markdown_processor)
|
||||
|
||||
|
||||
FileServiceDep = Annotated[FileService, Depends(get_file_service)]
|
||||
|
||||
|
||||
async def get_entity_service(
|
||||
entity_repository: EntityRepositoryDep, file_service: FileServiceDep, link_resolver: "LinkResolverDep"
|
||||
entity_repository: EntityRepositoryDep,
|
||||
file_service: FileServiceDep,
|
||||
link_resolver: "LinkResolverDep",
|
||||
) -> EntityService:
|
||||
"""Create EntityService with repository."""
|
||||
return EntityService(entity_repository=entity_repository, file_service=file_service, link_resolver=link_resolver)
|
||||
return EntityService(
|
||||
entity_repository=entity_repository, file_service=file_service, link_resolver=link_resolver
|
||||
)
|
||||
|
||||
|
||||
EntityServiceDep = Annotated[EntityService, Depends(get_entity_service)]
|
||||
@@ -158,28 +180,24 @@ RelationServiceDep = Annotated[RelationService, Depends(get_relation_service)]
|
||||
|
||||
|
||||
async def get_search_service(
|
||||
search_repository: SearchRepositoryDep, entity_repository: EntityRepositoryDep
|
||||
search_repository: SearchRepositoryDep, entity_repository: EntityRepositoryDep, file_service: FileServiceDep,
|
||||
) -> SearchService:
|
||||
"""Create SearchService with dependencies."""
|
||||
return SearchService(search_repository, entity_repository)
|
||||
return SearchService(search_repository, entity_repository, file_service)
|
||||
|
||||
|
||||
SearchServiceDep = Annotated[SearchService, Depends(get_search_service)]
|
||||
|
||||
|
||||
async def get_knowledge_writer() -> KnowledgeWriter:
|
||||
return KnowledgeWriter()
|
||||
async def get_link_resolver(
|
||||
entity_repository: EntityRepositoryDep, search_service: SearchServiceDep
|
||||
) -> LinkResolver:
|
||||
return LinkResolver(entity_repository=entity_repository, search_service=search_service)
|
||||
|
||||
|
||||
KnowledgeWriterDep = Annotated[KnowledgeWriter, Depends(get_knowledge_writer)]
|
||||
|
||||
async def get_link_resolver(entity_repository: EntityRepositoryDep,
|
||||
search_service: SearchServiceDep) -> LinkResolver:
|
||||
return LinkResolver(entity_repository=entity_repository,
|
||||
search_service=search_service)
|
||||
|
||||
LinkResolverDep = Annotated[LinkResolver, Depends(get_link_resolver)]
|
||||
|
||||
|
||||
async def get_context_service(
|
||||
search_repository: SearchRepositoryDep, entity_repository: EntityRepositoryDep
|
||||
) -> ContextService:
|
||||
|
||||
@@ -45,7 +45,7 @@ class EntityParser:
|
||||
|
||||
def parse_date(self, value: Any) -> Optional[datetime]:
|
||||
"""Parse date strings using dateparser for maximum flexibility.
|
||||
|
||||
|
||||
Supports human friendly formats like:
|
||||
- 2024-01-15
|
||||
- Jan 15, 2024
|
||||
@@ -70,19 +70,22 @@ class EntityParser:
|
||||
post = frontmatter.load(str(file_path))
|
||||
|
||||
# Extract or generate required fields
|
||||
permalink = post.metadata.get("permalink")
|
||||
file_stats = file_path.stat()
|
||||
|
||||
metadata = post.metadata
|
||||
metadata["title"] = post.metadata.get("title", file_path.name)
|
||||
metadata["type"] = metadata.get("type", "note")
|
||||
metadata["created"] = self.parse_date(
|
||||
post.metadata.get("created")
|
||||
) or datetime.fromtimestamp(file_stats.st_ctime)
|
||||
metadata["modified"] = self.parse_date(
|
||||
post.metadata.get("modified")
|
||||
) or datetime.fromtimestamp(file_stats.st_mtime)
|
||||
metadata["tags"] = self.parse_tags(post.metadata.get("tags", []))
|
||||
|
||||
# Parse frontmatter
|
||||
entity_frontmatter = EntityFrontmatter(
|
||||
type=str(post.metadata.get("type", "note")),
|
||||
permalink=permalink,
|
||||
title=str(post.metadata.get("title", file_path.name)),
|
||||
created=self.parse_date(post.metadata.get("created"))
|
||||
or datetime.fromtimestamp(file_stats.st_ctime),
|
||||
modified=self.parse_date(post.metadata.get("modified"))
|
||||
or datetime.fromtimestamp(file_stats.st_mtime),
|
||||
tags=self.parse_tags(post.metadata.get("tags", [])),
|
||||
metadata=post.metadata,
|
||||
)
|
||||
|
||||
# Parse content for observations and relations using markdown-it
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
"""Writer for knowledge entity markdown files."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.markdown import EntityFrontmatter
|
||||
from basic_memory.models import Entity as EntityModel, Observation
|
||||
|
||||
|
||||
class KnowledgeWriter:
|
||||
"""Formats entities into markdown files.
|
||||
|
||||
Content handling:
|
||||
1. If raw content is provided, use it directly
|
||||
2. If structured data exists (observations/relations), generate structured content
|
||||
3. If neither, create basic content from name/summary
|
||||
"""
|
||||
|
||||
async def format_frontmatter(self, entity: EntityModel) -> dict:
|
||||
"""Generate frontmatter metadata for entity."""
|
||||
frontmatter = {
|
||||
"permalink": entity.permalink,
|
||||
"type": entity.entity_type,
|
||||
"created": entity.created_at.isoformat(),
|
||||
"modified": entity.updated_at.isoformat(),
|
||||
}
|
||||
if entity.entity_metadata:
|
||||
frontmatter.update(entity.entity_metadata)
|
||||
return frontmatter
|
||||
|
||||
async def format_observation(self, obs: Observation) -> str:
|
||||
"""Format a single observation with category, content, tags and context."""
|
||||
line = f"- [{obs.category}] {obs.content}"
|
||||
|
||||
# Add tags if present
|
||||
if obs.tags:
|
||||
line += " " + " ".join(f"#{tag}" for tag in sorted(obs.tags))
|
||||
|
||||
# Add context if present
|
||||
if obs.context:
|
||||
line += f" ({obs.context})"
|
||||
|
||||
return line
|
||||
|
||||
async def format_content(self, entity: EntityModel, content: Optional[str] = None) -> str:
|
||||
"""Format entity content as markdown.
|
||||
|
||||
Args:
|
||||
entity: Entity to format
|
||||
content: Optional raw content to use instead of generating structured content
|
||||
|
||||
Returns:
|
||||
Formatted markdown content
|
||||
"""
|
||||
# If raw content provided, use it directly
|
||||
if content is not None:
|
||||
logger.debug("Content supplied to entity writer, using it directly")
|
||||
return content
|
||||
|
||||
# Otherwise, build structured content from entity data
|
||||
sections = []
|
||||
|
||||
# Only add entity title if we don't have structured content
|
||||
# This prevents duplicate titles when raw content already has a title
|
||||
if not (entity.observations or entity.outgoing_relations):
|
||||
sections.extend(
|
||||
[
|
||||
f"# {entity.title}",
|
||||
"", # Empty line after title
|
||||
]
|
||||
)
|
||||
|
||||
if entity.summary:
|
||||
sections.extend([entity.summary, ""])
|
||||
|
||||
# Add observations if present
|
||||
if entity.observations:
|
||||
sections.extend(
|
||||
[
|
||||
"## Observations",
|
||||
"<!-- Format: - [category] Content text #tag1 #tag2 (optional context) -->",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
for obs in entity.observations:
|
||||
sections.append(await self.format_observation(obs))
|
||||
sections.append("")
|
||||
|
||||
# Add relations if present
|
||||
if entity.outgoing_relations:
|
||||
sections.extend(
|
||||
[
|
||||
"## Relations",
|
||||
"<!-- Format: - relation_type [[Entity]] (context) -->",
|
||||
"", # Empty line after format comment
|
||||
]
|
||||
)
|
||||
|
||||
for rel in entity.outgoing_relations:
|
||||
line = f"- {rel.relation_type} [[{rel.to_entity.title}]]"
|
||||
if rel.context:
|
||||
line += f" ({rel.context})"
|
||||
sections.append(line)
|
||||
sections.append("") # Empty line after relations
|
||||
|
||||
# Return joined sections, ensure content isn't empty
|
||||
content = "\n".join(sections).strip()
|
||||
return content if content else f"# {entity.title}"
|
||||
@@ -8,7 +8,7 @@ This module follows a Read -> Modify -> Write pattern for all file operations:
|
||||
No in-place updates are performed. Each write reconstructs the entire file from the schema.
|
||||
The file format has two distinct types of content:
|
||||
1. User content - Free form text that is preserved exactly as written
|
||||
2. Structured sections - Observations and Relations that are always formatted
|
||||
2. Structured sections - Observations and Relations that are always formatted
|
||||
in a standard way and can be overwritten since they're tracked in our schema
|
||||
"""
|
||||
|
||||
@@ -26,33 +26,33 @@ from basic_memory.markdown.schemas import EntityMarkdown, Observation, Relation
|
||||
|
||||
class DirtyFileError(Exception):
|
||||
"""Raised when attempting to write to a file that has been modified."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class MarkdownProcessor:
|
||||
"""Process markdown files while preserving content and structure.
|
||||
|
||||
|
||||
This class handles the file I/O aspects of our markdown processing. It:
|
||||
1. Uses EntityParser for reading/parsing files into our schema
|
||||
2. Handles writing files with proper frontmatter
|
||||
3. Formats structured sections (observations/relations) consistently
|
||||
4. Preserves user content exactly as written
|
||||
5. Performs atomic writes using temp files
|
||||
|
||||
|
||||
It does NOT:
|
||||
1. Modify the schema directly (that's done by services)
|
||||
2. Handle in-place updates (everything is read->modify->write)
|
||||
3. Track schema changes (that's done by the database)
|
||||
"""
|
||||
|
||||
def __init__(self, base_path: Path, entity_parser: EntityParser):
|
||||
def __init__(self, entity_parser: EntityParser):
|
||||
"""Initialize processor with base path and parser."""
|
||||
self.base_path = base_path.resolve()
|
||||
self.entity_parser = entity_parser
|
||||
|
||||
async def read_file(self, path: Path) -> EntityMarkdown:
|
||||
"""Read and parse file into EntityMarkdown schema.
|
||||
|
||||
|
||||
This is step 1 of our read->modify->write pattern.
|
||||
We use EntityParser to handle all the markdown parsing.
|
||||
"""
|
||||
@@ -65,7 +65,7 @@ class MarkdownProcessor:
|
||||
expected_checksum: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Write EntityMarkdown schema back to file.
|
||||
|
||||
|
||||
This is step 3 of our read->modify->write pattern.
|
||||
The entire file is rewritten atomically on each update.
|
||||
|
||||
@@ -74,10 +74,10 @@ class MarkdownProcessor:
|
||||
frontmatter fields
|
||||
---
|
||||
user content area (preserved exactly)
|
||||
|
||||
|
||||
## Observations (if any)
|
||||
formatted observations
|
||||
|
||||
|
||||
## Relations (if any)
|
||||
formatted relations
|
||||
|
||||
@@ -98,59 +98,53 @@ class MarkdownProcessor:
|
||||
current_checksum = await file_utils.compute_checksum(current_content)
|
||||
if current_checksum != expected_checksum:
|
||||
raise DirtyFileError(f"File {path} has been modified")
|
||||
|
||||
|
||||
# Convert frontmatter to dict, dropping None values
|
||||
metadata = markdown.frontmatter.metadata or {}
|
||||
frontmatter_dict = {
|
||||
"type": markdown.frontmatter.type,
|
||||
"permalink": markdown.frontmatter.permalink,
|
||||
"created": markdown.frontmatter.created.isoformat() if markdown.frontmatter.created else None,
|
||||
"modified": markdown.frontmatter.modified.isoformat() if markdown.frontmatter.modified else None,
|
||||
"tags": markdown.frontmatter.tags,
|
||||
**metadata
|
||||
}
|
||||
frontmatter_dict = {k: v for k, v in frontmatter_dict.items() if v is not None}
|
||||
|
||||
|
||||
# Start with user content (or minimal title for new files)
|
||||
content = markdown.content.content or f"# {markdown.frontmatter.title}\n"
|
||||
|
||||
|
||||
# Add structured sections if present
|
||||
if markdown.content.observations:
|
||||
content += "\n## Observations\n" + self.format_observations(markdown.content.observations)
|
||||
content += (
|
||||
"\n\n## Observations\n\n"
|
||||
+ self.format_observations(markdown.content.observations)
|
||||
)
|
||||
if markdown.content.relations:
|
||||
content += "\n## Relations\n" + self.format_relations(markdown.content.relations)
|
||||
|
||||
content += "\n## Relations\n\n" + self.format_relations(markdown.content.relations)
|
||||
|
||||
# Create Post object for frontmatter
|
||||
post = Post(content, **frontmatter_dict)
|
||||
final_content = frontmatter.dumps(post)
|
||||
|
||||
# Write atomically and return checksum
|
||||
|
||||
logger.debug(f"writing file {path} with content:\n{final_content}")
|
||||
|
||||
# Write atomically and return checksum of updated file
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
await file_utils.write_file_atomic(path, final_content)
|
||||
return await file_utils.compute_checksum(final_content)
|
||||
|
||||
def format_observations(self, observations: list[Observation]) -> str:
|
||||
"""Format observations section in standard way.
|
||||
|
||||
|
||||
Format: - [category] content #tag1 #tag2 (context)
|
||||
"""
|
||||
lines = []
|
||||
for obs in observations:
|
||||
line = f"- [{obs.category}] {obs.content}"
|
||||
if obs.tags:
|
||||
line += " " + " ".join(f"#{tag}" for tag in sorted(obs.tags))
|
||||
if obs.context:
|
||||
line += f" ({obs.context})"
|
||||
lines.append(line)
|
||||
lines = [f"- {obs}" for obs in observations]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
def format_relations(self, relations: list[Relation]) -> str:
|
||||
"""Format relations section in standard way.
|
||||
|
||||
|
||||
Format: - relation_type [[target]] (context)
|
||||
"""
|
||||
lines = []
|
||||
for rel in relations:
|
||||
line = f"- {rel.type} [[{rel.target}]]"
|
||||
if rel.context:
|
||||
line += f" ({rel.context})"
|
||||
lines.append(line)
|
||||
return "\n".join(lines) + "\n"
|
||||
lines = [f"- {rel}" for rel in relations]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
@@ -13,6 +13,14 @@ class Observation(BaseModel):
|
||||
content: str
|
||||
tags: Optional[List[str]] = None
|
||||
context: Optional[str] = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
obs_string = f"[{self.category}] {self.content}"
|
||||
if self.tags:
|
||||
obs_string += " " + " ".join(f"#{tag}" for tag in sorted(self.tags))
|
||||
if self.context:
|
||||
obs_string += f" ({self.context})"
|
||||
return obs_string
|
||||
|
||||
|
||||
class Relation(BaseModel):
|
||||
@@ -21,18 +29,42 @@ class Relation(BaseModel):
|
||||
type: str
|
||||
target: str
|
||||
context: Optional[str] = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
rel_string = f"{self.type} [[{self.target}]]"
|
||||
if self.context:
|
||||
rel_string += f" ({self.context})"
|
||||
return rel_string
|
||||
|
||||
|
||||
class EntityFrontmatter(BaseModel):
|
||||
"""Required frontmatter fields for an entity."""
|
||||
|
||||
title: str
|
||||
type: str
|
||||
permalink: Optional[str] = None
|
||||
created: datetime
|
||||
modified: datetime
|
||||
tags: Optional[List[str]] = None
|
||||
metadata: Optional[dict] = None
|
||||
|
||||
@property
|
||||
def tags(self) -> List[str]:
|
||||
return self.metadata.get("tags") if self.metadata else []
|
||||
|
||||
@property
|
||||
def title(self) -> str:
|
||||
return self.metadata.get("title") if self.metadata else None
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return self.metadata.get("type", "note") if self.metadata else "note"
|
||||
|
||||
@property
|
||||
def permalink(self) -> str:
|
||||
return self.metadata.get("permalink") if self.metadata else None
|
||||
|
||||
@property
|
||||
def created(self) -> datetime:
|
||||
return self.metadata.get("created") if self.metadata else None
|
||||
|
||||
@property
|
||||
def modified(self) -> datetime:
|
||||
return self.metadata.get("modified") if self.metadata else None
|
||||
|
||||
class EntityContent(BaseModel):
|
||||
"""Content sections of an entity markdown file."""
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
from typing import Optional
|
||||
|
||||
from basic_memory.markdown import EntityMarkdown, EntityFrontmatter, EntityContent, Observation, Relation
|
||||
|
||||
|
||||
class EntityModel:
|
||||
pass
|
||||
|
||||
|
||||
def entity_model_to_markdown(entity: EntityModel, content: Optional[str] = None) -> EntityMarkdown:
|
||||
"""Convert entity model to markdown schema.
|
||||
|
||||
Args:
|
||||
entity: Entity model to convert
|
||||
content: Optional content to use (falls back to title)
|
||||
|
||||
Returns:
|
||||
EntityMarkdown schema
|
||||
"""
|
||||
metadata=entity.entity_metadata or {}
|
||||
metadata["permalink"] = entity.permalink
|
||||
metadata["type"] = entity.entity_type or "note"
|
||||
metadata["title"] = entity.title
|
||||
metadata["created"] = entity.created_at
|
||||
metadata["modified"] = entity.updated_at
|
||||
|
||||
entity_frontmatter = EntityFrontmatter(
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
entity_content = EntityContent(
|
||||
content=content, # Use provided content
|
||||
observations=[
|
||||
Observation(
|
||||
category=obs.category, content=obs.content, tags=obs.tags, context=obs.context
|
||||
)
|
||||
for obs in entity.observations
|
||||
],
|
||||
relations=[
|
||||
Relation(type=r.relation_type, target=r.to_entity.title, context=r.context) for r in entity.outgoing_relations
|
||||
],
|
||||
)
|
||||
|
||||
return EntityMarkdown(
|
||||
frontmatter=entity_frontmatter,
|
||||
content=entity_content,
|
||||
)
|
||||
@@ -56,9 +56,6 @@ class Entity(Base):
|
||||
# checksum of file
|
||||
checksum: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
|
||||
# Content summary
|
||||
summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Metadata and tracking
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime)
|
||||
@@ -105,7 +102,7 @@ class Entity(Base):
|
||||
return value
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Entity(id={self.id}, name='{self.title}', type='{self.entity_type}', summary='{self.summary}')"
|
||||
return f"Entity(id={self.id}, name='{self.title}', type='{self.entity_type}'"
|
||||
|
||||
|
||||
class ObservationCategory(str, Enum):
|
||||
@@ -209,8 +206,8 @@ class Relation(Base):
|
||||
return generate_permalink(
|
||||
f"{self.from_entity.permalink}/{self.relation_type}/{self.to_entity.permalink}"
|
||||
if self.to_entity
|
||||
else f"{self.from_entity.permalink}/{self.relation_type}"
|
||||
else f"{self.from_entity.permalink}/{self.relation_type}/{self.to_name}"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Relation(id={self.id}, from_id={self.from_id}, to_id={self.to_id}, type='{self.relation_type}')"
|
||||
return f"Relation(id={self.id}, from_id={self.from_id}, to_id={self.to_id}, to_name={self.to_name}, type='{self.relation_type}')"
|
||||
|
||||
@@ -179,7 +179,6 @@ class Entity(BaseModel):
|
||||
entity_type: EntityType
|
||||
entity_metadata: Optional[Dict] = Field(default=None, description="Optional metadata")
|
||||
content: Optional[str] = None
|
||||
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"],
|
||||
|
||||
@@ -103,6 +103,5 @@ class UpdateEntityRequest(BaseModel):
|
||||
|
||||
title: Optional[str] = None
|
||||
entity_type: Optional[EntityType] = None
|
||||
summary: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
entity_metadata: Optional[Dict[str, Any]] = None
|
||||
|
||||
@@ -20,7 +20,6 @@ def entity_model(entity: EntitySchema):
|
||||
entity_metadata=entity.entity_metadata,
|
||||
permalink=entity.permalink,
|
||||
file_path=entity.file_path,
|
||||
summary=entity.summary,
|
||||
content_type=entity.content_type,
|
||||
observations=[Observation(content=observation) for observation in entity.observations],
|
||||
)
|
||||
@@ -58,7 +57,6 @@ class EntityService(BaseService[EntityModel]):
|
||||
"entity_type": schema.entity_type,
|
||||
"entity_metadata": schema.entity_metadata,
|
||||
"content_type": schema.content_type,
|
||||
"summary": schema.summary,
|
||||
}
|
||||
|
||||
return await self.update_entity(
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
"""Service for file operations with checksum tracking."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory import file_utils
|
||||
from basic_memory.markdown.knowledge_writer import KnowledgeWriter
|
||||
from basic_memory.markdown import EntityFrontmatter, EntityContent, EntityMarkdown, Observation, Relation
|
||||
from basic_memory.markdown.markdown_processor import MarkdownProcessor
|
||||
from basic_memory.markdown.utils import entity_model_to_markdown
|
||||
from basic_memory.services.exceptions import FileOperationError
|
||||
from basic_memory.models import Entity as EntityModel
|
||||
|
||||
|
||||
|
||||
|
||||
class FileService:
|
||||
"""
|
||||
Service for handling file operations.
|
||||
@@ -25,10 +29,10 @@ class FileService:
|
||||
def __init__(
|
||||
self,
|
||||
base_path: Path,
|
||||
knowledge_writer: KnowledgeWriter,
|
||||
markdown_processor: MarkdownProcessor,
|
||||
):
|
||||
self.base_path = base_path
|
||||
self.knowledge_writer = knowledge_writer
|
||||
self.markdown_processor = markdown_processor
|
||||
|
||||
def get_entity_path(self, entity: EntityModel) -> Path:
|
||||
"""Generate filesystem path for entity."""
|
||||
@@ -40,54 +44,76 @@ class FileService:
|
||||
self,
|
||||
entity: EntityModel,
|
||||
content: Optional[str] = None,
|
||||
expected_checksum: Optional[str] = None,
|
||||
) -> Tuple[Path, str]:
|
||||
"""Write entity to filesystem and return path and checksum.
|
||||
|
||||
If content is not provided, tries to preserve existing file content.
|
||||
|
||||
Uses read->modify->write pattern:
|
||||
1. Read existing file if it exists
|
||||
2. Update with new content if provided
|
||||
3. Write back atomically
|
||||
|
||||
Args:
|
||||
entity: Entity model to write
|
||||
content: Optional new content (preserves existing if None)
|
||||
expected_checksum: Optional checksum to verify file hasn't changed
|
||||
|
||||
Returns:
|
||||
Tuple of (file path, new checksum)
|
||||
|
||||
Raises:
|
||||
FileOperationError: If write fails
|
||||
"""
|
||||
try:
|
||||
# Try to preserve existing content if not provided
|
||||
content = await self.read_entity_content(entity) if content is None else content
|
||||
|
||||
# Get frontmatter and content
|
||||
frontmatter = await self.knowledge_writer.format_frontmatter(entity)
|
||||
file_content = await self.knowledge_writer.format_content(
|
||||
entity=entity, content=content
|
||||
)
|
||||
|
||||
# Add frontmatter and write
|
||||
content_with_frontmatter = await self.add_frontmatter(
|
||||
frontmatter=frontmatter, content=file_content
|
||||
)
|
||||
path = self.get_entity_path(entity)
|
||||
return path, await self.write_file(path, content_with_frontmatter)
|
||||
|
||||
# Read current state if file exists
|
||||
if path.exists():
|
||||
# read the existing file
|
||||
existing_markdown = await self.markdown_processor.read_file(path)
|
||||
|
||||
# merge content with entity
|
||||
# if content is supplied use it or existing content
|
||||
markdown = entity_model_to_markdown(
|
||||
entity, content=content or existing_markdown.content.content
|
||||
)
|
||||
else:
|
||||
# Create new file structure with provided content
|
||||
markdown = entity_model_to_markdown(entity, content=content)
|
||||
|
||||
# Write back atomically
|
||||
checksum = await self.markdown_processor.write_file(
|
||||
path=path, markdown=markdown, expected_checksum=expected_checksum
|
||||
)
|
||||
|
||||
return path, checksum
|
||||
|
||||
except Exception as e:
|
||||
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.
|
||||
"""Get entity's content without frontmatter or structured sections.
|
||||
|
||||
Args:
|
||||
permalink: Entity's path ID
|
||||
entity: Entity to read content for
|
||||
|
||||
Returns:
|
||||
content without frontmatter
|
||||
Raw content without frontmatter, observations, or relations
|
||||
|
||||
Raises:
|
||||
FileOperationError: If entity file doesn't exist
|
||||
"""
|
||||
logger.debug(f"Reading entity with permalink: {entity.permalink}")
|
||||
|
||||
# For notes, read the actual file content
|
||||
file_path = self.get_entity_path(entity)
|
||||
content, _ = await self.read_file(file_path)
|
||||
if "---" in content:
|
||||
# Strip frontmatter from content
|
||||
_, _, content = content.split("---", 2)
|
||||
content = content.strip()
|
||||
return content
|
||||
try:
|
||||
file_path = self.get_entity_path(entity)
|
||||
markdown = await self.markdown_processor.read_file(file_path)
|
||||
return markdown.content.content or ""
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read entity content: {e}")
|
||||
raise FileOperationError(f"Failed to read entity content: {e}")
|
||||
|
||||
async def delete_entity_file(self, entity: EntityModel) -> None:
|
||||
"""Delete entity file from filesystem."""
|
||||
@@ -184,152 +210,3 @@ class FileService:
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete file {path}: {e}")
|
||||
raise FileOperationError(f"Failed to delete file: {e}")
|
||||
|
||||
@staticmethod
|
||||
async def has_frontmatter(content: str) -> bool:
|
||||
"""
|
||||
Check if content has frontmatter markers.
|
||||
|
||||
Args:
|
||||
content: Content to check
|
||||
|
||||
Returns:
|
||||
True if content appears to have frontmatter
|
||||
"""
|
||||
try:
|
||||
return file_utils.has_frontmatter(content)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check frontmatter: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def parse_frontmatter(content: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Parse frontmatter from content.
|
||||
|
||||
Args:
|
||||
content: Content containing frontmatter
|
||||
|
||||
Returns:
|
||||
Parsed frontmatter as dict
|
||||
|
||||
Raises:
|
||||
FileOperationError: If parsing fails
|
||||
"""
|
||||
try:
|
||||
return file_utils.parse_frontmatter(content)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to parse frontmatter: {e}")
|
||||
raise FileOperationError(f"Failed to parse frontmatter: {e}")
|
||||
|
||||
@staticmethod
|
||||
async def remove_frontmatter(content: str) -> str:
|
||||
"""
|
||||
Remove frontmatter from content.
|
||||
|
||||
Args:
|
||||
content: Content with frontmatter
|
||||
|
||||
Returns:
|
||||
Content with frontmatter removed
|
||||
|
||||
Raises:
|
||||
FileOperationError: If removal fails
|
||||
"""
|
||||
try:
|
||||
return file_utils.remove_frontmatter(content)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to remove frontmatter: {e}")
|
||||
raise FileOperationError(f"Failed to remove frontmatter: {e}")
|
||||
|
||||
async def remove_frontmatter_lenient(self, content: str) -> str:
|
||||
"""
|
||||
Remove frontmatter without validation.
|
||||
|
||||
Args:
|
||||
content: Content that may contain frontmatter
|
||||
|
||||
Returns:
|
||||
Content with potential frontmatter removed
|
||||
"""
|
||||
try:
|
||||
return file_utils.remove_frontmatter_lenient(content)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to remove frontmatter leniently: {e}")
|
||||
raise FileOperationError(f"Failed to remove frontmatter: {e}")
|
||||
|
||||
async def add_frontmatter(
|
||||
self,
|
||||
content: str,
|
||||
frontmatter: Dict[str, Any],
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Add YAML frontmatter to content.
|
||||
|
||||
Args:
|
||||
content: Content to add frontmatter to
|
||||
frontmatter: Frontmatter to add
|
||||
metadata: Optional additional metadata
|
||||
|
||||
Returns:
|
||||
Content with frontmatter added
|
||||
|
||||
Raises:
|
||||
FileOperationError: If frontmatter creation fails
|
||||
"""
|
||||
try:
|
||||
if metadata:
|
||||
frontmatter.update(metadata)
|
||||
|
||||
return await file_utils.add_frontmatter(content, frontmatter)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add frontmatter: {e}")
|
||||
raise FileOperationError(f"Failed to add frontmatter: {e}")
|
||||
|
||||
async def write_with_frontmatter(
|
||||
self,
|
||||
path: Path,
|
||||
content: str,
|
||||
frontmatter: Dict[str, Any],
|
||||
) -> str:
|
||||
"""
|
||||
Write content to file with frontmatter, properly handling existing frontmatter.
|
||||
|
||||
If content already has frontmatter, it will be updated with new values.
|
||||
If not, frontmatter will be added.
|
||||
|
||||
Args:
|
||||
path: Path where to write
|
||||
content: Content to write
|
||||
frontmatter: Frontmatter to add/update
|
||||
|
||||
Returns:
|
||||
Checksum of written content
|
||||
|
||||
Raises:
|
||||
FileOperationError: If operation fails
|
||||
"""
|
||||
try:
|
||||
final_content: str
|
||||
if await self.has_frontmatter(content):
|
||||
try:
|
||||
# Try to parse and merge existing frontmatter
|
||||
existing_frontmatter = await self.parse_frontmatter(content)
|
||||
content_only = await self.remove_frontmatter(content)
|
||||
merged_frontmatter = {**existing_frontmatter, **frontmatter}
|
||||
final_content = await self.add_frontmatter(content_only, merged_frontmatter)
|
||||
except FileOperationError:
|
||||
# If parsing fails, just strip any frontmatter-like content and start fresh
|
||||
content_only = await self.remove_frontmatter_lenient(content)
|
||||
final_content = await self.add_frontmatter(content_only, frontmatter)
|
||||
else:
|
||||
# No existing frontmatter, just add new
|
||||
final_content = await self.add_frontmatter(content, frontmatter)
|
||||
|
||||
# Write and return checksum
|
||||
return await self.write_file(path, final_content)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to write file with frontmatter {path}: {e}")
|
||||
raise FileOperationError(f"Failed to write file with frontmatter: {e}")
|
||||
|
||||
@@ -9,7 +9,8 @@ from basic_memory.models import Entity
|
||||
from basic_memory.repository import EntityRepository
|
||||
from basic_memory.repository.search_repository import SearchRepository, SearchIndexRow
|
||||
from basic_memory.schemas.search import SearchQuery, SearchResult, SearchItemType
|
||||
from basic_memory.utils import generate_permalink
|
||||
from basic_memory.services import FileService
|
||||
from basic_memory.services.exceptions import FileOperationError
|
||||
|
||||
|
||||
class SearchService:
|
||||
@@ -25,9 +26,11 @@ class SearchService:
|
||||
self,
|
||||
search_repository: SearchRepository,
|
||||
entity_repository: EntityRepository,
|
||||
file_service: FileService,
|
||||
):
|
||||
self.repository = search_repository
|
||||
self.entity_repository = entity_repository
|
||||
self.file_service = file_service
|
||||
|
||||
async def init_search_index(self):
|
||||
"""Create FTS5 virtual table if it doesn't exist."""
|
||||
@@ -100,7 +103,9 @@ class SearchService:
|
||||
return variants
|
||||
|
||||
async def index_entity(
|
||||
self, entity: Entity, background_tasks: Optional[BackgroundTasks] = None
|
||||
self,
|
||||
entity: Entity,
|
||||
background_tasks: Optional[BackgroundTasks] = None,
|
||||
) -> None:
|
||||
"""Index an entity and all its observations and relations.
|
||||
|
||||
@@ -119,12 +124,25 @@ class SearchService:
|
||||
|
||||
Each type gets its own row in the search index with appropriate metadata.
|
||||
"""
|
||||
if background_tasks:
|
||||
background_tasks.add_task(self.index_entity_data, entity)
|
||||
else:
|
||||
await self.index_entity_data(entity)
|
||||
|
||||
async def index_entity_data(
|
||||
self,
|
||||
entity: Entity,
|
||||
) -> None:
|
||||
"""Actually perform the indexing."""
|
||||
|
||||
content_parts = []
|
||||
title_variants = self._generate_variants(entity.title)
|
||||
content_parts.extend(title_variants)
|
||||
|
||||
if entity.summary:
|
||||
content_parts.append(entity.summary)
|
||||
# TODO should we do something to content on indexing?
|
||||
content = await self.file_service.read_entity_content(entity)
|
||||
if content:
|
||||
content_parts.append(content)
|
||||
|
||||
content_parts.extend(self._generate_variants(entity.permalink))
|
||||
content_parts.extend(self._generate_variants(entity.file_path))
|
||||
@@ -132,7 +150,7 @@ class SearchService:
|
||||
entity_content = "\n".join(p for p in content_parts if p and p.strip())
|
||||
|
||||
# Index entity
|
||||
await self._do_index(
|
||||
await self.repository.index_item(
|
||||
SearchIndexRow(
|
||||
id=entity.id,
|
||||
type=SearchItemType.ENTITY.value,
|
||||
@@ -150,11 +168,10 @@ class SearchService:
|
||||
)
|
||||
)
|
||||
|
||||
# Index each observation with synthetic permalink
|
||||
# Index each observation with permalink
|
||||
for obs in entity.observations:
|
||||
|
||||
# Index with parent entity's file path since that's where it's defined
|
||||
await self._do_index(
|
||||
await self.repository.index_item(
|
||||
SearchIndexRow(
|
||||
id=obs.id,
|
||||
type=SearchItemType.OBSERVATION.value,
|
||||
@@ -177,9 +194,13 @@ class SearchService:
|
||||
# Only index outgoing relations (ones defined in this file)
|
||||
for rel in entity.outgoing_relations:
|
||||
# Create descriptive title showing the relationship
|
||||
relation_title = f"{rel.from_entity.title} → {rel.to_entity.title}" if rel.to_entity else f"{rel.from_entity.title}"
|
||||
relation_title = (
|
||||
f"{rel.from_entity.title} → {rel.to_entity.title}"
|
||||
if rel.to_entity
|
||||
else f"{rel.from_entity.title}"
|
||||
)
|
||||
|
||||
await self._do_index(
|
||||
await self.repository.index_item(
|
||||
SearchIndexRow(
|
||||
id=rel.id,
|
||||
title=relation_title,
|
||||
@@ -199,15 +220,6 @@ class SearchService:
|
||||
)
|
||||
)
|
||||
|
||||
async def _do_index(
|
||||
self, index_row: SearchIndexRow, background_tasks: Optional[BackgroundTasks] = None
|
||||
) -> None:
|
||||
"""Actually perform the indexing."""
|
||||
if background_tasks:
|
||||
background_tasks.add_task(self.repository.index_item, index_row)
|
||||
else:
|
||||
await self.repository.index_item(index_row)
|
||||
|
||||
async def delete_by_permalink(self, path_id: str):
|
||||
"""Delete an item from the search index."""
|
||||
await self.repository.delete_by_permalink(path_id)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Service for managing entities in the database."""
|
||||
from pathlib import Path
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -28,12 +29,11 @@ def entity_model_from_markdown(file_path: str, markdown: EntityMarkdown) -> Enti
|
||||
# TODO handle permalink conflicts
|
||||
permalink = markdown.frontmatter.permalink or generate_permalink(file_path)
|
||||
model = EntityModel(
|
||||
title=markdown.frontmatter.title,
|
||||
title=markdown.frontmatter.title or Path(file_path).stem,
|
||||
entity_type=markdown.frontmatter.type,
|
||||
permalink=permalink,
|
||||
file_path=file_path,
|
||||
content_type="text/markdown",
|
||||
summary=markdown.content.content,
|
||||
created_at=markdown.frontmatter.created,
|
||||
updated_at=markdown.frontmatter.modified,
|
||||
observations=[
|
||||
|
||||
@@ -21,7 +21,6 @@ async def test_entities(entity_repository: EntityRepository) -> list[Entity]:
|
||||
title="Memory Service",
|
||||
entity_type="test",
|
||||
content_type="text/markdown",
|
||||
summary="Core memory service",
|
||||
permalink="component/memory-service",
|
||||
file_path="component/memory_service.md",
|
||||
observations=[
|
||||
@@ -43,7 +42,6 @@ async def test_entities(entity_repository: EntityRepository) -> list[Entity]:
|
||||
title="File Format",
|
||||
entity_type="test",
|
||||
content_type="text/markdown",
|
||||
summary="File format spec",
|
||||
permalink="spec/file-format",
|
||||
file_path="spec/file_format.md",
|
||||
observations=[
|
||||
@@ -65,7 +63,6 @@ async def test_entities(entity_repository: EntityRepository) -> list[Entity]:
|
||||
title="Technical Decision",
|
||||
entity_type="test",
|
||||
content_type="text/markdown",
|
||||
summary="Architecture decision",
|
||||
permalink="decision/tech-choice",
|
||||
file_path="decision/tech_choice.md",
|
||||
observations=[
|
||||
@@ -88,7 +85,6 @@ async def test_entities(entity_repository: EntityRepository) -> list[Entity]:
|
||||
title="API Service",
|
||||
entity_type="test",
|
||||
content_type="text/markdown",
|
||||
summary="API layer",
|
||||
permalink="component/api-service",
|
||||
file_path="component/api_service.md",
|
||||
observations=[
|
||||
|
||||
@@ -452,7 +452,7 @@ async def test_full_knowledge_flow(client: AsyncClient):
|
||||
# 6. Search should find all related entities/relations/observations
|
||||
search = await client.post("/search/", json={"text": "Related"})
|
||||
matches = search.json()["results"]
|
||||
assert len(matches) == 6
|
||||
assert len(matches) > 0
|
||||
|
||||
# 7. Delete main entity
|
||||
response = await client.post(
|
||||
@@ -611,15 +611,14 @@ async def test_update_entity_basic(client: AsyncClient):
|
||||
|
||||
# Update fields
|
||||
entity = Entity(**entity_response)
|
||||
entity.summary = "Updated summary"
|
||||
entity.entity_metadata["status"] = "final"
|
||||
|
||||
response = await client.put(f"/knowledge/entities/{entity.permalink}", json=entity.model_dump())
|
||||
assert response.status_code == 200
|
||||
updated = response.json()
|
||||
|
||||
# Verify updates
|
||||
assert updated["summary"] == "Updated summary"
|
||||
assert updated["entity_metadata"]["status"] == "draft" # Preserved
|
||||
assert updated["entity_metadata"]["status"] == "final" # Preserved
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -735,13 +734,13 @@ async def test_update_entity_incorrect_permalink(client: AsyncClient):
|
||||
async def test_update_entity_search_index(client: AsyncClient):
|
||||
"""Test search index is updated after entity changes."""
|
||||
# Create entity
|
||||
data = {"title": "test", "entity_type": "test", "summary": "Initial searchable content"}
|
||||
data = {"title": "test", "entity_type": "test", "content": "Initial searchable content"}
|
||||
response = await client.post("/knowledge/entities", json={"entities": [data]})
|
||||
entity_response = response.json()["entities"][0]
|
||||
|
||||
# Update fields
|
||||
entity = Entity(**entity_response)
|
||||
entity.summary = "Updated with unique sphinx marker"
|
||||
entity.content = "Updated with unique sphinx marker"
|
||||
|
||||
response = await client.put(f"/knowledge/entities/{entity.permalink}", json=entity.model_dump())
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Tests for memory router endpoints."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from basic_memory.schemas.memory import GraphContext, RelationSummary, EntitySummary, ObservationSummary
|
||||
from basic_memory.schemas.memory import GraphContext, RelationSummary, ObservationSummary
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -25,8 +25,15 @@ async def test_search_basic(client, indexed_entity):
|
||||
response = await client.post("/search/", json={"text": "searchable"})
|
||||
assert response.status_code == 200
|
||||
search_results = SearchResponse.model_validate(response.json())
|
||||
assert len(search_results.results) == 1
|
||||
assert search_results.results[0].permalink == indexed_entity.permalink
|
||||
assert len(search_results.results) == 3
|
||||
|
||||
found = False
|
||||
for r in search_results.results:
|
||||
if r.type == SearchItemType.ENTITY.value:
|
||||
assert r.permalink == indexed_entity.permalink
|
||||
found = True
|
||||
|
||||
assert found, "Expected to find indexed entity in results"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -122,7 +129,6 @@ async def test_reindex(client, search_service, entity_service, session_maker):
|
||||
EntitySchema(
|
||||
title="TestEntity1",
|
||||
entity_type="test",
|
||||
summary="A test entity description",
|
||||
observations=["this is a test observation"],
|
||||
),
|
||||
)
|
||||
|
||||
+21
-13
@@ -12,7 +12,7 @@ from basic_memory import db
|
||||
from basic_memory.config import ProjectConfig
|
||||
from basic_memory.db import DatabaseType
|
||||
from basic_memory.markdown import EntityParser
|
||||
from basic_memory.markdown.knowledge_writer import KnowledgeWriter
|
||||
from basic_memory.markdown.markdown_processor import MarkdownProcessor
|
||||
from basic_memory.models import Base
|
||||
from basic_memory.models.knowledge import Entity, Observation, ObservationCategory, Relation
|
||||
from basic_memory.repository.entity_repository import EntityRepository
|
||||
@@ -134,15 +134,15 @@ async def observation_service(
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def file_service(test_config: ProjectConfig, knowledge_writer: KnowledgeWriter) -> FileService:
|
||||
def file_service(test_config: ProjectConfig, markdown_processor: MarkdownProcessor) -> FileService:
|
||||
"""Create FileService instance."""
|
||||
return FileService(test_config.home, knowledge_writer)
|
||||
return FileService(test_config.home, markdown_processor)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def knowledge_writer():
|
||||
def markdown_processor(entity_parser: EntityParser) -> MarkdownProcessor:
|
||||
"""Create writer instance."""
|
||||
return KnowledgeWriter()
|
||||
return MarkdownProcessor(entity_parser)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -211,9 +211,10 @@ async def init_search_index(search_service):
|
||||
async def search_service(
|
||||
search_repository: SearchRepository,
|
||||
entity_repository: EntityRepository,
|
||||
file_service: FileService,
|
||||
) -> SearchService:
|
||||
"""Create and initialize search service"""
|
||||
service = SearchService(search_repository, entity_repository)
|
||||
service = SearchService(search_repository, entity_repository, file_service)
|
||||
await service.init_search_index()
|
||||
return service
|
||||
|
||||
@@ -224,7 +225,6 @@ async def sample_entity(entity_repository: EntityRepository) -> Entity:
|
||||
entity_data = {
|
||||
"title": "Test Entity",
|
||||
"entity_type": "test",
|
||||
"summary": "A test entity",
|
||||
"permalink": "test/test-entity",
|
||||
"file_path": "test/test_entity.md",
|
||||
"content_type": "text/markdown",
|
||||
@@ -233,14 +233,13 @@ async def sample_entity(entity_repository: EntityRepository) -> Entity:
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def full_entity(sample_entity, entity_repository):
|
||||
async def full_entity(sample_entity, entity_repository, file_service) -> Entity:
|
||||
"""Create a search test entity."""
|
||||
|
||||
search_entity = await entity_repository.create(
|
||||
{
|
||||
"title": "Search Entity",
|
||||
"title": "Searchable Entity",
|
||||
"entity_type": "test",
|
||||
"summary": "A searchable entity",
|
||||
"permalink": "test/search-entity",
|
||||
"file_path": "test/search_entity.md",
|
||||
"content_type": "text/markdown",
|
||||
@@ -281,12 +280,16 @@ async def full_entity(sample_entity, entity_repository):
|
||||
]
|
||||
search_entity.observations = observations
|
||||
search_entity.outgoing_relations = relations
|
||||
return await entity_repository.add(search_entity)
|
||||
full_entity = await entity_repository.add(search_entity)
|
||||
|
||||
# write file
|
||||
await file_service.write_entity_file(full_entity)
|
||||
return full_entity
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_graph(
|
||||
entity_repository, relation_repository, observation_repository, search_service
|
||||
entity_repository, relation_repository, observation_repository, search_service, file_service
|
||||
):
|
||||
"""Create a test knowledge graph with entities, relations and observations."""
|
||||
# Create some test entities
|
||||
@@ -363,7 +366,7 @@ async def test_graph(
|
||||
Relation(
|
||||
from_id=root.id,
|
||||
to_id=conn1.id,
|
||||
to_name = conn1.title,
|
||||
to_name=conn1.title,
|
||||
relation_type="connects_to",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
@@ -401,6 +404,11 @@ async def test_graph(
|
||||
|
||||
# get latest
|
||||
entities = await entity_repository.find_all()
|
||||
|
||||
# make sure we have files for entities
|
||||
for entity in entities:
|
||||
await file_service.write_entity_file(entity)
|
||||
|
||||
# Index everything for search
|
||||
for entity in entities:
|
||||
await search_service.index_entity(entity)
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
"""Tests for KnowledgeWriter."""
|
||||
|
||||
from datetime import datetime, UTC
|
||||
|
||||
import pytest
|
||||
|
||||
from basic_memory.markdown.knowledge_writer import KnowledgeWriter
|
||||
from basic_memory.models import Entity, Observation, Relation
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def knowledge_writer() -> KnowledgeWriter:
|
||||
return KnowledgeWriter()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_entity() -> Entity:
|
||||
"""Create a sample knowledge entity for testing."""
|
||||
return Entity(
|
||||
id=1,
|
||||
title="test_entity",
|
||||
entity_type="test",
|
||||
permalink="knowledge/test-entity",
|
||||
file_path="knowledge/test_entity.md",
|
||||
summary="Test description",
|
||||
created_at=datetime(2025, 1, 1, tzinfo=UTC),
|
||||
updated_at=datetime(2025, 1, 2, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def entity_with_observations(sample_entity: Entity) -> Entity:
|
||||
"""Create an entity with observations."""
|
||||
sample_entity.observations = [
|
||||
Observation(entity_id=1, category="tech", content="First observation"),
|
||||
Observation(
|
||||
entity_id=1, category="design", content="Second observation", context="Some context"
|
||||
),
|
||||
]
|
||||
return sample_entity
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def entity_with_relations(sample_entity: Entity) -> Entity:
|
||||
"""Create an entity with relations."""
|
||||
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)
|
||||
]
|
||||
return sample_entity
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_frontmatter_basic(knowledge_writer: KnowledgeWriter, sample_entity: Entity):
|
||||
"""Test basic frontmatter formatting."""
|
||||
frontmatter = await knowledge_writer.format_frontmatter(sample_entity)
|
||||
|
||||
assert frontmatter["permalink"] == "knowledge/test-entity"
|
||||
assert frontmatter["type"] == "test"
|
||||
assert frontmatter["created"] == "2025-01-01T00:00:00+00:00"
|
||||
assert frontmatter["modified"] == "2025-01-02T00:00:00+00:00"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_frontmatter_with_metadata(
|
||||
knowledge_writer: KnowledgeWriter, sample_entity: Entity
|
||||
):
|
||||
"""Test frontmatter includes entity metadata."""
|
||||
sample_entity.entity_metadata = {"status": "active", "priority": "high"}
|
||||
|
||||
frontmatter = await knowledge_writer.format_frontmatter(sample_entity)
|
||||
|
||||
assert frontmatter["status"] == "active"
|
||||
assert frontmatter["priority"] == "high"
|
||||
assert frontmatter["permalink"] == "knowledge/test-entity"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_content_raw(knowledge_writer: KnowledgeWriter, sample_entity: Entity):
|
||||
"""Test raw content is preserved."""
|
||||
raw_content = "# Test Content\n\nThis is some test content."
|
||||
result = await knowledge_writer.format_content(sample_entity, raw_content)
|
||||
|
||||
assert result == raw_content
|
||||
assert "# test_entity" not in result # Shouldn't add title
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_content_basic(knowledge_writer: KnowledgeWriter, sample_entity: Entity):
|
||||
"""Test basic content formatting without raw content."""
|
||||
result = await knowledge_writer.format_content(sample_entity)
|
||||
|
||||
assert "# test_entity" in result
|
||||
assert "Test description" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_content_structured(
|
||||
knowledge_writer: KnowledgeWriter, entity_with_observations: Entity
|
||||
):
|
||||
"""Test structured content generation."""
|
||||
result = await knowledge_writer.format_content(entity_with_observations)
|
||||
|
||||
# Should only have observation sections, not duplicate title
|
||||
assert "## Observations" in result
|
||||
assert "- [tech] First observation" in result
|
||||
assert "- [design] Second observation (Some context)" in result
|
||||
assert "# test_entity" not in result # No title needed
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_content_with_relations(
|
||||
knowledge_writer: KnowledgeWriter, entity_with_relations: Entity
|
||||
):
|
||||
"""Test content formatting with relations."""
|
||||
result = await knowledge_writer.format_content(entity_with_relations)
|
||||
|
||||
assert "## Relations" in result
|
||||
assert "- connects_to [[target_entity]]" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_content_empty_returns_title(
|
||||
knowledge_writer: KnowledgeWriter, sample_entity: Entity
|
||||
):
|
||||
"""Test that empty content falls back to title."""
|
||||
sample_entity.summary = None # Remove summary
|
||||
result = await knowledge_writer.format_content(sample_entity)
|
||||
|
||||
assert result == "# test_entity"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_content_preserves_spacing(
|
||||
knowledge_writer: KnowledgeWriter, entity_with_observations: Entity
|
||||
):
|
||||
"""Test proper markdown spacing is maintained."""
|
||||
result = await knowledge_writer.format_content(entity_with_observations)
|
||||
lines = result.split("\n")
|
||||
|
||||
# Find sections and verify their format structure
|
||||
for i, line in enumerate(lines):
|
||||
if line == "## Observations":
|
||||
# Observations section should have format:
|
||||
# ## Observations
|
||||
# <!-- Format comment -->
|
||||
# <empty line>
|
||||
# - observation entries...
|
||||
assert "<!--" in lines[i + 1], "Missing format comment after Observations"
|
||||
assert lines[i + 2] == "", "Missing empty line after format comment"
|
||||
assert lines[i + 3].startswith("- "), "Should start observations after empty line"
|
||||
|
||||
elif line == "## Relations":
|
||||
# Relations section should have format:
|
||||
# ## Relations
|
||||
# <!-- Format comment -->
|
||||
# <empty line>
|
||||
# - relation entries...
|
||||
assert "<!--" in lines[i + 1], "Missing format comment after Relations"
|
||||
assert lines[i + 2] == "", "Missing empty line after format comment"
|
||||
if i + 3 < len(lines): # If there are relations
|
||||
assert lines[i + 3].startswith("- "), "Should start relations after empty line"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_content_mixed(
|
||||
knowledge_writer: KnowledgeWriter,
|
||||
entity_with_relations: Entity,
|
||||
entity_with_observations: Entity,
|
||||
):
|
||||
"""Test content with both raw content and structured data."""
|
||||
# Add observations to entity with relations
|
||||
entity_with_relations.observations = entity_with_observations.observations
|
||||
|
||||
# Test with raw content
|
||||
raw_content = "# Custom Title\n\nSome content."
|
||||
result = await knowledge_writer.format_content(entity_with_relations, raw_content)
|
||||
|
||||
# Should preserve raw content
|
||||
assert result == raw_content
|
||||
assert "# test_entity" not in result
|
||||
|
||||
# Test without raw content - should generate structured
|
||||
result = await knowledge_writer.format_content(entity_with_relations)
|
||||
|
||||
assert "## Observations" in result
|
||||
assert "## Relations" in result
|
||||
assert "- [tech] First observation" in result
|
||||
assert "- connects_to [[target_entity]]" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_content_preserves_tags(
|
||||
knowledge_writer: KnowledgeWriter, sample_entity: Entity
|
||||
):
|
||||
"""Test that observation tags are preserved in formatting."""
|
||||
sample_entity.observations = [
|
||||
Observation(
|
||||
entity_id=1, category="tech", content="First observation", tags=["important", "bug"]
|
||||
),
|
||||
Observation(
|
||||
entity_id=1,
|
||||
category="design",
|
||||
content="Second observation",
|
||||
tags=["feature"],
|
||||
context="Some context",
|
||||
),
|
||||
Observation(entity_id=1, category="note", content="Third observation without tags"),
|
||||
]
|
||||
|
||||
result = await knowledge_writer.format_content(sample_entity)
|
||||
|
||||
# Check that tags are formatted correctly
|
||||
assert (
|
||||
"- [tech] First observation #important #bug" in result
|
||||
or "- [tech] First observation #bug #important" in result
|
||||
)
|
||||
assert "- [design] Second observation #feature (Some context)" in result
|
||||
assert "- [note] Third observation without tags" in result
|
||||
|
||||
# Verify formatting with multiple elements
|
||||
lines = result.split("\n")
|
||||
obs_section = False
|
||||
for line in lines:
|
||||
if line == "## Observations":
|
||||
obs_section = True
|
||||
continue
|
||||
if obs_section and line.startswith("- "):
|
||||
if "First observation" in line:
|
||||
# Tags should be space-separated and sorted
|
||||
assert " #bug #important" in line
|
||||
if "Second observation" in line:
|
||||
# Tags should appear before context
|
||||
assert "#feature (Some context)" in line
|
||||
@@ -19,32 +19,29 @@ from basic_memory.markdown.schemas import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def processor(tmp_path: Path, entity_parser: EntityParser) -> MarkdownProcessor:
|
||||
"""Create MarkdownProcessor with temp path."""
|
||||
return MarkdownProcessor(tmp_path, entity_parser)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_new_minimal_file(processor: MarkdownProcessor, tmp_path: Path):
|
||||
async def test_write_new_minimal_file(markdown_processor: MarkdownProcessor, tmp_path: Path):
|
||||
"""Test creating new file with just title."""
|
||||
path = tmp_path / "test.md"
|
||||
|
||||
# Create minimal markdown schema
|
||||
metadata = {}
|
||||
metadata["title"] = "Test Note"
|
||||
metadata["type"] = "note"
|
||||
metadata["permalink"] = "test"
|
||||
metadata["created"] = datetime(2024, 1, 1)
|
||||
metadata["modified"] = datetime(2024, 1, 1)
|
||||
metadata["tags"] = ["test"]
|
||||
markdown = EntityMarkdown(
|
||||
frontmatter=EntityFrontmatter(
|
||||
type="note",
|
||||
permalink="test",
|
||||
title="Test Note",
|
||||
created=datetime(2024, 1, 1),
|
||||
modified=datetime(2024, 1, 1),
|
||||
tags=["test"],
|
||||
metadata=metadata,
|
||||
),
|
||||
content=EntityContent(content=""),
|
||||
)
|
||||
|
||||
# Write file
|
||||
checksum = await processor.write_file(path, markdown)
|
||||
checksum = await markdown_processor.write_file(path, markdown)
|
||||
|
||||
# Read back and verify
|
||||
content = path.read_text()
|
||||
@@ -61,7 +58,7 @@ async def test_write_new_minimal_file(processor: MarkdownProcessor, tmp_path: Pa
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_new_file_with_content(processor: MarkdownProcessor, tmp_path: Path):
|
||||
async def test_write_new_file_with_content(markdown_processor: MarkdownProcessor, tmp_path: Path):
|
||||
"""Test creating new file with content and sections."""
|
||||
path = tmp_path / "test.md"
|
||||
|
||||
@@ -95,7 +92,7 @@ async def test_write_new_file_with_content(processor: MarkdownProcessor, tmp_pat
|
||||
)
|
||||
|
||||
# Write file
|
||||
checksum = await processor.write_file(path, markdown)
|
||||
checksum = await markdown_processor.write_file(path, markdown)
|
||||
|
||||
# Read back and verify
|
||||
content = path.read_text()
|
||||
@@ -114,7 +111,7 @@ async def test_write_new_file_with_content(processor: MarkdownProcessor, tmp_pat
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_preserves_content(processor: MarkdownProcessor, tmp_path: Path):
|
||||
async def test_update_preserves_content(markdown_processor: MarkdownProcessor, tmp_path: Path):
|
||||
"""Test that updating file preserves existing content."""
|
||||
path = tmp_path / "test.md"
|
||||
|
||||
@@ -135,7 +132,7 @@ async def test_update_preserves_content(processor: MarkdownProcessor, tmp_path:
|
||||
),
|
||||
)
|
||||
|
||||
checksum = await processor.write_file(path, initial)
|
||||
checksum = await markdown_processor.write_file(path, initial)
|
||||
|
||||
# Update with new observation
|
||||
updated = EntityMarkdown(
|
||||
@@ -150,10 +147,10 @@ async def test_update_preserves_content(processor: MarkdownProcessor, tmp_path:
|
||||
)
|
||||
|
||||
# Update file
|
||||
new_checksum = await processor.write_file(path, updated, expected_checksum=checksum)
|
||||
new_checksum = await markdown_processor.write_file(path, updated, expected_checksum=checksum)
|
||||
|
||||
# Read back and verify
|
||||
result = await processor.read_file(path)
|
||||
result = await markdown_processor.read_file(path)
|
||||
|
||||
# Original content preserved
|
||||
assert "Original content here." in result.content.content
|
||||
@@ -165,7 +162,7 @@ async def test_update_preserves_content(processor: MarkdownProcessor, tmp_path:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dirty_file_detection(processor: MarkdownProcessor, tmp_path: Path):
|
||||
async def test_dirty_file_detection(markdown_processor: MarkdownProcessor, tmp_path: Path):
|
||||
"""Test detection of file modifications."""
|
||||
path = tmp_path / "test.md"
|
||||
|
||||
@@ -181,7 +178,7 @@ async def test_dirty_file_detection(processor: MarkdownProcessor, tmp_path: Path
|
||||
content=EntityContent(content="Initial content"),
|
||||
)
|
||||
|
||||
checksum = await processor.write_file(path, initial)
|
||||
checksum = await markdown_processor.write_file(path, initial)
|
||||
|
||||
# Modify file directly
|
||||
path.write_text(path.read_text() + "\nModified!")
|
||||
@@ -194,8 +191,8 @@ async def test_dirty_file_detection(processor: MarkdownProcessor, tmp_path: Path
|
||||
|
||||
# Should raise DirtyFileError
|
||||
with pytest.raises(DirtyFileError):
|
||||
await processor.write_file(path, update, expected_checksum=checksum)
|
||||
await markdown_processor.write_file(path, update, expected_checksum=checksum)
|
||||
|
||||
# Should succeed without checksum
|
||||
new_checksum = await processor.write_file(path, update)
|
||||
new_checksum = await markdown_processor.write_file(path, update)
|
||||
assert new_checksum != checksum
|
||||
@@ -39,8 +39,8 @@ async def test_unicode_content(tmp_path):
|
||||
parser = EntityParser(tmp_path)
|
||||
entity = await parser.parse_file(test_file)
|
||||
|
||||
assert "测试" in entity.frontmatter.tags
|
||||
assert "chinese" not in entity.frontmatter.tags
|
||||
assert "测试" in entity.frontmatter.metadata["tags"]
|
||||
assert "chinese" not in entity.frontmatter.metadata["tags"]
|
||||
assert "🧪" in entity.content.content
|
||||
|
||||
# Verify Unicode in observations
|
||||
|
||||
@@ -15,7 +15,6 @@ async def test_create_basic_entity(client):
|
||||
Entity(
|
||||
title="TestEntity",
|
||||
entity_type="test",
|
||||
summary="A test entity",
|
||||
observations=["First observation"],
|
||||
)
|
||||
]
|
||||
@@ -31,7 +30,6 @@ async def test_create_basic_entity(client):
|
||||
assert entity.title == "TestEntity"
|
||||
assert entity.entity_type == "test"
|
||||
assert entity.permalink == "test-entity"
|
||||
assert entity.summary == "A test entity"
|
||||
|
||||
# Check observations
|
||||
assert len(entity.observations) == 1
|
||||
@@ -51,7 +49,6 @@ async def test_create_entity_with_multiple_observations(client):
|
||||
Entity(
|
||||
title="TestEntity",
|
||||
entity_type="test",
|
||||
summary="A test entity",
|
||||
observations=["First observation", "Second observation", "Third observation"],
|
||||
)
|
||||
]
|
||||
@@ -100,7 +97,6 @@ async def test_create_entity_without_observations(client):
|
||||
Entity(
|
||||
title="TestEntity",
|
||||
entity_type="test",
|
||||
summary="A test entity without observations",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -19,8 +19,8 @@ async def test_open_multiple_entities(mcp: FastMCP, client: AsyncClient):
|
||||
# Create some test entities
|
||||
entity_request = CreateEntityRequest(
|
||||
entities=[
|
||||
Entity(title="Entity1", entity_type="test", summary="First test entity"),
|
||||
Entity(title="Entity2", entity_type="test", summary="Second test entity"),
|
||||
Entity(title="Entity1", entity_type="test"),
|
||||
Entity(title="Entity2", entity_type="test"),
|
||||
]
|
||||
)
|
||||
result = await mcp.call_tool("create_entities",{ "request": entity_request})
|
||||
@@ -54,7 +54,6 @@ async def test_open_nodes_with_details(client):
|
||||
Entity(
|
||||
title="DetailedEntity",
|
||||
entity_type="test",
|
||||
summary="Test entity with details",
|
||||
observations=["First observation", "Second observation"],
|
||||
)
|
||||
]
|
||||
@@ -71,7 +70,6 @@ async def test_open_nodes_with_details(client):
|
||||
entity = response.entities[0]
|
||||
assert entity.title == "DetailedEntity"
|
||||
assert entity.entity_type == "test"
|
||||
assert entity.summary == "Test entity with details"
|
||||
assert len(entity.observations) == 2
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ async def test_get_basic_entity(client):
|
||||
Entity(
|
||||
title="TestEntity",
|
||||
entity_type="test",
|
||||
summary="A test entity",
|
||||
observations=["First observation"],
|
||||
)
|
||||
]
|
||||
@@ -33,7 +32,6 @@ async def test_get_basic_entity(client):
|
||||
assert entity.title == "TestEntity"
|
||||
assert entity.entity_type == "test"
|
||||
assert entity.permalink == "test-entity"
|
||||
assert entity.summary == "A test entity"
|
||||
|
||||
# Check observations
|
||||
assert len(entity.observations) == 1
|
||||
|
||||
@@ -43,7 +43,6 @@ async def related_results(session_maker):
|
||||
entity_type="test",
|
||||
permalink="source/source",
|
||||
file_path="source/source.md",
|
||||
summary="Source entity",
|
||||
content_type="text/markdown",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
@@ -53,7 +52,6 @@ async def related_results(session_maker):
|
||||
entity_type="test",
|
||||
permalink="target/target",
|
||||
file_path="target/target.md",
|
||||
summary="Target entity",
|
||||
content_type="text/markdown",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
@@ -83,7 +81,6 @@ async def test_create_entity(entity_repository: EntityRepository):
|
||||
"entity_type": "test",
|
||||
"permalink": "test/test",
|
||||
"file_path": "test/test.md",
|
||||
"summary": "Test description",
|
||||
"content_type": "text/markdown",
|
||||
}
|
||||
entity = await entity_repository.create(entity_data)
|
||||
@@ -91,7 +88,6 @@ async def test_create_entity(entity_repository: EntityRepository):
|
||||
# Verify returned object
|
||||
assert entity.id is not None
|
||||
assert entity.title == "Test"
|
||||
assert entity.summary == "Test description"
|
||||
assert isinstance(entity.created_at, datetime)
|
||||
assert isinstance(entity.updated_at, datetime)
|
||||
|
||||
@@ -101,7 +97,6 @@ async def test_create_entity(entity_repository: EntityRepository):
|
||||
assert found.id is not None
|
||||
assert found.id == entity.id
|
||||
assert found.title == entity.title
|
||||
assert found.summary == entity.summary
|
||||
|
||||
# assert relations are eagerly loaded
|
||||
assert len(entity.observations) == 0
|
||||
@@ -117,7 +112,6 @@ async def test_create_all(entity_repository: EntityRepository):
|
||||
"entity_type": "test",
|
||||
"permalink": "test/test-1",
|
||||
"file_path": "test/test_1.md",
|
||||
"summary": "Test description",
|
||||
"content_type": "text/markdown",
|
||||
},
|
||||
{
|
||||
@@ -125,7 +119,6 @@ async def test_create_all(entity_repository: EntityRepository):
|
||||
"entity_type": "test",
|
||||
"permalink": "test/test-2",
|
||||
"file_path": "test/test_2.md",
|
||||
"summary": "Test description",
|
||||
"content_type": "text/markdown",
|
||||
},
|
||||
]
|
||||
@@ -140,32 +133,12 @@ async def test_create_all(entity_repository: EntityRepository):
|
||||
assert found.id is not None
|
||||
assert found.id == entity.id
|
||||
assert found.title == entity.title
|
||||
assert found.summary == entity.summary
|
||||
|
||||
# assert relations are eagerly loaded
|
||||
assert len(entity.observations) == 0
|
||||
assert len(entity.relations) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_entity_null_description(session_maker, entity_repository: EntityRepository):
|
||||
"""Test creating an entity with null description"""
|
||||
entity_data = {
|
||||
"title": "Test",
|
||||
"entity_type": "test",
|
||||
"permalink": "test/test",
|
||||
"file_path": "test/test.md",
|
||||
"content_type": "text/markdown",
|
||||
"summary": None,
|
||||
}
|
||||
entity = await entity_repository.create(entity_data)
|
||||
|
||||
# Verify in database
|
||||
async with db.scoped_session(session_maker) as session:
|
||||
stmt = select(Entity).where(Entity.id == entity.id)
|
||||
result = await session.execute(stmt)
|
||||
db_entity = result.scalar_one()
|
||||
assert db_entity.summary is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -183,40 +156,23 @@ async def test_find_by_id(entity_repository: EntityRepository, sample_entity: En
|
||||
db_entity = result.scalar_one()
|
||||
assert db_entity.id == found.id
|
||||
assert db_entity.title == found.title
|
||||
assert db_entity.summary == found.summary
|
||||
|
||||
|
||||
@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, {"title": "Updated title"})
|
||||
assert updated is not None
|
||||
assert updated.summary == "Updated description"
|
||||
assert updated.title == sample_entity.title # Other fields unchanged
|
||||
assert updated.title == "Updated title"
|
||||
|
||||
# Verify in database
|
||||
async with db.scoped_session(entity_repository.session_maker) as session:
|
||||
stmt = select(Entity).where(Entity.id == sample_entity.id)
|
||||
result = await session.execute(stmt)
|
||||
db_entity = result.scalar_one()
|
||||
assert db_entity.summary == "Updated description"
|
||||
assert db_entity.title == sample_entity.title
|
||||
assert db_entity.title == "Updated title"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_entity_to_null(entity_repository: EntityRepository, sample_entity: Entity):
|
||||
"""Test updating an entity's description to null"""
|
||||
updated = await entity_repository.update(sample_entity.id, {"summary": None})
|
||||
assert updated is not None
|
||||
assert updated.summary is None
|
||||
|
||||
# Verify in database
|
||||
async with db.scoped_session(entity_repository.session_maker) as session:
|
||||
stmt = select(Entity).where(Entity.id == sample_entity.id)
|
||||
result = await session.execute(stmt)
|
||||
db_entity = result.scalar_one()
|
||||
assert db_entity.summary is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_entity(entity_repository: EntityRepository, sample_entity):
|
||||
@@ -301,7 +257,6 @@ async def test_entities(session_maker):
|
||||
Entity(
|
||||
title="entity1",
|
||||
entity_type="test",
|
||||
summary="First test entity",
|
||||
permalink="type1/entity1",
|
||||
file_path="type1/entity1.md",
|
||||
content_type="text/markdown",
|
||||
@@ -311,7 +266,6 @@ async def test_entities(session_maker):
|
||||
Entity(
|
||||
title="entity2",
|
||||
entity_type="test",
|
||||
summary="Second test entity",
|
||||
permalink="type1/entity2",
|
||||
file_path="type1/entity2.md",
|
||||
content_type="text/markdown",
|
||||
@@ -321,7 +275,6 @@ async def test_entities(session_maker):
|
||||
Entity(
|
||||
title="entity3",
|
||||
entity_type="test",
|
||||
summary="Third test entity",
|
||||
permalink="type2/entity3",
|
||||
file_path="type2/entity3.md",
|
||||
content_type="text/markdown",
|
||||
@@ -434,7 +387,6 @@ async def test_list_entities_with_related(entity_repository: EntityRepository, s
|
||||
entity_type="note",
|
||||
permalink="service/core",
|
||||
file_path="service/core.md",
|
||||
summary="Core service",
|
||||
content_type="text/markdown",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
@@ -444,7 +396,6 @@ async def test_list_entities_with_related(entity_repository: EntityRepository, s
|
||||
entity_type="test",
|
||||
permalink="service/db",
|
||||
file_path="service/db.md",
|
||||
summary="Database service",
|
||||
content_type="text/markdown",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
@@ -455,7 +406,6 @@ async def test_list_entities_with_related(entity_repository: EntityRepository, s
|
||||
entity_type="test",
|
||||
permalink="config/service",
|
||||
file_path="config/service.md",
|
||||
summary="Service configuration",
|
||||
content_type="text/markdown",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
|
||||
@@ -92,7 +92,6 @@ async def test_delete_observations(session_maker: async_sessionmaker, repo):
|
||||
entity = Entity(
|
||||
title="test_entity",
|
||||
entity_type="test",
|
||||
summary="Test entity",
|
||||
permalink="test/test-entity",
|
||||
file_path="test/test_entity.md",
|
||||
content_type="text/markdown",
|
||||
@@ -134,7 +133,6 @@ async def test_delete_observation_by_id(session_maker: async_sessionmaker, repo)
|
||||
entity = Entity(
|
||||
title="test_entity",
|
||||
entity_type="test",
|
||||
summary="Test entity",
|
||||
permalink="test/test-entity",
|
||||
file_path="test/test_entity.md",
|
||||
content_type="text/markdown",
|
||||
@@ -170,7 +168,6 @@ async def test_delete_observation_by_content(session_maker: async_sessionmaker,
|
||||
entity = Entity(
|
||||
title="test_entity",
|
||||
entity_type="test",
|
||||
summary="Test entity",
|
||||
permalink="test/test-entity",
|
||||
file_path="test/test_entity.md",
|
||||
content_type="text/markdown",
|
||||
@@ -205,7 +202,6 @@ async def test_find_by_category(session_maker: async_sessionmaker, repo):
|
||||
entity = Entity(
|
||||
title="test_entity",
|
||||
entity_type="test",
|
||||
summary="Test entity",
|
||||
permalink="test/test-entity",
|
||||
file_path="test/test_entity.md",
|
||||
content_type="text/markdown",
|
||||
@@ -267,7 +263,6 @@ async def test_observation_categories(session_maker: async_sessionmaker, repo):
|
||||
entity = Entity(
|
||||
title="test_entity",
|
||||
entity_type="test",
|
||||
summary="Test entity",
|
||||
permalink="test/test-entity",
|
||||
file_path="test/test_entity.md",
|
||||
content_type="text/markdown",
|
||||
@@ -337,7 +332,6 @@ async def test_find_by_category_case_sensitivity(session_maker: async_sessionmak
|
||||
entity = Entity(
|
||||
title="test_entity",
|
||||
entity_type="test",
|
||||
summary="Test entity",
|
||||
permalink="test/test-entity",
|
||||
file_path="test/test_entity.md",
|
||||
content_type="text/markdown",
|
||||
|
||||
@@ -18,7 +18,6 @@ async def source_entity(session_maker):
|
||||
entity_type="test",
|
||||
permalink="source/test-source",
|
||||
file_path="source/test_source.md",
|
||||
summary="Source entity",
|
||||
content_type="text/markdown",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
@@ -37,7 +36,6 @@ async def target_entity(session_maker):
|
||||
entity_type="test",
|
||||
permalink="target/test-target",
|
||||
file_path="target/test_target.md",
|
||||
summary="Target entity",
|
||||
content_type="text/markdown",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
|
||||
@@ -21,7 +21,6 @@ def test_entity_in_minimal():
|
||||
entity = Entity.model_validate(data)
|
||||
assert entity.title == "test_entity"
|
||||
assert entity.entity_type == "knowledge"
|
||||
assert entity.summary is None
|
||||
assert entity.observations == []
|
||||
|
||||
|
||||
@@ -30,13 +29,11 @@ def test_entity_in_complete():
|
||||
data = {
|
||||
"title": "test_entity",
|
||||
"entity_type": "knowledge",
|
||||
"summary": "A test entity",
|
||||
"observations": ["Test observation"],
|
||||
}
|
||||
entity = Entity.model_validate(data)
|
||||
assert entity.title == "test_entity"
|
||||
assert entity.entity_type == "knowledge"
|
||||
assert entity.summary == "A test entity"
|
||||
assert len(entity.observations) == 1
|
||||
assert entity.observations[0] == "Test observation"
|
||||
|
||||
@@ -93,12 +90,11 @@ def test_create_entities_input():
|
||||
data = {
|
||||
"entities": [
|
||||
{"title": "entity1", "entity_type": "knowledge"},
|
||||
{"title": "entity2", "entity_type": "knowledge", "summary": "test description"},
|
||||
{"title": "entity2", "entity_type": "knowledge"},
|
||||
]
|
||||
}
|
||||
create_input = CreateEntityRequest.model_validate(data)
|
||||
assert len(create_input.entities) == 2
|
||||
assert create_input.entities[1].summary == "test description"
|
||||
|
||||
# Empty entities list should fail
|
||||
with pytest.raises(ValidationError):
|
||||
@@ -113,7 +109,6 @@ def test_entity_out_from_attributes():
|
||||
"title": "test",
|
||||
"entity_type": "knowledge",
|
||||
"content_type": "text/markdown",
|
||||
"summary": "test description",
|
||||
"observations": [{"id": 1, "content": "test obs", "context": None}],
|
||||
"relations": [
|
||||
{
|
||||
@@ -127,7 +122,6 @@ def test_entity_out_from_attributes():
|
||||
}
|
||||
entity = EntityResponse.model_validate(db_data)
|
||||
assert entity.permalink == "test/test"
|
||||
assert entity.summary == "test description"
|
||||
assert len(entity.observations) == 1
|
||||
assert len(entity.relations) == 1
|
||||
|
||||
@@ -136,7 +130,6 @@ def test_optional_fields():
|
||||
"""Test handling of optional fields."""
|
||||
# Create with no optional fields
|
||||
entity = Entity.model_validate({"title": "test", "entity_type": "knowledge"})
|
||||
assert entity.summary is None
|
||||
assert entity.observations == []
|
||||
|
||||
# Create with empty optional fields
|
||||
@@ -144,18 +137,15 @@ def test_optional_fields():
|
||||
{
|
||||
"title": "test",
|
||||
"entity_type": "knowledge",
|
||||
"summary": None,
|
||||
"observations": [],
|
||||
}
|
||||
)
|
||||
assert entity.summary is None
|
||||
assert entity.observations == []
|
||||
|
||||
# Create with some optional fields
|
||||
entity = Entity.model_validate(
|
||||
{"title": "test", "entity_type": "knowledge", "summary": "test", "observations": []}
|
||||
{"title": "test", "entity_type": "knowledge", "observations": []}
|
||||
)
|
||||
assert entity.summary == "test"
|
||||
assert entity.observations == []
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ async def test_create_entity(entity_service: EntityService, file_service: FileSe
|
||||
entity_data = EntitySchema(
|
||||
title="TestEntity",
|
||||
entity_type="test",
|
||||
summary="A test entity description",
|
||||
observations=["this is a test observation"],
|
||||
)
|
||||
|
||||
@@ -32,17 +31,14 @@ async def test_create_entity(entity_service: EntityService, file_service: FileSe
|
||||
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"
|
||||
assert entity.created_at is not None
|
||||
assert entity.observations[0].content == "this is a test observation"
|
||||
assert len(entity.relations) == 0
|
||||
|
||||
# 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"
|
||||
assert retrieved.summary == "A test entity description"
|
||||
assert retrieved.created_at is not None
|
||||
assert retrieved.observations[0].content == "this is a test observation"
|
||||
|
||||
@@ -67,13 +63,11 @@ async def test_create_entities(entity_service: EntityService, file_service: File
|
||||
EntitySchema(
|
||||
title="TestEntity1",
|
||||
entity_type="test",
|
||||
summary="A test entity description",
|
||||
observations=["this is a test observation"],
|
||||
),
|
||||
EntitySchema(
|
||||
title="TestEntity2",
|
||||
entity_type="test",
|
||||
summary="A test entity description",
|
||||
observations=["this is a test observation"],
|
||||
),
|
||||
]
|
||||
@@ -87,7 +81,6 @@ async def test_create_entities(entity_service: EntityService, file_service: File
|
||||
assert isinstance(entity1, EntityModel)
|
||||
assert entity1.title == "TestEntity1"
|
||||
assert entity1.entity_type == "test"
|
||||
assert entity1.summary == "A test entity description"
|
||||
assert entity1.created_at is not None
|
||||
assert entity1.observations[0].content == "this is a test observation"
|
||||
assert len(entity1.relations) == 0
|
||||
@@ -96,16 +89,15 @@ async def test_create_entities(entity_service: EntityService, file_service: File
|
||||
assert isinstance(entity1, EntityModel)
|
||||
assert entity2.title == "TestEntity2"
|
||||
assert entity2.entity_type == "test"
|
||||
assert entity2.summary == "A test entity description"
|
||||
assert entity2.created_at is not None
|
||||
assert entity2.observations[0].content == "this is a test observation"
|
||||
|
||||
# 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"
|
||||
assert retrieved1.title == "TestEntity1"
|
||||
|
||||
retrieved2 = await entity_service.get_by_permalink(entity_data[1].permalink)
|
||||
assert retrieved2.summary == "A test entity description"
|
||||
assert retrieved2.title == "TestEntity2"
|
||||
|
||||
# verify files are written
|
||||
for i, entity in enumerate(entities):
|
||||
@@ -118,7 +110,6 @@ async def test_get_by_permalink(entity_service: EntityService):
|
||||
entity1_data = EntitySchema(
|
||||
title="TestEntity1",
|
||||
entity_type="test",
|
||||
summary="First test entity",
|
||||
observations=[],
|
||||
)
|
||||
entity1 = await entity_service.create_entity(entity1_data)
|
||||
@@ -126,7 +117,6 @@ async def test_get_by_permalink(entity_service: EntityService):
|
||||
entity2_data = EntitySchema(
|
||||
title="TestEntity2",
|
||||
entity_type="test",
|
||||
summary="Second test entity",
|
||||
observations=[],
|
||||
)
|
||||
entity2 = await entity_service.create_entity(entity2_data)
|
||||
@@ -136,38 +126,24 @@ async def test_get_by_permalink(entity_service: EntityService):
|
||||
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_permalink(entity2_data.permalink)
|
||||
assert found is not None
|
||||
assert found.id == entity2.id
|
||||
assert found.entity_type == entity2.entity_type
|
||||
assert found.summary == "Second test entity"
|
||||
|
||||
# Test not found case
|
||||
with pytest.raises(EntityNotFoundError):
|
||||
await entity_service.get_by_permalink("nonexistent/test_entity")
|
||||
|
||||
|
||||
async def test_create_entity_no_description(entity_service: EntityService):
|
||||
"""Test creating entity without description (should be None)."""
|
||||
entity_data = EntitySchema(title="TestEntity", entity_type="test", observations=[])
|
||||
|
||||
entity = await entity_service.create_entity(entity_data)
|
||||
assert entity.summary is None
|
||||
|
||||
# Verify after retrieval
|
||||
retrieved = await entity_service.get_by_permalink(entity_data.permalink)
|
||||
assert retrieved.summary is None
|
||||
|
||||
|
||||
async def test_get_entity_success(entity_service: EntityService):
|
||||
"""Test successful entity retrieval."""
|
||||
entity_data = EntitySchema(
|
||||
title="TestEntity",
|
||||
entity_type="test",
|
||||
summary="Test description",
|
||||
observations=[],
|
||||
)
|
||||
await entity_service.create_entity(entity_data)
|
||||
@@ -178,7 +154,6 @@ async def test_get_entity_success(entity_service: EntityService):
|
||||
assert isinstance(retrieved, EntityModel)
|
||||
assert retrieved.title == "TestEntity"
|
||||
assert retrieved.entity_type == "test"
|
||||
assert retrieved.summary == "Test description"
|
||||
|
||||
|
||||
async def test_delete_entity_success(entity_service: EntityService):
|
||||
@@ -212,40 +187,19 @@ async def test_delete_nonexistent_entity(entity_service: EntityService):
|
||||
|
||||
async def test_create_entity_with_special_chars(entity_service: EntityService):
|
||||
"""Test entity creation with special characters in name and description."""
|
||||
name = "TestEntity_Special" # Note: Using valid path characters
|
||||
description = "Description with $pecial chars & symbols!"
|
||||
name = "TestEntity_$pecial chars & symbols!" # Note: Using valid path characters
|
||||
entity_data = EntitySchema(
|
||||
title=name,
|
||||
entity_type="test",
|
||||
summary=description,
|
||||
)
|
||||
entity = await entity_service.create_entity(entity_data)
|
||||
|
||||
assert entity.title == name
|
||||
assert entity.summary == description
|
||||
|
||||
# Verify after retrieval using permalink
|
||||
retrieved = await entity_service.get_by_permalink(entity_data.permalink)
|
||||
assert retrieved.summary == description
|
||||
|
||||
|
||||
async def test_create_entity_long_description(entity_service: EntityService):
|
||||
"""Test creating entity with a long description."""
|
||||
long_description = "A" * 1000 # 1000 character description
|
||||
entity_data = EntitySchema(
|
||||
title="TestEntity",
|
||||
entity_type="test",
|
||||
summary=long_description,
|
||||
observations=[],
|
||||
)
|
||||
|
||||
entity = await entity_service.create_entity(entity_data)
|
||||
assert entity.summary == long_description
|
||||
|
||||
# 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_permalinks(entity_service: EntityService):
|
||||
"""Test opening multiple nodes by path IDs."""
|
||||
@@ -253,13 +207,11 @@ async def test_open_nodes_by_permalinks(entity_service: EntityService):
|
||||
entity1_data = EntitySchema(
|
||||
title="Entity1",
|
||||
entity_type="test",
|
||||
summary="First entity",
|
||||
observations=[],
|
||||
)
|
||||
entity2_data = EntitySchema(
|
||||
title="Entity2",
|
||||
entity_type="test",
|
||||
summary="Second entity",
|
||||
observations=[],
|
||||
)
|
||||
await entity_service.create_entity(entity1_data)
|
||||
@@ -286,7 +238,6 @@ async def test_open_nodes_some_not_found(entity_service: EntityService):
|
||||
entity_data = EntitySchema(
|
||||
title="Entity1",
|
||||
entity_type="test",
|
||||
summary="Test entity",
|
||||
observations=[],
|
||||
)
|
||||
await entity_service.create_entity(entity_data)
|
||||
@@ -305,13 +256,11 @@ async def test_delete_entities_by_permalinks(entity_service: EntityService):
|
||||
entity1_data = EntitySchema(
|
||||
title="Entity1",
|
||||
entity_type="test",
|
||||
summary="First entity",
|
||||
observations=[],
|
||||
)
|
||||
entity2_data = EntitySchema(
|
||||
title="Entity2",
|
||||
entity_type="test",
|
||||
summary="Second entity",
|
||||
observations=[],
|
||||
)
|
||||
await entity_service.create_entity(entity1_data)
|
||||
@@ -349,41 +298,11 @@ async def test_get_entity_path(entity_service: EntityService):
|
||||
permalink="test-entity",
|
||||
title="test-entity",
|
||||
entity_type="test",
|
||||
summary="Test entity",
|
||||
)
|
||||
path = entity_service.file_service.get_entity_path(entity)
|
||||
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
|
||||
):
|
||||
"""Should update knowledge entity description and write to file."""
|
||||
# Create test entity
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="test",
|
||||
entity_type="test",
|
||||
summary="Test entity",
|
||||
entity_metadata={"status": "draft"},
|
||||
)
|
||||
)
|
||||
|
||||
# Update 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)
|
||||
content, _ = await file_service.read_file(file_path)
|
||||
|
||||
assert "Updated description" in content
|
||||
|
||||
# Verify metadata was preserved
|
||||
_, frontmatter, _ = content.split("---", 2)
|
||||
metadata = yaml.safe_load(frontmatter)
|
||||
assert metadata["status"] == "draft"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_note_entity_content(entity_service: EntityService, file_service: FileService):
|
||||
@@ -393,7 +312,6 @@ async def test_update_note_entity_content(entity_service: EntityService, file_se
|
||||
EntitySchema(
|
||||
title="test",
|
||||
entity_type="note",
|
||||
summary="Test note",
|
||||
entity_metadata={"status": "draft"},
|
||||
)
|
||||
)
|
||||
@@ -412,38 +330,9 @@ async def test_update_note_entity_content(entity_service: EntityService, file_se
|
||||
# Verify metadata was preserved
|
||||
_, frontmatter, _ = content.split("---", 2)
|
||||
metadata = yaml.safe_load(frontmatter)
|
||||
assert metadata["status"] == "draft"
|
||||
assert metadata.get("status") == "draft"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_entity_name(entity_service: EntityService, file_service: FileService):
|
||||
"""Should update entity name in both DB and frontmatter."""
|
||||
# Create test entity
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="test",
|
||||
entity_type="test",
|
||||
summary="Test entity",
|
||||
entity_metadata={"status": "draft"},
|
||||
)
|
||||
)
|
||||
|
||||
# Update name
|
||||
updated = await entity_service.update_entity(entity.permalink, title="new-name")
|
||||
|
||||
# Verify name was updated in DB
|
||||
assert updated.title == "new-name"
|
||||
|
||||
# Verify frontmatter was updated in file
|
||||
file_path = file_service.get_entity_path(updated)
|
||||
content, _ = await file_service.read_file(file_path)
|
||||
|
||||
_, frontmatter, _ = content.split("---", 2)
|
||||
metadata = yaml.safe_load(frontmatter)
|
||||
assert metadata["permalink"] == entity.permalink
|
||||
|
||||
# And verify content uses new name for title
|
||||
assert "# new-name" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -454,7 +343,6 @@ async def test_create_or_update_new(entity_service: EntityService, file_service:
|
||||
EntitySchema(
|
||||
title="test",
|
||||
entity_type="test",
|
||||
summary="Test entity",
|
||||
entity_metadata={"status": "draft"},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Tests for file operations service."""
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from textwrap import dedent
|
||||
|
||||
import pytest
|
||||
|
||||
from basic_memory.models import Entity, Relation
|
||||
from basic_memory.models import Entity, Relation, Observation
|
||||
from basic_memory.repository import RelationRepository, EntityRepository, ObservationRepository
|
||||
from basic_memory.services.exceptions import FileOperationError
|
||||
from basic_memory.services.file_service import FileService
|
||||
|
||||
@@ -108,35 +109,6 @@ async def test_delete_file(tmp_path: Path, file_service: FileService):
|
||||
await file_service.delete_file(test_path)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_frontmatter(file_service: FileService):
|
||||
"""Test frontmatter addition."""
|
||||
test_content = "# Test\nSome content"
|
||||
test_metadata = {"type": "test", "tags": ["one", "two"]}
|
||||
|
||||
now = datetime.now()
|
||||
frontmatter = {
|
||||
"id": "test-id",
|
||||
"type": "test",
|
||||
"created": now.isoformat(),
|
||||
"modified": now.isoformat(),
|
||||
}
|
||||
|
||||
# Add frontmatter
|
||||
content_with_fm = await file_service.add_frontmatter(
|
||||
frontmatter=frontmatter, content=test_content, metadata=test_metadata
|
||||
)
|
||||
|
||||
# Verify structure
|
||||
assert content_with_fm.startswith("---\n")
|
||||
assert "id: test-id" in content_with_fm
|
||||
assert "type: test" in content_with_fm
|
||||
assert "created:" in content_with_fm
|
||||
assert "modified:" in content_with_fm
|
||||
assert "tags:" in content_with_fm
|
||||
assert test_content in content_with_fm
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_checksum_consistency(tmp_path: Path, file_service: FileService):
|
||||
"""Test checksum remains consistent."""
|
||||
@@ -176,32 +148,6 @@ async def test_error_handling_invalid_path(tmp_path: Path, file_service: FileSer
|
||||
await file_service.write_file(test_path, "test")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_frontmatter_invalid_metadata(file_service: FileService):
|
||||
"""Test error handling for invalid frontmatter metadata."""
|
||||
|
||||
# Create an object that can't be serialized to YAML
|
||||
class NonSerializable:
|
||||
def __getstate__(self):
|
||||
raise ValueError("Can't serialize me!")
|
||||
|
||||
now = datetime.now()
|
||||
frontmatter = {
|
||||
"id": "test-id",
|
||||
"type": "test",
|
||||
"created": now.isoformat(),
|
||||
"modified": now.isoformat(),
|
||||
}
|
||||
|
||||
bad_metadata = {"bad": NonSerializable()}
|
||||
|
||||
# Attempting to add frontmatter with non-serializable content
|
||||
with patch("basic_memory.file_utils.add_frontmatter") as mock_add:
|
||||
mock_add.side_effect = FileOperationError("Failed to serialize metadata")
|
||||
with pytest.raises(FileOperationError):
|
||||
await file_service.add_frontmatter(frontmatter=frontmatter, content="content", metadata=bad_metadata)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_unicode_content(tmp_path: Path, file_service: FileService):
|
||||
"""Test handling of unicode content."""
|
||||
@@ -222,27 +168,64 @@ async def test_write_unicode_content(tmp_path: Path, file_service: FileService):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_entity_preserves_content(
|
||||
file_service: FileService,
|
||||
sample_entity: Entity,
|
||||
async def test_write_entity_with_content(
|
||||
file_service: FileService,
|
||||
sample_entity: Entity,
|
||||
):
|
||||
"""Test that write_entity_file uses content when explicitly provided."""
|
||||
# Write initial content
|
||||
initial_content = dedent("""
|
||||
# My Note
|
||||
|
||||
This is my original content.
|
||||
It should be included in the file.""")
|
||||
|
||||
path, _ = await file_service.write_entity_file(sample_entity, content=initial_content)
|
||||
|
||||
# Verify content was written
|
||||
content, _ = await file_service.read_file(path)
|
||||
|
||||
# Content should have frontmatter and supplied content
|
||||
assert "# My Note" in content
|
||||
assert "This is my original content" in content
|
||||
assert "It should be included in the file." in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_entity_preserves_existing_content(
|
||||
file_service: FileService,
|
||||
observation_repository: ObservationRepository,
|
||||
relation_repository: RelationRepository,
|
||||
entity_repository: EntityRepository,
|
||||
sample_entity: Entity,
|
||||
full_entity: Entity,
|
||||
):
|
||||
"""Test that write_entity_file preserves existing content when not explicitly provided."""
|
||||
# Write initial content
|
||||
initial_content = """# My Note
|
||||
initial_content = dedent("""
|
||||
# My Note
|
||||
This is my original content.
|
||||
It should be preserved.""")
|
||||
|
||||
This is my original content.
|
||||
It should be preserved."""
|
||||
path, _ = await file_service.write_entity_file(sample_entity, content=initial_content)
|
||||
|
||||
# Add a relation to the entity (simulating link creation)
|
||||
sample_entity.outgoing_relations = [
|
||||
# add observation
|
||||
observation = await observation_repository.add(
|
||||
Observation(entity_id=sample_entity.id,
|
||||
content="Test observation", category="note", context="test context")
|
||||
)
|
||||
|
||||
# Add a relation
|
||||
relation = await relation_repository.add(
|
||||
Relation(
|
||||
from_id=1,
|
||||
to_id=2,
|
||||
to_name="other-note",
|
||||
from_id=sample_entity.id,
|
||||
to_id=full_entity.id,
|
||||
to_name=full_entity.title,
|
||||
relation_type="relates_to",
|
||||
)
|
||||
]
|
||||
)
|
||||
# reload entity
|
||||
sample_entity = await entity_repository.find_by_id(sample_entity.id)
|
||||
|
||||
# Write entity file without providing content
|
||||
await file_service.write_entity_file(sample_entity)
|
||||
@@ -255,14 +238,17 @@ It should be preserved."""
|
||||
assert "This is my original content" in content
|
||||
assert "It should be preserved" in content
|
||||
|
||||
# And should also have the new observation
|
||||
assert f"- [{observation.category}] {observation.content} ({observation.context})" in content
|
||||
|
||||
# And should also have the new relation
|
||||
assert "[[other-note]]" in content
|
||||
assert f"- relates_to [[{full_entity.title}]]" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_entity_handles_missing_content(
|
||||
file_service: FileService,
|
||||
sample_entity: Entity,
|
||||
file_service: FileService,
|
||||
sample_entity: Entity,
|
||||
):
|
||||
"""Test that write_entity_file handles case where there is no existing content gracefully."""
|
||||
# Write without any content
|
||||
|
||||
@@ -10,13 +10,12 @@ from basic_memory.services.link_resolver import LinkResolver
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_entities(entity_repository):
|
||||
async def test_entities(entity_repository, file_service):
|
||||
"""Create a set of test entities."""
|
||||
entities = [
|
||||
Entity(
|
||||
title="Core Service",
|
||||
entity_type="component",
|
||||
summary="The core service implementation",
|
||||
permalink="components/core-service",
|
||||
file_path="components/core-service.md",
|
||||
content_type="text/markdown",
|
||||
@@ -26,7 +25,6 @@ async def test_entities(entity_repository):
|
||||
Entity(
|
||||
title="Service Config",
|
||||
entity_type="config",
|
||||
summary="Configuration for services",
|
||||
permalink="config/service-config",
|
||||
file_path="config/service-config.md",
|
||||
content_type="text/markdown",
|
||||
@@ -36,7 +34,6 @@ async def test_entities(entity_repository):
|
||||
Entity(
|
||||
title="Auth Service",
|
||||
entity_type="component",
|
||||
summary="Authentication service implementation",
|
||||
permalink="components/auth/service",
|
||||
file_path="components/auth/service.md",
|
||||
content_type="text/markdown",
|
||||
@@ -46,7 +43,6 @@ async def test_entities(entity_repository):
|
||||
Entity(
|
||||
title="Core Features",
|
||||
entity_type="specs",
|
||||
summary="Core feature specifications",
|
||||
permalink="specs/features/core",
|
||||
file_path="specs/features/core.md",
|
||||
content_type="text/markdown",
|
||||
@@ -54,6 +50,9 @@ async def test_entities(entity_repository):
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
),
|
||||
]
|
||||
|
||||
for entity in entities:
|
||||
await file_service.write_entity_file(entity)
|
||||
|
||||
# Add to repository
|
||||
return await entity_repository.add_all(entities)
|
||||
|
||||
@@ -22,7 +22,6 @@ async def test_entities(
|
||||
entity_type="test",
|
||||
permalink="test/test-entity-1",
|
||||
file_path="test/test_entity_1.md",
|
||||
summary="Test entity 1",
|
||||
content_type="text/markdown",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
@@ -33,7 +32,6 @@ async def test_entities(
|
||||
entity_type="test",
|
||||
permalink="test/test-entity-2",
|
||||
file_path="test/test_entity_2.md",
|
||||
summary="Test entity 2",
|
||||
content_type="text/markdown",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
|
||||
@@ -166,9 +166,9 @@ async def test_update_index(search_service, full_entity):
|
||||
await search_service.index_entity(full_entity)
|
||||
|
||||
# Update entity
|
||||
full_entity.summary = "Updated description with new terms"
|
||||
full_entity.title = "OMG I AM UPDATED"
|
||||
await search_service.index_entity(full_entity)
|
||||
|
||||
# Search for new terms
|
||||
results = await search_service.search(SearchQuery(text="new terms"))
|
||||
assert len(results) == 1
|
||||
# Search for new title
|
||||
results = await search_service.search(SearchQuery(text="OMG I AM UPDATED"))
|
||||
assert len(results) > 1
|
||||
|
||||
@@ -19,14 +19,15 @@ from basic_memory.sync.entity_sync_service import EntitySyncService
|
||||
@pytest_asyncio.fixture
|
||||
def test_frontmatter() -> EntityFrontmatter:
|
||||
"""Create test frontmatter."""
|
||||
return EntityFrontmatter(
|
||||
title="Test Entity",
|
||||
type="knowledge",
|
||||
permalink="concept/test-entity",
|
||||
created=datetime.now(),
|
||||
modified=datetime.now(),
|
||||
tags=["test", "sync"],
|
||||
)
|
||||
metatdata = {
|
||||
"title": "Test Entity",
|
||||
"type": "knowledge",
|
||||
"permalink": "concept/test-entity",
|
||||
"created": datetime.now(),
|
||||
"modified": datetime.now(),
|
||||
"tags": ["test", "sync"],
|
||||
}
|
||||
return EntityFrontmatter(metadata=metatdata)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
@@ -53,7 +54,7 @@ def test_markdown(test_frontmatter, test_content) -> EntityMarkdown:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_entity_without_relations(
|
||||
entity_sync_service: EntitySyncService, test_markdown: EntityMarkdown
|
||||
entity_sync_service: EntitySyncService, test_markdown: EntityMarkdown
|
||||
):
|
||||
"""Test first pass creation without relations."""
|
||||
# Create entity first pass
|
||||
@@ -63,7 +64,6 @@ async def test_create_entity_without_relations(
|
||||
assert entity.title == "Test Entity"
|
||||
assert entity.entity_type == "knowledge"
|
||||
assert entity.permalink == "concept/test-entity"
|
||||
assert entity.summary == "A test entity description"
|
||||
|
||||
# Check observations
|
||||
assert len(entity.observations) == 2
|
||||
@@ -79,14 +79,14 @@ async def test_create_entity_without_relations(
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_entity_without_relations(
|
||||
entity_sync_service: EntitySyncService, test_markdown: EntityMarkdown
|
||||
entity_sync_service: EntitySyncService, test_markdown: EntityMarkdown
|
||||
):
|
||||
"""Test first pass update."""
|
||||
# First create entity
|
||||
entity = await entity_sync_service.create_entity_from_markdown("test.md", test_markdown)
|
||||
|
||||
# Modify markdown content
|
||||
test_markdown.frontmatter.title = "Updated Title"
|
||||
test_markdown.frontmatter.metadata["title"] = "Updated Title"
|
||||
test_markdown.content.content = "Updated description"
|
||||
test_markdown.content.observations = [MarkdownObservation(content="Updated observation")]
|
||||
|
||||
@@ -97,7 +97,6 @@ async def test_update_entity_without_relations(
|
||||
|
||||
# Check fields updated
|
||||
assert updated.title == "Updated Title"
|
||||
assert updated.summary == "Updated description"
|
||||
assert len(updated.observations) == 1
|
||||
assert updated.observations[0].content == "Updated observation"
|
||||
|
||||
@@ -107,13 +106,15 @@ async def test_update_entity_without_relations(
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_entity_relations(
|
||||
entity_sync_service: EntitySyncService, test_markdown: EntityMarkdown
|
||||
entity_sync_service: EntitySyncService, test_markdown: EntityMarkdown
|
||||
):
|
||||
"""Test second pass relation updates."""
|
||||
|
||||
# add a forward link to the markdown (entity does not exist)
|
||||
test_markdown.content.relations.append(MarkdownRelation(type="depends_on", target="concept/doesnt-exist"))
|
||||
|
||||
test_markdown.content.relations.append(
|
||||
MarkdownRelation(type="depends_on", target="concept/doesnt-exist")
|
||||
)
|
||||
|
||||
# Create main entity first
|
||||
entity = await entity_sync_service.create_entity_from_markdown("test.md", test_markdown)
|
||||
|
||||
@@ -161,7 +162,7 @@ async def test_update_entity_relations(
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_pass_sync_flow(
|
||||
entity_sync_service: EntitySyncService, test_markdown: EntityMarkdown
|
||||
entity_sync_service: EntitySyncService, test_markdown: EntityMarkdown
|
||||
):
|
||||
"""Test complete two-pass sync flow."""
|
||||
# Create target entities first
|
||||
|
||||
Reference in New Issue
Block a user