mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
feat: add context-aware wiki link resolution with source_path support (#527)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import List, Optional, Sequence, Union, Any
|
||||
|
||||
|
||||
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 @@ class EntityRepository(Repository[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())
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -28,7 +28,11 @@ class LinkResolver:
|
||||
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 @@ class LinkResolver:
|
||||
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 @@ class LinkResolver:
|
||||
# 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 @@ class LinkResolver:
|
||||
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 @@ class LinkResolver:
|
||||
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)
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user