Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b38f2ef45c | ||
|
|
b1dbd08c63 | ||
|
|
269d784c0f | ||
|
|
72c2a7f322 | ||
|
|
a930603c12 | ||
|
|
b53507efd3 | ||
|
|
fb430b1a32 | ||
|
|
7e0968bbc8 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-27
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
try:
|
||||
@@ -9,7 +8,6 @@ except ImportError:
|
||||
from mem0.configs.llms.base import BaseLlmConfig
|
||||
from mem0.configs.llms.ollama import OllamaConfig
|
||||
from mem0.llms.base import LLMBase
|
||||
from mem0.memory.utils import extract_json
|
||||
|
||||
|
||||
class OllamaLLM(LLMBase):
|
||||
@@ -63,28 +61,7 @@ class OllamaLLM(LLMBase):
|
||||
"tool_calls": [],
|
||||
}
|
||||
|
||||
if isinstance(response, dict):
|
||||
raw_calls = response.get("message", {}).get("tool_calls") or []
|
||||
else:
|
||||
raw_calls = getattr(response.message, "tool_calls", None) or []
|
||||
|
||||
for tool_call in raw_calls:
|
||||
if isinstance(tool_call, dict):
|
||||
fn = tool_call.get("function", {})
|
||||
name = fn.get("name", "")
|
||||
arguments = fn.get("arguments", {})
|
||||
else:
|
||||
fn = getattr(tool_call, "function", None)
|
||||
name = getattr(fn, "name", "") if fn else ""
|
||||
arguments = getattr(fn, "arguments", {}) if fn else {}
|
||||
|
||||
if isinstance(arguments, str):
|
||||
arguments = json.loads(extract_json(arguments))
|
||||
|
||||
processed_response["tool_calls"].append(
|
||||
{"name": name, "arguments": arguments}
|
||||
)
|
||||
|
||||
# Ollama doesn't support tool calls in the same way, so we return the content
|
||||
return processed_response
|
||||
else:
|
||||
return content
|
||||
@@ -136,8 +113,5 @@ class OllamaLLM(LLMBase):
|
||||
# Remove OpenAI-specific parameters that Ollama doesn't support
|
||||
params.pop("max_tokens", None) # Ollama uses different parameter names
|
||||
|
||||
if tools:
|
||||
params["tools"] = tools
|
||||
|
||||
response = self.client.chat(**params)
|
||||
return self._parse_response(response, tools)
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -32,111 +32,3 @@ def test_generate_response_without_tools(mock_ollama_client):
|
||||
model="llama3.1:70b", messages=messages, options={"temperature": 0.7, "num_predict": 100, "top_p": 1.0}
|
||||
)
|
||||
assert response == "I'm doing well, thank you for asking!"
|
||||
|
||||
|
||||
def test_generate_response_with_tools_passes_tools_to_client(mock_ollama_client):
|
||||
"""Tools should be forwarded to ollama client.chat()."""
|
||||
config = OllamaConfig(model="llama3.1:70b", temperature=0.1, max_tokens=100, top_p=1.0)
|
||||
llm = OllamaLLM(config)
|
||||
messages = [{"role": "user", "content": "Extract entities from: Alice works at UCSD"}]
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "extract_entities",
|
||||
"description": "Extract entities",
|
||||
"parameters": {"type": "object", "properties": {"entities": {"type": "array"}}},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
mock_response = {
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"name": "extract_entities",
|
||||
"arguments": {"entities": [{"name": "Alice"}, {"name": "UCSD"}]},
|
||||
}
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
mock_ollama_client.chat.return_value = mock_response
|
||||
|
||||
response = llm.generate_response(messages, tools=tools)
|
||||
|
||||
# Verify tools were passed to client.chat
|
||||
call_kwargs = mock_ollama_client.chat.call_args
|
||||
assert "tools" in call_kwargs.kwargs or (len(call_kwargs.args) > 0 and "tools" in call_kwargs[1])
|
||||
assert call_kwargs[1]["tools"] == tools
|
||||
|
||||
# Verify tool_calls were parsed correctly
|
||||
assert response["tool_calls"] == [
|
||||
{"name": "extract_entities", "arguments": {"entities": [{"name": "Alice"}, {"name": "UCSD"}]}}
|
||||
]
|
||||
|
||||
|
||||
def test_generate_response_with_tools_no_tool_calls_in_response(mock_ollama_client):
|
||||
"""When model returns content without tool_calls, tool_calls should be empty list."""
|
||||
config = OllamaConfig(model="llama3.1:70b", temperature=0.1, max_tokens=100, top_p=1.0)
|
||||
llm = OllamaLLM(config)
|
||||
messages = [{"role": "user", "content": "Hello"}]
|
||||
tools = [{"type": "function", "function": {"name": "noop", "parameters": {}}}]
|
||||
|
||||
mock_response = {"message": {"content": "I cannot use tools for this.", "tool_calls": []}}
|
||||
mock_ollama_client.chat.return_value = mock_response
|
||||
|
||||
response = llm.generate_response(messages, tools=tools)
|
||||
|
||||
assert response["content"] == "I cannot use tools for this."
|
||||
assert response["tool_calls"] == []
|
||||
|
||||
|
||||
def test_generate_response_with_tools_string_arguments(mock_ollama_client):
|
||||
"""When tool_call arguments come as JSON string, they should be parsed."""
|
||||
config = OllamaConfig(model="llama3.1:70b", temperature=0.1, max_tokens=100, top_p=1.0)
|
||||
llm = OllamaLLM(config)
|
||||
messages = [{"role": "user", "content": "test"}]
|
||||
tools = [{"type": "function", "function": {"name": "test_fn", "parameters": {}}}]
|
||||
|
||||
mock_response = {
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"function": {"name": "test_fn", "arguments": '{"key": "value"}'}}
|
||||
],
|
||||
}
|
||||
}
|
||||
mock_ollama_client.chat.return_value = mock_response
|
||||
|
||||
response = llm.generate_response(messages, tools=tools)
|
||||
|
||||
assert response["tool_calls"] == [{"name": "test_fn", "arguments": {"key": "value"}}]
|
||||
|
||||
|
||||
def test_parse_response_with_tools_object_style(mock_ollama_client):
|
||||
"""Test _parse_response with object-style response (non-dict)."""
|
||||
config = OllamaConfig(model="llama3.1:70b")
|
||||
llm = OllamaLLM(config)
|
||||
|
||||
# Simulate object-style response
|
||||
mock_fn = Mock()
|
||||
mock_fn.name = "extract"
|
||||
mock_fn.arguments = {"entities": ["Alice"]}
|
||||
|
||||
mock_tool_call = Mock()
|
||||
mock_tool_call.function = mock_fn
|
||||
|
||||
mock_message = Mock()
|
||||
mock_message.content = ""
|
||||
mock_message.tool_calls = [mock_tool_call]
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.message = mock_message
|
||||
|
||||
tools = [{"type": "function", "function": {"name": "extract"}}]
|
||||
result = llm._parse_response(mock_response, tools)
|
||||
|
||||
assert result["tool_calls"] == [{"name": "extract", "arguments": {"entities": ["Alice"]}}]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user