Skip to content

Commit 3069462

Browse files
authored
feat: support per-cube filter routing in multi-cube search (#1431)
1 parent 6703bf3 commit 3069462

File tree

4 files changed

+127
-3
lines changed

4 files changed

+127
-3
lines changed

src/memos/multi_mem_cube/single_cube.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
)
2323
from memos.memories.textual.item import TextualMemoryItem
2424
from memos.multi_mem_cube.views import MemCubeView
25-
from memos.search import search_text_memories
25+
from memos.search import resolve_filter_for_cube, search_text_memories
2626
from memos.templates.mem_reader_prompts import PROMPT_MAPPING
2727
from memos.types.general_types import (
2828
FINE_STRATEGY,
@@ -91,6 +91,13 @@ def search_memories(self, search_req: APISearchRequest) -> dict[str, Any]:
9191
Unified memory search handling (text + preference memories).
9292
Preference memories are now searched through the same _search_text flow.
9393
"""
94+
cube_filter = resolve_filter_for_cube(search_req.filter, self.cube_id)
95+
if cube_filter is not search_req.filter:
96+
import copy
97+
98+
search_req = copy.copy(search_req)
99+
search_req.filter = cube_filter
100+
94101
# Create UserContext object
95102
user_context = UserContext(
96103
user_id=search_req.user_id,

src/memos/search/__init__.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
1-
from .search_service import SearchContext, build_search_context, search_text_memories
1+
from .search_service import (
2+
SearchContext,
3+
build_search_context,
4+
resolve_filter_for_cube,
5+
search_text_memories,
6+
)
27

38

4-
__all__ = ["SearchContext", "build_search_context", "search_text_memories"]
9+
__all__ = [
10+
"SearchContext",
11+
"build_search_context",
12+
"resolve_filter_for_cube",
13+
"search_text_memories",
14+
]

src/memos/search/search_service.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,35 @@ def build_search_context(
3636
)
3737

3838

39+
def resolve_filter_for_cube(
40+
raw_filter: dict[str, Any] | None, cube_id: str
41+
) -> dict[str, Any] | None:
42+
"""Resolve a multi-cube filter dict into the sub-filter for a single cube.
43+
44+
Supported forms:
45+
- None → None (no filter)
46+
- {"and": [...]} / {"or": [...]} → returned as-is (unified, all cubes share)
47+
- {"cube_A": {...}, "cube_B": {...}} → return raw_filter[cube_id] or None
48+
Mixed top-level (and/or + cube keys) is rejected.
49+
"""
50+
if raw_filter is None:
51+
return None
52+
53+
has_logic_key = "and" in raw_filter or "or" in raw_filter
54+
other_keys = {k for k in raw_filter if k not in ("and", "or")}
55+
56+
if has_logic_key and other_keys:
57+
raise ValueError(
58+
"Invalid filter: top-level 'and'/'or' cannot coexist with per-cube keys "
59+
f"{other_keys}. Use either a unified filter or per-cube filter, not both."
60+
)
61+
62+
if has_logic_key:
63+
return raw_filter
64+
65+
return raw_filter.get(cube_id)
66+
67+
3968
def search_text_memories(
4069
text_mem: Any,
4170
search_req: APISearchRequest,
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import pytest
2+
3+
from memos.search.search_service import resolve_filter_for_cube
4+
5+
6+
class TestResolveFilterForCube:
7+
"""Tests for resolve_filter_for_cube — multi-cube filter routing."""
8+
9+
# ── None passthrough ──
10+
11+
def test_none_returns_none(self):
12+
assert resolve_filter_for_cube(None, "cube_001") is None
13+
14+
# ── Unified filter (filter2): top-level and/or ──
15+
16+
def test_unified_and_returns_same_for_any_cube(self):
17+
f = {"and": [{"tags": {"contains": "阅读"}}, {"created_at": {"gte": "2025-01-01"}}]}
18+
assert resolve_filter_for_cube(f, "cube_001") is f
19+
assert resolve_filter_for_cube(f, "cube_999") is f
20+
21+
def test_unified_or_returns_same_for_any_cube(self):
22+
f = {"or": [{"tags": {"contains": "A"}}, {"tags": {"contains": "B"}}]}
23+
assert resolve_filter_for_cube(f, "cube_001") is f
24+
25+
# ── Per-cube filter (filter1 / filter4) ──
26+
27+
def test_per_cube_returns_matching_sub_filter(self):
28+
sub_a = {"and": [{"tags": {"contains": "阅读"}}]}
29+
sub_b = {"and": [{"tags": {"contains": "工作"}}]}
30+
f = {"cube_A": sub_a, "cube_B": sub_b}
31+
32+
assert resolve_filter_for_cube(f, "cube_A") is sub_a
33+
assert resolve_filter_for_cube(f, "cube_B") is sub_b
34+
35+
def test_per_cube_missing_key_returns_none(self):
36+
f = {
37+
"cube_A": {"and": [{"tags": {"contains": "阅读"}}]},
38+
"cube_B": {"and": [{"tags": {"contains": "工作"}}]},
39+
}
40+
assert resolve_filter_for_cube(f, "cube_C") is None
41+
42+
def test_per_cube_single_key(self):
43+
sub = {"and": [{"created_at": {"gte": "2025-01-01"}}]}
44+
f = {"cube_only": sub}
45+
assert resolve_filter_for_cube(f, "cube_only") is sub
46+
assert resolve_filter_for_cube(f, "other") is None
47+
48+
# ── Mixed (filter3): illegal ──
49+
50+
def test_mixed_and_with_cube_key_raises(self):
51+
f = {
52+
"and": [{"tags": {"contains": "阅读"}}],
53+
"cube_A": {"and": [{"tags": {"contains": "工作"}}]},
54+
}
55+
with pytest.raises(ValueError, match="cannot coexist"):
56+
resolve_filter_for_cube(f, "cube_A")
57+
58+
def test_mixed_or_with_cube_key_raises(self):
59+
f = {
60+
"or": [{"tags": {"contains": "阅读"}}],
61+
"cube_B": {"and": [{"tags": {"contains": "工作"}}]},
62+
}
63+
with pytest.raises(ValueError, match="cannot coexist"):
64+
resolve_filter_for_cube(f, "cube_B")
65+
66+
# ── Edge cases ──
67+
68+
def test_empty_dict_returns_none(self):
69+
assert resolve_filter_for_cube({}, "cube_001") is None
70+
71+
def test_per_cube_with_empty_sub_filter(self):
72+
f = {"cube_A": {}}
73+
result = resolve_filter_for_cube(f, "cube_A")
74+
assert result == {}
75+
76+
def test_unified_and_empty_list(self):
77+
f = {"and": []}
78+
assert resolve_filter_for_cube(f, "any") is f

0 commit comments

Comments
 (0)