From eafe563cfa405dd358dcf893fc22a55f036c8b87 Mon Sep 17 00:00:00 2001 From: phernandez Date: Fri, 31 Jan 2025 19:29:16 -0600 Subject: [PATCH] add title/folder to EntitySchema --- .../markdown/markdown_processor.py | 26 ++-- src/basic_memory/markdown/utils.py | 27 ++-- src/basic_memory/mcp/tools/notes.py | 23 ++-- .../repository/search_repository.py | 6 +- src/basic_memory/schemas/base.py | 10 +- src/basic_memory/schemas/response.py | 1 + src/basic_memory/services/entity_service.py | 2 +- tests/api/test_knowledge_router.py | 72 ++++++----- tests/api/test_search_router.py | 3 +- tests/mcp/test_tool_get_entity.py | 7 +- tests/mcp/test_tool_notes.py | 116 ++++++++++-------- tests/schemas/test_schemas.py | 15 ++- tests/services/test_entity_service.py | 80 ++++++++---- 13 files changed, 230 insertions(+), 158 deletions(-) diff --git a/src/basic_memory/markdown/markdown_processor.py b/src/basic_memory/markdown/markdown_processor.py index 314a615e..33c60e53 100644 --- a/src/basic_memory/markdown/markdown_processor.py +++ b/src/basic_memory/markdown/markdown_processor.py @@ -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}") diff --git a/src/basic_memory/markdown/utils.py b/src/basic_memory/markdown/utils.py index 6a335f79..bb453c24 100644 --- a/src/basic_memory/markdown/utils.py +++ b/src/basic_memory/markdown/utils.py @@ -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 diff --git a/src/basic_memory/mcp/tools/notes.py b/src/basic_memory/mcp/tools/notes.py index c25c1d9e..4b93af72 100644 --- a/src/basic_memory/mcp/tools/notes.py +++ b/src/basic_memory/mcp/tools/notes.py @@ -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") diff --git a/src/basic_memory/repository/search_repository.py b/src/basic_memory/repository/search_repository.py index 9aff3790..f6b590a5 100644 --- a/src/basic_memory/repository/search_repository.py +++ b/src/basic_memory/repository/search_repository.py @@ -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( diff --git a/src/basic_memory/schemas/base.py b/src/basic_memory/schemas/base.py index 69f97542..2fc2d4e2 100644 --- a/src/basic_memory/schemas/base.py +++ b/src/basic_memory/schemas/base.py @@ -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}.""" diff --git a/src/basic_memory/schemas/response.py b/src/basic_memory/schemas/response.py index b9755bd9..0362a138 100644 --- a/src/basic_memory/schemas/response.py +++ b/src/basic_memory/schemas/response.py @@ -117,6 +117,7 @@ class EntityResponse(SQLAlchemyModel): """ permalink: PathId + title: str file_path: str entity_type: EntityType entity_metadata: Optional[Dict] = None diff --git a/src/basic_memory/services/entity_service.py b/src/basic_memory/services/entity_service.py index 47e93729..7ac22a4a 100644 --- a/src/basic_memory/services/entity_service.py +++ b/src/basic_memory/services/entity_service.py @@ -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 diff --git a/tests/api/test_knowledge_router.py b/tests/api/test_knowledge_router.py index c51baf88..1a15281d 100644 --- a/tests/api/test_knowledge_router.py +++ b/tests/api/test_knowledge_router.py @@ -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()) diff --git a/tests/api/test_search_router.py b/tests/api/test_search_router.py index 1d279bcc..6a8b42bc 100644 --- a/tests/api/test_search_router.py +++ b/tests/api/test_search_router.py @@ -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", ), ) diff --git a/tests/mcp/test_tool_get_entity.py b/tests/mcp/test_tool_get_entity.py index d87c9666..98fe74d7 100644 --- a/tests/mcp/test_tool_get_entity.py +++ b/tests/mcp/test_tool_get_entity.py @@ -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 diff --git a/tests/mcp/test_tool_notes.py b/tests/mcp/test_tool_notes.py index 163a1b9b..1a5d3404 100644 --- a/tests/mcp/test_tool_notes.py +++ b/tests/mcp/test_tool_notes.py @@ -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. diff --git a/tests/schemas/test_schemas.py b/tests/schemas/test_schemas.py index 4d1c71eb..caeba1fd 100644 --- a/tests/schemas/test_schemas.py +++ b/tests/schemas/test_schemas.py @@ -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: diff --git a/tests/services/test_entity_service.py b/tests/services/test_entity_service.py index 41b73d68..70ec2c00 100644 --- a/tests/services/test_entity_service.py +++ b/tests/services/test_entity_service.py @@ -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"