From a3c778210b652221489c49f61a99579749152896 Mon Sep 17 00:00:00 2001 From: jp Date: Sat, 18 Apr 2026 10:00:59 -0700 Subject: [PATCH] fix(searcher): guard against None metadata in CLI print path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `col.query(...)` can return `None` entries in the inner ``metadatas`` list for drawers whose metadata was never set (older palaces, rows written outside the normal mining path). The CLI `search()` function would render earlier results successfully and then crash mid-loop with: AttributeError: 'NoneType' object has no attribute 'get' at ``searcher.py:286`` — ``meta.get("source_file", "?")``. The user sees partial output followed by a traceback, with no indication of which drawers rendered OK and which were skipped. Guard with ``meta = meta or {}`` inside the loop so entries with missing metadata fall back to the existing ``"?"`` defaults instead of crashing, matching the hit dict assembly in ``search_memories()`` which already uses ``meta.get("wing", "unknown")`` etc. against the same data. Adds a regression test that mocks a ChromaDB result with a ``None`` metadata entry in the middle of the inner list and asserts both result blocks render to stdout. --- mempalace/searcher.py | 1 + tests/test_searcher.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/mempalace/searcher.py b/mempalace/searcher.py index db809d9..ef951e2 100644 --- a/mempalace/searcher.py +++ b/mempalace/searcher.py @@ -283,6 +283,7 @@ def search(query: str, palace_path: str, wing: str = None, room: str = None, n_r for i, (doc, meta, dist) in enumerate(zip(docs, metas, dists), 1): similarity = round(max(0.0, 1 - dist), 3) + meta = meta or {} source = Path(meta.get("source_file", "?")).name wing_name = meta.get("wing", "?") room_name = meta.get("room", "?") diff --git a/tests/test_searcher.py b/tests/test_searcher.py index 11e788d..127e95f 100644 --- a/tests/test_searcher.py +++ b/tests/test_searcher.py @@ -141,3 +141,22 @@ class TestSearchCLI: captured = capsys.readouterr() # Should have output with at least one result block assert "[1]" in captured.out + + def test_search_handles_none_metadata_without_crash(self, palace_path, capsys): + """ChromaDB can return `None` entries in the metadatas list when a + drawer has no metadata. The CLI print path must not crash on them + mid-render — it used to raise `AttributeError: 'NoneType' object has + no attribute 'get'` after printing earlier results.""" + mock_col = MagicMock() + mock_col.query.return_value = { + "documents": [["first doc", "second doc"]], + "metadatas": [[{"source_file": "a.md", "wing": "w", "room": "r"}, None]], + "distances": [[0.1, 0.2]], + } + with patch("mempalace.searcher.get_collection", return_value=mock_col): + search("anything", "/fake/path") + captured = capsys.readouterr() + assert "[1]" in captured.out + assert "[2]" in captured.out + # Second result renders with fallback '?' values instead of crashing + assert "second doc" in captured.out