mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
add title/folder to EntitySchema
This commit is contained in:
@@ -14,6 +14,7 @@ The file format has two distinct types of content:
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from collections import OrderedDict
|
||||
|
||||
import frontmatter
|
||||
from frontmatter import Post
|
||||
@@ -99,21 +100,16 @@ class MarkdownProcessor:
|
||||
if current_checksum != expected_checksum:
|
||||
raise DirtyFileError(f"File {path} has been modified")
|
||||
|
||||
# Convert frontmatter to dict, dropping None values
|
||||
# Convert frontmatter to dict
|
||||
frontmatter_dict = OrderedDict()
|
||||
frontmatter_dict["title"] = markdown.frontmatter.title
|
||||
frontmatter_dict["type"] = markdown.frontmatter.type
|
||||
frontmatter_dict["permalink"] = markdown.frontmatter.permalink
|
||||
|
||||
metadata = markdown.frontmatter.metadata or {}
|
||||
frontmatter_dict = {
|
||||
"type": markdown.frontmatter.type,
|
||||
"permalink": markdown.frontmatter.permalink,
|
||||
"created": markdown.frontmatter.created.isoformat()
|
||||
if markdown.created
|
||||
else None,
|
||||
"modified": markdown.frontmatter.modified.isoformat()
|
||||
if markdown.modified
|
||||
else None,
|
||||
**metadata,
|
||||
}
|
||||
frontmatter_dict = {k: v for k, v in frontmatter_dict.items() if v is not None}
|
||||
|
||||
for k,v in metadata.items():
|
||||
frontmatter_dict[k] = v
|
||||
|
||||
# Start with user content (or minimal title for new files)
|
||||
content = markdown.content or f"# {markdown.frontmatter.title}\n"
|
||||
|
||||
@@ -131,7 +127,7 @@ class MarkdownProcessor:
|
||||
|
||||
# Create Post object for frontmatter
|
||||
post = Post(content, **frontmatter_dict)
|
||||
final_content = frontmatter.dumps(post)
|
||||
final_content = frontmatter.dumps(post, sort_keys=False)
|
||||
|
||||
logger.debug(f"writing file {path} with content:\n{final_content}")
|
||||
|
||||
|
||||
@@ -29,11 +29,9 @@ def entity_model_to_markdown(entity: Entity, content: Optional[str] = None) -> E
|
||||
:rtype: EntityMarkdown
|
||||
"""
|
||||
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
|
||||
metadata["permalink"] = entity.permalink
|
||||
|
||||
# convert model to markdown
|
||||
entity_observations = [
|
||||
@@ -80,6 +78,8 @@ def entity_model_to_markdown(entity: Entity, content: Optional[str] = None) -> E
|
||||
content=content,
|
||||
observations=observations,
|
||||
relations=relations,
|
||||
created = entity.created_at,
|
||||
modified = entity.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ def entity_model_from_markdown(file_path: Path, markdown: EntityMarkdown, entity
|
||||
permalink = markdown.frontmatter.permalink or generate_permalink(file_path)
|
||||
model = entity or Entity()
|
||||
|
||||
model.title=markdown.frontmatter.title or file_path.stem
|
||||
model.title=markdown.frontmatter.title
|
||||
model.entity_type=markdown.frontmatter.type
|
||||
model.permalink=permalink
|
||||
model.file_path=str(file_path)
|
||||
@@ -128,14 +128,17 @@ async def schema_to_markdown(schema):
|
||||
:param schema: the schema to convert
|
||||
:return: Post
|
||||
"""
|
||||
# Add metadata to dict
|
||||
frontmatter_dict = schema.entity_metadata or {}
|
||||
|
||||
# set permalink and type
|
||||
frontmatter_dict["permalink"] = schema.permalink
|
||||
frontmatter_dict["type"] = schema.entity_type
|
||||
|
||||
# Create Post object
|
||||
content = schema.content or ""
|
||||
post = Post(content, **frontmatter_dict)
|
||||
frontmatter_metadata = schema.entity_metadata or {}
|
||||
|
||||
# remove from map so we can define ordering in frontmatter
|
||||
if "type" in frontmatter_metadata:
|
||||
del frontmatter_metadata["type"]
|
||||
if "title" in frontmatter_metadata:
|
||||
del frontmatter_metadata["title"]
|
||||
if "permalink" in frontmatter_metadata:
|
||||
del frontmatter_metadata["permalink"]
|
||||
|
||||
post = Post(content, title=schema.title, type=schema.entity_type, permalink=schema.permalink, **frontmatter_metadata)
|
||||
return post
|
||||
|
||||
@@ -19,15 +19,17 @@ from basic_memory.mcp.tools.utils import call_get, call_put, call_delete
|
||||
description="Create or update a markdown note. Returns the permalink for referencing.",
|
||||
)
|
||||
async def write_note(
|
||||
file_path: str,
|
||||
title: str,
|
||||
content: str,
|
||||
folder: str,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> str:
|
||||
"""Write a markdown note to the knowledge base.
|
||||
|
||||
Args:
|
||||
file_path: The note's title
|
||||
title: The title of the note
|
||||
content: Markdown content for the note
|
||||
folder: the folder where the file should be saved
|
||||
tags: Optional list of tags to categorize the note
|
||||
|
||||
Returns:
|
||||
@@ -36,23 +38,26 @@ async def write_note(
|
||||
Examples:
|
||||
# Create a simple note
|
||||
write_note(
|
||||
file_path="Meeting Notes: Project Planning",
|
||||
file_path="Meeting Notes: Project Planning.md",
|
||||
content="# Key Points\\n\\n- Discussed timeline\\n- Set priorities"
|
||||
folder="notes"
|
||||
)
|
||||
|
||||
# Create note with tags
|
||||
write_note(
|
||||
file_path="Security Review",
|
||||
file_path="Security Review.md",
|
||||
content="# Findings\\n\\n1. Updated auth flow\\n2. Added rate limiting",
|
||||
tags=["security", "development"]
|
||||
folder="security",
|
||||
tags=["security", "development"]
|
||||
)
|
||||
"""
|
||||
logger.info(f"Writing note: {file_path}")
|
||||
logger.info(f"Writing note folder:'{folder}' title: '{title}'")
|
||||
|
||||
# Create the entity request
|
||||
metadata = {"tags": [f"#{tag}" for tag in tags]} if tags else None
|
||||
entity = Entity(
|
||||
file_path=file_path,
|
||||
title=title,
|
||||
folder=folder,
|
||||
entity_type="note",
|
||||
content_type="text/markdown",
|
||||
content=content,
|
||||
@@ -79,8 +84,8 @@ async def read_note(identifier: str) -> str:
|
||||
The note's markdown content
|
||||
|
||||
Examples:
|
||||
# Read by title
|
||||
read_note("Meeting Notes: Project Planning")
|
||||
# Read by file path
|
||||
read_note("Meeting Notes: Project Planning.md")
|
||||
|
||||
# Read by permalink
|
||||
read_note("notes/project-planning")
|
||||
|
||||
@@ -169,7 +169,7 @@ class SearchRepository:
|
||||
LIMIT :limit
|
||||
"""
|
||||
|
||||
logger.debug(f"Search {sql} params: {params}")
|
||||
#logger.debug(f"Search {sql} params: {params}")
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
result = await session.execute(text(sql), params)
|
||||
rows = result.fetchall()
|
||||
@@ -195,8 +195,8 @@ class SearchRepository:
|
||||
for row in rows
|
||||
]
|
||||
|
||||
for r in results:
|
||||
logger.debug(f"Search result: type:{r.type} title: {r.title} permalink: {r.permalink} score: {r.score}")
|
||||
#for r in results:
|
||||
# logger.debug(f"Search result: type:{r.type} title: {r.title} permalink: {r.permalink} score: {r.score}")
|
||||
return results
|
||||
|
||||
async def index_item(
|
||||
|
||||
@@ -182,15 +182,21 @@ class Entity(BaseModel):
|
||||
- Optional description for high-level overview
|
||||
"""
|
||||
|
||||
file_path: str
|
||||
entity_type: EntityType = "note"
|
||||
title: str
|
||||
content: Optional[str] = None
|
||||
folder: str
|
||||
entity_type: EntityType = "note"
|
||||
entity_metadata: Optional[Dict] = Field(default=None, description="Optional metadata")
|
||||
content_type: ContentType = Field(
|
||||
description="MIME type of the content (e.g. text/markdown, image/jpeg)",
|
||||
examples=["text/markdown", "image/jpeg"], default="text/markdown"
|
||||
)
|
||||
|
||||
@property
|
||||
def file_path(self):
|
||||
"""Get the file path for this entity based on its permalink."""
|
||||
return f"{self.folder}/{self.title}.md" if self.folder else f"{self.title}.md"
|
||||
|
||||
@property
|
||||
def permalink(self) -> PathId:
|
||||
"""Get the path ID in format {snake_case_title}."""
|
||||
|
||||
@@ -117,6 +117,7 @@ class EntityResponse(SQLAlchemyModel):
|
||||
"""
|
||||
|
||||
permalink: PathId
|
||||
title: str
|
||||
file_path: str
|
||||
entity_type: EntityType
|
||||
entity_metadata: Optional[Dict] = None
|
||||
|
||||
@@ -72,7 +72,7 @@ class EntityService(BaseService[EntityModel]):
|
||||
post = await schema_to_markdown(schema)
|
||||
|
||||
# write file
|
||||
final_content = frontmatter.dumps(post)
|
||||
final_content = frontmatter.dumps(post, sort_keys=False)
|
||||
checksum = await self.file_service.write_file(file_path, final_content)
|
||||
|
||||
# parse entity from file
|
||||
|
||||
@@ -17,7 +17,8 @@ async def test_create_entity(client: AsyncClient, file_service):
|
||||
"""Should create entity successfully."""
|
||||
|
||||
data = {
|
||||
"file_path": "TestEntity",
|
||||
"title": "TestEntity",
|
||||
"folder": "test",
|
||||
"entity_type": "test",
|
||||
"content": "TestContent",
|
||||
}
|
||||
@@ -27,8 +28,8 @@ async def test_create_entity(client: AsyncClient, file_service):
|
||||
assert response.status_code == 200
|
||||
entity = EntityResponse.model_validate(response.json())
|
||||
|
||||
assert entity.permalink == "test-entity"
|
||||
assert entity.file_path == data["file_path"]
|
||||
assert entity.permalink == "test/test-entity"
|
||||
assert entity.file_path == "test/TestEntity.md"
|
||||
assert entity.entity_type == data["entity_type"]
|
||||
assert entity.content_type == "text/markdown"
|
||||
|
||||
@@ -44,7 +45,8 @@ async def test_create_entity_observations_relations(client: AsyncClient, file_se
|
||||
"""Should create entity successfully."""
|
||||
|
||||
data = {
|
||||
"file_path": "TestEntity.md",
|
||||
"title": "TestEntity",
|
||||
"folder": "test",
|
||||
"content": """
|
||||
# TestContent
|
||||
|
||||
@@ -59,8 +61,8 @@ async def test_create_entity_observations_relations(client: AsyncClient, file_se
|
||||
assert response.status_code == 200
|
||||
entity = EntityResponse.model_validate(response.json())
|
||||
|
||||
assert entity.permalink == "test-entity"
|
||||
assert entity.file_path == data["file_path"]
|
||||
assert entity.permalink == "test/test-entity"
|
||||
assert entity.file_path == "test/TestEntity.md"
|
||||
assert entity.entity_type == "note"
|
||||
assert entity.content_type == "text/markdown"
|
||||
|
||||
@@ -72,7 +74,7 @@ async def test_create_entity_observations_relations(client: AsyncClient, file_se
|
||||
|
||||
assert len(entity.relations) == 1
|
||||
assert entity.relations[0].relation_type == "related to"
|
||||
assert entity.relations[0].from_id == "test-entity"
|
||||
assert entity.relations[0].from_id == "test/test-entity"
|
||||
assert entity.relations[0].to_id is None
|
||||
|
||||
# TODO Relation.to_id should be name from link
|
||||
@@ -88,7 +90,7 @@ async def test_create_entity_observations_relations(client: AsyncClient, file_se
|
||||
async def test_get_entity(client: AsyncClient):
|
||||
"""Should retrieve an entity by path ID."""
|
||||
# First create an entity
|
||||
data = {"file_path": "TestEntity", "entity_type": "test"}
|
||||
data = {"title": "TestEntity", "folder": "test", "entity_type": "test"}
|
||||
response = await client.post("/knowledge/entities", json=data)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
@@ -100,17 +102,17 @@ async def test_get_entity(client: AsyncClient):
|
||||
# Verify retrieval
|
||||
assert response.status_code == 200
|
||||
entity = response.json()
|
||||
assert entity["file_path"] == "TestEntity"
|
||||
assert entity["file_path"] == "test/TestEntity.md"
|
||||
assert entity["entity_type"] == "test"
|
||||
assert entity["permalink"] == "test-entity"
|
||||
assert entity["permalink"] == "test/test-entity"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_entities(client: AsyncClient):
|
||||
"""Should open multiple entities by path IDs."""
|
||||
# Create a few entities with different names
|
||||
await client.post("/knowledge/entities", json={"file_path": "AlphaTest", "entity_type": "test"})
|
||||
await client.post("/knowledge/entities", json={"file_path": "BetaTest", "entity_type": "test"})
|
||||
await client.post("/knowledge/entities", json={"title": "AlphaTest", "folder": "", "entity_type": "test"})
|
||||
await client.post("/knowledge/entities", json={"title": "BetaTest", "folder": "", "entity_type": "test"})
|
||||
|
||||
# Open nodes by path IDs
|
||||
response = await client.get(
|
||||
@@ -123,12 +125,14 @@ async def test_get_entities(client: AsyncClient):
|
||||
assert len(data["entities"]) == 2
|
||||
|
||||
entity_0 = data["entities"][0]
|
||||
assert entity_0["file_path"] == "AlphaTest"
|
||||
assert entity_0["title"] == "AlphaTest"
|
||||
assert entity_0["file_path"] == "AlphaTest.md"
|
||||
assert entity_0["entity_type"] == "test"
|
||||
assert entity_0["permalink"] == "alpha-test"
|
||||
|
||||
entity_1 = data["entities"][1]
|
||||
assert entity_1["file_path"] == "BetaTest"
|
||||
assert entity_1["title"] == "BetaTest"
|
||||
assert entity_1["file_path"] == "BetaTest.md"
|
||||
assert entity_1["entity_type"] == "test"
|
||||
assert entity_1["permalink"] == "beta-test"
|
||||
|
||||
@@ -155,7 +159,7 @@ async def test_delete_entity(client: AsyncClient):
|
||||
async def test_delete_single_entity(client: AsyncClient):
|
||||
"""Test DELETE /knowledge/entities with path ID."""
|
||||
# Create test entity
|
||||
entity_data = {"file_path": "TestEntity", "entity_type": "test"}
|
||||
entity_data = {"title": "TestEntity", "folder": "", "entity_type": "test"}
|
||||
await client.post("/knowledge/entities", json=entity_data)
|
||||
|
||||
# Test deletion
|
||||
@@ -173,7 +177,7 @@ async def test_delete_single_entity(client: AsyncClient):
|
||||
async def test_delete_single_entity_by_title(client: AsyncClient):
|
||||
"""Test DELETE /knowledge/entities with path ID."""
|
||||
# Create test entity
|
||||
entity_data = {"file_path": "TestEntity", "entity_type": "test"}
|
||||
entity_data = {"title": "TestEntity", "folder": "", "entity_type": "test"}
|
||||
await client.post("/knowledge/entities", json=entity_data)
|
||||
|
||||
# Test deletion
|
||||
@@ -235,7 +239,8 @@ async def test_entity_indexing(client: AsyncClient):
|
||||
response = await client.post(
|
||||
"/knowledge/entities",
|
||||
json={
|
||||
"file_path": "SearchTest",
|
||||
"title": "SearchTest",
|
||||
"folder": "",
|
||||
"entity_type": "test",
|
||||
"observations": ["Unique searchable observation"],
|
||||
},
|
||||
@@ -261,7 +266,8 @@ async def test_entity_delete_indexing(client: AsyncClient):
|
||||
response = await client.post(
|
||||
"/knowledge/entities",
|
||||
json={
|
||||
"file_path": "DeleteTest",
|
||||
"title": "DeleteTest",
|
||||
"folder": "",
|
||||
"entity_type": "test",
|
||||
"observations": ["Searchable observation that should be removed"],
|
||||
},
|
||||
@@ -297,7 +303,8 @@ async def test_update_entity_basic(client: AsyncClient):
|
||||
response = await client.post(
|
||||
"/knowledge/entities",
|
||||
json={
|
||||
"file_path": "test",
|
||||
"title": "test",
|
||||
"folder": "",
|
||||
"entity_type": "test",
|
||||
"content": "Initial summary",
|
||||
"entity_metadata": {"status": "draft"},
|
||||
@@ -306,7 +313,7 @@ async def test_update_entity_basic(client: AsyncClient):
|
||||
entity_response = response.json()
|
||||
|
||||
# Update fields
|
||||
entity = Entity(**entity_response)
|
||||
entity = Entity(**entity_response, folder="")
|
||||
entity.entity_metadata["status"] = "final"
|
||||
entity.content = "Updated summary"
|
||||
|
||||
@@ -330,12 +337,12 @@ async def test_update_entity_content(client: AsyncClient):
|
||||
# Create a note entity
|
||||
response = await client.post(
|
||||
"/knowledge/entities",
|
||||
json={"file_path": "test-note", "entity_type": "note", "summary": "Test note"},
|
||||
json={"title": "test-note", "folder": "", "entity_type": "note", "summary": "Test note"},
|
||||
)
|
||||
note = response.json()
|
||||
|
||||
# Update fields
|
||||
entity = Entity(**note)
|
||||
entity = Entity(**note, folder="")
|
||||
entity.content = "# Updated Note\n\nNew content."
|
||||
|
||||
response = await client.put(
|
||||
@@ -358,7 +365,8 @@ async def test_update_entity_type_conversion(client: AsyncClient):
|
||||
"""Test converting between note and knowledge types."""
|
||||
# Create a note
|
||||
note_data = {
|
||||
"file_path": "test-note",
|
||||
"title": "test-note",
|
||||
"folder": "",
|
||||
"entity_type": "note",
|
||||
"summary": "Test note",
|
||||
"content": "# Test Note\n\nInitial content.",
|
||||
@@ -367,7 +375,7 @@ async def test_update_entity_type_conversion(client: AsyncClient):
|
||||
note = response.json()
|
||||
|
||||
# Update fields
|
||||
entity = Entity(**note)
|
||||
entity = Entity(**note, folder="")
|
||||
entity.entity_type = "test"
|
||||
|
||||
response = await client.put(
|
||||
@@ -389,12 +397,12 @@ async def test_update_entity_type_conversion(client: AsyncClient):
|
||||
async def test_update_entity_metadata(client: AsyncClient):
|
||||
"""Test updating entity metadata."""
|
||||
# Create entity
|
||||
data = {"file_path": "test", "entity_type": "test", "entity_metadata": {"status": "draft"}}
|
||||
data = {"title": "test", "folder": "", "entity_type": "test", "entity_metadata": {"status": "draft"}}
|
||||
response = await client.post("/knowledge/entities", json=data)
|
||||
entity_response = response.json()
|
||||
|
||||
# Update fields
|
||||
entity = Entity(**entity_response)
|
||||
entity = Entity(**entity_response, folder="")
|
||||
entity.entity_metadata["status"] = "final"
|
||||
entity.entity_metadata["reviewed"] = True
|
||||
|
||||
@@ -405,7 +413,7 @@ async def test_update_entity_metadata(client: AsyncClient):
|
||||
|
||||
# Verify metadata was merged, not replaced
|
||||
assert updated["entity_metadata"]["status"] == "final"
|
||||
assert updated["entity_metadata"]["reviewed"] in (True, "True")
|
||||
assert updated["entity_metadata"]["reviewed"] in (True, "True")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -413,7 +421,8 @@ async def test_update_entity_not_found_does_create(client: AsyncClient):
|
||||
"""Test updating non-existent entity does a create"""
|
||||
|
||||
data = {
|
||||
"file_path": "nonexistent",
|
||||
"title": "nonexistent",
|
||||
"folder": "",
|
||||
"entity_type": "test",
|
||||
"observations": ["First observation", "Second observation"],
|
||||
}
|
||||
@@ -427,7 +436,8 @@ async def test_update_entity_incorrect_permalink(client: AsyncClient):
|
||||
"""Test updating non-existent entity does a create"""
|
||||
|
||||
data = {
|
||||
"file_path": "Test Entity",
|
||||
"title": "Test Entity",
|
||||
"folder": "",
|
||||
"entity_type": "test",
|
||||
"observations": ["First observation", "Second observation"],
|
||||
}
|
||||
@@ -440,12 +450,12 @@ 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 = {"file_path": "test", "entity_type": "test", "content": "Initial searchable content"}
|
||||
data = {"title": "test", "folder": "", "entity_type": "test", "content": "Initial searchable content"}
|
||||
response = await client.post("/knowledge/entities", json=data)
|
||||
entity_response = response.json()
|
||||
|
||||
# Update fields
|
||||
entity = Entity(**entity_response)
|
||||
entity = Entity(**entity_response, folder="")
|
||||
entity.content = "Updated with unique sphinx marker"
|
||||
|
||||
response = await client.put(f"/knowledge/entities/{entity.permalink}", json=entity.model_dump())
|
||||
|
||||
@@ -127,7 +127,8 @@ async def test_reindex(client, search_service, entity_service, session_maker):
|
||||
# Create test entity and document
|
||||
await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
file_path="TestEntity1",
|
||||
title="TestEntity1",
|
||||
folder="test",
|
||||
entity_type="test",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -13,7 +13,8 @@ async def test_get_basic_entity(client):
|
||||
"""Test retrieving a basic entity."""
|
||||
# First create an entity
|
||||
permalink = await notes.write_note(
|
||||
file_path="Test Note",
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="""
|
||||
# Test\nThis is a test note
|
||||
- [note] First observation
|
||||
@@ -27,9 +28,9 @@ async def test_get_basic_entity(client):
|
||||
entity = await get_entity(permalink)
|
||||
|
||||
# Verify entity details
|
||||
assert entity.file_path == "Test Note"
|
||||
assert entity.file_path == "test/Test Note.md"
|
||||
assert entity.entity_type == "note"
|
||||
assert entity.permalink == "test-note"
|
||||
assert entity.permalink == "test/test-note"
|
||||
|
||||
# Check observations
|
||||
assert len(entity.observations) == 1
|
||||
|
||||
@@ -9,7 +9,7 @@ from basic_memory.mcp.tools import notes
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note(app):
|
||||
"""Test creating a new note.
|
||||
|
||||
|
||||
Should:
|
||||
- Create entity with correct type and content
|
||||
- Save markdown content
|
||||
@@ -17,33 +17,50 @@ async def test_write_note(app):
|
||||
- Return valid permalink
|
||||
"""
|
||||
permalink = await notes.write_note(
|
||||
file_path="Test Note",
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="# Test\nThis is a test note",
|
||||
tags=["test", "documentation"]
|
||||
tags=["test", "documentation"],
|
||||
)
|
||||
|
||||
|
||||
assert permalink # Got a valid permalink
|
||||
|
||||
# Try reading it back
|
||||
|
||||
# Try reading it back via permalink
|
||||
content = await notes.read_note(permalink)
|
||||
assert "# Test\nThis is a test note" in content
|
||||
assert "tags:" in content
|
||||
assert "- '#test'" in content
|
||||
assert "- '#documentation'" in content
|
||||
assert (
|
||||
"""
|
||||
---
|
||||
title: Test Note
|
||||
type: note
|
||||
permalink: test/test-note
|
||||
tags:
|
||||
- '#test'
|
||||
- '#documentation'
|
||||
---
|
||||
|
||||
# Test
|
||||
This is a test note
|
||||
""".strip()
|
||||
in content
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_no_tags(app):
|
||||
"""Test creating a note without tags."""
|
||||
permalink = await notes.write_note(
|
||||
file_path="Simple Note",
|
||||
content="Just some text"
|
||||
)
|
||||
|
||||
permalink = await notes.write_note(title="Simple Note", folder="test", content="Just some text")
|
||||
|
||||
# Should be able to read it back
|
||||
content = await notes.read_note(permalink)
|
||||
assert "Just some text" in content
|
||||
assert "tags:" not in content
|
||||
assert """
|
||||
--
|
||||
title: Simple Note
|
||||
type: note
|
||||
permalink: test/simple-note
|
||||
---
|
||||
|
||||
Just some text
|
||||
""".strip() in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -64,53 +81,54 @@ async def test_write_note_update_existing(app):
|
||||
- Return valid permalink
|
||||
"""
|
||||
permalink = await notes.write_note(
|
||||
file_path="Test Note",
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="# Test\nThis is a test note",
|
||||
tags=["test", "documentation"]
|
||||
tags=["test", "documentation"],
|
||||
)
|
||||
|
||||
assert permalink # Got a valid permalink
|
||||
|
||||
permalink = await notes.write_note(
|
||||
file_path="Test Note",
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="# Test\nThis is an updated note",
|
||||
tags=["test", "documentation"]
|
||||
tags=["test", "documentation"],
|
||||
)
|
||||
|
||||
|
||||
# Try reading it back
|
||||
content = await notes.read_note(permalink)
|
||||
assert "# Test\nThis is an updated note" in content
|
||||
assert "tags:" in content
|
||||
assert "- '#test'" in content
|
||||
assert "- '#documentation'" in content
|
||||
assert """
|
||||
---
|
||||
permalink: test/test-note
|
||||
tags:
|
||||
- '#test'
|
||||
- '#documentation'
|
||||
title: Test Note
|
||||
type: note
|
||||
---
|
||||
|
||||
# Test
|
||||
This is an updated note
|
||||
""".strip() in content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_note_by_title(app):
|
||||
"""Test reading a note by its title."""
|
||||
# First create a note
|
||||
await notes.write_note(
|
||||
file_path="Special Note",
|
||||
content="Note content here"
|
||||
)
|
||||
|
||||
await notes.write_note(title="Special Note", folder="test", content="Note content here")
|
||||
|
||||
# Should be able to read it by title
|
||||
content = await notes.read_note("Special Note")
|
||||
assert "Note content here" in content
|
||||
|
||||
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_note_unicode_content(app):
|
||||
"""Test handling of unicode content in notes."""
|
||||
content = "# Test 🚀\nThis note has emoji 🎉 and unicode ♠♣♥♦"
|
||||
permalink = await notes.write_note(
|
||||
file_path="Unicode Test",
|
||||
content=content
|
||||
)
|
||||
|
||||
permalink = await notes.write_note(title="Unicode Test", folder="test", content=content)
|
||||
|
||||
# Read back should preserve unicode
|
||||
result = await notes.read_note(permalink)
|
||||
assert content in result
|
||||
@@ -121,20 +139,20 @@ async def test_multiple_notes(app):
|
||||
"""Test creating and managing multiple notes."""
|
||||
# Create several notes
|
||||
notes_data = [
|
||||
("Note 1", "Content 1", ["tag1"]),
|
||||
("Note 2", "Content 2", ["tag1", "tag2"]),
|
||||
("Note 3", "Content 3", []),
|
||||
("Note 1", "test", "Content 1", ["tag1"]),
|
||||
("Note 2", "test", "Content 2", ["tag1", "tag2"]),
|
||||
("Note 3", "test", "Content 3", []),
|
||||
]
|
||||
|
||||
|
||||
permalinks = []
|
||||
for file_path, content, tags in notes_data:
|
||||
permalink = await notes.write_note(file_path=file_path, content=content, tags=tags)
|
||||
for title, folder, content, tags in notes_data:
|
||||
permalink = await notes.write_note(title=title, folder=folder, content=content, tags=tags)
|
||||
permalinks.append(permalink)
|
||||
|
||||
|
||||
# Should be able to read each one
|
||||
for i, permalink in enumerate(permalinks):
|
||||
content = await notes.read_note(permalink)
|
||||
assert f"Content {i+1}" in content
|
||||
assert f"Content {i + 1}" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -147,9 +165,10 @@ async def test_delete_note_existing(app):
|
||||
- Delete the note
|
||||
"""
|
||||
permalink = await notes.write_note(
|
||||
file_path="Test Note",
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="# Test\nThis is a test note",
|
||||
tags=["test", "documentation"]
|
||||
tags=["test", "documentation"],
|
||||
)
|
||||
|
||||
assert permalink # Got a valid permalink
|
||||
@@ -157,6 +176,7 @@ async def test_delete_note_existing(app):
|
||||
deleted = await notes.delete_note(permalink)
|
||||
assert deleted is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_note_doesnt_exist(app):
|
||||
"""Test deleting a new note.
|
||||
|
||||
@@ -16,9 +16,10 @@ from basic_memory.schemas.base import to_snake_case, TimeFrame
|
||||
|
||||
def test_entity():
|
||||
"""Test creating EntityIn with minimal required fields."""
|
||||
data = {"file_path": "test_entity.md", "entity_type": "knowledge"}
|
||||
data = {"title": "Test Entity", "folder": "test", "entity_type": "knowledge"}
|
||||
entity = Entity.model_validate(data)
|
||||
assert entity.file_path == "test_entity.md"
|
||||
assert entity.file_path == "test/Test Entity.md"
|
||||
assert entity.permalink == "test/test-entity"
|
||||
assert entity.entity_type == "knowledge"
|
||||
|
||||
|
||||
@@ -69,6 +70,7 @@ def test_entity_out_from_attributes():
|
||||
"""Test EntityOut creation from database model attributes."""
|
||||
# Simulate database model attributes
|
||||
db_data = {
|
||||
"title": "Test Entity",
|
||||
"permalink": "test/test",
|
||||
"file_path": "test",
|
||||
"entity_type": "knowledge",
|
||||
@@ -132,10 +134,11 @@ def test_path_sanitization():
|
||||
def test_permalink_generation():
|
||||
"""Test permalink property generates correct paths."""
|
||||
test_cases = [
|
||||
({"file_path": "BasicMemory", "entity_type": "test"}, "basic-memory"),
|
||||
({"file_path": "Memory Service", "entity_type": "test"}, "memory-service"),
|
||||
({"file_path": "API Gateway", "entity_type": "test"}, "api-gateway"),
|
||||
({"file_path": "TestCase1", "entity_type": "test"}, "test-case1"),
|
||||
({"title": "BasicMemory", "folder": "test"}, "test/basic-memory"),
|
||||
({"title": "Memory Service", "folder": "test"}, "test/memory-service"),
|
||||
({"title": "API Gateway", "folder": "test"}, "test/api-gateway"),
|
||||
({"title": "TestCase1", "folder": "test"}, "test/test-case1"),
|
||||
({"title": "TestCaseRoot", "folder": ""}, "test-case-root"),
|
||||
]
|
||||
|
||||
for input_data, expected_path in test_cases:
|
||||
|
||||
@@ -17,7 +17,8 @@ from basic_memory.services.exceptions import EntityNotFoundError
|
||||
async def test_create_entity(entity_service: EntityService, file_service: FileService):
|
||||
"""Test successful entity creation."""
|
||||
entity_data = EntitySchema(
|
||||
file_path="TestEntity.md",
|
||||
title="Test Entity",
|
||||
folder="",
|
||||
entity_type="test",
|
||||
)
|
||||
|
||||
@@ -34,7 +35,7 @@ async def test_create_entity(entity_service: EntityService, file_service: FileSe
|
||||
|
||||
# Verify we can retrieve it using permalink
|
||||
retrieved = await entity_service.get_by_permalink(entity_data.permalink)
|
||||
assert retrieved.title == "TestEntity.md"
|
||||
assert retrieved.title == "Test Entity"
|
||||
assert retrieved.entity_type == "test"
|
||||
assert retrieved.created_at is not None
|
||||
|
||||
@@ -56,13 +57,15 @@ async def test_create_entity(entity_service: EntityService, file_service: FileSe
|
||||
async def test_get_by_permalink(entity_service: EntityService):
|
||||
"""Test finding entity by type and name combination."""
|
||||
entity1_data = EntitySchema(
|
||||
file_path="TestEntity1.md",
|
||||
title="TestEntity1",
|
||||
folder="test",
|
||||
entity_type="test",
|
||||
)
|
||||
entity1 = await entity_service.create_entity(entity1_data)
|
||||
|
||||
entity2_data = EntitySchema(
|
||||
file_path="TestEntity2.md",
|
||||
title="TestEntity2",
|
||||
folder="test",
|
||||
entity_type="test",
|
||||
)
|
||||
entity2 = await entity_service.create_entity(entity2_data)
|
||||
@@ -88,16 +91,17 @@ async def test_get_by_permalink(entity_service: EntityService):
|
||||
async def test_get_entity_success(entity_service: EntityService):
|
||||
"""Test successful entity retrieval."""
|
||||
entity_data = EntitySchema(
|
||||
file_path="TestEntity.md",
|
||||
title="TestEntity",
|
||||
folder="test",
|
||||
entity_type="test",
|
||||
)
|
||||
await entity_service.create_entity(entity_data)
|
||||
|
||||
# Get by path ID
|
||||
# Get by permalink
|
||||
retrieved = await entity_service.get_by_permalink(entity_data.permalink)
|
||||
|
||||
assert isinstance(retrieved, EntityModel)
|
||||
assert retrieved.title == "TestEntity.md"
|
||||
assert retrieved.title == "TestEntity"
|
||||
assert retrieved.entity_type == "test"
|
||||
|
||||
|
||||
@@ -105,7 +109,8 @@ async def test_get_entity_success(entity_service: EntityService):
|
||||
async def test_delete_entity_success(entity_service: EntityService):
|
||||
"""Test successful entity deletion."""
|
||||
entity_data = EntitySchema(
|
||||
file_path="TestEntity.md",
|
||||
title="TestEntity",
|
||||
folder="test",
|
||||
entity_type="test",
|
||||
)
|
||||
await entity_service.create_entity(entity_data)
|
||||
@@ -135,9 +140,10 @@ async def test_delete_nonexistent_entity(entity_service: EntityService):
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_entity_with_special_chars(entity_service: EntityService):
|
||||
"""Test entity creation with special characters in name and description."""
|
||||
name = "TestEntity_$pecial chars & symbols!.md" # Note: Using valid path characters
|
||||
name = "TestEntity_$pecial chars & symbols!" # Note: Using valid path characters
|
||||
entity_data = EntitySchema(
|
||||
file_path=name,
|
||||
title=name,
|
||||
folder="test",
|
||||
entity_type="test",
|
||||
)
|
||||
entity = await entity_service.create_entity(entity_data)
|
||||
@@ -153,11 +159,13 @@ async def test_get_entities_by_permalinks(entity_service: EntityService):
|
||||
"""Test opening multiple entities by path IDs."""
|
||||
# Create test entities
|
||||
entity1_data = EntitySchema(
|
||||
file_path="Entity1.md",
|
||||
title="Entity1",
|
||||
folder="test",
|
||||
entity_type="test",
|
||||
)
|
||||
entity2_data = EntitySchema(
|
||||
file_path="Entity2.md",
|
||||
title="Entity2",
|
||||
folder="test",
|
||||
entity_type="test",
|
||||
)
|
||||
await entity_service.create_entity(entity1_data)
|
||||
@@ -169,7 +177,7 @@ async def test_get_entities_by_permalinks(entity_service: EntityService):
|
||||
|
||||
assert len(found) == 2
|
||||
names = {e.title for e in found}
|
||||
assert names == {"Entity1.md", "Entity2.md"}
|
||||
assert names == {"Entity1", "Entity2"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -184,7 +192,8 @@ async def test_get_entities_some_not_found(entity_service: EntityService):
|
||||
"""Test opening nodes with mix of existing and non-existent path IDs."""
|
||||
# Create one test entity
|
||||
entity_data = EntitySchema(
|
||||
file_path="Entity1.md",
|
||||
title="Entity1",
|
||||
folder="test",
|
||||
entity_type="test",
|
||||
)
|
||||
await entity_service.create_entity(entity_data)
|
||||
@@ -194,7 +203,7 @@ async def test_get_entities_some_not_found(entity_service: EntityService):
|
||||
found = await entity_service.get_entities_by_permalinks(permalinks)
|
||||
|
||||
assert len(found) == 1
|
||||
assert found[0].title == "Entity1.md"
|
||||
assert found[0].title == "Entity1"
|
||||
|
||||
|
||||
|
||||
@@ -202,7 +211,6 @@ async def test_get_entities_some_not_found(entity_service: EntityService):
|
||||
async def test_get_entity_path(entity_service: EntityService):
|
||||
"""Should generate correct filesystem path for entity."""
|
||||
entity = EntityModel(
|
||||
id=1,
|
||||
permalink="test-entity",
|
||||
file_path="test-entity.md",
|
||||
entity_type="test",
|
||||
@@ -215,7 +223,8 @@ async def test_get_entity_path(entity_service: EntityService):
|
||||
async def test_update_note_entity_content(entity_service: EntityService, file_service: FileService):
|
||||
"""Should update note content directly."""
|
||||
# Create test entity
|
||||
schema = EntitySchema(file_path="test.md", entity_type="note", entity_metadata={"status": "draft"}, )
|
||||
schema = EntitySchema(title="test", folder="test", entity_type="note", entity_metadata={"status": "draft"}, )
|
||||
|
||||
entity = await entity_service.create_entity(schema)
|
||||
assert entity.entity_metadata.get("status") == "draft"
|
||||
|
||||
@@ -248,12 +257,13 @@ async def test_create_or_update_new(entity_service: EntityService, file_service:
|
||||
# Create test entity
|
||||
entity, created = await entity_service.create_or_update_entity(
|
||||
EntitySchema(
|
||||
file_path="test.md",
|
||||
title="test",
|
||||
folder="test",
|
||||
entity_type="test",
|
||||
entity_metadata={"status": "draft"},
|
||||
)
|
||||
)
|
||||
assert entity.title == "test.md"
|
||||
assert entity.title == "test"
|
||||
assert created is True
|
||||
|
||||
|
||||
@@ -263,7 +273,8 @@ async def test_create_or_update_existing(entity_service: EntityService, file_ser
|
||||
# Create test entity
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
file_path="test.md",
|
||||
title="test",
|
||||
folder="test",
|
||||
entity_type="test",
|
||||
content="Test entity",
|
||||
entity_metadata={"status": "final"},
|
||||
@@ -275,7 +286,7 @@ async def test_create_or_update_existing(entity_service: EntityService, file_ser
|
||||
# Update name
|
||||
updated, created = await entity_service.create_or_update_entity(entity)
|
||||
|
||||
assert updated.title == "test.md"
|
||||
assert updated.title == "test"
|
||||
assert updated.entity_metadata["status"] == "final"
|
||||
assert created is False
|
||||
|
||||
@@ -300,14 +311,18 @@ See the [[Git Cheat Sheet]] for reference.
|
||||
# Create test entity
|
||||
entity, created = await entity_service.create_or_update_entity(
|
||||
EntitySchema(
|
||||
file_path="Git Workflow Guide.md",
|
||||
title="Git Workflow Guide",
|
||||
folder="test",
|
||||
entity_type="test",
|
||||
content=content,
|
||||
)
|
||||
)
|
||||
|
||||
assert created is True
|
||||
assert entity.title == "Git Workflow Guide.md"
|
||||
assert entity.title == "Git Workflow Guide"
|
||||
assert entity.entity_type == "test"
|
||||
assert entity.permalink == "test/git-workflow-guide"
|
||||
assert entity.file_path == "test/Git Workflow Guide.md"
|
||||
|
||||
assert len(entity.observations) == 1
|
||||
assert entity.observations[0].category == "design"
|
||||
@@ -332,6 +347,15 @@ See the [[Git Cheat Sheet]] for reference.
|
||||
|
||||
# assert content is in file
|
||||
assert content.strip() in file_content
|
||||
|
||||
# assert frontmatter
|
||||
assert """
|
||||
---
|
||||
title: Git Workflow Guide
|
||||
type: test
|
||||
permalink: test/git-workflow-guide
|
||||
---
|
||||
""".strip() in file_content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -343,14 +367,15 @@ async def test_update_with_content(
|
||||
# Create test entity
|
||||
entity, created = await entity_service.create_or_update_entity(
|
||||
EntitySchema(
|
||||
file_path="Git Workflow Guide.md",
|
||||
title="Git Workflow Guide",
|
||||
entity_type="test",
|
||||
folder="test",
|
||||
content=content,
|
||||
)
|
||||
)
|
||||
|
||||
assert created is True
|
||||
assert entity.title == "Git Workflow Guide.md"
|
||||
assert entity.title == "Git Workflow Guide"
|
||||
|
||||
assert len(entity.observations) == 0
|
||||
assert len(entity.relations) == 0
|
||||
@@ -379,14 +404,15 @@ See the [[Git Cheat Sheet]] for reference.
|
||||
# Create test entity
|
||||
entity, created = await entity_service.create_or_update_entity(
|
||||
EntitySchema(
|
||||
file_path="Git Workflow Guide.md",
|
||||
title="Git Workflow Guide",
|
||||
folder="test",
|
||||
entity_type="test",
|
||||
content=update_content,
|
||||
)
|
||||
)
|
||||
|
||||
assert created is False
|
||||
assert entity.title == "Git Workflow Guide.md"
|
||||
assert entity.title == "Git Workflow Guide"
|
||||
|
||||
assert len(entity.observations) == 1
|
||||
assert entity.observations[0].category == "design"
|
||||
|
||||
Reference in New Issue
Block a user