diff --git a/src/basic_memory/api/v2/utils.py b/src/basic_memory/api/v2/utils.py index 0b48f991..5c743b06 100644 --- a/src/basic_memory/api/v2/utils.py +++ b/src/basic_memory/api/v2/utils.py @@ -138,6 +138,8 @@ async def to_graph_context( total_results=context_result.metadata.primary_count + context_result.metadata.related_count, total_relations=context_result.metadata.total_relations, total_observations=context_result.metadata.total_observations, + resolved_path=context_result.metadata.resolved_path, + no_primary_match=context_result.metadata.no_primary_match, ) # Return new GraphContext with just hierarchical results diff --git a/src/basic_memory/schemas/memory.py b/src/basic_memory/schemas/memory.py index bf8c92ba..199384d1 100644 --- a/src/basic_memory/schemas/memory.py +++ b/src/basic_memory/schemas/memory.py @@ -200,6 +200,9 @@ class MemoryMetadata(BaseModel): total_results: Optional[int] = None # For backward compatibility total_relations: Optional[int] = None total_observations: Optional[int] = None + # Diagnostics for empty-result debugging + resolved_path: Optional[str] = None + no_primary_match: Optional[str] = None @field_serializer("generated_at") def serialize_generated_at(self, dt: datetime) -> str: diff --git a/src/basic_memory/services/context_service.py b/src/basic_memory/services/context_service.py index 51ebab65..0cdb26b8 100644 --- a/src/basic_memory/services/context_service.py +++ b/src/basic_memory/services/context_service.py @@ -57,6 +57,9 @@ class ContextMetadata: related_count: int = 0 total_observations: int = 0 total_relations: int = 0 + # Diagnostics for empty-result debugging + resolved_path: Optional[str] = None + no_primary_match: Optional[str] = None @dataclass @@ -103,6 +106,7 @@ class ContextService: ) normalized_path: Optional[str] = None + no_primary_match: Optional[str] = None if memory_url: path = memory_url_path(memory_url) # Check for wildcards before normalization @@ -127,6 +131,59 @@ class ContextService: primary = await self.search_repository.search( permalink=normalized_path, limit=limit, offset=offset ) + + # --- Fallback resolution when exact permalink returns no results --- + # Trigger: exact permalink lookup found nothing, but caller may have passed a + # path-like identifier that doesn't align with stored permalinks + # (e.g., project-prefix mismatch or .md extension not stripped). + # Why: build_context should behave like read_note — try alternate resolution + # strategies before giving up, rather than silently returning empty. + # Outcome: if a fallback succeeds, primary is populated; otherwise diagnostics + # explain the miss so callers can debug identifier mismatches quickly. + if not primary: + logger.debug( + f"Exact permalink lookup returned 0 results for '{normalized_path}', trying fallback" + ) + # Fallback 1: wildcard prefix match — handles the case where the stored + # permalink has an extra leading segment (e.g. project prefix) that the + # caller omitted. We search for "*/" to catch entries like + # "my-project/specs/search" when caller passed "specs/search". + wildcard_path = f"*/{normalized_path}" + logger.debug(f"Fallback: trying wildcard prefix match '{wildcard_path}'") + primary = await self.search_repository.search( + permalink_match=wildcard_path, limit=limit, offset=offset + ) + + # Fallback 2: strip the leading path segment from the caller's path and + # retry exact lookup. Handles the opposite case: caller included an extra + # prefix (e.g. the project name) that is NOT present in stored permalinks. + if not primary and "/" in normalized_path: + _, remainder = normalized_path.split("/", 1) + if remainder: + logger.debug(f"Fallback: trying stripped path '{remainder}'") + primary = await self.search_repository.search( + permalink=remainder, limit=limit, offset=offset + ) + + # Fallback 3: title search using the last path segment as query text. + # When permalink normalization changes spaces/casing, a title match can + # recover the note the caller intended. + if not primary: + title_fragment = normalized_path.rsplit("/", 1)[-1] + logger.debug(f"Fallback: trying title search for '{title_fragment}'") + primary = await self.search_repository.search( + title=title_fragment, limit=limit, offset=offset + ) + + if primary: + logger.debug( + f"Fallback succeeded: found {len(primary)} result(s) for '{normalized_path}'" + ) + else: + # Record diagnostic reason so clients can distinguish lookup-miss + # from "note exists but has no relations". + no_primary_match = f"no entity found for permalink '{normalized_path}'" + logger.debug(f"Fallback exhausted: {no_primary_match}") else: logger.debug(f"Build context for '{types}'") primary = await self.search_repository.search( @@ -171,6 +228,8 @@ class ContextService: related_count=len(related), total_observations=sum(len(obs) for obs in observations_by_entity.values()), total_relations=sum(1 for r in related if r.type == SearchItemType.RELATION), + resolved_path=normalized_path, + no_primary_match=no_primary_match, ) # Build context results list directly with ContextResultItem objects diff --git a/tests/services/test_context_service.py b/tests/services/test_context_service.py index 72e61889..c1ab425e 100644 --- a/tests/services/test_context_service.py +++ b/tests/services/test_context_service.py @@ -333,3 +333,89 @@ async def test_project_isolation_in_find_related(session_maker, app_config): assert entity1_p1.project_id == project1.id assert entity2_p1.project_id == project1.id assert entity1_p2.project_id == project2.id + + +@pytest.mark.asyncio +async def test_build_context_fallback_wildcard_prefix(context_service, test_graph): + """Fallback 1: wildcard prefix match recovers note when caller omits leading segment. + + The root entity is stored with permalink 'test-project/test/root'. + When the caller passes 'memory://test/root' (missing the project segment), + the exact lookup fails and the wildcard-prefix fallback '*/test/root' should + find the entity. + """ + # Pass the path WITHOUT the project prefix — exact lookup will miss, + # wildcard fallback '*/test/root' should succeed. + url = memory_url.validate_strings("memory://test/root") + context_result = await context_service.build_context(url) + + assert len(context_result.results) == 1 + primary = context_result.results[0].primary_result + assert primary.id == test_graph["root"].id + assert primary.title == "Root" + # Diagnostic fields: fallback succeeded so no_primary_match is None + assert context_result.metadata.no_primary_match is None + assert context_result.metadata.resolved_path == "test/root" + + +@pytest.mark.asyncio +async def test_build_context_fallback_strip_prefix(context_service, test_graph): + """Fallback 2: stripping leading segment recovers note when caller adds extra prefix. + + If the caller passes 'memory://extra/test-project/test/root' (with an extra + leading segment not in the stored permalink), the strip-prefix fallback should + find the entity stored at 'test-project/test/root'. + """ + # Pass path with an extra leading segment; exact lookup and wildcard-prefix + # both fail, then strip-prefix retry should succeed. + url = memory_url.validate_strings("memory://extra/test-project/test/root") + context_result = await context_service.build_context(url) + + assert len(context_result.results) == 1 + primary = context_result.results[0].primary_result + assert primary.id == test_graph["root"].id + assert context_result.metadata.no_primary_match is None + + +@pytest.mark.asyncio +async def test_build_context_fallback_title_search(context_service, test_graph): + """Fallback 3: title search recovers note when permalink normalization differs. + + When the path doesn't match any stored permalink variant but the last segment + happens to be the title slug, a title FTS search should still find the entity. + """ + # Use a path whose last segment 'root' matches the title 'Root' via FTS + url = memory_url.validate_strings("memory://completely/different/path/root") + context_result = await context_service.build_context(url) + + # Title search for 'root' should find the Root entity + assert len(context_result.results) == 1 + primary = context_result.results[0].primary_result + assert primary.id == test_graph["root"].id + assert context_result.metadata.no_primary_match is None + + +@pytest.mark.asyncio +async def test_build_context_diagnostics_no_match(context_service): + """When all fallbacks fail, no_primary_match and resolved_path explain the miss.""" + url = memory_url.validate_strings("memory://nonexistent/note/xyz123") + context_result = await context_service.build_context(url) + + assert len(context_result.results) == 0 + assert context_result.metadata.primary_count == 0 + # Diagnostic fields must be populated for debugging + assert context_result.metadata.resolved_path == "nonexistent/note/xyz123" + assert context_result.metadata.no_primary_match is not None + assert "nonexistent/note/xyz123" in context_result.metadata.no_primary_match + + +@pytest.mark.asyncio +async def test_build_context_diagnostics_exact_match_no_diagnostic(context_service, test_graph): + """When exact lookup succeeds, diagnostic fields are absent (None).""" + url = memory_url.validate_strings("memory://test-project/test/root") + context_result = await context_service.build_context(url) + + assert len(context_result.results) == 1 + # No diagnostic needed — exact lookup succeeded + assert context_result.metadata.no_primary_match is None + assert context_result.metadata.resolved_path == "test-project/test/root"