Skip to content

Commit c372dfb

Browse files
phernandezclaude
andcommitted
fix: use LinkResolver fallback in build_context for flexible identifier matching (#582)
build_context now falls back to LinkResolver when an exact permalink lookup returns empty results. This reuses the same resolution pipeline as read_note (permalink candidates, title match, file path, FTS) so callers no longer get empty results for valid note identifiers. Also changes ensure_frontmatter_on_sync default to True — frontmatter is now added during sync by default. Tests updated accordingly. 🔧 ContextService accepts optional LinkResolver, wired via DI in all 3 factory variants ✅ 2027 unit + 278 integration tests passing Closes #582 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
1 parent d763d86 commit c372dfb

9 files changed

Lines changed: 107 additions & 15 deletions

File tree

src/basic_memory/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ class BasicMemoryConfig(BaseSettings):
246246
)
247247

248248
ensure_frontmatter_on_sync: bool = Field(
249-
default=False,
249+
default=True,
250250
description="Ensure markdown files have frontmatter during sync by adding derived title/type/permalink when missing. When combined with disable_permalinks=True, this setting takes precedence for missing-frontmatter files and still writes permalinks.",
251251
)
252252

src/basic_memory/deps/services.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,11 +309,13 @@ async def get_context_service(
309309
search_repository: SearchRepositoryDep,
310310
entity_repository: EntityRepositoryDep,
311311
observation_repository: ObservationRepositoryDep,
312+
link_resolver: LinkResolverDep,
312313
) -> ContextService:
313314
return ContextService(
314315
search_repository=search_repository,
315316
entity_repository=entity_repository,
316317
observation_repository=observation_repository,
318+
link_resolver=link_resolver,
317319
)
318320

319321

@@ -324,12 +326,14 @@ async def get_context_service_v2( # pragma: no cover
324326
search_repository: SearchRepositoryV2Dep,
325327
entity_repository: EntityRepositoryV2Dep,
326328
observation_repository: ObservationRepositoryV2Dep,
329+
link_resolver: LinkResolverV2Dep,
327330
) -> ContextService:
328331
"""Create ContextService for v2 API."""
329332
return ContextService(
330333
search_repository=search_repository,
331334
entity_repository=entity_repository,
332335
observation_repository=observation_repository,
336+
link_resolver=link_resolver,
333337
)
334338

335339

@@ -340,12 +344,14 @@ async def get_context_service_v2_external(
340344
search_repository: SearchRepositoryV2ExternalDep,
341345
entity_repository: EntityRepositoryV2ExternalDep,
342346
observation_repository: ObservationRepositoryV2ExternalDep,
347+
link_resolver: LinkResolverV2ExternalDep,
343348
) -> ContextService:
344349
"""Create ContextService for v2 API (uses external_id)."""
345350
return ContextService(
346351
search_repository=search_repository,
347352
entity_repository=entity_repository,
348353
observation_repository=observation_repository,
354+
link_resolver=link_resolver,
349355
)
350356

351357

src/basic_memory/services/context_service.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
"""Service for building rich context from the knowledge graph."""
22

3+
from __future__ import annotations
4+
35
from dataclasses import dataclass, field
46
from datetime import datetime, timezone
5-
from typing import List, Optional, Tuple
7+
from typing import List, Optional, Tuple, TYPE_CHECKING
68

79

810
from loguru import logger
@@ -16,6 +18,9 @@
1618
from basic_memory.schemas.search import SearchItemType
1719
from basic_memory.utils import generate_permalink
1820

21+
if TYPE_CHECKING:
22+
from basic_memory.services.link_resolver import LinkResolver
23+
1924

2025
@dataclass
2126
class ContextResultRow:
@@ -82,10 +87,12 @@ def __init__(
8287
search_repository: SearchRepository,
8388
entity_repository: EntityRepository,
8489
observation_repository: ObservationRepository,
90+
link_resolver: Optional[LinkResolver] = None,
8591
):
8692
self.search_repository = search_repository
8793
self.entity_repository = entity_repository
8894
self.observation_repository = observation_repository
95+
self.link_resolver = link_resolver
8996

