Compare commits

..
Author SHA1 Message Date
kartik-mem0andClaude Opus 4.6 b38f2ef45c fix(qdrant): deduplicate $or/$not keys and add icontains debug warning
Memory._process_metadata_filters() renames OR→$or and NOT→$not, but
effective_filters also retains the original OR/NOT keys from the
deepcopy of input_filters. Without dedup, the same sub-conditions were
evaluated twice in the Qdrant filter.

Normalize the filter dict upfront so $or/$not/$and map to OR/NOT/AND
and only the first occurrence is kept. Also replace the fragile
value.get("contains") or value.get("icontains") pattern with explicit
key lookup, and add a logger.debug for icontains explaining that Qdrant
MatchText case sensitivity depends on full-text index configuration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 19:04:42 +05:30
kartik-mem0andClaude Opus 4.6 b1dbd08c63 fix(qdrant): handle $or/$not keys injected by Memory middleware
Memory._process_metadata_filters() renames OR→$or and NOT→$not before
passing filters to the vector store. Without this fix, $or/$not keys
with list values hit _build_field_condition and crash with
ValidationError (MatchValue rejects lists).

Normalize $or→OR, $not→NOT, $and→AND at the start of _create_filter
so both naming conventions work.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:32:57 +05:30
kartik-mem0andClaude Sonnet 4.6 269d784c0f test(qdrant): add edge-case tests for enhanced metadata filtering
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 18:08:31 +05:30
kartik-mem0andClaude Sonnet 4.6 72c2a7f322 fix(qdrant): fix MatchText comment and validate logical operator inputs
Corrects the MatchText comment to accurately describe Qdrant behavior
(tokenized match with a full-text index; exact substring without one),
and adds ValueError validation when AND/OR/NOT filter values are not
lists, replacing unhelpful AttributeError crashes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 18:07:07 +05:30
kartik-mem0andClaude Sonnet 4.6 a930603c12 fix(qdrant): add wildcard * support and update type hints
Wildcard '*' values now return None from _build_field_condition (match any /
field exists semantics), and _create_filter skips None conditions so a
filter containing only wildcards returns None. Updated return type annotations
to Optional[FieldCondition] and Optional[Filter] accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 18:05:48 +05:30
kartik-mem0andClaude Sonnet 4.6 b53507efd3 fix(qdrant): reject mixed range and non-range filter operators
Raise ValueError when a filter dict for a single field combines range
operators (gt/gte/lt/lte) with non-range operators (eq/ne/in/nin/etc),
preventing silent data loss where the non-range conditions were dropped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 18:04:10 +05:30
kartik-mem0andClaude Sonnet 4.6 fb430b1a32 fix(qdrant): raise ValueError for unsupported filter operators
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 18:02:54 +05:30
Longwei Liu 7e0968bbc8 fix(qdrant): implement enhanced metadata filtering operators
The Qdrant vector store's _create_filter only handled simple equality
and a single gte+lte range case, causing ValidationError for all other
operators documented at docs.mem0.ai/open-source/features/metadata-filtering.

Add _build_field_condition to translate the universal filter syntax into
Qdrant-native models:
- Comparison: eq, ne, gt, gte, lt, lte  → MatchValue / MatchExcept / Range
- List:       in, nin                    → MatchAny / MatchExcept
- String:     contains, icontains        → MatchText
- Logical:    AND, OR, NOT              → Filter(must/should/must_not), recursive

Existing simple equality and gte+lte range filters continue to work
unchanged (backward compatible).

