Skip to content

Commit 4fe6fe0

Browse files
phernandezclaude
andauthored
feat(core): add observation category filter to search (#908)
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8acdb49 commit 4fe6fe0

18 files changed

Lines changed: 395 additions & 8 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: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ def _format_search_error_response(
155155
- Boolean NOT: `project NOT archived`
156156
- Grouped: `(project OR planning) AND notes`
157157
- Exact phrases: `"weekly standup meeting"`
158-
- Content-specific: `tag:example` or `category:observation`
158+
- Content-specific: `tag:example`
159159
160160
## Try again with:
161161
```
@@ -222,7 +222,7 @@ def _format_search_error_response(
222222
223223
6. **Try advanced search patterns**:
224224
- Tag search: `search_notes("{project}","tag:your-tag")`
225-
- Category search: `search_notes("{project}","category:observation")`
225+
- Observation category: `search_notes("{project}","{query}", entity_types=["observation"], categories=["requirement"])`
226226
- Pattern matching: `search_notes("{project}","*{query}*", search_type="permalink")`
227227
228228
## Explore what content exists:
@@ -299,7 +299,8 @@ def _format_search_error_response(
299299
- **Boolean**: `term1 AND term2`, `term1 OR term2`, `term1 NOT term2`
300300
- **Phrases**: `"exact phrase"`
301301
- **Grouping**: `(term1 OR term2) AND term3`
302-
- **Patterns**: `tag:example`, `category:observation`"""
302+
- **Tags**: `tag:example`
303+
- **Observation categories**: `entity_types=["observation"], categories=["requirement"]`"""
303304

304305

305306
def _format_search_markdown(result: SearchResponse, project: str, query: str | None) -> str:
@@ -497,6 +498,7 @@ async def _search_all_projects(
497498
output_format: Literal["text", "json"],
498499
note_types: list[str],
499500
entity_types: list[str],
501+
categories: list[str],
500502
after_date: str | None,
501503
metadata_filters: dict[str, Any] | None,
502504
tags: list[str] | None,
@@ -555,6 +557,7 @@ async def _search_all_projects(
555557
output_format="json",
556558
note_types=note_types or None,
557559
entity_types=entity_types or None,
560+
categories=categories or None,
558561
after_date=after_date,
559562
metadata_filters=metadata_filters,
560563
tags=tags,
@@ -653,6 +656,14 @@ async def search_notes(
653656
"'relation'. Defaults to 'entity'. Do NOT pass schema/frontmatter types like "
654657
"'Chapter' here — use note_types instead.",
655658
] = None,
659+
categories: Annotated[
660+
List[str] | None,
661+
BeforeValidator(coerce_list),
662+
Field(default=None, validation_alias=AliasChoices("categories", "category")),
663+
"Filter observation results to these exact categories (e.g. ['requirement']). "
664+
"Pair with entity_types=['observation'] to return only observations whose "
665+
"category matches exactly — not every row mentioning the word.",
666+
] = None,
656667
# Time-filter naming varies wildly across APIs.
657668
after_date: Annotated[
658669
Optional[str],
@@ -707,7 +718,8 @@ async def search_notes(
707718
708719
### Content-Specific Searches
709720
- `search_notes("research", "tag:example")` - Search within specific tags (if supported by content)
710-
- `search_notes("work-project", "category:observation")` - Filter by observation categories
721+
- `search_notes("work-project", "req", entity_types=["observation"], categories=["requirement"])`
722+
- Return only observations whose category is exactly "requirement"
711723
- `search_notes("team-docs", "author:username")` - Find content by author (if metadata available)
712724
713725
**Note:** `tag:` shorthand is automatically converted to a `tags` filter, so it works
@@ -725,6 +737,8 @@ async def search_notes(
725737
- `search_notes("my-project", "query", note_types=["note"])` - Search only notes
726738
- `search_notes("work-docs", "query", note_types=["note", "person"])` - Multiple note types
727739
- `search_notes("research", "query", entity_types=["observation"])` - Filter by entity type
740+
- `search_notes("research", "query", entity_types=["observation"], categories=["requirement"])`
741+
- Filter observations to an exact category
728742
- `search_notes("team-docs", "query", after_date="2024-01-01")` - Recent content only
729743
- `search_notes("my-project", "query", after_date="1 week")` - Relative date filtering
730744
- `search_notes("my-project", "query", tags=["security"])` - Filter by frontmatter tags
@@ -776,6 +790,9 @@ async def search_notes(
776790
"json" returns a machine-readable dictionary payload.
777791
note_types: Optional list of note types to search (e.g., ["note", "person"])
778792
entity_types: Optional list of entity types to filter by (e.g., ["entity", "observation"])
793+
categories: Optional list of observation categories for exact matching (e.g.,
794+
["requirement"]). Pair with entity_types=["observation"] to return only
795+
observations whose category matches exactly.
779796
after_date: Optional date filter for recent content (e.g., "1 week", "2d", "2024-01-01")
780797
metadata_filters: Optional structured frontmatter filters (e.g., {"status": "in-progress"})
781798
tags: Optional tag filter (frontmatter tags); shorthand for metadata_filters["tags"]
@@ -853,6 +870,9 @@ async def search_notes(
853870
# Lowercase note_types so "Chapter" matches the stored "chapter".
854871
note_types = [t.lower() for t in note_types] if note_types else []
855872
entity_types = entity_types or []
873+
# Categories are matched exactly against the indexed observation category,
874+
# so preserve their original casing (unlike the lowercased note_types).
875+
categories = categories or []
856876

857877
# Parse tag:<value> shorthand at tool level so it works with all search modes.
858878
# Handles "tag:security", "tag:coffee tag:brewing", "tag:coffee AND tag:brewing".
@@ -894,6 +914,7 @@ async def search_notes(
894914
output_format=output_format,
895915
note_types=note_types,
896916
entity_types=entity_types,
917+
categories=categories,
897918
after_date=after_date,
898919
metadata_filters=metadata_filters,
899920
tags=tags,
@@ -917,8 +938,15 @@ async def search_notes(
917938
has_query=bool(query and query.strip()),
918939
note_type_filter_count=len(note_types),
919940
entity_type_filter_count=len(entity_types),
941+
category_filter_count=len(categories),
920942
has_filters=bool(
921-
metadata_filters or tags or status or note_types or entity_types or after_date
943+
metadata_filters
944+
or tags
945+
or status
946+
or note_types
947+
or entity_types
948+
or categories
949+
or after_date
922950
),
923951
has_tags_filter=bool(tags),
924952
has_status_filter=bool(status),
@@ -981,6 +1009,8 @@ async def search_notes(
9811009
# Add optional filters if provided (empty lists are treated as no filter)
9821010
if entity_types:
9831011
search_query.entity_types = [SearchItemType(t) for t in entity_types]
1012+
if categories:
1013+
search_query.categories = categories
9841014
if note_types:
9851015
search_query.note_types = note_types
9861016
if after_date:
@@ -1006,15 +1036,26 @@ async def search_notes(
10061036
return (
10071037
"# No Search Criteria\n\n"
10081038
"Please provide at least one of: `query`, `metadata_filters`, "
1009-
"`tags`, `status`, `note_types`, `entity_types`, or `after_date`."
1039+
"`tags`, `status`, `note_types`, `entity_types`, `categories`, "
1040+
"or `after_date`."
10101041
)
10111042

10121043
# Default to entity-level results to avoid returning individual
10131044
# observations/relations as separate search results (see issue #31).
10141045
# Applied after no_criteria() so that the implicit default doesn't
10151046
# mask a truly empty search request.
10161047
if not search_query.entity_types:
1017-
search_query.entity_types = [SearchItemType("entity")]
1048+
# Trigger: a category filter was supplied without an explicit
1049+
# entity_types.
1050+
# Why: categories only exist on observations — defaulting to "entity"
1051+
# (whose rows have NULL category) would AND a category filter against
1052+
# entity rows and return nothing, defeating a category-only search.
1053+
# Outcome: scope the implicit default to observations so
1054+
# search_notes(categories=[...]) returns the matching bullets.
1055+
if search_query.categories:
1056+
search_query.entity_types = [SearchItemType("observation")]
1057+
else:
1058+
search_query.entity_types = [SearchItemType("entity")]
10181059

10191060
logger.debug(
10201061
f"Search request: project={active_project.name} "

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
@@ -42,6 +42,7 @@ async def search(
4242
note_types: Optional[List[str]] = None,
4343
after_date: Optional[datetime] = None,
4444
search_item_types: Optional[List[SearchItemType]] = None,
45+
categories: Optional[List[str]] = None,
4546
metadata_filters: Optional[dict] = None,
4647
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
4748
min_similarity: Optional[float] = None,
@@ -60,6 +61,7 @@ async def count(
6061
note_types: Optional[List[str]] = None,
6162
after_date: Optional[datetime] = None,
6263
search_item_types: Optional[List[SearchItemType]] = None,
64+
categories: Optional[List[str]] = None,
6365
metadata_filters: Optional[dict] = None,
6466
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
6567
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)