diff --git a/src/basic_memory/api/v2/routers/knowledge_router.py b/src/basic_memory/api/v2/routers/knowledge_router.py index 3ed221d3..ad6540c4 100644 --- a/src/basic_memory/api/v2/routers/knowledge_router.py +++ b/src/basic_memory/api/v2/routers/knowledge_router.py @@ -103,8 +103,12 @@ async def resolve_identifier( resolution_method = "external_id" if entity else "search" # If not found by external_id, try other resolution methods + # Pass source_path for context-aware resolution (prefers notes closer to source) + # Pass strict to control fuzzy search fallback (default False allows fuzzy matching) if not entity: - entity = await link_resolver.resolve_link(data.identifier) + entity = await link_resolver.resolve_link( + data.identifier, source_path=data.source_path, strict=data.strict + ) if entity: # Determine resolution method if entity.permalink == data.identifier: diff --git a/src/basic_memory/repository/entity_repository.py b/src/basic_memory/repository/entity_repository.py index 84660917..56271800 100644 --- a/src/basic_memory/repository/entity_repository.py +++ b/src/basic_memory/repository/entity_repository.py @@ -5,7 +5,7 @@ from loguru import logger -from sqlalchemy import select +from sqlalchemy import select, func from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.orm import selectinload @@ -69,12 +69,21 @@ async def get_by_permalink(self, permalink: str) -> Optional[Entity]: return await self.find_one(query) async def get_by_title(self, title: str) -> Sequence[Entity]: - """Get entity by title. + """Get entities by title, ordered by shortest path first. + + When multiple entities share the same title (in different folders), + returns them ordered by file_path length then alphabetically. + This provides "shortest path" resolution for duplicate titles. Args: title: Title of the entity to find """ - query = self.select().where(Entity.title == title).options(*self.get_load_options()) + query = ( + self.select() + .where(Entity.title == title) + .order_by(func.length(Entity.file_path), Entity.file_path) + .options(*self.get_load_options()) + ) result = await self.execute_query(query) return list(result.scalars().all()) diff --git a/src/basic_memory/schemas/v2/entity.py b/src/basic_memory/schemas/v2/entity.py index 6f9b7915..86b497df 100644 --- a/src/basic_memory/schemas/v2/entity.py +++ b/src/basic_memory/schemas/v2/entity.py @@ -15,6 +15,9 @@ class EntityResolveRequest(BaseModel): - Permalinks (e.g., "specs/search") - Titles (e.g., "Search Specification") - File paths (e.g., "specs/search.md") + + When source_path is provided, resolution prefers notes closer to the source + (context-aware resolution for duplicate titles). """ identifier: str = Field( @@ -23,6 +26,15 @@ class EntityResolveRequest(BaseModel): min_length=1, max_length=500, ) + source_path: Optional[str] = Field( + None, + description="Path of the source file containing the link (for context-aware resolution)", + max_length=500, + ) + strict: bool = Field( + False, + description="If True, only exact matches are allowed (no fuzzy search fallback)", + ) class EntityResolveResponse(BaseModel): diff --git a/src/basic_memory/services/link_resolver.py b/src/basic_memory/services/link_resolver.py index d22613d2..dec21a42 100644 --- a/src/basic_memory/services/link_resolver.py +++ b/src/basic_memory/services/link_resolver.py @@ -28,7 +28,11 @@ def __init__(self, entity_repository: EntityRepository, search_service: SearchSe self.search_service = search_service async def resolve_link( - self, link_text: str, use_search: bool = True, strict: bool = False + self, + link_text: str, + use_search: bool = True, + strict: bool = False, + source_path: Optional[str] = None, ) -> Optional[Entity]: """Resolve a markdown link to a permalink. @@ -36,12 +40,69 @@ async def resolve_link( link_text: The link text to resolve use_search: Whether to use search-based fuzzy matching as fallback strict: If True, only exact matches are allowed (no fuzzy search fallback) + source_path: Optional path of the source file containing the link. + Used to prefer notes closer to the source (context-aware resolution). """ - logger.trace(f"Resolving link: {link_text}") + logger.trace(f"Resolving link: {link_text} (source: {source_path})") # Clean link text and extract any alias clean_text, alias = self._normalize_link_text(link_text) + # --- Path Resolution --- + # Note: All paths in Basic Memory are stored as POSIX strings (forward slashes) + # for cross-platform compatibility. See entity_repository.py which normalizes + # paths using Path().as_posix(). This allows consistent path operations here. + + # --- Relative Path Resolution --- + # Trigger: source_path is provided AND link contains "/" + # Why: Resolve paths like [[nested/deep-note]] relative to source folder first + # Outcome: [[nested/deep-note]] from testing/link-test.md → testing/nested/deep-note.md + if source_path and "/" in clean_text: + source_folder = source_path.rsplit("/", 1)[0] if "/" in source_path else "" + if source_folder: + # Construct relative path from source folder + relative_path = f"{source_folder}/{clean_text}" + + # Try with .md extension + if not relative_path.endswith(".md"): + relative_path_md = f"{relative_path}.md" + entity = await self.entity_repository.get_by_file_path(relative_path_md) + if entity: + return entity + + # Try as-is (already has extension or is a permalink) + entity = await self.entity_repository.get_by_file_path(relative_path) + if entity: + return entity + + # When source_path is provided, use context-aware resolution: + # Check both permalink and title matches, prefer closest to source. + # Example: [[testing]] from folder/note.md prefers folder/testing.md + # over a root testing.md with permalink "testing". + if source_path: + # Gather all potential matches + candidates: list[Entity] = [] + + # Check permalink match + permalink_entity = await self.entity_repository.get_by_permalink(clean_text) + if permalink_entity: + candidates.append(permalink_entity) + + # Check title matches + title_entities = await self.entity_repository.get_by_title(clean_text) + for entity in title_entities: + # Avoid duplicates (permalink match might also be in title matches) + if entity.id not in [c.id for c in candidates]: + candidates.append(entity) + + if candidates: + if len(candidates) == 1: + return candidates[0] + else: + # Multiple candidates - pick closest to source + return self._find_closest_entity(candidates, source_path) + + # Standard resolution (no source context): permalink first, then title # 1. Try exact permalink match first (most efficient) entity = await self.entity_repository.get_by_permalink(clean_text) if entity: @@ -51,7 +112,7 @@ async def resolve_link( # 2. Try exact title match found = await self.entity_repository.get_by_title(clean_text) if found: - # Return first match if there are duplicates (consistent behavior) + # Return first match (shortest path) if no source context entity = found[0] logger.debug(f"Found title match: {entity.title}") return entity @@ -108,7 +169,7 @@ def _normalize_link_text(self, link_text: str) -> Tuple[str, Optional[str]]: if text.startswith("[[") and text.endswith("]]"): text = text[2:-2] - # Handle Obsidian-style aliases (format: [[actual|alias]]) + # Handle wiki link aliases (format: [[actual|alias]]) alias = None if "|" in text: text, alias = text.split("|", 1) @@ -119,3 +180,72 @@ def _normalize_link_text(self, link_text: str) -> Tuple[str, Optional[str]]: text = text.strip() return text, alias + + def _find_closest_entity(self, entities: list[Entity], source_path: str) -> Entity: + """Find the entity closest to the source file path. + + Context-aware resolution: prefer notes in the same folder or closer in hierarchy. + + Proximity Scoring Algorithm: + - Priority 0: Same folder as source (best match) + - Priority 1-N: Ancestor folders (N = levels up from source) + - Priority 100+N: Descendant folders (N = levels down, deprioritized) + - Priority 1000: Completely unrelated paths (least preferred) + - Ties are broken by shortest absolute path (consistent behavior) + + Args: + entities: List of entities with the same title + source_path: Path of the file containing the link + + Returns: + The entity closest to the source path + """ + # Extract source folder (everything before the last /) + source_folder = source_path.rsplit("/", 1)[0] if "/" in source_path else "" + + def path_proximity(entity: Entity) -> Tuple[int, int]: + """Return (proximity_score, path_length) for sorting. + + Lower is better for both values. + """ + entity_path = entity.file_path + entity_folder = entity_path.rsplit("/", 1)[0] if "/" in entity_path else "" + + # Trigger: entity is in the same folder as source + # Why: same-folder notes are most contextually relevant + # Outcome: priority = 0 (best), ties broken by shortest path + if entity_folder == source_folder: + return (0, len(entity_path)) + + # Trigger: entity is in an ancestor folder of source + # e.g., source is "a/b/c/file.md", entity is "a/b/note.md" -> ancestor + # Why: ancestors are contextually relevant (shared parent context) + # Outcome: priority = levels_up (1, 2, 3...), closer ancestors preferred + if source_folder.startswith(entity_folder + "/") if entity_folder else source_folder: + # Count how many levels up + if entity_folder: + levels_up = source_folder.count("/") - entity_folder.count("/") + else: + # Root level + levels_up = source_folder.count("/") + 1 + return (levels_up, len(entity_path)) + + # Trigger: entity is in a descendant folder of source + # e.g., source is "a/file.md", entity is "a/b/c/note.md" -> descendant + # Why: descendants are less contextually relevant than ancestors + # Outcome: priority = 100 + levels_down, significantly deprioritized + if entity_folder.startswith(source_folder + "/") if source_folder else entity_folder: + if source_folder: + levels_down = entity_folder.count("/") - source_folder.count("/") + else: + # Source is at root + levels_down = entity_folder.count("/") + 1 + return (100 + levels_down, len(entity_path)) + + # Trigger: entity is in a completely unrelated path + # Why: no folder relationship means minimal contextual relevance + # Outcome: priority = 1000, only selected if no related paths exist + return (1000, len(entity_path)) + + # Sort by proximity (lower is better), then by path length (shorter is better) + return min(entities, key=path_proximity) diff --git a/tests/api/v2/test_knowledge_router.py b/tests/api/v2/test_knowledge_router.py index 6bb1b266..1952c485 100644 --- a/tests/api/v2/test_knowledge_router.py +++ b/tests/api/v2/test_knowledge_router.py @@ -52,6 +52,60 @@ async def test_resolve_identifier_not_found(client: AsyncClient, v2_project_url) assert "Entity not found" in response.json()["detail"] +@pytest.mark.asyncio +async def test_resolve_identifier_no_fuzzy_match(client: AsyncClient, v2_project_url): + """Test that resolve uses strict mode - no fuzzy search fallback. + + This ensures wiki links only resolve to exact matches (permalink, title, or path), + not to similar-sounding entities via fuzzy search. + """ + # Create an entity with a specific name + entity_data = { + "title": "link-test", + "folder": "testing", + "content": "A test note", + } + response = await client.post(f"{v2_project_url}/knowledge/entities", json=entity_data) + assert response.status_code == 200 + + # Try to resolve "nonexistent" - should NOT fuzzy match to "link-test" + resolve_data = {"identifier": "nonexistent"} + response = await client.post(f"{v2_project_url}/knowledge/resolve", json=resolve_data) + + # Must return 404, not a fuzzy match to "link-test" + assert response.status_code == 404 + assert "Entity not found" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_resolve_identifier_with_source_path_no_fuzzy_match(client: AsyncClient, v2_project_url): + """Test that context-aware resolution also uses strict mode. + + Even with source_path for context-aware resolution, nonexistent + links should return 404, not fuzzy match to nearby entities. + """ + # Create entities in a folder structure + entity_data = { + "title": "link-test", + "folder": "testing/nested", + "content": "A nested test note", + } + response = await client.post(f"{v2_project_url}/knowledge/entities", json=entity_data) + assert response.status_code == 200 + + # Try to resolve "nonexistent" with source_path context + # Should NOT fuzzy match to "link-test" in the same or nearby folder + resolve_data = { + "identifier": "nonexistent", + "source_path": "testing/nested/other-note.md", + } + response = await client.post(f"{v2_project_url}/knowledge/resolve", json=resolve_data) + + # Must return 404, not a fuzzy match + assert response.status_code == 404 + assert "Entity not found" in response.json()["detail"] + + @pytest.mark.asyncio async def test_get_entity_by_id(client: AsyncClient, test_graph, v2_project_url, entity_repository): """Test getting an entity by its external_id (UUID).""" diff --git a/tests/repository/test_entity_repository.py b/tests/repository/test_entity_repository.py index f0c7c68f..bf01f7c9 100644 --- a/tests/repository/test_entity_repository.py +++ b/tests/repository/test_entity_repository.py @@ -456,6 +456,66 @@ async def test_get_by_title(entity_repository: EntityRepository, session_maker): assert len(found) == 2 +@pytest.mark.asyncio +async def test_get_by_title_returns_shortest_path_first( + entity_repository: EntityRepository, session_maker +): + """Test that duplicate titles are returned with shortest path first. + + When multiple entities share the same title in different folders, + the one with the shortest file path should be returned first. + This provides consistent, predictable link resolution. + """ + async with db.scoped_session(session_maker) as session: + # Create entities with same title but different path lengths + # Insert in reverse order to ensure we're testing ordering, not insertion order + entities = [ + Entity( + project_id=entity_repository.project_id, + title="My Note", + entity_type="note", + permalink="archive/old/2024/my-note", + file_path="archive/old/2024/My Note.md", # longest path + content_type="text/markdown", + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ), + Entity( + project_id=entity_repository.project_id, + title="My Note", + entity_type="note", + permalink="docs/my-note", + file_path="docs/My Note.md", # medium path + content_type="text/markdown", + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ), + Entity( + project_id=entity_repository.project_id, + title="My Note", + entity_type="note", + permalink="my-note", + file_path="My Note.md", # shortest path (root) + content_type="text/markdown", + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ), + ] + session.add_all(entities) + await session.flush() + + # Get all entities with title "My Note" + found = await entity_repository.get_by_title("My Note") + + # Should return all 3 + assert len(found) == 3 + + # Should be ordered by path length (shortest first) + assert found[0].file_path == "My Note.md" # shortest + assert found[1].file_path == "docs/My Note.md" # medium + assert found[2].file_path == "archive/old/2024/My Note.md" # longest + + @pytest.mark.asyncio async def test_get_by_file_path(entity_repository: EntityRepository, session_maker): """Test getting an entity by title.""" diff --git a/tests/services/test_link_resolver.py b/tests/services/test_link_resolver.py index 10615b35..0c390dd2 100644 --- a/tests/services/test_link_resolver.py +++ b/tests/services/test_link_resolver.py @@ -357,3 +357,475 @@ async def test_duplicate_title_handling_in_strict_mode(link_resolver, test_entit assert result is not None # Should return the first match (components/core-service based on test fixture order) assert result.permalink == "components/core-service" + + +# ============================================================================ +# Context-aware resolution tests (source_path parameter) +# ============================================================================ + + +@pytest_asyncio.fixture +async def context_aware_entities(entity_repository): + """Create entities for testing context-aware resolution. + + Structure: + ├── testing.md (title: "testing", root level) + ├── main/ + │ └── testing/ + │ ├── testing.md (title: "testing", nested) + │ └── another-test.md (title: "another-test") + ├── other/ + │ └── testing.md (title: "testing", different branch) + └── deep/ + └── nested/ + └── folder/ + └── note.md (title: "note") + """ + entities = [] + now = datetime.now(timezone.utc) + project_id = entity_repository.project_id + + # Root level testing.md + e1 = await entity_repository.add( + EntityModel( + title="testing", + entity_type="note", + content_type="text/markdown", + file_path="testing.md", + permalink="testing", + created_at=now, + updated_at=now, + project_id=project_id, + ) + ) + entities.append(e1) + + # main/testing/testing.md + e2 = await entity_repository.add( + EntityModel( + title="testing", + entity_type="note", + content_type="text/markdown", + file_path="main/testing/testing.md", + permalink="main/testing/testing", + created_at=now, + updated_at=now, + project_id=project_id, + ) + ) + entities.append(e2) + + # main/testing/another-test.md + e3 = await entity_repository.add( + EntityModel( + title="another-test", + entity_type="note", + content_type="text/markdown", + file_path="main/testing/another-test.md", + permalink="main/testing/another-test", + created_at=now, + updated_at=now, + project_id=project_id, + ) + ) + entities.append(e3) + + # other/testing.md + e4 = await entity_repository.add( + EntityModel( + title="testing", + entity_type="note", + content_type="text/markdown", + file_path="other/testing.md", + permalink="other/testing", + created_at=now, + updated_at=now, + project_id=project_id, + ) + ) + entities.append(e4) + + # deep/nested/folder/note.md + e5 = await entity_repository.add( + EntityModel( + title="note", + entity_type="note", + content_type="text/markdown", + file_path="deep/nested/folder/note.md", + permalink="deep/nested/folder/note", + created_at=now, + updated_at=now, + project_id=project_id, + ) + ) + entities.append(e5) + + # deep/note.md (for ancestor testing) + e6 = await entity_repository.add( + EntityModel( + title="note", + entity_type="note", + content_type="text/markdown", + file_path="deep/note.md", + permalink="deep/note", + created_at=now, + updated_at=now, + project_id=project_id, + ) + ) + entities.append(e6) + + # note.md at root (for ancestor testing) + e7 = await entity_repository.add( + EntityModel( + title="note", + entity_type="note", + content_type="text/markdown", + file_path="note.md", + permalink="note", + created_at=now, + updated_at=now, + project_id=project_id, + ) + ) + entities.append(e7) + + return entities + + +@pytest_asyncio.fixture +async def context_link_resolver(entity_repository, search_service, context_aware_entities): + """Create LinkResolver instance with context-aware test data. + + Note: We don't index entities for search because these tests focus on + exact title/permalink matching, not fuzzy search. The entities are + database-only records (no files on disk). + """ + return LinkResolver(entity_repository, search_service) + + +@pytest.mark.asyncio +async def test_source_path_same_folder_preference(context_link_resolver): + """Test that links prefer notes in the same folder as the source.""" + # From main/testing/another-test.md, [[testing]] should find main/testing/testing.md + result = await context_link_resolver.resolve_link( + "testing", + source_path="main/testing/another-test.md" + ) + assert result is not None + assert result.file_path == "main/testing/testing.md" + + +@pytest.mark.asyncio +async def test_source_path_from_root_prefers_root(context_link_resolver): + """Test that links from root-level notes prefer root-level matches.""" + # From root-note.md, [[testing]] should find testing.md (root level) + result = await context_link_resolver.resolve_link( + "testing", + source_path="some-root-note.md" + ) + assert result is not None + assert result.file_path == "testing.md" + + +@pytest.mark.asyncio +async def test_source_path_different_branch_prefers_closest(context_link_resolver): + """Test resolution when source is in a different branch of the folder tree.""" + # From other/testing.md, [[testing]] should find other/testing.md (same folder) + # Wait, other/testing.md IS the testing note in that folder, so this tests self-reference + # Let's test from a hypothetical other/different.md + result = await context_link_resolver.resolve_link( + "testing", + source_path="other/different.md" + ) + assert result is not None + # Should find other/testing.md since it's in the same folder + assert result.file_path == "other/testing.md" + + +@pytest.mark.asyncio +async def test_source_path_ancestor_preference(context_link_resolver): + """Test that closer ancestors are preferred over distant ones.""" + # From deep/nested/folder/note.md, [[note]] with multiple "note" titles + # should prefer the closest ancestor match + + # First verify there are multiple "note" entities + # deep/nested/folder/note.md, deep/note.md, note.md + + # From deep/nested/folder/some-file.md, [[note]] should prefer: + # 1. deep/nested/folder/note.md (same folder) - but that's the note itself + # Let's say we're linking from a different file in that folder + result = await context_link_resolver.resolve_link( + "note", + source_path="deep/nested/folder/other-file.md" + ) + assert result is not None + # Should find deep/nested/folder/note.md (same folder) + assert result.file_path == "deep/nested/folder/note.md" + + +@pytest.mark.asyncio +async def test_source_path_parent_folder_preference(context_link_resolver): + """Test that parent folder is preferred when no same-folder match exists.""" + # From deep/nested/folder/x.md where there's no "common" in same folder, + # but there's one in deep/nested/ - should prefer closer ancestor + + # For this test, let's check that from deep/nested/other/file.md, + # [[note]] finds deep/note.md (ancestor) rather than note.md (root) + result = await context_link_resolver.resolve_link( + "note", + source_path="deep/nested/other/file.md" + ) + assert result is not None + # No note.md in deep/nested/other/, so should find deep/note.md (closest ancestor) + # Actually deep/nested/folder/note.md might be considered... let me think + # deep/nested/other/file.md -> ancestors are deep/nested/, deep/, root + # Siblings/cousins like deep/nested/folder/ are NOT ancestors + # So should find deep/note.md + assert result.file_path == "deep/note.md" + + +@pytest.mark.asyncio +async def test_source_path_no_context_falls_back_to_shortest_path(context_link_resolver): + """Test that without source_path, resolution falls back to shortest path.""" + # Without source_path, should use standard resolution (permalink first, then title) + result = await context_link_resolver.resolve_link("testing") + assert result is not None + # Should get the one with shortest path or matching permalink + # "testing" matches permalink "testing" of root testing.md + assert result.file_path == "testing.md" + + +@pytest.mark.asyncio +async def test_source_path_unique_title_ignores_context(context_link_resolver): + """Test that unique titles resolve correctly regardless of source_path.""" + # "another-test" only exists in one place + result = await context_link_resolver.resolve_link( + "another-test", + source_path="other/some-file.md" # Different folder + ) + assert result is not None + assert result.file_path == "main/testing/another-test.md" + + +@pytest.mark.asyncio +async def test_source_path_with_permalink_conflict(context_link_resolver): + """Test that same-folder title match beats permalink match from different folder.""" + # Root testing.md has permalink "testing" + # main/testing/testing.md has title "testing" + # From main/testing/another-test.md, [[testing]] should prefer the same-folder match + # even though there's a permalink match at root + + result = await context_link_resolver.resolve_link( + "testing", + source_path="main/testing/another-test.md" + ) + assert result is not None + # Should prefer same-folder title match over root permalink match + assert result.file_path == "main/testing/testing.md" + + +@pytest.mark.asyncio +async def test_find_closest_entity_same_folder(context_link_resolver, context_aware_entities): + """Test _find_closest_entity helper with same folder match.""" + # Get entities with title "testing" + testing_entities = [e for e in context_aware_entities if e.title == "testing"] + assert len(testing_entities) == 3 # root, main/testing, other + + closest = context_link_resolver._find_closest_entity( + testing_entities, + "main/testing/another-test.md" + ) + assert closest.file_path == "main/testing/testing.md" + + +@pytest.mark.asyncio +async def test_find_closest_entity_ancestor_preference(context_link_resolver, context_aware_entities): + """Test _find_closest_entity prefers closer ancestors.""" + # Get entities with title "note" + note_entities = [e for e in context_aware_entities if e.title == "note"] + assert len(note_entities) == 3 # deep/nested/folder, deep, root + + # From deep/nested/other/file.md, should prefer deep/note.md over note.md + closest = context_link_resolver._find_closest_entity( + note_entities, + "deep/nested/other/file.md" + ) + assert closest.file_path == "deep/note.md" + + +@pytest.mark.asyncio +async def test_find_closest_entity_root_source(context_link_resolver, context_aware_entities): + """Test _find_closest_entity when source is at root.""" + testing_entities = [e for e in context_aware_entities if e.title == "testing"] + + # From root level, should prefer root testing.md + closest = context_link_resolver._find_closest_entity( + testing_entities, + "some-root-file.md" + ) + assert closest.file_path == "testing.md" + + +@pytest.mark.asyncio +async def test_nonexistent_link_with_source_path(context_link_resolver): + """Test that non-existent links return None even with source_path.""" + result = await context_link_resolver.resolve_link( + "does-not-exist", + source_path="main/testing/another-test.md" + ) + assert result is None + + +# ============================================================================ +# Relative path resolution tests +# ============================================================================ + + +@pytest_asyncio.fixture +async def relative_path_entities(entity_repository): + """Create entities for testing relative path resolution. + + Structure: + ├── testing/ + │ ├── link-test.md (source file for testing) + │ └── nested/ + │ └── deep-note.md (target for relative path) + ├── nested/ + │ └── deep-note.md (different deep-note at root level) + └── other/ + └── file.md + """ + entities = [] + now = datetime.now(timezone.utc) + project_id = entity_repository.project_id + + # testing/link-test.md (source file) + e1 = await entity_repository.add( + EntityModel( + title="link-test", + entity_type="note", + content_type="text/markdown", + file_path="testing/link-test.md", + permalink="testing/link-test", + created_at=now, + updated_at=now, + project_id=project_id, + ) + ) + entities.append(e1) + + # testing/nested/deep-note.md (relative target) + e2 = await entity_repository.add( + EntityModel( + title="deep-note", + entity_type="note", + content_type="text/markdown", + file_path="testing/nested/deep-note.md", + permalink="testing/nested/deep-note", + created_at=now, + updated_at=now, + project_id=project_id, + ) + ) + entities.append(e2) + + # nested/deep-note.md (absolute path target) + e3 = await entity_repository.add( + EntityModel( + title="deep-note", + entity_type="note", + content_type="text/markdown", + file_path="nested/deep-note.md", + permalink="nested/deep-note", + created_at=now, + updated_at=now, + project_id=project_id, + ) + ) + entities.append(e3) + + # other/file.md + e4 = await entity_repository.add( + EntityModel( + title="file", + entity_type="note", + content_type="text/markdown", + file_path="other/file.md", + permalink="other/file", + created_at=now, + updated_at=now, + project_id=project_id, + ) + ) + entities.append(e4) + + return entities + + +@pytest_asyncio.fixture +async def relative_path_resolver(entity_repository, search_service, relative_path_entities): + """Create LinkResolver instance with relative path test data.""" + return LinkResolver(entity_repository, search_service) + + +@pytest.mark.asyncio +async def test_relative_path_resolution_from_subfolder(relative_path_resolver): + """Test that [[nested/deep-note]] from testing/link-test.md resolves to testing/nested/deep-note.md.""" + # From testing/link-test.md, [[nested/deep-note]] should resolve to testing/nested/deep-note.md + result = await relative_path_resolver.resolve_link( + "nested/deep-note", + source_path="testing/link-test.md" + ) + assert result is not None + assert result.file_path == "testing/nested/deep-note.md" + + +@pytest.mark.asyncio +async def test_relative_path_falls_back_to_absolute(relative_path_resolver): + """Test that if relative path doesn't exist, falls back to absolute resolution.""" + # From other/file.md, [[nested/deep-note]] should resolve to nested/deep-note.md (absolute) + # because other/nested/deep-note.md doesn't exist + result = await relative_path_resolver.resolve_link( + "nested/deep-note", + source_path="other/file.md" + ) + assert result is not None + assert result.file_path == "nested/deep-note.md" + + +@pytest.mark.asyncio +async def test_relative_path_without_source_uses_absolute(relative_path_resolver): + """Test that without source_path, paths are resolved as absolute.""" + # Without source_path, [[nested/deep-note]] should resolve to nested/deep-note.md + result = await relative_path_resolver.resolve_link("nested/deep-note") + assert result is not None + assert result.file_path == "nested/deep-note.md" + + +@pytest.mark.asyncio +async def test_relative_path_from_root_falls_through(relative_path_resolver): + """Test that paths from root-level files don't try relative resolution.""" + # From root-file.md (no folder), [[nested/deep-note]] should resolve to nested/deep-note.md + result = await relative_path_resolver.resolve_link( + "nested/deep-note", + source_path="root-file.md" + ) + assert result is not None + assert result.file_path == "nested/deep-note.md" + + +@pytest.mark.asyncio +async def test_simple_link_no_slash_skips_relative_resolution(relative_path_resolver): + """Test that links without '/' don't trigger relative path resolution.""" + # [[deep-note]] should use context-aware title matching, not relative paths + result = await relative_path_resolver.resolve_link( + "deep-note", + source_path="testing/link-test.md" + ) + assert result is not None + # Should find testing/nested/deep-note.md via title match with same-folder preference + # Actually both have title "deep-note", so it should prefer the one closer to source + # testing/nested/ is not the same folder as testing/, but it's closer than nested/ + # The context-aware resolution will pick the closest match + assert result.file_path == "testing/nested/deep-note.md"