From 8c5795705e02d300371ea48696a2bbd4dd2dceca Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:57:51 +0000 Subject: [PATCH] fix: intercept note_type in metadata_filters and route to note_types metadata_filters={"note_type": "note"} returned empty results because note_type is stored in search_index.metadata (set during indexing), NOT in entity.entity_metadata which metadata_filters queries. Add a model_validator to SearchQuery that intercepts note_type from metadata_filters and moves it to the note_types parameter, which correctly queries search_index.metadata via json_extract/jsonb_extract. This fixes both Postgres and SQLite backends with a single schema-level change and handles string or list values, deduplication, and merging with existing note_types. Fixes #642 Co-authored-by: Paul Hernandez Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> --- src/basic_memory/schemas/search.py | 32 +++++++++++++++++++- tests/schemas/test_search.py | 48 ++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/src/basic_memory/schemas/search.py b/src/basic_memory/schemas/search.py index a2b94173..d1b6b931 100644 --- a/src/basic_memory/schemas/search.py +++ b/src/basic_memory/schemas/search.py @@ -9,7 +9,7 @@ The search system supports three primary modes: from typing import Optional, List, Union, Any from datetime import datetime from enum import Enum -from pydantic import BaseModel, field_validator +from pydantic import BaseModel, field_validator, model_validator from basic_memory.schemas.base import Permalink @@ -70,6 +70,36 @@ class SearchQuery(BaseModel): retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS min_similarity: Optional[float] = None # Per-query override for semantic_min_similarity + @model_validator(mode="after") + def intercept_system_fields_in_metadata_filters(self) -> "SearchQuery": + """Route system fields from metadata_filters to their dedicated parameters. + + `note_type` is stored in search_index.metadata (set during indexing), NOT in + entity.entity_metadata. The metadata_filters parameter queries entity.entity_metadata, + so passing note_type there always returns empty results. Intercept it here and + move it to the note_types parameter which correctly queries search_index.metadata. + """ + if not self.metadata_filters: + return self + + note_type_value = self.metadata_filters.pop("note_type", None) + if note_type_value is not None: + # Normalize to list — note_type_value may be a single string or a list + if isinstance(note_type_value, list): + new_note_types = note_type_value + else: + new_note_types = [note_type_value] + + # Merge with any existing note_types values + existing = self.note_types or [] + self.note_types = existing + [t for t in new_note_types if t not in existing] + + # If metadata_filters is now empty, set it to None so no_criteria() works correctly + if not self.metadata_filters: + self.metadata_filters = None + + return self + @field_validator("after_date") @classmethod def validate_date(cls, v: Optional[Union[datetime, str]]) -> Optional[str]: diff --git a/tests/schemas/test_search.py b/tests/schemas/test_search.py index b1fe51e8..fbae7b2a 100644 --- a/tests/schemas/test_search.py +++ b/tests/schemas/test_search.py @@ -102,6 +102,54 @@ def test_relation_result(): assert result.relation_type == "depends_on" +def test_metadata_filters_note_type_routed_to_note_types(): + """metadata_filters with note_type should be intercepted and routed to note_types. + + note_type lives in search_index.metadata, NOT entity.entity_metadata. + Passing it through metadata_filters would query the wrong column and return + empty results. The validator intercepts it and moves it to note_types. + """ + query = SearchQuery(metadata_filters={"note_type": "note"}) + assert query.note_types == ["note"] + assert query.metadata_filters is None + + +def test_metadata_filters_note_type_list_routed_to_note_types(): + """metadata_filters with note_type as list is normalized and merged into note_types.""" + query = SearchQuery(metadata_filters={"note_type": ["note", "spec"]}) + assert query.note_types == ["note", "spec"] + assert query.metadata_filters is None + + +def test_metadata_filters_note_type_merges_with_existing_note_types(): + """note_type from metadata_filters is merged with existing note_types, deduped.""" + query = SearchQuery(note_types=["spec"], metadata_filters={"note_type": "note"}) + assert "spec" in query.note_types + assert "note" in query.note_types + assert query.metadata_filters is None + + +def test_metadata_filters_note_type_deduplicates(): + """note_type already in note_types is not added twice.""" + query = SearchQuery(note_types=["note"], metadata_filters={"note_type": "note"}) + assert query.note_types.count("note") == 1 + assert query.metadata_filters is None + + +def test_metadata_filters_non_system_fields_untouched(): + """metadata_filters with non-system fields are passed through unchanged.""" + query = SearchQuery(metadata_filters={"status": "active", "priority": "high"}) + assert query.metadata_filters == {"status": "active", "priority": "high"} + assert query.note_types is None + + +def test_metadata_filters_mixed_system_and_user_fields(): + """note_type is extracted; remaining metadata_filters are preserved.""" + query = SearchQuery(metadata_filters={"note_type": "spec", "status": "active"}) + assert query.note_types == ["spec"] + assert query.metadata_filters == {"status": "active"} + + def test_search_response(): """Test search response wrapper.""" results = [