mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e70ade74ea |
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 "*/<path>" 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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user