From 9862ef5411e37b97ce97e1f899c60a22d3e74107 Mon Sep 17 00:00:00 2001 From: Drew Cain Date: Mon, 11 May 2026 09:14:06 -0500 Subject: [PATCH] fix(core): use updated_at for recent_activity filter and ordering (#812) Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Signed-off-by: Drew Cain Signed-off-by: phernandez Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 Co-authored-by: phernandez --- .../repository/postgres_search_repository.py | 5 +- .../repository/sqlite_search_repository.py | 3 +- tests/services/test_search_service.py | 77 +++++++++++++++++-- 3 files changed, 76 insertions(+), 9 deletions(-) diff --git a/src/basic_memory/repository/postgres_search_repository.py b/src/basic_memory/repository/postgres_search_repository.py index 8e44f371..fd83217f 100644 --- a/src/basic_memory/repository/postgres_search_repository.py +++ b/src/basic_memory/repository/postgres_search_repository.py @@ -774,7 +774,8 @@ class PostgresSearchRepository(SearchRepositoryBase): # Handle date filter if after_date: params["after_date"] = after_date - conditions.append("search_index.created_at > :after_date") + # Filter on updated_at so recently-edited notes are included even when created_at is old + conditions.append("search_index.updated_at > :after_date") # order by most recent first order_by_clause = ", search_index.updated_at DESC" @@ -945,7 +946,7 @@ class PostgresSearchRepository(SearchRepositoryBase): {score_expr} as score FROM {from_clause} WHERE {where_clause} - ORDER BY score DESC, search_index.id ASC {order_by_clause} + ORDER BY score DESC {order_by_clause}, search_index.id ASC LIMIT :limit OFFSET :offset """ diff --git a/src/basic_memory/repository/sqlite_search_repository.py b/src/basic_memory/repository/sqlite_search_repository.py index 2eaec06c..e18501ee 100644 --- a/src/basic_memory/repository/sqlite_search_repository.py +++ b/src/basic_memory/repository/sqlite_search_repository.py @@ -789,7 +789,8 @@ class SQLiteSearchRepository(SearchRepositoryBase): # Handle date filter using datetime() for proper comparison if after_date: params["after_date"] = after_date - conditions.append("datetime(search_index.created_at) > datetime(:after_date)") + # Filter on updated_at so recently-edited notes are included even when created_at is old + conditions.append("datetime(search_index.updated_at) > datetime(:after_date)") # order by most recent first order_by_clause = ", search_index.updated_at DESC" diff --git a/tests/services/test_search_service.py b/tests/services/test_search_service.py index bc88bdc7..8c23db96 100644 --- a/tests/services/test_search_service.py +++ b/tests/services/test_search_service.py @@ -1,6 +1,6 @@ """Tests for search service.""" -from datetime import datetime +from datetime import datetime, timezone import pytest from sqlalchemy import text @@ -174,12 +174,12 @@ async def test_after_date(search_service, test_graph): ) for r in results: # Handle both string (SQLite) and datetime (Postgres) formats - created_at = ( - r.created_at - if isinstance(r.created_at, datetime) - else datetime.fromisoformat(r.created_at) + updated_at = ( + r.updated_at + if isinstance(r.updated_at, datetime) + else datetime.fromisoformat(r.updated_at) ) - assert created_at > past_date + assert updated_at > past_date # Should not find with future date future_date = datetime(2030, 1, 1).astimezone() @@ -192,6 +192,71 @@ async def test_after_date(search_service, test_graph): assert len(results) == 0 +@pytest.mark.asyncio +async def test_after_date_uses_updated_at(search_service): + """Regression: after_date should filter on updated_at, not created_at. + + An entity created before the timeframe but updated within it must appear + in recent-activity results. A stale entity (updated_at also old) must not. + """ + cutoff = datetime(2020, 1, 1, tzinfo=timezone.utc) + old_created = datetime(2015, 6, 1, tzinfo=timezone.utc) + recently_updated = datetime(2023, 3, 15, tzinfo=timezone.utc) + stale_updated = datetime(2018, 6, 1, tzinfo=timezone.utc) + + project_id = search_service.repository.project_id + + # Leave metadata at its None default — SearchIndexRow.to_insert only + # JSON-serializes truthy metadata, so passing {} would slip an + # un-serialized dict into the SQLite bind and raise ProgrammingError. + recently_updated_row = SearchIndexRow( + project_id=project_id, + id=99001, + type="entity", + file_path="test/recently_updated.md", + title="Recently Updated Entity", + content_snippet="recently updated content", + permalink="test/recently-updated-entity", + created_at=old_created, + updated_at=recently_updated, + ) + stale_row = SearchIndexRow( + project_id=project_id, + id=99002, + type="entity", + file_path="test/stale.md", + title="Stale Entity", + content_snippet="stale content", + permalink="test/stale-entity", + created_at=old_created, + updated_at=stale_updated, + ) + + await search_service.repository.index_item(recently_updated_row) + await search_service.repository.index_item(stale_row) + + results = await search_service.search( + SearchQuery(after_date=cutoff.isoformat()) + ) + + permalinks = {r.permalink for r in results} + # recently-updated entity must appear despite old created_at + assert "test/recently-updated-entity" in permalinks + # stale entity must not appear (updated_at is before cutoff) + assert "test/stale-entity" not in permalinks + + # results should be ordered newest updated_at first + updated_ats = [] + for r in results: + ua = ( + r.updated_at + if isinstance(r.updated_at, datetime) + else datetime.fromisoformat(r.updated_at) + ) + updated_ats.append(ua.replace(tzinfo=timezone.utc) if ua.tzinfo is None else ua) + assert updated_ats == sorted(updated_ats, reverse=True) + + @pytest.mark.asyncio async def test_search_type(search_service, test_graph): """Test search filters."""