mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
286 lines
9.0 KiB
Python
286 lines
9.0 KiB
Python
"""Tests for graph context hydration in to_graph_context().
|
|
|
|
Proves that recent-activity/build-context hydration batches entity lookups
|
|
for entities, observations, and relations in a single repository call.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from basic_memory.api.v2.utils import to_graph_context
|
|
from basic_memory.schemas.memory import EntitySummary, ObservationSummary, RelationSummary
|
|
from basic_memory.schemas.search import SearchItemType
|
|
from basic_memory.services.context_service import (
|
|
ContextMetadata,
|
|
ContextResult as ServiceContextResult,
|
|
ContextResultItem,
|
|
ContextResultRow,
|
|
)
|
|
|
|
|
|
# --- Helpers ---
|
|
|
|
|
|
def _make_entity(id: int, title: str, external_id: str) -> SimpleNamespace:
|
|
return SimpleNamespace(id=id, title=title, external_id=external_id)
|
|
|
|
|
|
def _make_row(*, type: str, id: int, root_id: int, **kwargs: Any) -> ContextResultRow:
|
|
now = kwargs.pop("created_at", datetime.now(timezone.utc))
|
|
defaults: dict[str, Any] = dict(
|
|
title=f"Item {id}",
|
|
permalink=f"notes/{id}",
|
|
file_path=f"notes/{id}.md",
|
|
depth=0,
|
|
root_id=root_id,
|
|
created_at=now,
|
|
)
|
|
defaults.update(kwargs)
|
|
return ContextResultRow(type=type, id=id, **defaults)
|
|
|
|
|
|
class SpyEntityRepository:
|
|
"""Tracks batched ID lookups and returns entities from a preset map."""
|
|
|
|
def __init__(self, entities_by_id: dict[int, SimpleNamespace]):
|
|
self.entities_by_id = entities_by_id
|
|
self.calls: list[tuple[list[int], bool]] = []
|
|
|
|
async def find_by_ids(self, ids: list[int]):
|
|
self.calls.append((ids, False))
|
|
return [self.entities_by_id[i] for i in ids if i in self.entities_by_id]
|
|
|
|
async def find_by_ids_for_hydration(
|
|
self, ids: list[int], *, include_cross_project: bool = False
|
|
):
|
|
self.calls.append((ids, include_cross_project))
|
|
return [self.entities_by_id[i] for i in ids if i in self.entities_by_id]
|
|
|
|
|
|
class LightweightOnlyEntityRepository:
|
|
"""Raises if graph hydration uses the eager-loading repository method."""
|
|
|
|
def __init__(self, entities_by_id: dict[int, SimpleNamespace]):
|
|
self.entities_by_id = entities_by_id
|
|
self.hydration_calls: list[tuple[list[int], bool]] = []
|
|
|
|
async def find_by_ids(self, ids: list[int]):
|
|
raise AssertionError("graph hydration must use the lightweight hydration lookup")
|
|
|
|
async def find_by_ids_for_hydration(
|
|
self, ids: list[int], *, include_cross_project: bool = False
|
|
):
|
|
self.hydration_calls.append((ids, include_cross_project))
|
|
return [self.entities_by_id[i] for i in ids if i in self.entities_by_id]
|
|
|
|
|
|
# --- Single batch fetch (N+1 elimination) ---
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_to_graph_context_batches_entity_hydration_for_recent_activity():
|
|
"""Mixed entity, observation, and relation items must hydrate in one lookup."""
|
|
repo = SpyEntityRepository(
|
|
{
|
|
1: _make_entity(1, "Root", "ext-root"),
|
|
2: _make_entity(2, "Child", "ext-child"),
|
|
3: _make_entity(3, "Peer", "ext-peer"),
|
|
}
|
|
)
|
|
now = datetime.now(timezone.utc)
|
|
|
|
root_entity = _make_row(
|
|
type="entity",
|
|
id=1,
|
|
root_id=1,
|
|
title="Root",
|
|
permalink="notes/root",
|
|
file_path="notes/root.md",
|
|
created_at=now,
|
|
)
|
|
root_observation = _make_row(
|
|
type="observation",
|
|
id=10,
|
|
root_id=1,
|
|
title="fact: observed",
|
|
permalink="notes/root/observations/fact/observed",
|
|
file_path="notes/root.md",
|
|
category="fact",
|
|
content="observed",
|
|
entity_id=1,
|
|
created_at=now,
|
|
)
|
|
root_relation = _make_row(
|
|
type="relation",
|
|
id=20,
|
|
root_id=1,
|
|
title="links_to: Child",
|
|
permalink="notes/root",
|
|
file_path="notes/root.md",
|
|
relation_type="links_to",
|
|
from_id=1,
|
|
to_id=2,
|
|
depth=1,
|
|
created_at=now,
|
|
)
|
|
child_observation = _make_row(
|
|
type="observation",
|
|
id=11,
|
|
root_id=11,
|
|
title="note: child update",
|
|
permalink="notes/child/observations/note/update",
|
|
file_path="notes/child.md",
|
|
category="note",
|
|
content="child update",
|
|
entity_id=2,
|
|
created_at=now,
|
|
)
|
|
peer_entity = _make_row(
|
|
type="entity",
|
|
id=3,
|
|
root_id=11,
|
|
title="Peer",
|
|
permalink="notes/peer",
|
|
file_path="notes/peer.md",
|
|
depth=1,
|
|
created_at=now,
|
|
)
|
|
|
|
context = ServiceContextResult(
|
|
results=[
|
|
ContextResultItem(
|
|
primary_result=root_entity,
|
|
observations=[root_observation],
|
|
related_results=[root_relation],
|
|
),
|
|
ContextResultItem(
|
|
primary_result=child_observation,
|
|
observations=[],
|
|
related_results=[peer_entity],
|
|
),
|
|
],
|
|
metadata=ContextMetadata(
|
|
types=[
|
|
SearchItemType.ENTITY,
|
|
SearchItemType.OBSERVATION,
|
|
SearchItemType.RELATION,
|
|
],
|
|
depth=1,
|
|
primary_count=2,
|
|
related_count=2,
|
|
total_relations=1,
|
|
total_observations=1,
|
|
),
|
|
)
|
|
|
|
graph = await to_graph_context(context, entity_repository=repo, page=1, page_size=10)
|
|
|
|
assert len(repo.calls) == 1, f"Expected 1 entity lookup, got {len(repo.calls)}"
|
|
assert set(repo.calls[0][0]) == {1, 2, 3}
|
|
assert repo.calls[0][1] is True
|
|
|
|
first_result = graph.results[0]
|
|
first_primary = first_result.primary_result
|
|
assert isinstance(first_primary, EntitySummary)
|
|
assert first_primary.external_id == "ext-root"
|
|
|
|
first_observation = first_result.observations[0]
|
|
assert isinstance(first_observation, ObservationSummary)
|
|
assert first_observation.entity_external_id == "ext-root"
|
|
assert first_observation.title == "Root"
|
|
|
|
relation = first_result.related_results[0]
|
|
assert isinstance(relation, RelationSummary)
|
|
assert relation.from_entity == "Root"
|
|
assert relation.from_entity_external_id == "ext-root"
|
|
assert relation.to_entity == "Child"
|
|
assert relation.to_entity_external_id == "ext-child"
|
|
|
|
second_result = graph.results[1]
|
|
second_primary = second_result.primary_result
|
|
assert isinstance(second_primary, ObservationSummary)
|
|
assert second_primary.entity_external_id == "ext-child"
|
|
assert second_primary.title == "Child"
|
|
|
|
peer_result = second_result.related_results[0]
|
|
assert isinstance(peer_result, EntitySummary)
|
|
assert peer_result.external_id == "ext-peer"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_to_graph_context_empty_results_skip_entity_lookup():
|
|
"""An empty context result should not perform any entity hydration lookup."""
|
|
repo = SpyEntityRepository({})
|
|
context = ServiceContextResult(results=[], metadata=ContextMetadata(depth=1))
|
|
|
|
graph = await to_graph_context(context, entity_repository=repo)
|
|
|
|
assert repo.calls == []
|
|
assert list(graph.results) == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_to_graph_context_uses_lightweight_hydration_lookup():
|
|
"""Hydration should not load observations/relations when only entity fields are needed."""
|
|
repo = LightweightOnlyEntityRepository(
|
|
{
|
|
1: _make_entity(1, "Root", "ext-root"),
|
|
2: _make_entity(2, "Child", "ext-child"),
|
|
}
|
|
)
|
|
now = datetime.now(timezone.utc)
|
|
|
|
context = ServiceContextResult(
|
|
results=[
|
|
ContextResultItem(
|
|
primary_result=_make_row(
|
|
type="entity",
|
|
id=1,
|
|
root_id=1,
|
|
title="Root",
|
|
permalink="notes/root",
|
|
file_path="notes/root.md",
|
|
created_at=now,
|
|
),
|
|
observations=[],
|
|
related_results=[
|
|
_make_row(
|
|
type="relation",
|
|
id=20,
|
|
root_id=1,
|
|
title="links_to: Child",
|
|
permalink="notes/root",
|
|
file_path="notes/root.md",
|
|
relation_type="links_to",
|
|
from_id=1,
|
|
to_id=2,
|
|
depth=1,
|
|
created_at=now,
|
|
)
|
|
],
|
|
)
|
|
],
|
|
metadata=ContextMetadata(
|
|
types=[SearchItemType.ENTITY, SearchItemType.RELATION],
|
|
depth=1,
|
|
primary_count=1,
|
|
related_count=1,
|
|
total_relations=1,
|
|
),
|
|
)
|
|
|
|
graph = await to_graph_context(context, entity_repository=repo)
|
|
|
|
assert len(repo.hydration_calls) == 1
|
|
assert set(repo.hydration_calls[0][0]) == {1, 2}
|
|
assert repo.hydration_calls[0][1] is True
|
|
relation = graph.results[0].related_results[0]
|
|
assert isinstance(relation, RelationSummary)
|
|
assert relation.from_entity_external_id == "ext-root"
|
|
assert relation.to_entity_external_id == "ext-child"
|