mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
1ad3a350ad
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
747 lines
30 KiB
Python
747 lines
30 KiB
Python
"""Tests for context service."""
|
|
|
|
from datetime import datetime, timedelta, UTC
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
|
|
from basic_memory.repository.search_repository import SearchIndexRow
|
|
from basic_memory.schemas.memory import memory_url, memory_url_path
|
|
from basic_memory.schemas.search import SearchItemType
|
|
from basic_memory.services.context_service import ContextService
|
|
from basic_memory.models.knowledge import Entity, Relation
|
|
from basic_memory.models.project import Project
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def context_service(
|
|
search_repository, entity_repository, observation_repository, link_resolver
|
|
):
|
|
"""Create context service for testing."""
|
|
return ContextService(
|
|
search_repository, entity_repository, observation_repository, link_resolver=link_resolver
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_find_connected_depth_limit(context_service, test_graph):
|
|
"""Test depth limiting works.
|
|
Our traversal path is:
|
|
- Depth 0: Root
|
|
- Depth 1: Relations + directly connected entities (Connected1, Connected2)
|
|
- Depth 2: Relations + next level entities (Deep)
|
|
"""
|
|
type_id_pairs = [("entity", test_graph["root"].id)]
|
|
|
|
# With depth=1, we get direct connections
|
|
# shallow_results = await context_service.find_related(type_id_pairs, max_depth=1)
|
|
# shallow_entities = {(r.id, r.type) for r in shallow_results if r.type == "entity"}
|
|
#
|
|
# assert (test_graph["deep"].id, "entity") not in shallow_entities
|
|
|
|
# search deeper
|
|
deep_results = await context_service.find_related(type_id_pairs, max_depth=3, max_results=100)
|
|
deep_entities = {(r.id, r.type) for r in deep_results if r.type == "entity"}
|
|
print(deep_entities)
|
|
# Should now include Deep entity
|
|
assert (test_graph["deep"].id, "entity") in deep_entities
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_find_connected_timeframe(
|
|
context_service, test_graph, search_repository, entity_repository, app_config
|
|
):
|
|
"""Test timeframe filtering.
|
|
This tests how traversal is affected by the item dates.
|
|
When we filter by date, items are only included if:
|
|
1. They match the timeframe
|
|
2. There is a valid path to them through other items in the timeframe
|
|
"""
|
|
# Skip for Postgres - needs investigation of duplicate key violations
|
|
from basic_memory.config import DatabaseBackend
|
|
|
|
if app_config.database_backend == DatabaseBackend.POSTGRES:
|
|
pytest.skip("Not yet supported for Postgres - duplicate key violation issue")
|
|
|
|
now = datetime.now(UTC)
|
|
old_date = now - timedelta(days=10)
|
|
recent_date = now - timedelta(days=1)
|
|
|
|
# Update entity table timestamps directly
|
|
# Root entity uses old date
|
|
root_entity = test_graph["root"]
|
|
await entity_repository.update(root_entity.id, {"created_at": old_date, "updated_at": old_date})
|
|
|
|
# Connected entity uses recent date
|
|
connected_entity = test_graph["connected1"]
|
|
await entity_repository.update(
|
|
connected_entity.id, {"created_at": recent_date, "updated_at": recent_date}
|
|
)
|
|
|
|
# Also update search_index for test consistency
|
|
await search_repository.index_item(
|
|
SearchIndexRow(
|
|
project_id=entity_repository.project_id,
|
|
id=test_graph["root"].id,
|
|
title=test_graph["root"].title,
|
|
content_snippet="Root content",
|
|
permalink=test_graph["root"].permalink,
|
|
file_path=test_graph["root"].file_path,
|
|
type=SearchItemType.ENTITY,
|
|
metadata={"created_at": old_date.isoformat()},
|
|
created_at=old_date,
|
|
updated_at=old_date,
|
|
)
|
|
)
|
|
await search_repository.index_item(
|
|
SearchIndexRow(
|
|
project_id=entity_repository.project_id,
|
|
id=test_graph["relations"][0].id,
|
|
title="Root Entity → Connected Entity 1",
|
|
content_snippet="",
|
|
permalink=f"{test_graph['root'].permalink}/connects_to/{test_graph['connected1'].permalink}",
|
|
file_path=test_graph["root"].file_path,
|
|
type=SearchItemType.RELATION,
|
|
from_id=test_graph["root"].id,
|
|
to_id=test_graph["connected1"].id,
|
|
relation_type="connects_to",
|
|
metadata={"created_at": old_date.isoformat()},
|
|
created_at=old_date,
|
|
updated_at=old_date,
|
|
)
|
|
)
|
|
await search_repository.index_item(
|
|
SearchIndexRow(
|
|
project_id=entity_repository.project_id,
|
|
id=test_graph["connected1"].id,
|
|
title=test_graph["connected1"].title,
|
|
content_snippet="Connected 1 content",
|
|
permalink=test_graph["connected1"].permalink,
|
|
file_path=test_graph["connected1"].file_path,
|
|
type=SearchItemType.ENTITY,
|
|
metadata={"created_at": recent_date.isoformat()},
|
|
created_at=recent_date,
|
|
updated_at=recent_date,
|
|
)
|
|
)
|
|
|
|
type_id_pairs = [("entity", test_graph["root"].id)]
|
|
|
|
# Search with a 7-day cutoff
|
|
since_date = now - timedelta(days=7)
|
|
results = await context_service.find_related(type_id_pairs, since=since_date)
|
|
|
|
# Only connected1 is recent, but we can't get to it
|
|
# because its connecting relation is too old and is filtered out
|
|
# (we can only reach connected1 through a relation starting from root)
|
|
entity_ids = {r.id for r in results if r.type == "entity"}
|
|
assert len(entity_ids) == 0 # No accessible entities within timeframe
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_build_context(context_service, test_graph):
|
|
"""Test exact permalink lookup."""
|
|
url = memory_url.validate_strings("memory://test-project/test/root")
|
|
context_result = await context_service.build_context(url)
|
|
|
|
# Check metadata
|
|
assert context_result.metadata.uri == memory_url_path(url)
|
|
assert context_result.metadata.depth == 1
|
|
assert context_result.metadata.primary_count == 1
|
|
assert context_result.metadata.related_count > 0
|
|
assert context_result.metadata.generated_at is not None
|
|
|
|
# Check results
|
|
assert len(context_result.results) == 1
|
|
context_item = context_result.results[0]
|
|
|
|
# Check primary result
|
|
primary_result = context_item.primary_result
|
|
assert primary_result.id == test_graph["root"].id
|
|
assert primary_result.type == "entity"
|
|
assert primary_result.title == "Root"
|
|
assert primary_result.permalink == "test-project/test/root"
|
|
assert primary_result.file_path == "test/Root.md"
|
|
assert primary_result.created_at is not None
|
|
|
|
# Check related results
|
|
assert len(context_item.related_results) > 0
|
|
|
|
# Find related relation
|
|
relation = next((r for r in context_item.related_results if r.type == "relation"), None)
|
|
assert relation is not None
|
|
assert relation.relation_type == "connects_to"
|
|
assert relation.from_id == test_graph["root"].id
|
|
assert relation.to_id == test_graph["connected1"].id
|
|
|
|
# Find related entity
|
|
related_entity = next((r for r in context_item.related_results if r.type == "entity"), None)
|
|
assert related_entity is not None
|
|
assert related_entity.id == test_graph["connected1"].id
|
|
assert related_entity.title == test_graph["connected1"].title
|
|
assert related_entity.permalink == test_graph["connected1"].permalink
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_build_context_with_observations(context_service, test_graph):
|
|
"""Test context building with observations."""
|
|
# The test_graph fixture already creates observations for root entity
|
|
# Let's use those existing observations
|
|
|
|
# Build context
|
|
url = memory_url.validate_strings("memory://test-project/test/root")
|
|
context_result = await context_service.build_context(url, include_observations=True)
|
|
|
|
# Check the metadata
|
|
assert context_result.metadata.total_observations > 0
|
|
assert len(context_result.results) == 1
|
|
|
|
# Check that observations were included
|
|
context_item = context_result.results[0]
|
|
assert len(context_item.observations) > 0
|
|
|
|
# Check observation properties
|
|
for observation in context_item.observations:
|
|
assert observation.type == "observation"
|
|
assert observation.category in ["note", "tech"] # Categories from test_graph fixture
|
|
assert observation.entity_id == test_graph["root"].id
|
|
|
|
# Verify at least one observation has the correct category and content
|
|
note_observation = next((o for o in context_item.observations if o.category == "note"), None)
|
|
assert note_observation is not None
|
|
assert "Root note" in note_observation.content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_build_context_observation_permalinks_match_search_index(
|
|
context_service, search_service, entity_service
|
|
):
|
|
"""Regression test for #929: observation permalinks must match the search index.
|
|
|
|
build_context used to rebuild the synthetic observation permalink inline,
|
|
without the 200-char truncation (#446) or the content digest (#931) that
|
|
Observation.permalink applies, so for long observations it returned
|
|
permalinks the search index doesn't contain.
|
|
"""
|
|
from basic_memory.schemas.base import Entity as EntitySchema
|
|
from basic_memory.schemas.search import SearchQuery
|
|
|
|
long_observation = "x" * 210 + " LONG_OBS_MARKER"
|
|
entity, _ = await entity_service.create_or_update_entity(
|
|
EntitySchema(
|
|
title="Long Obs Entity",
|
|
note_type="test",
|
|
directory="test",
|
|
content=f"# Long Obs Entity\n- [note] {long_observation}\n",
|
|
)
|
|
)
|
|
await search_service.index_entity(entity)
|
|
|
|
url = memory_url.validate_strings(f"memory://{entity.permalink}")
|
|
context_result = await context_service.build_context(url, include_observations=True)
|
|
assert len(context_result.results) == 1
|
|
context_item = context_result.results[0]
|
|
assert len(context_item.observations) == 1
|
|
obs_row = context_item.observations[0]
|
|
|
|
# The model property is the single definition of the permalink format
|
|
assert obs_row.permalink == entity.observations[0].permalink
|
|
|
|
# The search index row for this observation carries the same permalink
|
|
index_rows = await search_service.search(SearchQuery(text="LONG_OBS_MARKER"))
|
|
obs_permalinks = [r.permalink for r in index_rows if r.type == SearchItemType.OBSERVATION.value]
|
|
assert obs_permalinks == [obs_row.permalink]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_build_context_not_found(context_service):
|
|
"""Test handling non-existent permalinks."""
|
|
context = await context_service.build_context("memory://does/not/exist")
|
|
assert len(context.results) == 0
|
|
assert context.metadata.primary_count == 0
|
|
assert context.metadata.related_count == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_context_metadata(context_service, test_graph):
|
|
"""Test metadata is correctly populated."""
|
|
context = await context_service.build_context("memory://test-project/test/root", depth=2)
|
|
metadata = context.metadata
|
|
assert metadata.uri == "test-project/test/root"
|
|
assert metadata.depth == 2
|
|
assert metadata.generated_at is not None
|
|
assert metadata.primary_count > 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_project_isolation_in_find_related(session_maker, app_config):
|
|
"""Test that find_related respects project boundaries and doesn't leak data."""
|
|
from basic_memory.repository.entity_repository import EntityRepository
|
|
from basic_memory.repository.observation_repository import ObservationRepository
|
|
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
|
|
from basic_memory.repository.postgres_search_repository import PostgresSearchRepository
|
|
from basic_memory.config import DatabaseBackend
|
|
from basic_memory import db
|
|
|
|
# Create database session
|
|
async with db.scoped_session(session_maker) as db_session:
|
|
# Create two separate projects
|
|
project1 = Project(name="project1", path="/test1")
|
|
project2 = Project(name="project2", path="/test2")
|
|
db_session.add(project1)
|
|
db_session.add(project2)
|
|
await db_session.flush()
|
|
|
|
# Create entities in project1
|
|
entity1_p1 = Entity(
|
|
title="Entity1_P1",
|
|
note_type="document",
|
|
content_type="text/markdown",
|
|
project_id=project1.id,
|
|
permalink="project1/entity1",
|
|
file_path="project1/entity1.md",
|
|
created_at=datetime.now(UTC),
|
|
updated_at=datetime.now(UTC),
|
|
)
|
|
entity2_p1 = Entity(
|
|
title="Entity2_P1",
|
|
note_type="document",
|
|
content_type="text/markdown",
|
|
project_id=project1.id,
|
|
permalink="project1/entity2",
|
|
file_path="project1/entity2.md",
|
|
created_at=datetime.now(UTC),
|
|
updated_at=datetime.now(UTC),
|
|
)
|
|
|
|
# Create entities in project2
|
|
entity1_p2 = Entity(
|
|
title="Entity1_P2",
|
|
note_type="document",
|
|
content_type="text/markdown",
|
|
project_id=project2.id,
|
|
permalink="project2/entity1",
|
|
file_path="project2/entity1.md",
|
|
created_at=datetime.now(UTC),
|
|
updated_at=datetime.now(UTC),
|
|
)
|
|
|
|
db_session.add_all([entity1_p1, entity2_p1, entity1_p2])
|
|
await db_session.flush()
|
|
|
|
# Create relation in project1 (between entities of project1)
|
|
relation_p1 = Relation(
|
|
project_id=project1.id,
|
|
from_id=entity1_p1.id,
|
|
to_id=entity2_p1.id,
|
|
to_name="Entity2_P1",
|
|
relation_type="connects_to",
|
|
)
|
|
db_session.add(relation_p1)
|
|
await db_session.commit()
|
|
|
|
# Create database-specific search repositories based on backend
|
|
if app_config.database_backend == DatabaseBackend.POSTGRES:
|
|
search_repo_p1 = PostgresSearchRepository(session_maker, project1.id)
|
|
search_repo_p2 = PostgresSearchRepository(session_maker, project2.id)
|
|
else:
|
|
search_repo_p1 = SQLiteSearchRepository(session_maker, project1.id)
|
|
search_repo_p2 = SQLiteSearchRepository(session_maker, project2.id)
|
|
|
|
# Create repositories for project1
|
|
entity_repo_p1 = EntityRepository(session_maker, project1.id)
|
|
obs_repo_p1 = ObservationRepository(session_maker, project1.id)
|
|
context_service_p1 = ContextService(search_repo_p1, entity_repo_p1, obs_repo_p1)
|
|
|
|
# Create repositories for project2
|
|
entity_repo_p2 = EntityRepository(session_maker, project2.id)
|
|
obs_repo_p2 = ObservationRepository(session_maker, project2.id)
|
|
context_service_p2 = ContextService(search_repo_p2, entity_repo_p2, obs_repo_p2)
|
|
|
|
# Test: find_related for project1 should only return project1 entities
|
|
type_id_pairs_p1 = [("entity", entity1_p1.id)]
|
|
related_p1 = await context_service_p1.find_related(type_id_pairs_p1, max_depth=2)
|
|
|
|
# Verify only project1 entities are returned
|
|
related_entity_ids = [r.id for r in related_p1 if r.type == "entity"]
|
|
assert entity2_p1.id in related_entity_ids # Should find connected entity2 in project1
|
|
assert entity1_p2.id not in related_entity_ids # Should NOT find entity from project2
|
|
|
|
# Test: find_related for project2 should return empty (no relations)
|
|
type_id_pairs_p2 = [("entity", entity1_p2.id)]
|
|
related_p2 = await context_service_p2.find_related(type_id_pairs_p2, max_depth=2)
|
|
|
|
# Project2 has no relations, so should return empty
|
|
assert len(related_p2) == 0
|
|
|
|
# Double-check: verify entities exist in their respective projects
|
|
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_find_related_expands_cross_project_relation_targets(session_maker, app_config):
|
|
"""Explicit cross-project links should expand without exposing unrelated incoming links."""
|
|
from basic_memory.repository.entity_repository import EntityRepository
|
|
from basic_memory.repository.observation_repository import ObservationRepository
|
|
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
|
|
from basic_memory.repository.postgres_search_repository import PostgresSearchRepository
|
|
from basic_memory.config import DatabaseBackend
|
|
from basic_memory import db
|
|
|
|
async with db.scoped_session(session_maker) as db_session:
|
|
project1 = Project(name="project1", path="/test1")
|
|
project2 = Project(name="project2", path="/test2")
|
|
project3 = Project(name="project3", path="/test3")
|
|
db_session.add_all([project1, project2, project3])
|
|
await db_session.flush()
|
|
|
|
now = datetime.now(UTC)
|
|
source = Entity(
|
|
title="Source",
|
|
note_type="document",
|
|
content_type="text/markdown",
|
|
project_id=project1.id,
|
|
permalink="project1/source",
|
|
file_path="project1/source.md",
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
target = Entity(
|
|
title="Company Standards",
|
|
note_type="document",
|
|
content_type="text/markdown",
|
|
project_id=project2.id,
|
|
permalink="project2/company-standards",
|
|
file_path="project2/company-standards.md",
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
target_child = Entity(
|
|
title="Review Checklist",
|
|
note_type="document",
|
|
content_type="text/markdown",
|
|
project_id=project2.id,
|
|
permalink="project2/review-checklist",
|
|
file_path="project2/review-checklist.md",
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
unrelated_source = Entity(
|
|
title="Unrelated Source",
|
|
note_type="document",
|
|
content_type="text/markdown",
|
|
project_id=project3.id,
|
|
permalink="project3/unrelated-source",
|
|
file_path="project3/unrelated-source.md",
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
db_session.add_all([source, target, target_child, unrelated_source])
|
|
await db_session.flush()
|
|
|
|
cross_project_relation = Relation(
|
|
project_id=project1.id,
|
|
from_id=source.id,
|
|
to_id=target.id,
|
|
to_name="Company Standards",
|
|
relation_type="links_to",
|
|
)
|
|
target_relation = Relation(
|
|
project_id=project2.id,
|
|
from_id=target.id,
|
|
to_id=target_child.id,
|
|
to_name="Review Checklist",
|
|
relation_type="links_to",
|
|
)
|
|
unrelated_incoming_relation = Relation(
|
|
project_id=project3.id,
|
|
from_id=unrelated_source.id,
|
|
to_id=target.id,
|
|
to_name="Company Standards",
|
|
relation_type="links_to",
|
|
)
|
|
db_session.add_all([cross_project_relation, target_relation, unrelated_incoming_relation])
|
|
await db_session.commit()
|
|
|
|
if app_config.database_backend == DatabaseBackend.POSTGRES:
|
|
search_repo_p1 = PostgresSearchRepository(session_maker, project1.id)
|
|
else:
|
|
search_repo_p1 = SQLiteSearchRepository(session_maker, project1.id)
|
|
|
|
entity_repo_p1 = EntityRepository(session_maker, project1.id)
|
|
obs_repo_p1 = ObservationRepository(session_maker, project1.id)
|
|
context_service_p1 = ContextService(search_repo_p1, entity_repo_p1, obs_repo_p1)
|
|
|
|
await search_repo_p1.index_item(
|
|
SearchIndexRow(
|
|
project_id=project1.id,
|
|
id=source.id,
|
|
title=source.title,
|
|
content_snippet="Source content",
|
|
permalink=source.permalink,
|
|
file_path=source.file_path,
|
|
type=SearchItemType.ENTITY,
|
|
metadata={"created_at": now.isoformat()},
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
)
|
|
|
|
context = await context_service_p1.build_context(
|
|
memory_url.validate_strings("memory://project1/source"),
|
|
depth=2,
|
|
max_related=100,
|
|
)
|
|
assert len(context.results) == 1
|
|
|
|
context_related_entity_ids = {
|
|
row.id for row in context.results[0].related_results if row.type == "entity"
|
|
}
|
|
context_related_relation_ids = {
|
|
row.id for row in context.results[0].related_results if row.type == "relation"
|
|
}
|
|
|
|
assert target.id in context_related_entity_ids
|
|
assert target_child.id in context_related_entity_ids
|
|
assert unrelated_source.id not in context_related_entity_ids
|
|
assert cross_project_relation.id in context_related_relation_ids
|
|
assert target_relation.id in context_related_relation_ids
|
|
assert unrelated_incoming_relation.id not in context_related_relation_ids
|
|
|
|
related = await context_service_p1.find_related(
|
|
[("entity", source.id)], max_depth=2, max_results=100
|
|
)
|
|
|
|
related_entity_ids = {row.id for row in related if row.type == "entity"}
|
|
related_relation_ids = {row.id for row in related if row.type == "relation"}
|
|
|
|
assert target.id in related_entity_ids
|
|
assert target_child.id in related_entity_ids
|
|
assert unrelated_source.id not in related_entity_ids
|
|
assert cross_project_relation.id in related_relation_ids
|
|
assert target_relation.id in related_relation_ids
|
|
assert unrelated_incoming_relation.id not in related_relation_ids
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_find_related_does_not_revisit_entities_in_cycles(session_maker, app_config):
|
|
"""Recursive graph expansion should stop when a path loops back to a visited entity."""
|
|
from basic_memory.repository.entity_repository import EntityRepository
|
|
from basic_memory.repository.observation_repository import ObservationRepository
|
|
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
|
|
from basic_memory.repository.postgres_search_repository import PostgresSearchRepository
|
|
from basic_memory.config import DatabaseBackend
|
|
from basic_memory import db
|
|
|
|
async with db.scoped_session(session_maker) as db_session:
|
|
project = Project(name="cycle-project", path="/cycle")
|
|
db_session.add(project)
|
|
await db_session.flush()
|
|
|
|
now = datetime.now(UTC)
|
|
root = Entity(
|
|
title="Root",
|
|
note_type="document",
|
|
content_type="text/markdown",
|
|
project_id=project.id,
|
|
permalink="cycle/root",
|
|
file_path="cycle/root.md",
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
connected = Entity(
|
|
title="Connected",
|
|
note_type="document",
|
|
content_type="text/markdown",
|
|
project_id=project.id,
|
|
permalink="cycle/connected",
|
|
file_path="cycle/connected.md",
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
db_session.add_all([root, connected])
|
|
await db_session.flush()
|
|
|
|
root_to_connected = Relation(
|
|
project_id=project.id,
|
|
from_id=root.id,
|
|
to_id=connected.id,
|
|
to_name="Connected",
|
|
relation_type="links_to",
|
|
)
|
|
connected_to_root = Relation(
|
|
project_id=project.id,
|
|
from_id=connected.id,
|
|
to_id=root.id,
|
|
to_name="Root",
|
|
relation_type="links_to",
|
|
)
|
|
db_session.add_all([root_to_connected, connected_to_root])
|
|
await db_session.commit()
|
|
|
|
if app_config.database_backend == DatabaseBackend.POSTGRES:
|
|
search_repo = PostgresSearchRepository(session_maker, project.id)
|
|
else:
|
|
search_repo = SQLiteSearchRepository(session_maker, project.id)
|
|
|
|
entity_repo = EntityRepository(session_maker, project.id)
|
|
obs_repo = ObservationRepository(session_maker, project.id)
|
|
context_service = ContextService(search_repo, entity_repo, obs_repo)
|
|
|
|
related = await context_service.find_related(
|
|
[("entity", root.id)], max_depth=4, max_results=100
|
|
)
|
|
|
|
related_entity_ids = [row.id for row in related if row.type == "entity"]
|
|
related_relation_ids = {row.id for row in related if row.type == "relation"}
|
|
|
|
assert related_entity_ids == [connected.id]
|
|
assert related_relation_ids == {root_to_connected.id, connected_to_root.id}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_build_context_fallback_via_link_resolver(context_service, test_graph):
|
|
"""Test that build_context falls back to LinkResolver when exact permalink fails.
|
|
|
|
The test_graph creates entities with permalinks like 'test-project/test/root'.
|
|
Looking up by title ('Root') won't match the exact permalink, but LinkResolver
|
|
can resolve it via title matching.
|
|
"""
|
|
# This identifier is the entity title, not a permalink — exact lookup will fail
|
|
url = memory_url.validate_strings("memory://Root")
|
|
context_result = await context_service.build_context(url)
|
|
|
|
# LinkResolver should resolve 'Root' → entity with permalink 'test-project/test/root'
|
|
assert context_result.metadata.primary_count == 1
|
|
assert len(context_result.results) == 1
|
|
assert context_result.results[0].primary_result.id == test_graph["root"].id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_build_context_fallback_not_found(context_service):
|
|
"""Test that build_context returns empty when both exact lookup and fallback fail."""
|
|
url = memory_url.validate_strings("memory://completely-nonexistent-note-xyz")
|
|
context_result = await context_service.build_context(url)
|
|
|
|
assert context_result.metadata.primary_count == 0
|
|
assert len(context_result.results) == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_build_context_without_link_resolver(
|
|
search_repository, entity_repository, observation_repository, test_graph
|
|
):
|
|
"""Test that build_context still works without a link_resolver (no fallback)."""
|
|
service = ContextService(search_repository, entity_repository, observation_repository)
|
|
|
|
# Exact permalink lookup should still work
|
|
url = memory_url.validate_strings("memory://test-project/test/root")
|
|
context_result = await service.build_context(url)
|
|
assert context_result.metadata.primary_count == 1
|
|
|
|
# Title-based lookup should return empty (no fallback available)
|
|
url = memory_url.validate_strings("memory://Root")
|
|
context_result = await service.build_context(url)
|
|
assert context_result.metadata.primary_count == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_find_related_carries_to_name_for_unresolved_relations(session_maker, app_config):
|
|
"""Relation rows expose to_name so unresolved forward refs render by name (#955).
|
|
|
|
A forward reference (to_id NULL) previously surfaced with no usable target
|
|
text — build_context printed [[None]] even though the markdown named the
|
|
target. The context query must select to_name for both resolved and
|
|
unresolved relation rows.
|
|
"""
|
|
from basic_memory.repository.entity_repository import EntityRepository
|
|
from basic_memory.repository.observation_repository import ObservationRepository
|
|
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
|
|
from basic_memory.repository.postgres_search_repository import PostgresSearchRepository
|
|
from basic_memory.config import DatabaseBackend
|
|
from basic_memory import db
|
|
|
|
async with db.scoped_session(session_maker) as db_session:
|
|
project = Project(name="forward-ref-project", path="/forward-ref")
|
|
db_session.add(project)
|
|
await db_session.flush()
|
|
|
|
source = Entity(
|
|
title="write-note(3)",
|
|
note_type="document",
|
|
content_type="text/markdown",
|
|
project_id=project.id,
|
|
permalink="man3/write-note-3",
|
|
file_path="man3/write-note-3.md",
|
|
created_at=datetime.now(UTC),
|
|
updated_at=datetime.now(UTC),
|
|
)
|
|
resolved_target = Entity(
|
|
title="bm-note(5)",
|
|
note_type="document",
|
|
content_type="text/markdown",
|
|
project_id=project.id,
|
|
permalink="man5/bm-note-5",
|
|
file_path="man5/bm-note-5.md",
|
|
created_at=datetime.now(UTC),
|
|
updated_at=datetime.now(UTC),
|
|
)
|
|
db_session.add_all([source, resolved_target])
|
|
await db_session.flush()
|
|
|
|
resolved = Relation(
|
|
project_id=project.id,
|
|
from_id=source.id,
|
|
to_id=resolved_target.id,
|
|
to_name="bm-note(5)",
|
|
relation_type="see_also",
|
|
)
|
|
# Forward reference: the target page does not exist yet
|
|
unresolved = Relation(
|
|
project_id=project.id,
|
|
from_id=source.id,
|
|
to_id=None,
|
|
to_name="edit-note(3)",
|
|
relation_type="see_also",
|
|
)
|
|
db_session.add_all([resolved, unresolved])
|
|
await db_session.commit()
|
|
|
|
if app_config.database_backend == DatabaseBackend.POSTGRES:
|
|
search_repo = PostgresSearchRepository(session_maker, project.id)
|
|
else:
|
|
search_repo = SQLiteSearchRepository(session_maker, project.id)
|
|
entity_repo = EntityRepository(session_maker, project.id)
|
|
obs_repo = ObservationRepository(session_maker, project.id)
|
|
context_service = ContextService(search_repo, entity_repo, obs_repo)
|
|
|
|
related = await context_service.find_related([("entity", source.id)], max_depth=2)
|
|
relation_rows = {r.to_name: r for r in related if r.type == "relation"}
|
|
|
|
assert "edit-note(3)" in relation_rows, "unresolved relation row missing to_name"
|
|
assert relation_rows["edit-note(3)"].to_id is None
|
|
assert relation_rows["bm-note(5)"].to_name == "bm-note(5)"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pattern_search_falls_back_for_legacy_unqualified_rows(context_service, test_graph):
|
|
"""Workspace-qualified patterns fall back to the project form for legacy rows (#957).
|
|
|
|
Rows written before workspace canonicalization (or via clients that did not
|
|
forward workspace headers) store project-qualified permalinks. A pattern
|
|
canonicalized under an active workspace context would otherwise match
|
|
nothing — the field failure that opened the issue.
|
|
"""
|
|
from basic_memory.workspace_context import workspace_permalink_context
|
|
|
|
# test_graph rows are stored without any workspace prefix (legacy form).
|
|
# Query with a workspace-qualified pattern under an active context.
|
|
with workspace_permalink_context(workspace_slug="team-paul", workspace_type="organization"):
|
|
context = await context_service.build_context("memory://team-paul/test-project/test/*")
|
|
|
|
permalinks = {result.primary_result.permalink for result in context.results}
|
|
assert permalinks, "fallback did not match legacy rows"
|
|
assert all(p and p.startswith("test-project/test/") for p in permalinks), permalinks
|