diff --git a/ml-algorithms/src/main/java/org/opensearch/ml/engine/tools/AbstractRetrieverTool.java b/ml-algorithms/src/main/java/org/opensearch/ml/engine/tools/AbstractRetrieverTool.java new file mode 100644 index 0000000000..587dfeb7f9 --- /dev/null +++ b/ml-algorithms/src/main/java/org/opensearch/ml/engine/tools/AbstractRetrieverTool.java @@ -0,0 +1,154 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ml.engine.tools; + +import static org.opensearch.ml.common.utils.StringUtils.gson; + +import java.io.IOException; +import java.security.AccessController; +import java.security.PrivilegedExceptionAction; +import java.util.HashMap; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; +import org.opensearch.action.search.SearchRequest; +import org.opensearch.action.search.SearchResponse; +import org.opensearch.client.Client; +import org.opensearch.common.xcontent.LoggingDeprecationHandler; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.ml.common.spi.tools.Tool; +import org.opensearch.search.SearchHit; +import org.opensearch.search.builder.SearchSourceBuilder; + +import lombok.Getter; +import lombok.Setter; +import lombok.extern.log4j.Log4j2; + +/** + * Abstract tool supports search paradigms in neural-search plugin. + */ +@Log4j2 +@Getter +@Setter +public abstract class AbstractRetrieverTool implements Tool { + public static final String DEFAULT_DESCRIPTION = "Use this tool to search data in OpenSearch index."; + public static final String INPUT_FIELD = "input"; + public static final String INDEX_FIELD = "index"; + public static final String SOURCE_FIELD = "source_field"; + public static final String DOC_SIZE_FIELD = "doc_size"; + + protected String description = DEFAULT_DESCRIPTION; + protected Client client; + protected NamedXContentRegistry xContentRegistry; + protected String index; + protected String[] sourceFields; + protected Integer docSize; + + protected AbstractRetrieverTool( + Client client, + NamedXContentRegistry xContentRegistry, + String index, + String[] sourceFields, + Integer docSize + ) { + this.client = client; + this.xContentRegistry = xContentRegistry; + this.index = index; + this.sourceFields = sourceFields; + this.docSize = docSize == null ? 2 : docSize; + } + + protected abstract String getQueryBody(String queryText); + + @Override + public void run(Map parameters, ActionListener listener) { + try { + String question = parameters.get(INPUT_FIELD); + try { + question = gson.fromJson(question, String.class); + } catch (Exception e) { + // throw new IllegalArgumentException("wrong input"); + } + String query = getQueryBody(question); + if (StringUtils.isBlank(query)) { + throw new IllegalArgumentException("[" + INPUT_FIELD + "] is null or empty, can not process it."); + } + + SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder(); + XContentParser queryParser = XContentType.JSON + .xContent() + .createParser(xContentRegistry, LoggingDeprecationHandler.INSTANCE, query); + searchSourceBuilder.parseXContent(queryParser); + searchSourceBuilder.fetchSource(sourceFields, null); + searchSourceBuilder.size(docSize); + SearchRequest searchRequest = new SearchRequest().source(searchSourceBuilder).indices(index); + ActionListener actionListener = ActionListener.wrap(r -> { + SearchHit[] hits = r.getHits().getHits(); + + if (hits != null && hits.length > 0) { + StringBuilder contextBuilder = new StringBuilder(); + for (int i = 0; i < hits.length; i++) { + SearchHit hit = hits[i]; + String doc = AccessController.doPrivileged((PrivilegedExceptionAction) () -> { + Map docContent = new HashMap<>(); + docContent.put("_index", hit.getIndex()); + docContent.put("_id", hit.getId()); + docContent.put("_score", hit.getScore()); + docContent.put("_source", hit.getSourceAsMap()); + return gson.toJson(docContent); + }); + contextBuilder.append(doc).append("\n"); + } + listener.onResponse((T) gson.toJson(contextBuilder.toString())); + } else { + listener.onResponse((T) ""); + } + }, e -> { + log.error("Failed to search index", e); + listener.onFailure(e); + }); + client.search(searchRequest, actionListener); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public String getVersion() { + return null; + } + + @Override + public boolean validate(Map parameters) { + if (parameters == null || parameters.size() == 0) { + return false; + } + String question = parameters.get("input"); + return question != null; + } + + public void setClient(Client client) { + this.client = client; + } + + protected static abstract class Factory implements Tool.Factory { + protected Client client; + protected NamedXContentRegistry xContentRegistry; + + public void init(Client client, NamedXContentRegistry xContentRegistry) { + this.client = client; + this.xContentRegistry = xContentRegistry; + } + + @Override + public String getDefaultDescription() { + return DEFAULT_DESCRIPTION; + } + } +} diff --git a/ml-algorithms/src/main/java/org/opensearch/ml/engine/tools/NeuralSparseTool.java b/ml-algorithms/src/main/java/org/opensearch/ml/engine/tools/NeuralSparseTool.java new file mode 100644 index 0000000000..33a3f45fb1 --- /dev/null +++ b/ml-algorithms/src/main/java/org/opensearch/ml/engine/tools/NeuralSparseTool.java @@ -0,0 +1,125 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ml.engine.tools; + +import static org.opensearch.ml.common.utils.StringUtils.gson; + +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; +import org.opensearch.client.Client; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.ml.common.spi.tools.ToolAnnotation; + +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.log4j.Log4j2; + +/** + * This tool supports neural_sparse search with sparse encoding models and rank_features field. + */ +@Log4j2 +@Getter +@Setter +@ToolAnnotation(NeuralSparseTool.TYPE) +public class NeuralSparseTool extends AbstractRetrieverTool { + public static final String TYPE = "NeuralSparseTool"; + public static final String MODEL_ID_FIELD = "model_id"; + public static final String EMBEDDING_FIELD = "embedding_field"; + private String name = TYPE; + private String modelId; + private String embeddingField; + + @Builder + public NeuralSparseTool( + Client client, + NamedXContentRegistry xContentRegistry, + String index, + String embeddingField, + String[] sourceFields, + Integer k, + Integer docSize, + String modelId + ) { + super(client, xContentRegistry, index, sourceFields, docSize); + this.modelId = modelId; + this.embeddingField = embeddingField; + } + + @Override + protected String getQueryBody(String queryText) { + if (StringUtils.isBlank(embeddingField) || StringUtils.isBlank(modelId)) { + throw new IllegalArgumentException( + "Parameter [" + EMBEDDING_FIELD + "] and [" + MODEL_ID_FIELD + "] can not be null or empty." + ); + } + return "{\"query\":{\"neural_sparse\":{\"" + + embeddingField + + "\":{\"query_text\":\"" + + queryText + + "\",\"model_id\":\"" + + modelId + + "\"}}}" + + " }"; + } + + @Override + public String getType() { + return TYPE; + } + + @Override + public String getName() { + return this.name; + } + + @Override + public void setName(String s) { + this.name = s; + } + + public static class Factory extends AbstractRetrieverTool.Factory { + private static Factory INSTANCE; + + public static Factory getInstance() { + if (INSTANCE != null) { + return INSTANCE; + } + synchronized (NeuralSparseTool.class) { + if (INSTANCE != null) { + return INSTANCE; + } + INSTANCE = new Factory(); + return INSTANCE; + } + } + + @Override + public NeuralSparseTool create(Map params) { + String index = (String) params.get(INDEX_FIELD); + String embeddingField = (String) params.get(EMBEDDING_FIELD); + String[] sourceFields = gson.fromJson((String) params.get(SOURCE_FIELD), String[].class); + String modelId = (String) params.get(MODEL_ID_FIELD); + Integer docSize = params.containsKey(DOC_SIZE_FIELD) ? Integer.parseInt((String) params.get(DOC_SIZE_FIELD)) : 2; + return NeuralSparseTool + .builder() + .client(client) + .xContentRegistry(xContentRegistry) + .index(index) + .embeddingField(embeddingField) + .sourceFields(sourceFields) + .modelId(modelId) + .docSize(docSize) + .build(); + } + + @Override + public String getDefaultDescription() { + return DEFAULT_DESCRIPTION; + } + } +} diff --git a/ml-algorithms/src/test/java/org/opensearch/ml/engine/tools/AbstractRetrieverToolTests.java b/ml-algorithms/src/test/java/org/opensearch/ml/engine/tools/AbstractRetrieverToolTests.java new file mode 100644 index 0000000000..f5251498da --- /dev/null +++ b/ml-algorithms/src/test/java/org/opensearch/ml/engine/tools/AbstractRetrieverToolTests.java @@ -0,0 +1,151 @@ +package org.opensearch.ml.engine.tools; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.opensearch.ml.common.utils.StringUtils.gson; + +import java.io.InputStream; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import org.opensearch.action.search.SearchRequest; +import org.opensearch.action.search.SearchResponse; +import org.opensearch.client.Client; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.xcontent.json.JsonXContent; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.xcontent.DeprecationHandler; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.search.SearchModule; + +import lombok.SneakyThrows; + +public class AbstractRetrieverToolTests { + static public final String TEST_QUERY = "{\"query\":{\"match_all\":{}}}"; + static public final String TEST_INDEX = "test index"; + static public final String[] TEST_SOURCE_FIELDS = new String[] { "test 1", "test 2" }; + static public final Integer TEST_DOC_SIZE = 3; + static public final NamedXContentRegistry TEST_XCONTENT_REGISTRY_FOR_QUERY = new NamedXContentRegistry( + new SearchModule(Settings.EMPTY, List.of()).getNamedXContents() + ); + + private String mockedSearchResponseString; + private String mockedEmptySearchResponseString; + private AbstractRetrieverTool mockedImpl; + + @Before + @SneakyThrows + public void setup() { + try (InputStream searchResponseIns = AbstractRetrieverTool.class.getResourceAsStream("retrieval_tool_search_response.json")) { + if (searchResponseIns != null) { + mockedSearchResponseString = new String(searchResponseIns.readAllBytes()); + } + } + try (InputStream searchResponseIns = AbstractRetrieverTool.class.getResourceAsStream("retrieval_tool_empty_search_response.json")) { + if (searchResponseIns != null) { + mockedEmptySearchResponseString = new String(searchResponseIns.readAllBytes()); + } + } + + mockedImpl = Mockito + .mock( + AbstractRetrieverTool.class, + Mockito + .withSettings() + .useConstructor(null, TEST_XCONTENT_REGISTRY_FOR_QUERY, TEST_INDEX, TEST_SOURCE_FIELDS, TEST_DOC_SIZE) + .defaultAnswer(Mockito.CALLS_REAL_METHODS) + ); + when(mockedImpl.getQueryBody(any(String.class))).thenReturn(TEST_QUERY); + } + + @Test + @SneakyThrows + public void testRunAsyncWithSearchResults() { + Client client = mock(Client.class); + SearchResponse mockedSearchResponse = SearchResponse + .fromXContent( + JsonXContent.jsonXContent + .createParser(NamedXContentRegistry.EMPTY, DeprecationHandler.IGNORE_DEPRECATIONS, mockedSearchResponseString) + ); + doAnswer(invocation -> { + SearchRequest searchRequest = invocation.getArgument(0); + assertEquals((long) TEST_DOC_SIZE, (long) searchRequest.source().size()); + ActionListener listener = invocation.getArgument(1); + listener.onResponse(mockedSearchResponse); + return null; + }).when(client).search(any(), any()); + mockedImpl.setClient(client); + + final CompletableFuture future = new CompletableFuture<>(); + ActionListener listener = ActionListener.wrap(r -> { future.complete(r); }, e -> { future.completeExceptionally(e); }); + + mockedImpl.run(Map.of(AbstractRetrieverTool.INPUT_FIELD, "hello world"), listener); + + future.join(); + assertEquals( + "{\"_index\":\"hybrid-index\",\"_source\":{\"passage_text\":\"Company test_mock have a history of 100 years.\"},\"_id\":\"1\",\"_score\":89.2917}\n" + + "{\"_index\":\"hybrid-index\",\"_source\":{\"passage_text\":\"the price of the api is 2$ per invokation\"},\"_id\":\"2\",\"_score\":0.10702579}\n", + gson.fromJson(future.get(), String.class) + ); + } + + @Test + @SneakyThrows + public void testRunAsyncWithEmptySearchResponse() { + Client client = mock(Client.class); + SearchResponse mockedEmptySearchResponse = SearchResponse + .fromXContent( + JsonXContent.jsonXContent + .createParser(NamedXContentRegistry.EMPTY, DeprecationHandler.IGNORE_DEPRECATIONS, mockedEmptySearchResponseString) + ); + doAnswer(invocation -> { + SearchRequest searchRequest = invocation.getArgument(0); + assertEquals((long) TEST_DOC_SIZE, (long) searchRequest.source().size()); + ActionListener listener = invocation.getArgument(1); + listener.onResponse(mockedEmptySearchResponse); + return null; + }).when(client).search(any(), any()); + mockedImpl.setClient(client); + + final CompletableFuture future = new CompletableFuture<>(); + ActionListener listener = ActionListener.wrap(r -> { future.complete(r); }, e -> { future.completeExceptionally(e); }); + + mockedImpl.run(Map.of(AbstractRetrieverTool.INPUT_FIELD, "hello world"), listener); + + future.join(); + assertEquals("", future.get()); + } + + @Test + @SneakyThrows + public void testRunAsyncWithIllegalQueryThenThrowException() { + Client client = mock(Client.class); + mockedImpl.setClient(client); + + assertThrows( + "[input] is null or empty, can not process it.", + IllegalArgumentException.class, + () -> mockedImpl.run(Map.of(AbstractRetrieverTool.INPUT_FIELD, ""), null) + ); + + assertThrows( + "[input] is null or empty, can not process it.", + IllegalArgumentException.class, + () -> mockedImpl.run(Map.of(AbstractRetrieverTool.INPUT_FIELD, " "), null) + ); + + assertThrows( + "[input] is null or empty, can not process it.", + IllegalArgumentException.class, + () -> mockedImpl.run(Map.of("test", "hello world"), null) + ); + } +} diff --git a/ml-algorithms/src/test/java/org/opensearch/ml/engine/tools/NeuralSparseToolTests.java b/ml-algorithms/src/test/java/org/opensearch/ml/engine/tools/NeuralSparseToolTests.java new file mode 100644 index 0000000000..fef948bc0c --- /dev/null +++ b/ml-algorithms/src/test/java/org/opensearch/ml/engine/tools/NeuralSparseToolTests.java @@ -0,0 +1,75 @@ +package org.opensearch.ml.engine.tools; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.opensearch.ml.common.utils.StringUtils.gson; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; + +import lombok.SneakyThrows; + +public class NeuralSparseToolTests { + public static final String TEST_EMBEDDING_FIELD = "test embedding"; + public static final String TEST_MODEL_ID = "123fsd23134"; + private Map params = new HashMap<>(); + + @Before + public void setup() { + params.put(NeuralSparseTool.INDEX_FIELD, AbstractRetrieverToolTests.TEST_INDEX); + params.put(NeuralSparseTool.EMBEDDING_FIELD, TEST_EMBEDDING_FIELD); + params.put(NeuralSparseTool.SOURCE_FIELD, gson.toJson(AbstractRetrieverToolTests.TEST_SOURCE_FIELDS)); + params.put(NeuralSparseTool.MODEL_ID_FIELD, TEST_MODEL_ID); + params.put(NeuralSparseTool.DOC_SIZE_FIELD, AbstractRetrieverToolTests.TEST_DOC_SIZE.toString()); + } + + @Test + @SneakyThrows + public void testCreateTool() { + NeuralSparseTool tool = NeuralSparseTool.Factory.getInstance().create(params); + assertEquals(AbstractRetrieverToolTests.TEST_INDEX, tool.getIndex()); + assertEquals(TEST_EMBEDDING_FIELD, tool.getEmbeddingField()); + assertEquals(AbstractRetrieverToolTests.TEST_SOURCE_FIELDS, tool.getSourceFields()); + assertEquals(TEST_MODEL_ID, tool.getModelId()); + assertEquals(AbstractRetrieverToolTests.TEST_DOC_SIZE, tool.getDocSize()); + assertEquals("NeuralSparseTool", tool.getType()); + assertEquals("NeuralSparseTool", tool.getName()); + assertEquals("Use this tool to search data in OpenSearch index.", NeuralSparseTool.Factory.getInstance().getDefaultDescription()); + } + + @Test + @SneakyThrows + public void testGetQueryBody() { + NeuralSparseTool tool = NeuralSparseTool.Factory.getInstance().create(params); + assertEquals( + "{\"query\":{\"neural_sparse\":{\"test embedding\":{\"" + + "query_text\":\"{\"query\":{\"match_all\":{}}}\",\"model_id\":\"123fsd23134\"}}} }", + tool.getQueryBody(AbstractRetrieverToolTests.TEST_QUERY) + ); + } + + @Test + @SneakyThrows + public void testGetQueryBodyWithIllegalParams() { + Map illegalParams1 = new HashMap<>(params); + illegalParams1.remove(NeuralSparseTool.MODEL_ID_FIELD); + NeuralSparseTool tool1 = NeuralSparseTool.Factory.getInstance().create(illegalParams1); + assertThrows( + "Parameter [embedding_field] and [model_id] can not be null or empty.", + IllegalArgumentException.class, + () -> tool1.getQueryBody(AbstractRetrieverToolTests.TEST_QUERY) + ); + + Map illegalParams2 = new HashMap<>(params); + illegalParams1.remove(NeuralSparseTool.EMBEDDING_FIELD); + NeuralSparseTool tool2 = NeuralSparseTool.Factory.getInstance().create(illegalParams1); + assertThrows( + "Parameter [embedding_field] and [model_id] can not be null or empty.", + IllegalArgumentException.class, + () -> tool2.getQueryBody(AbstractRetrieverToolTests.TEST_QUERY) + ); + } +} diff --git a/ml-algorithms/src/test/resources/org/opensearch/ml/engine/tools/retrieval_tool_empty_search_response.json b/ml-algorithms/src/test/resources/org/opensearch/ml/engine/tools/retrieval_tool_empty_search_response.json new file mode 100644 index 0000000000..7ca6bfa76f --- /dev/null +++ b/ml-algorithms/src/test/resources/org/opensearch/ml/engine/tools/retrieval_tool_empty_search_response.json @@ -0,0 +1,18 @@ +{ + "took": 4, + "timed_out": false, + "_shards": { + "total": 1, + "successful": 1, + "skipped": 0, + "failed": 0 + }, + "hits": { + "total": { + "value": 0, + "relation": "eq" + }, + "max_score": null, + "hits": [] + } +} \ No newline at end of file diff --git a/ml-algorithms/src/test/resources/org/opensearch/ml/engine/tools/retrieval_tool_search_response.json b/ml-algorithms/src/test/resources/org/opensearch/ml/engine/tools/retrieval_tool_search_response.json new file mode 100644 index 0000000000..7e66dd60e8 --- /dev/null +++ b/ml-algorithms/src/test/resources/org/opensearch/ml/engine/tools/retrieval_tool_search_response.json @@ -0,0 +1,35 @@ +{ + "took": 201, + "timed_out": false, + "_shards": { + "total": 1, + "successful": 1, + "skipped": 0, + "failed": 0 + }, + "hits": { + "total": { + "value": 2, + "relation": "eq" + }, + "max_score": 89.2917, + "hits": [ + { + "_index": "hybrid-index", + "_id": "1", + "_score": 89.2917, + "_source": { + "passage_text": "Company test_mock have a history of 100 years." + } + }, + { + "_index": "hybrid-index", + "_id": "2", + "_score": 0.10702579, + "_source": { + "passage_text": "the price of the api is 2$ per invokation" + } + } + ] + } +} \ No newline at end of file diff --git a/plugin/src/main/java/org/opensearch/ml/plugin/MachineLearningPlugin.java b/plugin/src/main/java/org/opensearch/ml/plugin/MachineLearningPlugin.java index bbfd5fdfbc..f6c5eb8aa1 100644 --- a/plugin/src/main/java/org/opensearch/ml/plugin/MachineLearningPlugin.java +++ b/plugin/src/main/java/org/opensearch/ml/plugin/MachineLearningPlugin.java @@ -134,6 +134,9 @@ import org.opensearch.ml.engine.encryptor.EncryptorImpl; import org.opensearch.ml.engine.indices.MLIndicesHandler; import org.opensearch.ml.engine.indices.MLInputDatasetHandler; +import org.opensearch.ml.engine.memory.ConversationIndexMemory; +import org.opensearch.ml.engine.memory.MLMemoryManager; +import org.opensearch.ml.engine.tools.*; import org.opensearch.ml.helper.ConnectorAccessControlHelper; import org.opensearch.ml.helper.ModelAccessControlHelper; import org.opensearch.ml.memory.ConversationalMemoryHandler; @@ -478,6 +481,44 @@ public Collection createComponents( // Register thread-safe ML objects here. LocalSampleCalculator localSampleCalculator = new LocalSampleCalculator(client, settings); + + toolFactories = new HashMap<>(); + + MLModelTool.Factory.getInstance().init(client); + MathTool.Factory.getInstance().init(scriptService); + VectorDBTool.Factory.getInstance().init(client, xContentRegistry); + NeuralSparseTool.Factory.getInstance().init(client, xContentRegistry); + AgentTool.Factory.getInstance().init(client); + CatIndexTool.Factory.getInstance().init(client, clusterService); + PainlessScriptTool.Factory.getInstance().init(client, scriptService); + VisualizationsTool.Factory.getInstance().init(client); + toolFactories.put(MLModelTool.TYPE, MLModelTool.Factory.getInstance()); + toolFactories.put(MathTool.TYPE, MathTool.Factory.getInstance()); + toolFactories.put(VectorDBTool.TYPE, VectorDBTool.Factory.getInstance()); + toolFactories.put(NeuralSparseTool.TYPE, NeuralSparseTool.Factory.getInstance()); + toolFactories.put(AgentTool.TYPE, AgentTool.Factory.getInstance()); + toolFactories.put(CatIndexTool.TYPE, CatIndexTool.Factory.getInstance()); + toolFactories.put(PainlessScriptTool.TYPE, PainlessScriptTool.Factory.getInstance()); + toolFactories.put(VisualizationsTool.TYPE, VisualizationsTool.Factory.getInstance()); + + if (externalToolFactories != null) { + toolFactories.putAll(externalToolFactories); + } + + MLMemoryManager memoryManager = new MLMemoryManager(client, clusterService, new ConversationMetaIndex(client, clusterService)); + Map memoryFactoryMap = new HashMap<>(); + ConversationIndexMemory.Factory conversationIndexMemoryFactory = new ConversationIndexMemory.Factory(); + conversationIndexMemoryFactory.init(client, mlIndicesHandler, memoryManager); + memoryFactoryMap.put(ConversationIndexMemory.TYPE, conversationIndexMemoryFactory); + + MLAgentExecutor agentExecutor = new MLAgentExecutor( + client, + settings, + clusterService, + xContentRegistry, + toolFactories, + memoryFactoryMap + ); MLEngineClassLoader.register(FunctionName.LOCAL_SAMPLE_CALCULATOR, localSampleCalculator); AnomalyLocalizerImpl anomalyLocalizer = new AnomalyLocalizerImpl(client, settings, clusterService, indexNameExpressionResolver);