Skip to content

Commit 2df4e73

Browse files
phernandezclaude
andcommitted
feat(core): add observation category filter to search
Add a `categories` filter that scopes observation results to exact category matches, mirroring the existing entity_types/note_types filter end-to-end. Previously `entity_types=["observation"]` only narrowed to the observation row type, so a text search for "requirement" still returned [decision] observations whose body merely mentions the word. The new `categories` parameter adds a parameterized `search_index.category IN (...)` predicate so only rows whose indexed category matches exactly survive (in FTS, vector, and hybrid modes). The search_notes docstrings advertised a 'category:observation' token that was never implemented (no token parsing in code); those misleading lines are removed and replaced with the real `categories=[...]` usage. Plumbing: - SearchQuery gains `categories` (included in no_criteria()). - SearchService threads it through _PreparedSearchQuery, has_criteria, _prepared_has_filters, _search_repository, and _count_repository. - SearchRepository protocol + base abstract search/count, plus _search_vector_only and _search_hybrid (filter re-scan path) carry the new param. - SQLite and Postgres _build_fts_query_parts add the parameterized IN condition. - search_notes MCP tool exposes `categories` (with `category` alias) and wires it into SearchQuery and the search_all_projects fan-out. Tests (integration-first, both backends): - tests/repository/test_search_repository.py + test_postgres_search_repository.py: exact-category match excludes a [decision] obs that mentions "requirement". - tests/services/test_search_service.py: categories propagates through _prepare_query/has_criteria and count agrees with search. - tests/mcp/test_tool_search.py: end-to-end via the MCP tool (FTS + count path). Closes #430 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 1667cdc commit 2df4e73

16 files changed

Lines changed: 346 additions & 7 deletions

src/basic_memory/api/v2/routers/search_router.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ async def search(
6464
or query.permalink
6565
or query.permalink_match
6666
),
67-
has_filters=bool(query.note_types or query.entity_types or query.metadata_filters),
67+
has_filters=bool(
68+
query.note_types or query.entity_types or query.categories or query.metadata_filters
69+
),
6870
):
6971
offset = (page - 1) * page_size
7072
exact_count_available = query.retrieval_mode == SearchRetrievalMode.FTS