9097
async def build_context(
9198
self,
@@ -131,6 +138,24 @@ async def build_context(
131138
primary = await self.search_repository.search(
132139
permalink=normalized_path, limit=fetch_limit, offset=offset
133140
)
141+
142+
# Trigger: exact permalink lookup returned no results
143+
# Why: the identifier may be valid but not an exact permalink match
144+
# (e.g., missing project prefix, title instead of permalink)
145+
# Outcome: use LinkResolver's multi-strategy resolution to find the entity,
146+
# then retry search with its actual permalink
147+
if not primary and self.link_resolver:
148+
entity = await self.link_resolver.resolve_link(
149+
path, use_search=True, strict=False
150+
)
151+
if entity:
152+
logger.debug(
153+
f"LinkResolver resolved '{path}' to permalink '{entity.permalink}'"
154+
)
155+
normalized_path = entity.permalink
156+
primary = await self.search_repository.search(
157+
permalink=entity.permalink, limit=fetch_limit, offset=offset
158+
)
134159
else:
135160
logger.debug(f"Build context for '{types}'")
136161
primary = await self.search_repository.search(

test-int/mcp/test_build_context_underscore.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,8 @@ async def test_build_context_underscore_normalization(mcp_server, app, test_proj
9999
assert "related-to" in response_text_related.lower()
100100

101101
# Test 4: Test exact path (non-wildcard) with underscore
102-
# Exact relation permalink would be child/relation/target
102+
# Previously this returned empty (no exact permalink match). Now LinkResolver
103+
# resolves to the child entity, so we get its relations back.
103104
result_exact = await client.call_tool(
104105
"build_context",
105106
{
@@ -110,7 +111,8 @@ async def test_build_context_underscore_normalization(mcp_server, app, test_proj
110111

111112
response_text_exact = result_exact.content[0].text # pyright: ignore
112113
assert '"results"' in response_text_exact
113-
assert "part-of" in response_text_exact.lower()
114+
# LinkResolver resolves to child-with-underscore entity; its relation_type is "part_of"
115+
assert "part_of" in response_text_exact.lower()
114116

115117

116118
@pytest.mark.asyncio

test-int/test_disable_permalinks_integration.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ async def test_disable_permalinks_create_entity(tmp_path, engine_factory, app_co
2828

2929
# Override app config to enable disable_permalinks
3030
app_config.disable_permalinks = True
31+
app_config.ensure_frontmatter_on_sync = False
3132

3233
# Setup repositories
3334
entity_repository = EntityRepository(session_maker, project_id=test_project.id)
@@ -88,6 +89,7 @@ async def test_disable_permalinks_sync_workflow(tmp_path, engine_factory, app_co
8889

8990
# Override app config to enable disable_permalinks
9091
app_config.disable_permalinks = True
92+
app_config.ensure_frontmatter_on_sync = False
9193

9294
# Create a test markdown file without frontmatter
9395
test_file = tmp_path / "test_note.md"

tests/api/v2/test_prompt_router.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@
99

1010

1111
@pytest_asyncio.fixture
12-
async def context_service(entity_repository, search_service, observation_repository):
12+
async def context_service(
13+
search_repository, entity_repository, observation_repository, link_resolver
14+
):
1315
"""Create a real context service for testing."""
14-
return ContextService(entity_repository, search_service, observation_repository)
16+
return ContextService(
17+
search_repository, entity_repository, observation_repository, link_resolver=link_resolver
18+
)
1519

1620

1721
@pytest.mark.asyncio

tests/services/test_context_service.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,13 @@
1414

1515

1616
@pytest_asyncio.fixture
17-
async def context_service(search_repository, entity_repository, observation_repository):
17+
async def context_service(
18+
search_repository, entity_repository, observation_repository, link_resolver
19+
):
1820
"""Create context service for testing."""
19-
return ContextService(search_repository, entity_repository, observation_repository)
21+
return ContextService(
22+
search_repository, entity_repository, observation_repository, link_resolver=link_resolver
23+
)
2024

2125

2226
@pytest.mark.asyncio
@@ -333,3 +337,49 @@ async def test_project_isolation_in_find_related(session_maker, app_config):
333337
assert entity1_p1.project_id == project1.id
334338
assert entity2_p1.project_id == project1.id
335339
assert entity1_p2.project_id == project2.id
340+
341+
342+
@pytest.mark.asyncio
343+
async def test_build_context_fallback_via_link_resolver(context_service, test_graph):
344+
"""Test that build_context falls back to LinkResolver when exact permalink fails.
345+
346+
The test_graph creates entities with permalinks like 'test-project/test/root'.
347+
Looking up by title ('Root') won't match the exact permalink, but LinkResolver
348+
can resolve it via title matching.
349+
"""
350+
# This identifier is the entity title, not a permalink — exact lookup will fail
351+
url = memory_url.validate_strings("memory://Root")
352+
context_result = await context_service.build_context(url)
353+
354+
# LinkResolver should resolve 'Root' → entity with permalink 'test-project/test/root'
355+
assert context_result.metadata.primary_count == 1
356+
assert len(context_result.results) == 1
357+
assert context_result.results[0].primary_result.id == test_graph["root"].id
358+
359+
360+
@pytest.mark.asyncio
361+
async def test_build_context_fallback_not_found(context_service):
362+
"""Test that build_context returns empty when both exact lookup and fallback fail."""
363+
url = memory_url.validate_strings("memory://completely-nonexistent-note-xyz")
364+
context_result = await context_service.build_context(url)
365+
366+
assert context_result.metadata.primary_count == 0
367+
assert len(context_result.results) == 0
368+
369+
370+
@pytest.mark.asyncio
371+
async def test_build_context_without_link_resolver(
372+
search_repository, entity_repository, observation_repository, test_graph
373+
):
374+
"""Test that build_context still works without a link_resolver (no fallback)."""
375+
service = ContextService(search_repository, entity_repository, observation_repository)
376+
377+
# Exact permalink lookup should still work
378+
url = memory_url.validate_strings("memory://test-project/test/root")
379+
context_result = await service.build_context(url)
380+
assert context_result.metadata.primary_count == 1
381+
382+
# Title-based lookup should return empty (no fallback available)
383+
url = memory_url.validate_strings("memory://Root")
384+
context_result = await service.build_context(url)
385+
assert context_result.metadata.primary_count == 0

tests/sync/test_sync_service.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1101,8 +1101,11 @@ async def test_sync_permalink_not_created_if_no_frontmatter(
11011101
sync_service: SyncService,
11021102
project_config: ProjectConfig,
11031103
file_service: FileService,
1104+
app_config: BasicMemoryConfig,
11041105
):
1105-
"""Test that sync resolves permalink conflicts on update."""
1106+
"""Test that sync does not add frontmatter when ensure_frontmatter_on_sync is disabled."""
1107+
app_config.ensure_frontmatter_on_sync = False
1108+
11061109
project_dir = project_config.home
11071110

11081111
file = project_dir / "one.md"

tests/test_config.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -342,15 +342,15 @@ def test_disable_permalinks_flag_can_be_enabled(self):
342342
assert config.disable_permalinks is True
343343

344344
def test_ensure_frontmatter_on_sync_flag_default(self):
345-
"""Test that ensure_frontmatter_on_sync defaults to False."""
345+
"""Test that ensure_frontmatter_on_sync defaults to True."""
346346
config = BasicMemoryConfig()
347-
assert config.ensure_frontmatter_on_sync is False
348-
349-
def test_ensure_frontmatter_on_sync_flag_can_be_enabled(self):
350-
"""Test that ensure_frontmatter_on_sync can be set to True."""
351-
config = BasicMemoryConfig(ensure_frontmatter_on_sync=True)
352347
assert config.ensure_frontmatter_on_sync is True
353348

349+
def test_ensure_frontmatter_on_sync_flag_can_be_disabled(self):
350+
"""Test that ensure_frontmatter_on_sync can be set to False."""
351+
config = BasicMemoryConfig(ensure_frontmatter_on_sync=False)
352+
assert config.ensure_frontmatter_on_sync is False
353+
354354
def test_permalinks_include_project_flag_default(self):
355355
"""Test that permalinks_include_project defaults to True."""
356356
config = BasicMemoryConfig()

0 commit comments

Comments
 (0)