diff --git a/memory-repository/argi-model-chat-memory-repository-mem0/src/main/java/io/github/agentic/ai/memory/mem0/core/Mem0FilterExpressionConverter.java b/memory-repository/argi-model-chat-memory-repository-mem0/src/main/java/io/github/agentic/ai/memory/mem0/core/Mem0FilterExpressionConverter.java index 9d0f6d4ad..df2f9c70a 100644 --- a/memory-repository/argi-model-chat-memory-repository-mem0/src/main/java/io/github/agentic/ai/memory/mem0/core/Mem0FilterExpressionConverter.java +++ b/memory-repository/argi-model-chat-memory-repository-mem0/src/main/java/io/github/agentic/ai/memory/mem0/core/Mem0FilterExpressionConverter.java @@ -18,7 +18,6 @@ import org.springframework.ai.vectorstore.filter.Filter; import org.springframework.ai.vectorstore.filter.converter.AbstractFilterExpressionConverter; -import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -29,7 +28,7 @@ * Mem0 Filter Converter * * Converts Spring AI's Filter.Expression into a Map format supported by the Mem0 API. - * Reference: https://docs.mem0.ai/api-reference/memory/v2-search-memories + * Reference: https://docs.mem0.ai/open-source/features/metadata-filtering */ public class Mem0FilterExpressionConverter extends AbstractFilterExpressionConverter { @@ -110,51 +109,46 @@ public static Filter.Expression wildcardExpression(String field) { @Override public String convertExpression(Filter.Expression expression) { + return mapToJson(convertToMap(expression)); + } + + private Map convertToMap(Filter.Expression expression) { switch (expression.type()) { - case AND: - case OR: - case NOT: - return convertLogicalOperator(expression); - case EQ: - case NE: - case GT: - case GTE: - case LT: - case LTE: - case IN: - case NIN: - return convertComparisonOperator(expression); - default: - throw new IllegalArgumentException("Unsupported operator: " + expression.type()); + case AND: + case OR: + case NOT: + return convertLogicalOperator(expression); + case EQ: + case NE: + case GT: + case GTE: + case LT: + case LTE: + case IN: + case NIN: + return convertComparisonOperator(expression); + default: + throw new IllegalArgumentException("Unsupported operator: " + expression.type()); } } - private String convertLogicalOperator(Filter.Expression expression) { - Map result = new HashMap<>(); - switch (expression.type()) { - case AND: - case OR: { - List expressionsList = new ArrayList<>(); - if (expression.left() instanceof Filter.Expression) { - expressionsList.add(convertExpression((Filter.Expression) expression.left())); - } - if (expression.right() instanceof Filter.Expression) { - expressionsList.add(convertExpression((Filter.Expression) expression.right())); - } - result.put(expression.type().name(), expressionsList); - break; + private Map convertLogicalOperator(Filter.Expression expression) { + if (!(expression.left() instanceof Filter.Expression left)) { + throw new IllegalArgumentException("Invalid expression structure for logical operator"); } - case NOT: { - if (expression.left() instanceof Filter.Expression) { - result.put("NOT", convertExpression((Filter.Expression) expression.left())); + if (expression.type() == Filter.ExpressionType.NOT) { + if (expression.right() != null) { + throw new IllegalArgumentException("NOT requires exactly one expression operand"); } - break; + return Map.of("NOT", List.of(convertToMap(left))); } + if (!(expression.right() instanceof Filter.Expression right)) { + throw new IllegalArgumentException("Invalid expression structure for logical operator"); } - return mapToJson(result); + return Map.of(expression.type().name(), List.of(convertToMap(left), convertToMap(right))); } - private String convertComparisonOperator(Filter.Expression expression) { + private Map convertComparisonOperator(Filter.Expression expression) { Map result = new HashMap<>(); String fieldName = null; Object value = null; @@ -168,34 +162,34 @@ private String convertComparisonOperator(Filter.Expression expression) { throw new IllegalArgumentException("Invalid expression structure for comparison operator"); } switch (expression.type()) { - case EQ: - result.put(fieldName, value); - break; - case NE: - result.put(fieldName, Map.of("ne", value)); - break; - case GT: - result.put(fieldName, Map.of("gt", value)); - break; - case GTE: - result.put(fieldName, Map.of("gte", value)); - break; - case LT: - result.put(fieldName, Map.of("lt", value)); - break; - case LTE: - result.put(fieldName, Map.of("lte", value)); - break; - case IN: - result.put(fieldName, Map.of("in", value)); - break; - case NIN: - result.put(fieldName, Map.of("nin", value)); - break; - default: - throw new IllegalArgumentException("Unsupported comparison operator: " + expression.type()); + case EQ: + result.put(fieldName, value); + break; + case NE: + result.put(fieldName, Map.of("ne", value)); + break; + case GT: + result.put(fieldName, Map.of("gt", value)); + break; + case GTE: + result.put(fieldName, Map.of("gte", value)); + break; + case LT: + result.put(fieldName, Map.of("lt", value)); + break; + case LTE: + result.put(fieldName, Map.of("lte", value)); + break; + case IN: + result.put(fieldName, Map.of("in", value)); + break; + case NIN: + result.put(fieldName, Map.of("nin", value)); + break; + default: + throw new IllegalArgumentException("Unsupported comparison operator: " + expression.type()); } - return mapToJson(result); + return result; } private String mapToJson(Map map) { @@ -217,13 +211,14 @@ protected void doKey(Filter.Key filterKey, StringBuilder context) { // Optional: Implement key-to-string conversion } - @Override - protected void doSingleValue(Object value, StringBuilder context) { - if (value instanceof String) { - context.append(String.format("\"%s\"", value)); - } else { - context.append(value); - } - } + @Override + protected void doSingleValue(Object value, StringBuilder context) { + if (value instanceof String) { + context.append(String.format("\"%s\"", value)); + } + else { + context.append(value); + } + } } diff --git a/memory-repository/argi-model-chat-memory-repository-mem0/src/test/java/io/github/agentic/ai/memory/mem0/core/Mem0FilterExpressionConverterTest.java b/memory-repository/argi-model-chat-memory-repository-mem0/src/test/java/io/github/agentic/ai/memory/mem0/core/Mem0FilterExpressionConverterTest.java new file mode 100644 index 000000000..102e82e11 --- /dev/null +++ b/memory-repository/argi-model-chat-memory-repository-mem0/src/test/java/io/github/agentic/ai/memory/mem0/core/Mem0FilterExpressionConverterTest.java @@ -0,0 +1,124 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.agentic.ai.memory.mem0.core; + +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.ai.vectorstore.filter.Filter; +import tools.jackson.databind.json.JsonMapper; + +import static io.github.agentic.ai.memory.mem0.core.Mem0FilterExpressionConverter.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class Mem0FilterExpressionConverterTest { + + private static final JsonMapper JSON = JsonMapper.shared(); + + private final Mem0FilterExpressionConverter converter = new Mem0FilterExpressionConverter(); + + @ParameterizedTest + @MethodSource("logicalExpressions") + void preservesLogicalObjectsAndOperandOrder(Filter.Expression expression, Map expected) { + assertThat(JSON.readTree(converter.convertExpression(expression))).isEqualTo(JSON.valueToTree(expected)); + } + + static Stream logicalExpressions() { + return Stream.of( + Arguments.of(and(eq("category", "private"), gt("score", 4)), Map.of("AND", + List.of(Map.of("category", "private"), Map.of("score", Map.of("gt", 4))))), + Arguments + .of(or(eq("category", "private"), eq("category", + "public")), Map.of("OR", + List.of(Map.of("category", "private"), Map.of("category", "public")))), + Arguments.of(not(eq("archived", + true)), Map.of("NOT", + List.of(Map.of("archived", true)))), + Arguments.of( + and(or(in("category", List.of("private", + "shared")), eq("owner", + "me")), + not(nin("score", List.of(1, 2)))), + Map.of("AND", List.of( + Map.of("OR", + List.of(Map.of("category", Map.of("in", List.of("private", "shared"))), + Map.of("owner", "me"))), + Map.of("NOT", List.of(Map.of("score", Map.of("nin", List.of(1, 2)))))))), + Arguments.of(not(and(gte("score", 2), lte("score", 8))), Map.of("NOT", List.of(Map.of("AND", + List.of(Map.of("score", Map.of("gte", 2)), Map.of("score", Map.of("lte", 8)))))))); + } + + @ParameterizedTest + @MethodSource("comparisons") + void preservesComparisonValues(Filter.Expression expression, Map expected) { + assertThat(JSON.readTree(converter.convertExpression(expression))).isEqualTo(JSON.valueToTree(expected)); + } + + static Stream comparisons() { + return Stream.of(Arguments.of(eq("flag", false), Map.of("flag", false)), + Arguments.of(ne("category", "private"), Map.of("category", Map.of("ne", "private"))), + Arguments.of(gt("score", 2), Map.of("score", Map.of("gt", 2))), + Arguments.of(gte("score", 2), Map.of("score", Map.of("gte", 2))), + Arguments.of(lt("score", 2), Map.of("score", Map.of("lt", 2))), + Arguments.of(lte("score", 2), Map.of("score", Map.of("lte", 2))), + Arguments.of(in("tags", List.of("one", "two")), Map.of("tags", Map.of("in", List.of("one", "two")))), + Arguments.of(nin("tags", List.of()), Map.of("tags", Map.of("nin", List.of())))); + } + + @Test + void roundTripsSpecialCharactersAndNonAsciiAtEveryDepth() { + String key = "字段\"\\"; + String value = "引号\"路径\\目录\n\t"; + assertThat(JSON.readTree(converter.convertExpression(and(eq(key, value), not(in("tags", List.of(value))))))) + .isEqualTo(JSON.valueToTree(Map.of("AND", List.of(Map.of(key, value), + Map.of("NOT", List.of(Map.of("tags", Map.of("in", List.of(value))))))))); + } + + @ParameterizedTest + @MethodSource("invalidExpressions") + void rejectsInvalidOperandsInsteadOfDroppingConditions(Filter.Expression expression) { + assertThatThrownBy(() -> converter.convertExpression(expression)).isInstanceOf(IllegalArgumentException.class); + } + + static Stream invalidExpressions() { + return Stream.of(Arguments.of(new Filter.Expression(Filter.ExpressionType.AND, eq("a", 1), null)), + Arguments.of(new Filter.Expression(Filter.ExpressionType.OR, new Filter.Key("a"), eq("b", 2))), + Arguments.of(new Filter.Expression(Filter.ExpressionType.NOT, new Filter.Key("a"))), + Arguments.of(new Filter.Expression(Filter.ExpressionType.NOT, eq("a", 1), eq("b", 2))), + Arguments + .of(new Filter.Expression(Filter.ExpressionType.AND, new Filter.Group(eq("a", 1)), eq("b", 2))), + Arguments.of(new Filter.Expression(Filter.ExpressionType.EQ, new Filter.Value(1), new Filter.Value(2))), + Arguments + .of(new Filter.Expression(Filter.ExpressionType.EQ, new Filter.Key("a"), new Filter.Value(null))), + Arguments.of(new Filter.Expression(Filter.ExpressionType.ISNULL, new Filter.Key("a"))), + Arguments.of(new Filter.Expression(Filter.ExpressionType.ISNOTNULL, new Filter.Key("a")))); + } + + @Test + void preservesDirectMem0Helpers() { + assertThat(icontains("text", "Needle")).isEqualTo(Map.of("text", Map.of("icontains", "Needle"))); + assertThat(wildcard("field")).isEqualTo(Map.of("field", "*")); + assertThat(JSON.readTree(converter.convertExpression(wildcardExpression("field")))) + .isEqualTo(JSON.valueToTree(Map.of("field", "*"))); + } + +} diff --git a/memory-repository/argi-model-chat-memory-repository-mem0/src/test/java/io/github/agentic/ai/memory/mem0/core/Mem0FilterHttpRequestTest.java b/memory-repository/argi-model-chat-memory-repository-mem0/src/test/java/io/github/agentic/ai/memory/mem0/core/Mem0FilterHttpRequestTest.java new file mode 100644 index 000000000..4a0e0876f --- /dev/null +++ b/memory-repository/argi-model-chat-memory-repository-mem0/src/test/java/io/github/agentic/ai/memory/mem0/core/Mem0FilterHttpRequestTest.java @@ -0,0 +1,150 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.agentic.ai.memory.mem0.core; + +import com.sun.net.httpserver.HttpServer; +import io.github.agentic.ai.memory.mem0.model.Mem0ServerRequest; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.ai.vectorstore.filter.Filter; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +import static io.github.agentic.ai.memory.mem0.core.Mem0FilterExpressionConverter.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** Tests the real Java HTTP client against a loopback stub, not a Mem0 server. */ +class Mem0FilterHttpRequestTest { + + private static final JsonMapper JSON = JsonMapper.shared(); + + private final AtomicReference requestBody = new AtomicReference<>(); + + private HttpServer server; + + private Mem0MemoryStore store; + + @BeforeEach + void setUp() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/search", exchange -> { + try { + assertThat(exchange.getRequestMethod()).isEqualTo("POST"); + assertThat(exchange.getRequestHeaders().getFirst("Content-Type")).startsWith("application/json"); + requestBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + byte[] response = "{\"results\":[],\"relations\":[]}".getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, response.length); + exchange.getResponseBody().write(response); + } + finally { + exchange.close(); + } + }); + server.start(); + Mem0Client config = Mem0Client.builder() + .baseUrl("http://127.0.0.1:" + server.getAddress().getPort()) + .timeoutSeconds(5) + .maxRetryAttempts(0) + .build(); + config.getAsync().setEnabled(false); + store = Mem0MemoryStore + .builder(new Mem0ServiceClient(config, Mem0Server.builder().build(), new DefaultResourceLoader())) + .build(); + } + + @AfterEach + void tearDown() { + if (server != null) { + server.stop(0); + } + } + + @ParameterizedTest + @MethodSource("scopes") + void sendsStructuredFiltersAndPreservesEntityScopes(Map scope) { + Filter.Expression expression = and(or(eq("category", "私密\"\\"), gt("score", 3)), + not(in("tags", List.of("hidden", "归档")))); + Mem0ServerRequest.SearchRequest request = Mem0ServerRequest.SearchRequest.mem0Builder() + .query("寻找记忆") + .userId(scope.get("user_id")) + .agentId(scope.get("agent_id")) + .runId(scope.get("run_id")) + .topK(3) + .filterExpression(expression) + .build(); + + assertThat(store.similaritySearch(request)).isEmpty(); + + JsonNode body = JSON.readTree(requestBody.get()); + assertThat(body.get("query").asString()).isEqualTo("寻找记忆"); + for (String entity : List.of("user_id", "agent_id", "run_id")) { + if (scope.containsKey(entity)) { + assertThat(body.get(entity).asString()).isEqualTo(scope.get(entity)); + } + else { + assertThat(body.has(entity)).isFalse(); + } + } + assertThat(body.get("filters")).isEqualTo(JSON.valueToTree(Map.of("AND", + List.of(Map.of("OR", List.of(Map.of("category", "私密\"\\"), Map.of("score", Map.of("gt", 3)))), + Map.of("NOT", List.of(Map.of("tags", Map.of("in", List.of("hidden", "归档"))))))))); + assertThat(request.getUserId()).isEqualTo(scope.get("user_id")); + assertThat(request.getAgentId()).isEqualTo(scope.get("agent_id")); + assertThat(request.getRunId()).isEqualTo(scope.get("run_id")); + } + + static Stream scopes() { + return Stream.of(Arguments.of(Map.of("user_id", "user-1")), Arguments.of(Map.of("agent_id", "agent-1")), + Arguments.of(Map.of("run_id", "run-1")), + Arguments.of(Map.of("user_id", "user-1", "agent_id", "agent-1", "run_id", "run-1"))); + } + + @Test + void preservesExplicitFiltersWhenThereIsNoExpression() { + Map filters = Map.of("category", "private"); + store.similaritySearch( + Mem0ServerRequest.SearchRequest.mem0Builder().query("query").userId("user-1").filters(filters).build()); + assertThat(JSON.readTree(requestBody.get()).get("filters")).isEqualTo(JSON.valueToTree(filters)); + } + + @Test + void invalidLogicalOperandFailsBeforeSendingARequest() { + Filter.Expression invalid = new Filter.Expression(Filter.ExpressionType.OR, new Filter.Key("category"), + eq("score", 1)); + Mem0ServerRequest.SearchRequest request = Mem0ServerRequest.SearchRequest.mem0Builder() + .query("query") + .userId("user-1") + .filterExpression(invalid) + .build(); + assertThatThrownBy(() -> store.similaritySearch(request)).isInstanceOf(IllegalArgumentException.class); + assertThat(requestBody.get()).isNull(); + } + +}