Closes #3975
2026-02-25 00:01:24 +08:00
15 changed files with 627 additions and 355 deletions
-5
View File
@@ -7,11 +7,6 @@ mode: "wide"
<Tabs>
<Tab title="Python">
<Update label="2026-03-03" description="v1.0.5">
- **Telemetry Fix**
- Fixed an issue where the PostHog client was initialized even after telemetry was disabled. Although events were not captured, the client was unnecessarily initialized.
</Update>
<Update label="2026-02-17" description="v1.0.4">
**New Features & Updates:**
@@ -107,12 +107,6 @@ client = MemoryClient(api_key="your-api-key")
# Delete all memories for a specific user
client.delete_all(user_id="alice")
# Delete all memories for a specific agent
client.delete_all(agent_id="support-bot")
# Delete all memories for a specific run
client.delete_all(run_id="session-xyz")
```
```javascript JavaScript
@@ -133,48 +127,7 @@ You can also filter by other parameters such as:
- `metadata` (as JSON string)
<Warning>
**Breaking change:** `delete_all` previously wiped all project memories when called with no filters. It now **raises an error** if no filters are provided. Use `"*"` wildcards for intentional bulk deletion (see below).
</Warning>
### Wildcard deletes
Setting a filter to `"*"` deletes **all memories** for that entity type across the entire project. This is an intentionally explicit opt-in to bulk deletion.
<CodeGroup>
```python Python
from mem0 import MemoryClient
client = MemoryClient(api_key="your-api-key")
# Delete all memories across every user in the project
client.delete_all(user_id="*")
# Delete all memories across every agent in the project
client.delete_all(agent_id="*")
# Full project wipe — all four filters must be explicitly set to "*"
client.delete_all(user_id="*", agent_id="*", app_id="*", run_id="*")
```
```javascript JavaScript
import MemoryClient from 'mem0ai';
const client = new MemoryClient({ apiKey: "your-api-key" });
// Delete all memories across every user in the project
client.deleteAll({ user_id: "*" })
.then(result => console.log(result))
.catch(error => console.error(error));
// Full project wipe — all four filters must be explicitly set to "*"
client.deleteAll({ user_id: "*", agent_id: "*", app_id: "*", run_id: "*" })
.then(result => console.log(result))
.catch(error => console.error(error));
```
</CodeGroup>
<Warning>
A full project wipe requires **all four** filters set to `"*"`. Setting only some to `"*"` deletes memories only for those entity types, not the entire project.
`delete_all` requires at least one filter (user, agent, run, or metadata). Calling it with no filters raises an error to prevent accidental data loss.
</Warning>
## Delete with Mem0 OSS
+6 -21
View File
@@ -255,28 +255,13 @@ def delete(
### delete_all() Method
#### Breaking Change — Empty filter no longer silently deletes everything
**Before:** calling `delete_all()` with no filters silently deleted **all memories in the project**.
**After:**
- No filters → raises a validation error (prevents accidental full-project wipe).
- Concrete ID (e.g. `user_id="alice"`) → deletes memories for that entity (unchanged).
- `"*"` for a filter → deletes all memories for that entity type across the project (new).
- All four filters set to `"*"` → explicit full project wipe (new, requires opt-in on every parameter).
This change replaces the silent full-project delete (triggered by an empty or missing filter) with a validation error, and introduces `"*"` wildcards as the intentional path for bulk deletion.
#### No Breaking Changes
```python
# v0.x — no filter silently wiped all project memories
m.delete_all() # DANGER: deleted everything
m.delete_all(user_id="alice") # deleted alice's memories
# v1.x — no filter now raises an error; use "*" for intentional bulk deletes
m.delete_all() # ERROR: at least one filter required
m.delete_all(user_id="alice") # unchanged
m.delete_all(user_id="*") # NEW — delete all users' memories
m.delete_all(user_id="*", agent_id="*", app_id="*", run_id="*") # NEW — full project wipe
# Same signature in both versions
def delete_all(
self,
user_id: str
) -> dict
```
## Platform Client (MemoryClient) Changes
+13 -13
View File
@@ -1180,7 +1180,7 @@
"tags": [
"memories"
],
"description": "Delete memories by filter. At least one filter is required — previously omitting all filters silently deleted everything; now it returns a validation error.",
"description": "Delete memories.",
"operationId": "memories_delete",
"parameters": [
{
@@ -1189,7 +1189,7 @@
"schema": {
"type": "string"
},
"description": "Filter by user ID. Pass `*` to delete memories for all users."
"description": "Filter memories by user ID."
},
{
"name": "agent_id",
@@ -1197,7 +1197,7 @@
"schema": {
"type": "string"
},
"description": "Filter by agent ID. Pass `*` to delete memories for all agents."
"description": "Filter memories by agent ID."
},
{
"name": "app_id",
@@ -1205,7 +1205,7 @@
"schema": {
"type": "string"
},
"description": "Filter by app ID. Pass `*` to delete memories for all apps."
"description": "Filter memories by app ID."
},
{
"name": "run_id",
@@ -1213,7 +1213,7 @@
"schema": {
"type": "string"
},
"description": "Filter by run ID. Pass `*` to delete memories for all runs."
"description": "Filter memories by run ID."
},
{
"name": "metadata",
@@ -1263,15 +1263,15 @@
"x-code-samples": [
{
"lang": "Python",
"source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\", org_id=\"your_org_id\", project_id=\"your_project_id\")\n\n# Delete all memories for a specific user\nclient.delete_all(user_id=\"<user_id>\")\n\n# Delete all memories for every user in the project (wildcard)\nclient.delete_all(user_id=\"*\")\n\n# Full project wipe — all four filters must be explicitly set to \"*\"\nclient.delete_all(user_id=\"*\", agent_id=\"*\", app_id=\"*\", run_id=\"*\")\n\n# NOTE: Calling delete_all() with no filters raises a validation error.\n# At least one filter is required to prevent accidental data loss."
"source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\", org_id=\"your_org_id\", project_id=\"your_project_id\")\n\n# Delete all memories for a specific user\nclient.delete_all(user_id=\"<user_id>\")"
},
{
"lang": "JavaScript",
"source": "// To use the JavaScript SDK, install the package:\n// npm i mem0ai\n\nimport MemoryClient from 'mem0ai';\nconst client = new MemoryClient({ apiKey: \"your-api-key\" });\n\n// Delete all memories for a specific user\nclient.deleteAll({ user_id: \"<user_id>\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));\n\n// Delete all memories for every user in the project (wildcard)\nclient.deleteAll({ user_id: \"*\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));\n\n// Full project wipe — all four filters must be explicitly set to \"*\"\nclient.deleteAll({ user_id: \"*\", agent_id: \"*\", app_id: \"*\", run_id: \"*\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));"
"source": "// To use the JavaScript SDK, install the package:\n// npm i mem0ai\n\nimport MemoryClient from 'mem0ai';\nconst client = new MemoryClient({ apiKey: \"your-api-key\" });\n\n// Delete all memories for a specific user\nclient.deleteAll({ user_id: \"<user_id>\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));"
},
{
"lang": "cURL",
"source": "# Delete memories for a specific user\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/v1/memories/?user_id=<user_id>' \\\n --header 'Authorization: Token <api-key>'\n\n# Delete memories for all users (wildcard)\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/v1/memories/?user_id=*' \\\n --header 'Authorization: Token <api-key>'\n\n# Full project wipe — all four filters must be set to *\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/v1/memories/?user_id=*&agent_id=*&app_id=*&run_id=*' \\\n --header 'Authorization: Token <api-key>'"
"source": "curl --request DELETE \\\n --url https://api.mem0.ai/v1/memories/ \\\n --header 'Authorization: Token <api-key>'"
},
{
"lang": "Go",
@@ -5570,26 +5570,26 @@
},
"DeleteMemoriesInput": {
"type": "object",
"description": "Filters for bulk memory deletion. At least one field is required. Pass \"*\" for a field to delete all memories for that entity type. Set all four to \"*\" for a full project wipe.",
"description": "Input for deleting memories associated with a specific user, agent, app, or run.",
"properties": {
"user_id": {
"type": "string",
"description": "User ID to delete memories for. Pass \"*\" for all users.",
"description": "The unique identifier of the user whose memories should be deleted.",
"nullable": true
},
"agent_id": {
"type": "string",
"description": "Agent ID to delete memories for. Pass \"*\" for all agents.",
"description": "The unique identifier of the agent whose memories should be deleted.",
"nullable": true
},
"app_id": {
"type": "string",
"description": "App ID to delete memories for. Pass \"*\" for all apps.",
"description": "The unique identifier of the application whose memories should be deleted.",
"nullable": true
},
"run_id": {
"type": "string",
"description": "Run ID to delete memories for. Pass \"*\" for all runs.",
"description": "The unique identifier of the run whose memories should be deleted.",
"nullable": true
}
},
-4
View File
@@ -123,10 +123,6 @@ await client.deleteAll({ user_id: "alice" });
</CodeGroup>
<Note>
At least one filter (`user_id`, `agent_id`, `app_id`, or `run_id`) is required — calling `delete_all` with no filters raises an error to prevent accidental data loss. You can pass `"*"` as a value to delete all memories for a given entity type (e.g., `user_id="*"` removes memories for every user). A full project wipe requires all four filters set to `"*"`.
</Note>
### History
Get the history of a specific memory asynchronously.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "mem0ai",
"version": "2.2.4",
"version": "2.2.3",
"description": "The Memory Layer For Your AI Apps",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
@@ -91,7 +91,7 @@
"typescript": "5.5.4"
},
"dependencies": {
"axios": "1.13.6",
"axios": "1.7.7",
"openai": "^4.93.0",
"uuid": "9.0.1",
"zod": "^3.24.1"
+10 -25
View File
@@ -44,8 +44,8 @@ importers:
specifier: 3.1.11
version: 3.1.11
axios:
specifier: 1.13.6
version: 1.13.6
specifier: 1.7.7
version: 1.7.7
cloudflare:
specifier: ^4.2.0
version: 4.3.0(encoding@0.1.13)
@@ -1621,10 +1621,10 @@ packages:
integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==,
}
axios@1.13.6:
axios@1.7.7:
resolution:
{
integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==,
integrity: sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==,
}
babel-jest@29.7.0:
@@ -2560,10 +2560,10 @@ packages:
}
hasBin: true
follow-redirects@1.15.11:
follow-redirects@1.15.9:
resolution:
{
integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==,
integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==,
}
engines: { node: ">=4.0" }
peerDependencies:
@@ -2592,13 +2592,6 @@ packages:
}
engines: { node: ">= 6" }
form-data@4.0.5:
resolution:
{
integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==,
}
engines: { node: ">= 6" }
formdata-node@4.4.1:
resolution:
{
@@ -6573,10 +6566,10 @@ snapshots:
asynckit@0.4.0: {}
axios@1.13.6:
axios@1.7.7:
dependencies:
follow-redirects: 1.15.11
form-data: 4.0.5
follow-redirects: 1.15.9
form-data: 4.0.2
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
@@ -7177,7 +7170,7 @@ snapshots:
kolorist: 1.8.0
read-pkg: 8.1.0
follow-redirects@1.15.11: {}
follow-redirects@1.15.9: {}
foreground-child@3.3.1:
dependencies:
@@ -7193,14 +7186,6 @@ snapshots:
es-set-tostringtag: 2.1.0
mime-types: 2.1.35
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.2
mime-types: 2.1.35
formdata-node@4.4.1:
dependencies:
node-domexception: 1.0.0
+1 -4
View File
@@ -28,7 +28,6 @@ from mem0.memory.telemetry import capture_event
from mem0.memory.utils import (
extract_json,
get_fact_retrieval_messages,
normalize_facts,
parse_messages,
parse_vision_messages,
process_telemetry_filters,
@@ -38,8 +37,8 @@ from mem0.utils.factory import (
EmbedderFactory,
GraphStoreFactory,
LlmFactory,
RerankerFactory,
VectorStoreFactory,
RerankerFactory,
)
# Suppress SWIG deprecation warnings globally
@@ -452,7 +451,6 @@ class Memory(MemoryBase):
# Try extracting JSON from response using built-in function
extracted_json = extract_json(response)
new_retrieved_facts = json.loads(extracted_json)["facts"]
new_retrieved_facts = normalize_facts(new_retrieved_facts)
except Exception as e:
logger.error(f"Error in new_retrieved_facts: {e}")
new_retrieved_facts = []
@@ -1479,7 +1477,6 @@ class AsyncMemory(MemoryBase):
# Try extracting JSON from response using built-in function
extracted_json = extract_json(response)
new_retrieved_facts = json.loads(extracted_json)["facts"]
new_retrieved_facts = normalize_facts(new_retrieved_facts)
except Exception as e:
logger.error(f"Error in new_retrieved_facts: {e}")
new_retrieved_facts = []
+5 -16
View File
@@ -24,18 +24,14 @@ logging.getLogger("urllib3").setLevel(logging.CRITICAL + 1)
class AnonymousTelemetry:
def __init__(self, vector_store=None):
if not MEM0_TELEMETRY:
self.posthog = None
self.user_id = None
return
self.posthog = Posthog(project_api_key=PROJECT_API_KEY, host=HOST)
self.user_id = get_or_create_user_id(vector_store)
def capture_event(self, event_name, properties=None, user_email=None):
if self.posthog is None:
return
if not MEM0_TELEMETRY:
self.posthog.disabled = True
def capture_event(self, event_name, properties=None, user_email=None):
if properties is None:
properties = {}
properties = {
@@ -53,17 +49,13 @@ class AnonymousTelemetry:
self.posthog.capture(distinct_id=distinct_id, event=event_name, properties=properties)
def close(self):
if self.posthog is not None:
self.posthog.shutdown()
self.posthog.shutdown()
client_telemetry = AnonymousTelemetry()
def capture_event(event_name, memory_instance, additional_data=None):
if not MEM0_TELEMETRY:
return
oss_telemetry = AnonymousTelemetry(
vector_store=memory_instance._telemetry_vector_store
if hasattr(memory_instance, "_telemetry_vector_store")
@@ -89,9 +81,6 @@ def capture_event(event_name, memory_instance, additional_data=None):
def capture_client_event(event_name, instance, additional_data=None):
if not MEM0_TELEMETRY:
return
event_data = {
"function": f"{instance.__class__.__module__}.{instance.__class__.__name__}",
}
+1 -28
View File
@@ -1,15 +1,12 @@
import hashlib
import logging
import re
from mem0.configs.prompts import (
AGENT_MEMORY_EXTRACTION_PROMPT,
FACT_RETRIEVAL_PROMPT,
USER_MEMORY_EXTRACTION_PROMPT,
AGENT_MEMORY_EXTRACTION_PROMPT,
)
logger = logging.getLogger(__name__)
def get_fact_retrieval_messages(message, is_agent_memory=False):
"""Get fact retrieval messages based on the memory type.
@@ -55,30 +52,6 @@ def format_entities(entities):
return "\n".join(formatted_lines)
def normalize_facts(raw_facts):
"""Normalize LLM-extracted facts to a list of strings.
Smaller LLMs (e.g. llama3.1:8b) sometimes return facts as objects
like {"fact": "..."} or {"text": "..."} instead of plain strings.
This mirrors the TypeScript FactRetrievalSchema validation.
"""
if not raw_facts:
return []
normalized = []
for item in raw_facts:
if isinstance(item, str):
fact = item
elif isinstance(item, dict):
fact = item.get("fact") or item.get("text")
if fact is None:
logger.warning("Unexpected fact shape from LLM, skipping: %s", item)
continue
else:
fact = str(item)
if fact:
normalized.append(fact)
return normalized
def remove_code_blocks(content: str) -> str:
"""
+125 -8
View File
@@ -1,12 +1,16 @@
import logging
import os
import shutil
from typing import Optional
from qdrant_client import QdrantClient
from qdrant_client.models import (
Distance,
FieldCondition,
Filter,
MatchAny,
MatchExcept,
MatchText,
MatchValue,
PointIdsList,
PointStruct,
@@ -138,26 +142,139 @@ class Qdrant(VectorStoreBase):
]
self.client.upsert(collection_name=self.collection_name, points=points)
def _create_filter(self, filters: dict) -> Filter:
def _build_field_condition(self, key: str, value) -> Optional[FieldCondition]:
"""
Build a single FieldCondition from a key-value filter pair.
Supports the enhanced filter syntax documented at
https://docs.mem0.ai/open-source/features/metadata-filtering
Args:
key (str): The payload field name.
value: A scalar for simple equality, or a dict with one operator key.
Returns:
Optional[FieldCondition]: The Qdrant field condition, or None if the
value is the wildcard '*' (match any / field exists — skip filter).
"""
if not isinstance(value, dict):
if value == "*":
# Wildcard: match any value. Qdrant has no direct "field exists"
# condition via FieldCondition, so we skip this filter (match all).
return None
# Simple equality: {"field": "value"}
return FieldCondition(key=key, match=MatchValue(value=value))
ops = set(value.keys())
range_ops = {"gt", "gte", "lt", "lte"}
non_range_ops = ops - range_ops
if ops & range_ops:
if non_range_ops:
raise ValueError(
f"Cannot mix range operators ({ops & range_ops}) with "
f"non-range operators ({non_range_ops}) for field '{key}'. "
f"Use AND to combine them as separate conditions."
)
range_kwargs = {op: value[op] for op in range_ops if op in value}
return FieldCondition(key=key, range=Range(**range_kwargs))
elif "eq" in value:
return FieldCondition(key=key, match=MatchValue(value=value["eq"]))
elif "ne" in value:
return FieldCondition(key=key, match=MatchExcept(**{"except": [value["ne"]]}))
elif "in" in value:
return FieldCondition(key=key, match=MatchAny(any=value["in"]))
elif "nin" in value:
return FieldCondition(key=key, match=MatchExcept(**{"except": value["nin"]}))
elif "contains" in value or "icontains" in value:
# MatchText: with a full-text index, tokenized matching (all words must appear).
# Without a full-text index, exact substring match.
op = "icontains" if "icontains" in value else "contains"
text = value[op]
if op == "icontains":
logger.debug(
"icontains on field '%s': Qdrant MatchText case sensitivity depends on "
"full-text index configuration. Without a full-text index this behaves "
"as a case-sensitive substring match (same as 'contains').",
key,
)
return FieldCondition(key=key, match=MatchText(text=text))
else:
supported = {"eq", "ne", "gt", "gte", "lt", "lte", "in", "nin", "contains", "icontains"}
raise ValueError(
f"Unsupported filter operator(s) for field '{key}': {ops}. "
f"Supported operators: {supported}"
)
def _create_filter(self, filters: dict) -> Optional[Filter]:
"""
Create a Filter object from the provided filters.
Supports the enhanced filter syntax with comparison operators (eq, ne,
gt, gte, lt, lte), list operators (in, nin), string operators (contains,
icontains), and logical operators (AND, OR, NOT).
Args:
filters (dict): Filters to apply.
Returns:
Filter: The created Filter object.
Filter: The created Filter object, or None if filters is empty.
"""
if not filters:
return None
conditions = []
# Normalize $or/$not/$and → OR/NOT/AND and deduplicate.
# Memory._process_metadata_filters() renames OR→$or and NOT→$not,
# but effective_filters retains the original OR/NOT keys from
# deepcopy(input_filters). Without dedup the same sub-conditions
# would be evaluated twice.
key_map = {"$or": "OR", "$not": "NOT", "$and": "AND"}
normalized = {}
for key, value in filters.items():
if isinstance(value, dict) and "gte" in value and "lte" in value:
conditions.append(FieldCondition(key=key, range=Range(gte=value["gte"], lte=value["lte"])))
norm_key = key_map.get(key, key)
if norm_key not in normalized:
normalized[norm_key] = value
must = []
should = []
must_not = []
for key, value in normalized.items():
if key in ("AND", "OR", "NOT"):
if not isinstance(value, list):
raise ValueError(
f"{key} filter value must be a list of filter dicts, "
f"got {type(value).__name__}"
)
if key == "AND":
for sub in value:
built = self._create_filter(sub)
if built:
must.append(built)
elif key == "OR":
for sub in value:
built = self._create_filter(sub)
if built:
should.append(built)
elif key == "NOT":
for sub in value:
built = self._create_filter(sub)
if built:
must_not.append(built)
else:
conditions.append(FieldCondition(key=key, match=MatchValue(value=value)))
return Filter(must=conditions) if conditions else None
condition = self._build_field_condition(key, value)
if condition is not None:
must.append(condition)
if not any([must, should, must_not]):
return None
return Filter(
must=must or None,
should=should or None,
must_not=must_not or None,
)
def search(self, query: str, vectors: list, limit: int = 5, filters: dict = None) -> list:
"""
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "mem0ai"
version = "1.0.5"
version = "1.0.4"
description = "Long-term memory for AI Agents"
authors = [
{ name = "Mem0", email = "founders@mem0.ai" }
+1 -76
View File
@@ -1,11 +1,9 @@
import json
from unittest.mock import MagicMock, patch
import pytest
from mem0 import Memory
from mem0.configs.base import MemoryConfig
from mem0.memory.utils import normalize_facts
class MockVectorMemory:
@@ -246,77 +244,4 @@ def test_get_all_handles_flat_list_from_postgres(mock_sqlite, mock_llm_factory,
assert len(result) == 2
assert result[0]["memory"] == "Memory 1"
assert result[1]["memory"] == "Memory 2"
@patch('mem0.utils.factory.EmbedderFactory.create')
@patch('mem0.utils.factory.VectorStoreFactory.create')
@patch('mem0.utils.factory.LlmFactory.create')
@patch('mem0.memory.storage.SQLiteManager')
def test_add_infer_with_malformed_llm_facts(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
"""
Repro for: 'list' object has no attribute 'replace' on infer=true.
When an LLM (especially smaller models like llama3.1:8b) returns facts as
objects ({"fact": "..."} or {"text": "..."}) instead of plain strings,
the embedding model's .replace() call crashes with AttributeError.
"""
mock_embedder = MagicMock()
mock_embedder.embed.side_effect = lambda text, action: (_ for _ in ()).throw(
AttributeError("'dict' object has no attribute 'replace'")
) if not isinstance(text, str) else [0.1, 0.2, 0.3]
mock_embedder_factory.return_value = mock_embedder
mock_vector_store = MagicMock()
mock_vector_store.search.return_value = []
mock_vector_factory.return_value = mock_vector_store
# LLM returns malformed facts: dicts instead of strings
malformed_response = json.dumps({
"facts": [
{"fact": "User likes Python"},
{"text": "User is a developer"},
]
})
mock_llm = MagicMock()
mock_llm.generate_response.return_value = malformed_response
mock_llm_factory.return_value = mock_llm
mock_sqlite.return_value = MagicMock()
from mem0.memory.main import Memory as MemoryClass
config = MemoryConfig()
memory = MemoryClass(config)
# This should NOT raise AttributeError
memory._add_to_vector_store(
messages=[{"role": "user", "content": "I like Python and I'm a developer"}],
metadata={"user_id": "test_user"},
filters={"user_id": "test_user"},
infer=True,
)
def test_normalize_facts_plain_strings():
assert normalize_facts(["fact one", "fact two"]) == ["fact one", "fact two"]
def test_normalize_facts_dict_with_fact_key():
assert normalize_facts([{"fact": "User likes Python"}]) == ["User likes Python"]
def test_normalize_facts_dict_with_text_key():
assert normalize_facts([{"text": "User is a developer"}]) == ["User is a developer"]
def test_normalize_facts_mixed():
raw = [
"plain string",
{"fact": "from fact key"},
{"text": "from text key"},
]
assert normalize_facts(raw) == ["plain string", "from fact key", "from text key"]
def test_normalize_facts_filters_empty_strings():
assert normalize_facts(["", "valid", ""]) == ["valid"]
assert result[1]["memory"] == "Memory 2"
+25 -104
View File
@@ -1,114 +1,35 @@
import threading
from unittest.mock import MagicMock, patch
import os
from unittest.mock import patch
import pytest
import mem0.memory.telemetry as telemetry_module
MEM0_TELEMETRY = os.environ.get("MEM0_TELEMETRY", "True")
if isinstance(MEM0_TELEMETRY, str):
MEM0_TELEMETRY = MEM0_TELEMETRY.lower() in ("true", "1", "yes")
class TestTelemetryDisabled:
"""Verify PostHog is never instantiated when telemetry is disabled."""
def test_posthog_not_created_when_disabled(self):
"""Posthog() constructor should never be called when MEM0_TELEMETRY=False."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
with patch("mem0.memory.telemetry.Posthog") as mock_posthog:
at = telemetry_module.AnonymousTelemetry()
mock_posthog.assert_not_called()
assert at.posthog is None
assert at.user_id is None
def test_capture_event_noop_when_disabled(self):
"""capture_event() should return immediately without creating AnonymousTelemetry."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
with patch("mem0.memory.telemetry.AnonymousTelemetry") as mock_cls:
telemetry_module.capture_event("test.event", MagicMock())
mock_cls.assert_not_called()
def test_capture_client_event_noop_when_disabled(self):
"""capture_client_event() should return immediately without calling posthog."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
mock_instance = MagicMock()
mock_client_telemetry = MagicMock()
with patch.object(telemetry_module, "client_telemetry", mock_client_telemetry):
telemetry_module.capture_client_event("test.event", mock_instance)
mock_client_telemetry.capture_event.assert_not_called()
def test_instance_capture_event_noop_when_posthog_is_none(self):
"""AnonymousTelemetry.capture_event() should be a no-op when posthog is None."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
at = telemetry_module.AnonymousTelemetry()
at.capture_event("test.event", {"key": "value"}) # should not raise
def test_close_noop_when_posthog_is_none(self):
"""close() should not raise when posthog is None."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
at = telemetry_module.AnonymousTelemetry()
at.close() # should not raise
def test_no_threads_spawned_when_disabled(self):
"""No consumer threads should be created when telemetry is disabled."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
threads_before = threading.active_count()
telemetry_module.AnonymousTelemetry()
threads_after = threading.active_count()
assert threads_after == threads_before
def use_telemetry():
if os.getenv("MEM0_TELEMETRY", "true").lower() == "true":
return True
return False
class TestTelemetryEnabled:
"""Verify PostHog works normally when telemetry is enabled."""
def test_posthog_created_when_enabled(self):
"""Posthog() should be instantiated when MEM0_TELEMETRY=True."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.Posthog") as mock_posthog:
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="test-user"):
at = telemetry_module.AnonymousTelemetry()
mock_posthog.assert_called_once()
assert at.posthog is not None
assert at.user_id == "test-user"
def test_capture_event_sends_when_enabled(self):
"""capture_event() should create AnonymousTelemetry and call capture when enabled."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.AnonymousTelemetry") as mock_cls:
mock_at = MagicMock()
mock_cls.return_value = mock_at
mock_memory = MagicMock()
mock_memory.config.graph_store.config = None
mock_memory.api_version = "v1"
telemetry_module.capture_event("test.event", mock_memory)
mock_at.capture_event.assert_called_once()
def test_capture_client_event_sends_when_enabled(self):
"""capture_client_event() should call client_telemetry.capture_event when enabled."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
mock_client_telemetry = MagicMock()
with patch.object(telemetry_module, "client_telemetry", mock_client_telemetry):
mock_instance = MagicMock()
mock_instance.user_email = "test@example.com"
telemetry_module.capture_client_event("test.event", mock_instance)
mock_client_telemetry.capture_event.assert_called_once()
@pytest.fixture(autouse=True)
def reset_env():
with patch.dict(os.environ, {}, clear=True):
yield
class TestTelemetryEnvVar:
"""Verify the MEM0_TELEMETRY env var parsing logic."""
def test_telemetry_enabled():
with patch.dict(os.environ, {"MEM0_TELEMETRY": "true"}):
assert use_telemetry() is True
@pytest.mark.parametrize(
"value,expected",
[
("true", True),
("True", True),
("TRUE", True),
("1", True),
("yes", True),
("false", False),
("False", False),
("0", False),
("no", False),
("anything_else", False),
],
)
def test_env_var_parsing(self, value, expected):
result = value.lower() in ("true", "1", "yes")
assert result == expected
def test_telemetry_disabled():
with patch.dict(os.environ, {"MEM0_TELEMETRY": "false"}):
assert use_telemetry() is False
def test_telemetry_default_enabled():
assert use_telemetry() is True
+436
View File
@@ -5,9 +5,15 @@ from unittest.mock import MagicMock
from qdrant_client import QdrantClient
from qdrant_client.models import (
Distance,
FieldCondition,
Filter,
MatchAny,
MatchExcept,
MatchText,
MatchValue,
PointIdsList,
PointStruct,
Range,
VectorParams,
)
@@ -298,3 +304,433 @@ class TestQdrant(unittest.TestCase):
def tearDown(self):
del self.qdrant
class TestQdrantEnhancedFilters(unittest.TestCase):
"""Tests for enhanced metadata filtering operators (issue #3975)."""
def setUp(self):
self.client_mock = MagicMock(spec=QdrantClient)
self.qdrant = Qdrant(
collection_name="test_collection",
embedding_model_dims=128,
client=self.client_mock,
)
# ------------------------------------------------------------------ #
# _build_field_condition #
# ------------------------------------------------------------------ #
def test_simple_equality(self):
"""Plain value maps to MatchValue."""
cond = self.qdrant._build_field_condition("category", "programming")
self.assertIsInstance(cond, FieldCondition)
self.assertEqual(cond.key, "category")
self.assertIsInstance(cond.match, MatchValue)
self.assertEqual(cond.match.value, "programming")
def test_eq_operator(self):
"""{\"eq\": v} maps to MatchValue."""
cond = self.qdrant._build_field_condition("category", {"eq": "programming"})
self.assertIsInstance(cond.match, MatchValue)
self.assertEqual(cond.match.value, "programming")
def test_ne_operator(self):
"""{\"ne\": v} maps to MatchExcept with a single-element list."""
cond = self.qdrant._build_field_condition("category", {"ne": "spam"})
self.assertIsInstance(cond.match, MatchExcept)
self.assertIn("spam", getattr(cond.match, "except_", None) or getattr(cond.match, "except"))
def test_in_operator(self):
"""{\"in\": [...]} maps to MatchAny."""
cond = self.qdrant._build_field_condition("category", {"in": ["a", "b", "c"]})
self.assertIsInstance(cond.match, MatchAny)
self.assertEqual(cond.match.any, ["a", "b", "c"])
def test_nin_operator(self):
"""{\"nin\": [...]} maps to MatchExcept."""
cond = self.qdrant._build_field_condition("status", {"nin": ["deleted", "banned"]})
self.assertIsInstance(cond.match, MatchExcept)
excluded = getattr(cond.match, "except_", None) or getattr(cond.match, "except")
self.assertIn("deleted", excluded)
self.assertIn("banned", excluded)
def test_contains_operator(self):
"""{\"contains\": x} maps to MatchText."""
cond = self.qdrant._build_field_condition("bio", {"contains": "python"})
self.assertIsInstance(cond.match, MatchText)
self.assertEqual(cond.match.text, "python")
def test_icontains_operator(self):
"""{\"icontains\": x} maps to MatchText (same as contains for Qdrant)."""
cond = self.qdrant._build_field_condition("bio", {"icontains": "Python"})
self.assertIsInstance(cond.match, MatchText)
self.assertEqual(cond.match.text, "Python")
def test_gt_operator(self):
"""{\"gt\": v} maps to Range with gt only."""
cond = self.qdrant._build_field_condition("priority", {"gt": 5})
self.assertIsInstance(cond.range, Range)
self.assertEqual(cond.range.gt, 5)
self.assertIsNone(cond.range.gte)
self.assertIsNone(cond.range.lt)
self.assertIsNone(cond.range.lte)
def test_gte_operator(self):
"""{\"gte\": v} maps to Range with gte only."""
cond = self.qdrant._build_field_condition("priority", {"gte": 5})
self.assertIsInstance(cond.range, Range)
self.assertEqual(cond.range.gte, 5)
self.assertIsNone(cond.range.gt)
def test_lt_operator(self):
"""{\"lt\": v} maps to Range with lt only."""
cond = self.qdrant._build_field_condition("priority", {"lt": 10})
self.assertIsInstance(cond.range, Range)
self.assertEqual(cond.range.lt, 10)
def test_lte_operator(self):
"""{\"lte\": v} maps to Range with lte only."""
cond = self.qdrant._build_field_condition("priority", {"lte": 10})
self.assertIsInstance(cond.range, Range)
self.assertEqual(cond.range.lte, 10)
def test_range_gte_lte(self):
"""Combined {\"gte\": x, \"lte\": y} maps to Range with both bounds."""
cond = self.qdrant._build_field_condition("priority", {"gte": 5, "lte": 10})
self.assertIsInstance(cond.range, Range)
self.assertEqual(cond.range.gte, 5)
self.assertEqual(cond.range.lte, 10)
def test_range_gt_lt(self):
"""Open interval {\"gt\": x, \"lt\": y} maps to Range with both bounds."""
cond = self.qdrant._build_field_condition("score", {"gt": 0.5, "lt": 0.9})
self.assertIsInstance(cond.range, Range)
self.assertEqual(cond.range.gt, 0.5)
self.assertEqual(cond.range.lt, 0.9)
# ------------------------------------------------------------------ #
# _create_filter — comparison and list operators #
# ------------------------------------------------------------------ #
def test_create_filter_eq(self):
result = self.qdrant._create_filter({"category": {"eq": "programming"}})
self.assertIsInstance(result, Filter)
self.assertEqual(len(result.must), 1)
self.assertIsInstance(result.must[0].match, MatchValue)
def test_create_filter_ne(self):
result = self.qdrant._create_filter({"category": {"ne": "spam"}})
self.assertIsInstance(result, Filter)
self.assertIsInstance(result.must[0].match, MatchExcept)
def test_create_filter_in(self):
result = self.qdrant._create_filter({"category": {"in": ["prog", "data"]}})
self.assertIsInstance(result, Filter)
self.assertIsInstance(result.must[0].match, MatchAny)
self.assertEqual(result.must[0].match.any, ["prog", "data"])
def test_create_filter_nin(self):
result = self.qdrant._create_filter({"status": {"nin": ["deleted", "banned"]}})
self.assertIsInstance(result, Filter)
self.assertIsInstance(result.must[0].match, MatchExcept)
def test_create_filter_gt(self):
result = self.qdrant._create_filter({"priority": {"gt": 7}})
self.assertIsInstance(result, Filter)
self.assertIsInstance(result.must[0].range, Range)
self.assertEqual(result.must[0].range.gt, 7)
def test_create_filter_gte_lte_range(self):
result = self.qdrant._create_filter({"priority": {"gte": 5, "lte": 9}})
self.assertIsInstance(result, Filter)
r = result.must[0].range
self.assertEqual(r.gte, 5)
self.assertEqual(r.lte, 9)
def test_create_filter_contains(self):
result = self.qdrant._create_filter({"bio": {"contains": "python"}})
self.assertIsInstance(result, Filter)
self.assertIsInstance(result.must[0].match, MatchText)
# ------------------------------------------------------------------ #
# _create_filter — logical operators #
# ------------------------------------------------------------------ #
def test_and_operator(self):
"""AND populates Filter.must with nested Filter objects."""
filters = {"AND": [{"category": "programming"}, {"priority": 10}]}
result = self.qdrant._create_filter(filters)
self.assertIsInstance(result, Filter)
self.assertEqual(len(result.must), 2)
# Each item is a Filter that wraps a single FieldCondition
for item in result.must:
self.assertIsInstance(item, Filter)
def test_or_operator(self):
"""OR populates Filter.should with nested Filter objects."""
filters = {"OR": [{"category": "programming"}, {"category": "data"}]}
result = self.qdrant._create_filter(filters)
self.assertIsInstance(result, Filter)
self.assertEqual(len(result.should), 2)
self.assertIsNone(result.must)
def test_not_operator(self):
"""NOT populates Filter.must_not with nested Filter objects."""
filters = {"NOT": [{"category": "spam"}]}
result = self.qdrant._create_filter(filters)
self.assertIsInstance(result, Filter)
self.assertEqual(len(result.must_not), 1)
self.assertIsNone(result.must)
self.assertIsNone(result.should)
def test_mixed_field_and_and(self):
"""Top-level field conditions and AND can coexist in must."""
filters = {
"user_id": "alice",
"AND": [{"priority": {"gte": 5}}, {"category": "programming"}],
}
result = self.qdrant._create_filter(filters)
self.assertIsInstance(result, Filter)
# user_id FieldCondition + 2 nested AND Filters = 3 items in must
self.assertEqual(len(result.must), 3)
def test_nested_and_or(self):
"""AND containing an OR sub-condition produces correct nesting."""
filters = {
"AND": [
{"OR": [{"category": "prog"}, {"category": "data"}]},
{"priority": {"gt": 3}},
]
}
result = self.qdrant._create_filter(filters)
self.assertIsInstance(result, Filter)
self.assertEqual(len(result.must), 2)
# First item is a Filter with should (the OR)
or_filter = result.must[0]
self.assertIsInstance(or_filter, Filter)
self.assertEqual(len(or_filter.should), 2)
# ------------------------------------------------------------------ #
# Edge cases #
# ------------------------------------------------------------------ #
def test_empty_filter_returns_none(self):
self.assertIsNone(self.qdrant._create_filter({}))
self.assertIsNone(self.qdrant._create_filter(None))
def test_backward_compat_simple_equality(self):
"""Plain scalar equality still works as before."""
result = self.qdrant._create_filter({"user_id": "alice", "run_id": "r1"})
self.assertIsInstance(result, Filter)
keys = [c.key for c in result.must]
self.assertIn("user_id", keys)
self.assertIn("run_id", keys)
def test_backward_compat_gte_lte_range(self):
"""Original gte+lte range filter still produces a Range condition."""
result = self.qdrant._create_filter({"count": {"gte": 5, "lte": 10}})
self.assertIsInstance(result, Filter)
self.assertIsInstance(result.must[0].range, Range)
self.assertEqual(result.must[0].range.gte, 5)
self.assertEqual(result.must[0].range.lte, 10)
def test_unknown_operator_raises_error(self):
"""Unknown operator dict should raise ValueError, not ValidationError."""
with self.assertRaises(ValueError) as ctx:
self.qdrant._build_field_condition("field", {"unknown_op": "foo"})
self.assertIn("Unsupported", str(ctx.exception))
def test_mixed_range_and_non_range_raises_error(self):
"""Mixing range ops with non-range ops should raise ValueError."""
with self.assertRaises(ValueError):
self.qdrant._build_field_condition("priority", {"gte": 5, "ne": 10})
def test_mixed_range_and_eq_raises_error(self):
"""Mixing range ops with eq should raise ValueError."""
with self.assertRaises(ValueError):
self.qdrant._build_field_condition("score", {"gt": 0.5, "eq": 1.0})
def test_wildcard_returns_none(self):
"""Wildcard '*' should return None (skip filter — match any)."""
result = self.qdrant._build_field_condition("category", "*")
self.assertIsNone(result)
def test_create_filter_with_wildcard_skips_it(self):
"""Wildcard fields should be skipped in the final filter."""
result = self.qdrant._create_filter({"category": "*", "user_id": "alice"})
self.assertIsInstance(result, Filter)
self.assertEqual(len(result.must), 1)
self.assertEqual(result.must[0].key, "user_id")
def test_create_filter_only_wildcard_returns_none(self):
"""Filter with only wildcard should return None."""
result = self.qdrant._create_filter({"category": "*"})
self.assertIsNone(result)
def test_and_with_non_list_raises_error(self):
"""AND with non-list value should raise ValueError."""
with self.assertRaises(ValueError):
self.qdrant._create_filter({"AND": "not_a_list"})
def test_or_with_non_list_raises_error(self):
"""OR with non-list value should raise ValueError."""
with self.assertRaises(ValueError):
self.qdrant._create_filter({"OR": {"category": "work"}})
def test_not_with_non_list_raises_error(self):
"""NOT with non-list value should raise ValueError."""
with self.assertRaises(ValueError):
self.qdrant._create_filter({"NOT": "invalid"})
def test_empty_and_list(self):
"""AND with empty list should return None."""
result = self.qdrant._create_filter({"AND": []})
self.assertIsNone(result)
def test_empty_or_list(self):
"""OR with empty list should return None."""
result = self.qdrant._create_filter({"OR": []})
self.assertIsNone(result)
def test_empty_not_list(self):
"""NOT with empty list should return None."""
result = self.qdrant._create_filter({"NOT": []})
self.assertIsNone(result)
def test_deeply_nested_logical(self):
"""3-level nesting: AND > OR > NOT."""
filters = {
"AND": [
{
"OR": [
{"category": "work"},
{"category": "personal"}
]
},
{"priority": {"gte": 5}},
{
"NOT": [
{"status": "archived"}
]
}
]
}
result = self.qdrant._create_filter(filters)
self.assertIsInstance(result, Filter)
self.assertEqual(len(result.must), 3)
def test_boolean_equality(self):
"""Boolean values should work with MatchValue."""
cond = self.qdrant._build_field_condition("active", True)
self.assertIsInstance(cond.match, MatchValue)
self.assertEqual(cond.match.value, True)
def test_integer_equality(self):
"""Integer values should work with MatchValue."""
cond = self.qdrant._build_field_condition("count", 42)
self.assertIsInstance(cond.match, MatchValue)
self.assertEqual(cond.match.value, 42)
def test_eq_with_boolean(self):
"""eq operator with boolean should work."""
cond = self.qdrant._build_field_condition("active", {"eq": False})
self.assertIsInstance(cond.match, MatchValue)
self.assertEqual(cond.match.value, False)
def test_in_with_integers(self):
"""in operator with integer list should use MatchAny."""
cond = self.qdrant._build_field_condition("priority", {"in": [1, 2, 3]})
self.assertIsInstance(cond.match, MatchAny)
self.assertEqual(cond.match.any, [1, 2, 3])
def test_wildcard_inside_and(self):
"""Wildcard inside AND should be skipped, other conditions preserved."""
result = self.qdrant._create_filter({
"AND": [{"category": "*"}, {"user_id": "alice"}]
})
self.assertIsInstance(result, Filter)
# AND produces nested Filters; the wildcard sub-filter returns None and is skipped
# Only the user_id sub-filter remains
self.assertEqual(len(result.must), 1)
def test_empty_dict_value_raises_error(self):
"""Empty dict as filter value should raise ValueError."""
with self.assertRaises(ValueError):
self.qdrant._build_field_condition("field", {})
def test_ne_alone_does_not_trigger_mixed_error(self):
"""ne as sole operator should NOT trigger mixed-operator error (regression guard)."""
cond = self.qdrant._build_field_condition("status", {"ne": "deleted"})
self.assertIsInstance(cond.match, MatchExcept)
def test_eq_with_literal_star(self):
"""eq operator with literal '*' should match the string '*', not wildcard."""
cond = self.qdrant._build_field_condition("category", {"eq": "*"})
self.assertIsInstance(cond.match, MatchValue)
self.assertEqual(cond.match.value, "*")
# ------------------------------------------------------------------ #
# $or / $not normalization (Memory._process_metadata_filters injects #
# these keys alongside the original OR/NOT) #
# ------------------------------------------------------------------ #
def test_dollar_or_handled_as_or(self):
"""$or injected by Memory middleware should be treated as OR."""
filters = {
"user_id": "alice",
"$or": [{"category": "programming"}, {"category": "data"}],
}
result = self.qdrant._create_filter(filters)
self.assertIsInstance(result, Filter)
self.assertIsNotNone(result.should)
self.assertEqual(len(result.should), 2)
def test_dollar_not_handled_as_not(self):
"""$not injected by Memory middleware should be treated as NOT."""
filters = {
"user_id": "alice",
"$not": [{"category": "spam"}],
}
result = self.qdrant._create_filter(filters)
self.assertIsInstance(result, Filter)
self.assertIsNotNone(result.must_not)
self.assertEqual(len(result.must_not), 1)
def test_memory_search_or_shape(self):
"""Simulate exact shape Memory.search() sends for OR filters.
effective_filters keeps the original OR key (via deepcopy of
input_filters) and _process_metadata_filters adds $or with the
same content. _create_filter should deduplicate so only the
first occurrence (OR) is used — exactly 2 should entries.
"""
filters = {
"OR": [{"category": "programming"}, {"category": "data"}],
"user_id": "test_user",
"$or": [{"category": "programming"}, {"category": "data"}],
}
result = self.qdrant._create_filter(filters)
self.assertIsInstance(result, Filter)
self.assertIsNotNone(result.should)
# Deduplicated: OR wins, $or is skipped — exactly 2 entries
self.assertEqual(len(result.should), 2)
def test_memory_search_not_shape(self):
"""Simulate exact shape Memory.search() sends for NOT filters.
Same deduplication as OR: NOT wins, $not is skipped.
"""
filters = {
"NOT": [{"category": "spam"}],
"user_id": "test_user",
"$not": [{"category": "spam"}],
}
result = self.qdrant._create_filter(filters)
self.assertIsInstance(result, Filter)
self.assertIsNotNone(result.must_not)
# Deduplicated: NOT wins, $not is skipped — exactly 1 entry
self.assertEqual(len(result.must_not), 1)
def tearDown(self):
del self.qdrant