From 0100a6e63980aa7a73960c67403cc8b77d96ff3d Mon Sep 17 00:00:00 2001 From: Drew Cain Date: Wed, 10 Jun 2026 10:32:01 -0500 Subject: [PATCH] fix(core): use Observation.permalink in build_context so permalinks match the search index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_context rebuilt the synthetic observation permalink inline with no 200-char truncation (#446) and no content digest (#931), so for long observations it returned permalinks that don't exist in the search index. Use the Observation.permalink model property instead — the single definition of the format; the parent entity is already eager-loaded by ObservationRepository's load options. Regression test asserts the permalink returned by build_context equals the model property and the indexed row's permalink for a >200-char observation; it fails against the previous inline construction. Fixes #929 Co-Authored-By: Claude Fable 5 Signed-off-by: Drew Cain --- src/basic_memory/services/context_service.py | 9 +++-- tests/services/test_context_service.py | 41 ++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/basic_memory/services/context_service.py b/src/basic_memory/services/context_service.py index 678af8836..a27b595ff 100644 --- a/src/basic_memory/services/context_service.py +++ b/src/basic_memory/services/context_service.py @@ -245,9 +245,12 @@ async def build_context( type="observation", id=obs.id, title=f"{obs.category}: {obs.content[:50]}...", - permalink=generate_permalink( - f"{primary_item.permalink}/observations/{obs.category}/{obs.content}" - ), + # Observation.permalink is the single definition of the + # synthetic permalink format (200-char truncation plus + # content digest); rebuilding it inline diverged from the + # search index for long observations (#929). The parent + # entity is eager-loaded by ObservationRepository. + permalink=obs.permalink, file_path=primary_item.file_path, content=obs.content, category=obs.category, diff --git a/tests/services/test_context_service.py b/tests/services/test_context_service.py index 0cb4d5d7e..b56f40f28 100644 --- a/tests/services/test_context_service.py +++ b/tests/services/test_context_service.py @@ -212,6 +212,47 @@ async def test_build_context_with_observations(context_service, test_graph): 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."""