src/basic_memory/mcp/tools/search.py

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ def _format_search_error_response(
114114
- Boolean NOT: `project NOT archived`
115115
- Grouped: `(project OR planning) AND notes`
116116
- Exact phrases: `"weekly standup meeting"`
117-
- Content-specific: `tag:example` or `category:observation`
117+
- Content-specific: `tag:example`
118118
119119
## Try again with:
120120
```
@@ -181,7 +181,7 @@ def _format_search_error_response(
181181
182182
6. **Try advanced search patterns**:
183183
- Tag search: `search_notes("{project}","tag:your-tag")`
184-
- Category search: `search_notes("{project}","category:observation")`
184+
- Observation category: `search_notes("{project}","{query}", entity_types=["observation"], categories=["requirement"])`
185185
- Pattern matching: `search_notes("{project}","*{query}*", search_type="permalink")`
186186
187187
## Explore what content exists:
@@ -258,7 +258,8 @@ def _format_search_error_response(
258258
- **Boolean**: `term1 AND term2`, `term1 OR term2`, `term1 NOT term2`
259259
- **Phrases**: `"exact phrase"`
260260
- **Grouping**: `(term1 OR term2) AND term3`
261-
- **Patterns**: `tag:example`, `category:observation`"""
261+
- **Tags**: `tag:example`
262+
- **Observation categories**: `entity_types=["observation"], categories=["requirement"]`"""
262263

263264

264265
def _format_search_markdown(result: SearchResponse, project: str, query: str | None) -> str:
@@ -456,6 +457,7 @@ async def _search_all_projects(
456457
output_format: Literal["text", "json"],
457458
note_types: list[str],
458459
entity_types: list[str],
460+
categories: list[str],
459461
after_date: str | None,
460462
metadata_filters: dict[str, Any] | None,
461463
tags: list[str] | None,
@@ -514,6 +516,7 @@ async def _search_all_projects(
514516
output_format="json",
515517
note_types=note_types or None,
516518
entity_types=entity_types or None,
519+
categories=categories or None,
517520
after_date=after_date,
518521
metadata_filters=metadata_filters,
519522
tags=tags,
@@ -612,6 +615,14 @@ async def search_notes(
612615
"'relation'. Defaults to 'entity'. Do NOT pass schema/frontmatter types like "
613616
"'Chapter' here — use note_types instead.",
614617
] = None,
618+
categories: Annotated[
619+
List[str] | None,
620+
BeforeValidator(coerce_list),
621+
Field(default=None, validation_alias=AliasChoices("categories", "category")),
622+
"Filter observation results to these exact categories (e.g. ['requirement']). "
623+
"Pair with entity_types=['observation'] to return only observations whose "
624+
"category matches exactly — not every row mentioning the word.",
625+
] = None,
615626
# Time-filter naming varies wildly across APIs.
616627
after_date: Annotated[
617628
Optional[str],
@@ -666,7 +677,8 @@ async def search_notes(
666677
667678
### Content-Specific Searches
668679
- `search_notes("research", "tag:example")` - Search within specific tags (if supported by content)
669-
- `search_notes("work-project", "category:observation")` - Filter by observation categories
680+
- `search_notes("work-project", "req", entity_types=["observation"], categories=["requirement"])`
681+
- Return only observations whose category is exactly "requirement"
670682
- `search_notes("team-docs", "author:username")` - Find content by author (if metadata available)
671683
672684
**Note:** `tag:` shorthand is automatically converted to a `tags` filter, so it works
@@ -684,6 +696,8 @@ async def search_notes(
684696
- `search_notes("my-project", "query", note_types=["note"])` - Search only notes
685697
- `search_notes("work-docs", "query", note_types=["note", "person"])` - Multiple note types
686698
- `search_notes("research", "query", entity_types=["observation"])` - Filter by entity type
699+
- `search_notes("research", "query", entity_types=["observation"], categories=["requirement"])`
700+
- Filter observations to an exact category
687701
- `search_notes("team-docs", "query", after_date="2024-01-01")` - Recent content only
688702
- `search_notes("my-project", "query", after_date="1 week")` - Relative date filtering
689703
- `search_notes("my-project", "query", tags=["security"])` - Filter by frontmatter tags
@@ -735,6 +749,9 @@ async def search_notes(
735749
"json" returns a machine-readable dictionary payload.
736750
note_types: Optional list of note types to search (e.g., ["note", "person"])
737751
entity_types: Optional list of entity types to filter by (e.g., ["entity", "observation"])
752+
categories: Optional list of observation categories for exact matching (e.g.,
753+
["requirement"]). Pair with entity_types=["observation"] to return only
754+
observations whose category matches exactly.
738755
after_date: Optional date filter for recent content (e.g., "1 week", "2d", "2024-01-01")
739756
metadata_filters: Optional structured frontmatter filters (e.g., {"status": "in-progress"})
740757
tags: Optional tag filter (frontmatter tags); shorthand for metadata_filters["tags"]
@@ -812,6 +829,9 @@ async def search_notes(
812829
# Lowercase note_types so "Chapter" matches the stored "chapter".
813830
note_types = [t.lower() for t in note_types] if note_types else []
814831
entity_types = entity_types or []
832+
# Categories are matched exactly against the indexed observation category,
833+
# so preserve their original casing (unlike the lowercased note_types).
834+
categories = categories or []
815835

816836
# Parse tag:<value> shorthand at tool level so it works with all search modes.
817837
# Handles "tag:security", "tag:coffee tag:brewing", "tag:coffee AND tag:brewing".
@@ -853,6 +873,7 @@ async def search_notes(
853873
output_format=output_format,
854874
note_types=note_types,
855875
entity_types=entity_types,
876+
categories=categories,
856877
after_date=after_date,
857878
metadata_filters=metadata_filters,
858879
tags=tags,
@@ -876,8 +897,15 @@ async def search_notes(
876897
has_query=bool(query and query.strip()),
877898
note_type_filter_count=len(note_types),
878899
entity_type_filter_count=len(entity_types),
900+
category_filter_count=len(categories),
879901
has_filters=bool(
880-
metadata_filters or tags or status or note_types or entity_types or after_date
902+
metadata_filters
903+
or tags
904+
or status
905+
or note_types
906+
or entity_types
907+
or categories
908+
or after_date
881909
),
882910
has_tags_filter=bool(tags),
883911
has_status_filter=bool(status),
@@ -940,6 +968,8 @@ async def search_notes(
940968
# Add optional filters if provided (empty lists are treated as no filter)
941969
if entity_types:
942970
search_query.entity_types = [SearchItemType(t) for t in entity_types]
971+
if categories:
972+
search_query.categories = categories
943973
if note_types:
944974
search_query.note_types = note_types
945975
if after_date:
@@ -965,7 +995,8 @@ async def search_notes(
965995
return (
966996
"# No Search Criteria\n\n"
967997
"Please provide at least one of: `query`, `metadata_filters`, "
968-
"`tags`, `status`, `note_types`, `entity_types`, or `after_date`."
998+
"`tags`, `status`, `note_types`, `entity_types`, `categories`, "
999+
"or `after_date`."
9691000
)
9701001

9711002
# Default to entity-level results to avoid returning individual

src/basic_memory/repository/postgres_search_repository.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,6 +705,7 @@ async def _build_fts_query_parts(
705705
note_types: Optional[List[str]] = None,
706706
after_date: Optional[datetime] = None,
707707
search_item_types: Optional[List[SearchItemType]] = None,
708+
categories: Optional[List[str]] = None,
708709
metadata_filters: Optional[dict] = None,
709710
) -> tuple[str, str, dict, str, str]:
710711
"""Build Postgres FTS FROM/WHERE params shared by search and count."""
@@ -762,6 +763,20 @@ async def _build_fts_query_parts(
762763
type_placeholders.append(f":{param_name}")
763764
conditions.append(f"search_index.type IN ({', '.join(type_placeholders)})")
764765

766+
# Handle observation category filter (parameterized for defense-in-depth).
767+
# Trigger: caller passed `categories` to scope observation results.
768+
# Why: `entity_types=["observation"]` only narrows to the observation row type;
769+
# callers expect exact-category matching, not incidental text matches.
770+
# Outcome: only rows whose indexed category exactly equals a requested value
771+
# survive (entities/relations have NULL category and are excluded).
772+
if categories:
773+
category_placeholders = []
774+
for idx, category in enumerate(categories):
775+
param_name = f"category_{idx}"
776+
params[param_name] = category
777+
category_placeholders.append(f":{param_name}")
778+
conditions.append(f"search_index.category IN ({', '.join(category_placeholders)})")
779+
765780
# Handle note type filter using JSONB containment (parameterized)
766781
if note_types:
767782
type_conditions = []
@@ -879,6 +894,7 @@ async def search(
879894
note_types: Optional[List[str]] = None,
880895
after_date: Optional[datetime] = None,
881896
search_item_types: Optional[List[SearchItemType]] = None,
897+
categories: Optional[List[str]] = None,
882898
metadata_filters: Optional[dict] = None,
883899
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
884900
min_similarity: Optional[float] = None,
@@ -895,6 +911,7 @@ async def search(
895911
note_types=note_types,
896912
after_date=after_date,
897913
search_item_types=search_item_types,
914+
categories=categories,
898915
metadata_filters=metadata_filters,
899916
retrieval_mode=retrieval_mode,
900917
min_similarity=min_similarity,
@@ -919,6 +936,7 @@ async def search(
919936
note_types=note_types,
920937
after_date=after_date,
921938
search_item_types=search_item_types,
939+
categories=categories,
922940
metadata_filters=metadata_filters,
923941
)
924942

@@ -1008,6 +1026,7 @@ async def count(
10081026
note_types: Optional[List[str]] = None,
10091027
after_date: Optional[datetime] = None,
10101028
search_item_types: Optional[List[SearchItemType]] = None,
1029+
categories: Optional[List[str]] = None,
10111030
metadata_filters: Optional[dict] = None,
10121031
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
10131032
min_similarity: Optional[float] = None,
@@ -1022,6 +1041,7 @@ async def count(
10221041
note_types=note_types,
10231042
after_date=after_date,
10241043
search_item_types=search_item_types,
1044+
categories=categories,
10251045
metadata_filters=metadata_filters,
10261046
retrieval_mode=retrieval_mode,
10271047
min_similarity=min_similarity,
@@ -1041,6 +1061,7 @@ async def count(
10411061
note_types=note_types,
10421062
after_date=after_date,
10431063
search_item_types=search_item_types,
1064+
categories=categories,
10441065
metadata_filters=metadata_filters,
10451066
)
10461067
sql = f"SELECT COUNT(*) FROM {from_clause} WHERE {where_clause}"

src/basic_memory/repository/search_repository.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ async def search(
4141
note_types: Optional[List[str]] = None,
4242
after_date: Optional[datetime] = None,
4343
search_item_types: Optional[List[SearchItemType]] = None,
44+
categories: Optional[List[str]] = None,
4445
metadata_filters: Optional[dict] = None,
4546
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
4647
min_similarity: Optional[float] = None,
@@ -59,6 +60,7 @@ async def count(
5960
note_types: Optional[List[str]] = None,
6061
after_date: Optional[datetime] = None,
6162
search_item_types: Optional[List[SearchItemType]] = None,
63+
categories: Optional[List[str]] = None,
6264
metadata_filters: Optional[dict] = None,
6365
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
6466
min_similarity: Optional[float] = None,

src/basic_memory/repository/search_repository_base.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ async def search(
218218
note_types: Optional[List[str]] = None,
219219
after_date: Optional[datetime] = None,
220220
search_item_types: Optional[List[SearchItemType]] = None,
221+
categories: Optional[List[str]] = None,
221222
metadata_filters: Optional[Dict[str, Any]] = None,
222223
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
223224
min_similarity: Optional[float] = None,
@@ -234,6 +235,7 @@ async def search(
234235
note_types: Filter by note types (from metadata.note_type)
235236
after_date: Filter by created_at > after_date
236237
search_item_types: Filter by SearchItemType (ENTITY, OBSERVATION, RELATION)
238+
categories: Filter observations by exact category (e.g. "requirement")
237239
metadata_filters: Structured frontmatter metadata filters
238240
limit: Maximum results to return
239241
offset: Number of results to skip
@@ -256,6 +258,7 @@ async def count(
256258
note_types: Optional[List[str]] = None,
257259
after_date: Optional[datetime] = None,
258260
search_item_types: Optional[List[SearchItemType]] = None,
261+
categories: Optional[List[str]] = None,
259262
metadata_filters: Optional[Dict[str, Any]] = None,
260263
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
261264
min_similarity: Optional[float] = None,
@@ -1785,6 +1788,7 @@ async def _dispatch_retrieval_mode(
17851788
note_types: Optional[List[str]],
17861789
after_date: Optional[datetime],
17871790
search_item_types: Optional[List[SearchItemType]],
1791+
categories: Optional[List[str]],
17881792
metadata_filters: Optional[dict],
17891793
retrieval_mode: SearchRetrievalMode,
17901794
min_similarity: Optional[float] = None,
@@ -1818,6 +1822,7 @@ async def _dispatch_retrieval_mode(
18181822
note_types=note_types,
18191823
after_date=after_date,
18201824
search_item_types=search_item_types,
1825+
categories=categories,
18211826
metadata_filters=metadata_filters,
18221827
min_similarity=min_similarity,
18231828
limit=limit,
@@ -1837,6 +1842,7 @@ async def _dispatch_retrieval_mode(
18371842
note_types=note_types,
18381843
after_date=after_date,
18391844
search_item_types=search_item_types,
1845+
categories=categories,
18401846
metadata_filters=metadata_filters,
18411847
min_similarity=min_similarity,
18421848
limit=limit,
@@ -1866,6 +1872,7 @@ async def _search_vector_only(
18661872
note_types: Optional[List[str]],
18671873
after_date: Optional[datetime],
18681874
search_item_types: Optional[List[SearchItemType]],
1875+
categories: Optional[List[str]],
18691876
metadata_filters: Optional[dict],
18701877
min_similarity: Optional[float] = None,
18711878
limit: int,
@@ -1976,6 +1983,7 @@ def _log_vector_summary() -> None:
19761983
note_types,
19771984
after_date,
19781985
search_item_types,
1986+
categories,
19791987
metadata_filters,
19801988
]
19811989
)
@@ -1989,6 +1997,7 @@ def _log_vector_summary() -> None:
19891997
note_types=note_types,
19901998
after_date=after_date,
19911999
search_item_types=search_item_types,
2000+
categories=categories,
19922001
metadata_filters=metadata_filters,
19932002
retrieval_mode=SearchRetrievalMode.FTS,
19942003
limit=VECTOR_FILTER_SCAN_LIMIT,
@@ -2139,6 +2148,7 @@ async def _search_hybrid(
21392148
note_types: Optional[List[str]],
21402149
after_date: Optional[datetime],
21412150
search_item_types: Optional[List[SearchItemType]],
2151+
categories: Optional[List[str]],
21422152
metadata_filters: Optional[dict],
21432153
min_similarity: Optional[float] = None,
21442154
limit: int,
@@ -2163,6 +2173,7 @@ async def _search_hybrid(
21632173
note_types=note_types,
21642174
after_date=after_date,
21652175
search_item_types=search_item_types,
2176+
categories=categories,
21662177
metadata_filters=metadata_filters,
21672178
retrieval_mode=SearchRetrievalMode.FTS,
21682179
limit=candidate_limit,
@@ -2178,6 +2189,7 @@ async def _search_hybrid(
21782189
note_types=note_types,
21792190
after_date=after_date,
21802191
search_item_types=search_item_types,
2192+
categories=categories,
21812193
metadata_filters=metadata_filters,
21822194
min_similarity=min_similarity,
21832195
limit=candidate_limit,

0 commit comments

Comments
 (0)