From 58a83a6e7f27877c13b6aea37aa0db3d54ba6f83 Mon Sep 17 00:00:00 2001 From: mnhnam-axonivy Date: Mon, 4 Nov 2024 14:38:38 +0700 Subject: [PATCH 1/3] IVYPORTAL-17735: Stage 1: Use internal OpenSearch of ivy engine as the vector store - Change from Elastic Search to Open Search - Adapt code, error handling approach - Improve approach to parsing Portal document - Categorize questions before handle them --- .../.settings/ch.ivyteam.ivy.designer.prefs | 3 +- ai-assistant/config/variables.yaml | 16 +- .../variables/AiAssistant/AiFunctions.json | 8 +- ai-assistant/pom.xml | 11 +- .../utils/aiassistant/core/AbstractAIBot.java | 10 +- .../utils/aiassistant/core/OpenAIBot.java | 100 ++- .../ElasticsearchMetadataFilterMapper.java | 156 ----- ...EsDocument.java => EmbeddingDocument.java} | 8 +- .../IvyElasticSearchEmbeddingStore.java | 574 ------------------ .../IvyOpenSearchEmbeddingStore.java | 354 +++++++++++ .../utils/aiassistant/dto/flow/AiStep.java | 40 -- .../aiassistant/dto/flow/RephraseStep.java | 4 +- .../aiassistant/dto/flow/SwitchStep.java | 4 +- .../utils/aiassistant/dto/flow/TextStep.java | 3 +- .../utils/aiassistant/dto/tool/IvyTool.java | 7 +- .../aiassistant/dto/tool/RetrievalQATool.java | 13 + .../utils/aiassistant/enums/AiVariable.java | 2 +- .../prompts/RagPromptTemplates.java | 114 +++- .../rest/AssistantRestService.java | 4 + .../aiassistant/service/PortalDocService.java | 122 ++-- .../aiassistant/utils/AiFunctionUtils.java | 41 ++ .../utils/aiassistant/utils/BotUtils.java | 2 +- .../webContent/resources/css/chatbot.css | 4 +- .../webContent/resources/js/ai-assistant.js | 13 + 24 files changed, 738 insertions(+), 875 deletions(-) delete mode 100644 ai-assistant/src/com/axonivy/utils/aiassistant/core/embedding/ElasticsearchMetadataFilterMapper.java rename ai-assistant/src/com/axonivy/utils/aiassistant/core/embedding/{EsDocument.java => EmbeddingDocument.java} (71%) delete mode 100644 ai-assistant/src/com/axonivy/utils/aiassistant/core/embedding/IvyElasticSearchEmbeddingStore.java create mode 100644 ai-assistant/src/com/axonivy/utils/aiassistant/core/embedding/IvyOpenSearchEmbeddingStore.java diff --git a/ai-assistant/.settings/ch.ivyteam.ivy.designer.prefs b/ai-assistant/.settings/ch.ivyteam.ivy.designer.prefs index 5a4fdc5..c87b190 100644 --- a/ai-assistant/.settings/ch.ivyteam.ivy.designer.prefs +++ b/ai-assistant/.settings/ch.ivyteam.ivy.designer.prefs @@ -1,5 +1,4 @@ -ch.ivyteam.ivy.designer.preferences.DataClassPreferencePage\:DEFAULT_DATA_CLASS=com.axonivy.utils.aiassistant.Data ch.ivyteam.ivy.designer.preferences.DataClassPreferencePage\:DEFAULT_NAMESPACE=com.axonivy.utils.aiassistant.assistant ch.ivyteam.ivy.project.preferences\:PRIMEFACES_VERSION=13 -ch.ivyteam.ivy.project.preferences\:PROJECT_VERSION=120000 +ch.ivyteam.ivy.project.preferences\:PROJECT_VERSION=120001 eclipse.preferences.version=1 diff --git a/ai-assistant/config/variables.yaml b/ai-assistant/config/variables.yaml index 2f6fde5..76063a4 100644 --- a/ai-assistant/config/variables.yaml +++ b/ai-assistant/config/variables.yaml @@ -1,23 +1,23 @@ -# yaml-language-server: $schema=https://json-schema.axonivy.com/app/11.4.1/variables.json +# yaml-language-server: $schema=https://json-schema.axonivy.com/app/12.0.0/variables.json Variables: AiAssistant: - ElasticSearchUrl: http://localhost:9200 + OpenSearchVectorStoreUrl: http://localhost:19201 AiModels: # Open AI models OpenAI: - # The primary Open AI model. This instance use the 'GPT 4 Omni' and 'text-embedding-3-large' models by default + # The primary Open AI model. This instance use the 'GPT 4 Omni Mini' and 'text-embedding-3-large' models by default PrimaryModel: - # Open AI model 'GPT 4 Omni' - Model: gpt-4o + # Open AI model 'GPT 4 Omni Mini' + Model: gpt-4o-mini # Open AI text embedding model 'text-embedding-3-large' EmbeddingModel: text-embedding-3-large #[password] ApiKey: ${decrypt:} - # The secondary Open AI model. This instance use the 'GPT 4 Omni Mini' and 'text-embedding-3-large' models by default + # The secondary Open AI model. This instance use the 'GPT 4 Omni' and 'text-embedding-3-large' models by default. SecondaryModel: # Open AI model 'GPT 4 Omni Mini' - Model: gpt-4o-mini - # Open AI text embedding model 'text-embedding-3-small' + Model: gpt-4o + # Open AI text embedding model 'text-embedding-3-large' EmbeddingModel: text-embedding-3-large #[password] ApiKey: ${decrypt:} diff --git a/ai-assistant/config/variables/AiAssistant/AiFunctions.json b/ai-assistant/config/variables/AiAssistant/AiFunctions.json index 3a6ce49..1475c27 100644 --- a/ai-assistant/config/variables/AiAssistant/AiFunctions.json +++ b/ai-assistant/config/variables/AiAssistant/AiFunctions.json @@ -355,8 +355,12 @@ "type": "SWITCH", "cases": [ { - "action": 1, - "case": "User clarify or make another request to find task" + "action": 0, + "case": "User update the condition to find task" + }, + { + "action": 0, + "case": "User make another request to find task" }, { "action": -1, diff --git a/ai-assistant/pom.xml b/ai-assistant/pom.xml index bde848a..160f443 100644 --- a/ai-assistant/pom.xml +++ b/ai-assistant/pom.xml @@ -18,17 +18,12 @@ dev.langchain4j langchain4j-open-ai - 0.31.0 + 0.35.0 dev.langchain4j - langchain4j-elasticsearch - 0.31.0 - - - co.elastic.clients - elasticsearch-java - 8.9.2 + langchain4j-opensearch + 0.35.0 org.apache.pdfbox diff --git a/ai-assistant/src/com/axonivy/utils/aiassistant/core/AbstractAIBot.java b/ai-assistant/src/com/axonivy/utils/aiassistant/core/AbstractAIBot.java index d0449dc..9993111 100644 --- a/ai-assistant/src/com/axonivy/utils/aiassistant/core/AbstractAIBot.java +++ b/ai-assistant/src/com/axonivy/utils/aiassistant/core/AbstractAIBot.java @@ -3,7 +3,7 @@ import java.util.List; import java.util.Map; -import com.axonivy.utils.aiassistant.core.embedding.IvyElasticSearchEmbeddingStore; +import com.axonivy.utils.aiassistant.core.embedding.IvyOpenSearchEmbeddingStore; import com.axonivy.utils.aiassistant.enums.ModelType; import dev.langchain4j.data.message.AiMessage; @@ -15,7 +15,7 @@ public abstract class AbstractAIBot { private ModelType modelType; - private IvyElasticSearchEmbeddingStore embeddingStore; + private IvyOpenSearchEmbeddingStore embeddingStore; public ModelType getModelType() { return modelType; @@ -25,11 +25,11 @@ public void setModelType(ModelType modelType) { this.modelType = modelType; } - public IvyElasticSearchEmbeddingStore getEmbeddingStore() { + public IvyOpenSearchEmbeddingStore getEmbeddingStore() { return embeddingStore; } - public void setEmbeddingStore(IvyElasticSearchEmbeddingStore embeddingStore) { + public void setEmbeddingStore(IvyOpenSearchEmbeddingStore embeddingStore) { this.embeddingStore = embeddingStore; } @@ -47,6 +47,8 @@ public abstract void embed(String collectionName, public abstract String chat(Map variables, String promptTemplate); + public abstract String chat(String message); + public abstract String streamChat(Map variables, String promptTemplate, StreamingResponseHandler handler); diff --git a/ai-assistant/src/com/axonivy/utils/aiassistant/core/OpenAIBot.java b/ai-assistant/src/com/axonivy/utils/aiassistant/core/OpenAIBot.java index fe3c58c..39b6005 100644 --- a/ai-assistant/src/com/axonivy/utils/aiassistant/core/OpenAIBot.java +++ b/ai-assistant/src/com/axonivy/utils/aiassistant/core/OpenAIBot.java @@ -1,15 +1,22 @@ package com.axonivy.utils.aiassistant.core; +import static java.util.stream.Collectors.toList; + +import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.Optional; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; +import org.opensearch.client.opensearch.core.SearchResponse; import com.axonivy.portal.components.persistence.converter.BusinessEntityConverter; -import com.axonivy.utils.aiassistant.core.embedding.IvyElasticSearchEmbeddingStore; +import com.axonivy.utils.aiassistant.core.embedding.EmbeddingDocument; +import com.axonivy.utils.aiassistant.core.embedding.IvyOpenSearchEmbeddingStore; import com.axonivy.utils.aiassistant.core.error.OpenAIErrorResponse; import com.axonivy.utils.aiassistant.dto.AiModel; import com.axonivy.utils.aiassistant.enums.AiVariable; @@ -17,6 +24,7 @@ import com.axonivy.utils.aiassistant.prompts.RagPromptTemplates; import ch.ivyteam.ivy.environment.Ivy; +import dev.langchain4j.data.document.Metadata; import dev.langchain4j.data.embedding.Embedding; import dev.langchain4j.data.message.AiMessage; import dev.langchain4j.data.message.UserMessage; @@ -27,8 +35,6 @@ import dev.langchain4j.model.openai.OpenAiEmbeddingModel; import dev.langchain4j.model.openai.OpenAiStreamingChatModel; import dev.langchain4j.store.embedding.EmbeddingMatch; -import dev.langchain4j.store.embedding.EmbeddingSearchRequest; -import dev.langchain4j.store.embedding.EmbeddingSearchResult; public class OpenAIBot extends AbstractAIBot { @@ -103,7 +109,8 @@ public void setEmbeddingModel(OpenAiEmbeddingModel embeddingModel) { @Override public void initModel() { setModel( - OpenAiChatModel.builder().apiKey(apiKey).modelName(modelName).build()); + OpenAiChatModel.builder().apiKey(apiKey).modelName(modelName) + .temperature(Double.valueOf(0)).build()); } @Override @@ -115,15 +122,14 @@ public void initEmbeddingModel() { @Override public void initStreamingChatModel() { - setChatModel(OpenAiStreamingChatModel.builder().apiKey(apiKey) - .modelName(modelName).build()); + setChatModel(OpenAiStreamingChatModel.builder().apiKey(apiKey).modelName(modelName).temperature(Double.valueOf(0)).build()); } @Override public void initEmbeddingStore(String collectionName) { - setEmbeddingStore(IvyElasticSearchEmbeddingStore.builder() - .serverUrl(Ivy.var().get(AiVariable.ELASTIC_SEARCH_URL.key)) - .dimension(DEFAULT_DIMENSIONS).indexName(collectionName).build()); + setEmbeddingStore(IvyOpenSearchEmbeddingStore.builder() + .serverUrl(Ivy.var().get(AiVariable.OPEN_SEARCH_VECTOR_STORE_URL.key)) + .indexName(collectionName).build()); } @Override @@ -137,17 +143,40 @@ public void embed(String collectionName, List textSegments) { // Remove old index before embed Ivy.log().info("Remove old index " + collectionName); - getEmbeddingStore().removeIndex(); + try { + getEmbeddingStore().removeIndex(); + } catch (IOException e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } // re-create index Ivy.log().info("Recreate index " + collectionName); initEmbeddingStore(collectionName); + try { + getEmbeddingStore().createIndexIfNotExist(DEFAULT_DIMENSIONS); + } catch (IOException e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } + Ivy.log().info("Start embed vector store"); - for (var segment : textSegments) { - getEmbeddingStore().add(getEmbeddingModel().embed(segment).content(), - segment); + List embeddings = new ArrayList<>(); + for (TextSegment segment : textSegments) { + EmbeddingDocument doc = new EmbeddingDocument(); + doc.setMetadata(segment.metadata().asMap()); + doc.setText(segment.text()); + doc.setVector(getEmbeddingModel().embed(segment).content().vector()); + embeddings.add(doc); + } + + try { + getEmbeddingStore().bulkIndex(collectionName, embeddings); + } catch (IOException e) { + // TODO Auto-generated catch block + e.printStackTrace(); } Ivy.log().info("End embed vector store"); } @@ -156,8 +185,23 @@ public void embed(String collectionName, List textSegments) { public String chat(Map variables, String promptTemplate) { try { return getModel().generate( - PromptTemplate.from(promptTemplate).apply(variables).toUserMessage()) - .content().text(); + PromptTemplate.from(promptTemplate).apply(variables).text()); + } catch (Exception e) { + OpenAIErrorResponse error = BusinessEntityConverter.jsonValueToEntity( + e.getCause().getMessage(), OpenAIErrorResponse.class); + return error.getError().getMessage(); + } + } + + @Override + public String chat(String message) { + try { + Ivy.log().error(message); + + String x = getModel().generate(message); + Ivy.log().error(x); + + return x; } catch (Exception e) { OpenAIErrorResponse error = BusinessEntityConverter.jsonValueToEntity( e.getCause().getMessage(), OpenAIErrorResponse.class); @@ -169,6 +213,8 @@ public String chat(Map variables, String promptTemplate) { public String streamChat(Map variables, String promptTemplate, StreamingResponseHandler handler) { try { + Ivy.log() + .error(PromptTemplate.from(promptTemplate).apply(variables).text()); getChatModel().generate( PromptTemplate.from(promptTemplate).apply(variables).text(), handler); @@ -186,12 +232,11 @@ public String retrieveDocumentsAsString(String collectionName, String query) { Embedding queryEmbedding = embeddingModel.embed(query).content(); - EmbeddingSearchResult relevant = getEmbeddingStore() - .searchApproximateKnn(EmbeddingSearchRequest.builder().maxResults(10) - .minScore(0.8).queryEmbedding(queryEmbedding).build()); + List> relevant = toEmbeddingMatch( + getEmbeddingStore().findRelevantDocuments(queryEmbedding, 10, 0.6)); - relevant.matches().sort(Comparator.comparing(EmbeddingMatch::score)); - return RagPromptTemplates.formatRetrievedDocuments(relevant.matches()); + relevant.sort(Comparator.comparing(EmbeddingMatch::score)); + return RagPromptTemplates.formatRetrievedDocuments(relevant); } @Override @@ -213,6 +258,19 @@ public String testEmbeddingStoreConnection(String collectionName) { } catch (Exception e) { return e.getCause().getMessage(); } - return StringUtils.EMPTY; + return getEmbeddingStore().isIndexActive(); + } + + private List> toEmbeddingMatch( + SearchResponse response) { + return response.hits().hits().stream() + .map(hit -> Optional.ofNullable(hit.source()) + .map(document -> new EmbeddingMatch<>(hit.score(), hit.id(), + new Embedding(document.getVector()), + document.getText() == null ? null + : TextSegment.from(document.getText(), + new Metadata(document.getMetadata())))) + .orElse(null)) + .collect(toList()); } } \ No newline at end of file diff --git a/ai-assistant/src/com/axonivy/utils/aiassistant/core/embedding/ElasticsearchMetadataFilterMapper.java b/ai-assistant/src/com/axonivy/utils/aiassistant/core/embedding/ElasticsearchMetadataFilterMapper.java deleted file mode 100644 index f53c1ae..0000000 --- a/ai-assistant/src/com/axonivy/utils/aiassistant/core/embedding/ElasticsearchMetadataFilterMapper.java +++ /dev/null @@ -1,156 +0,0 @@ -package com.axonivy.utils.aiassistant.core.embedding; - -import static java.util.stream.Collectors.toList; - -import java.util.Collection; -import java.util.List; -import java.util.UUID; - -import co.elastic.clients.elasticsearch._types.FieldValue; -import co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery; -import co.elastic.clients.elasticsearch._types.query_dsl.Query; -import co.elastic.clients.json.JsonData; -import dev.langchain4j.store.embedding.filter.Filter; -import dev.langchain4j.store.embedding.filter.comparison.IsEqualTo; -import dev.langchain4j.store.embedding.filter.comparison.IsGreaterThan; -import dev.langchain4j.store.embedding.filter.comparison.IsGreaterThanOrEqualTo; -import dev.langchain4j.store.embedding.filter.comparison.IsIn; -import dev.langchain4j.store.embedding.filter.comparison.IsLessThan; -import dev.langchain4j.store.embedding.filter.comparison.IsLessThanOrEqualTo; -import dev.langchain4j.store.embedding.filter.comparison.IsNotEqualTo; -import dev.langchain4j.store.embedding.filter.comparison.IsNotIn; -import dev.langchain4j.store.embedding.filter.logical.And; -import dev.langchain4j.store.embedding.filter.logical.Not; -import dev.langchain4j.store.embedding.filter.logical.Or; - -class ElasticsearchMetadataFilterMapper { - - static Query map(Filter filter) { - if (filter instanceof IsEqualTo) { - return mapEqual((IsEqualTo) filter); - } else if (filter instanceof IsNotEqualTo) { - return mapNotEqual((IsNotEqualTo) filter); - } else if (filter instanceof IsGreaterThan) { - return mapGreaterThan((IsGreaterThan) filter); - } else if (filter instanceof IsGreaterThanOrEqualTo) { - return mapGreaterThanOrEqual((IsGreaterThanOrEqualTo) filter); - } else if (filter instanceof IsLessThan) { - return mapLessThan((IsLessThan) filter); - } else if (filter instanceof IsLessThanOrEqualTo) { - return mapLessThanOrEqual((IsLessThanOrEqualTo) filter); - } else if (filter instanceof IsIn) { - return mapIn((IsIn) filter); - } else if (filter instanceof IsNotIn) { - return mapNotIn((IsNotIn) filter); - } else if (filter instanceof And) { - return mapAnd((And) filter); - } else if (filter instanceof Not) { - return mapNot((Not) filter); - } else if (filter instanceof Or) { - return mapOr((Or) filter); - } else { - throw new UnsupportedOperationException( - "Unsupported filter type: " + filter.getClass().getName()); - } - } - - private static Query mapEqual(IsEqualTo isEqualTo) { - return new Query.Builder() - .bool(b -> b.filter(f -> f.term(t -> t - .field(formatKey(isEqualTo.key(), isEqualTo.comparisonValue())) - .value(v -> v.anyValue(JsonData.of(isEqualTo.comparisonValue())))))) - .build(); - } - - private static Query mapNotEqual(IsNotEqualTo isNotEqualTo) { - return new Query.Builder() - .bool(b -> b.mustNot(mn -> mn.term(t -> t - .field( - formatKey(isNotEqualTo.key(), isNotEqualTo.comparisonValue())) - .value( - v -> v.anyValue(JsonData.of(isNotEqualTo.comparisonValue())))))) - .build(); - } - - private static Query mapGreaterThan(IsGreaterThan isGreaterThan) { - return new Query.Builder().bool(b -> b - .filter(f -> f.range(r -> r.field("metadata." + isGreaterThan.key()) - .gt(JsonData.of(isGreaterThan.comparisonValue()))))) - .build(); - } - - private static Query mapGreaterThanOrEqual( - IsGreaterThanOrEqualTo isGreaterThanOrEqualTo) { - return new Query.Builder() - .bool(b -> b.filter(f -> f - .range(r -> r.field("metadata." + isGreaterThanOrEqualTo.key()) - .gte(JsonData.of(isGreaterThanOrEqualTo.comparisonValue()))))) - .build(); - } - - private static Query mapLessThan(IsLessThan isLessThan) { - return new Query.Builder().bool( - b -> b.filter(f -> f.range(r -> r.field("metadata." + isLessThan.key()) - .lt(JsonData.of(isLessThan.comparisonValue()))))) - .build(); - } - - private static Query mapLessThanOrEqual( - IsLessThanOrEqualTo isLessThanOrEqualTo) { - return new Query.Builder() - .bool(b -> b.filter( - f -> f.range(r -> r.field("metadata." + isLessThanOrEqualTo.key()) - .lte(JsonData.of(isLessThanOrEqualTo.comparisonValue()))))) - .build(); - } - - public static Query mapIn(IsIn isIn) { - return new Query.Builder().bool(b -> b.filter(f -> f.terms(t -> t - .field(formatKey(isIn.key(), isIn.comparisonValues())).terms(terms -> { - List values = isIn.comparisonValues().stream() - .map(it -> FieldValue.of(JsonData.of(it))).collect(toList()); - return terms.value(values); - })))).build(); - } - - public static Query mapNotIn(IsNotIn isNotIn) { - return new Query.Builder().bool(b -> b.mustNot(mn -> mn.terms( - t -> t.field(formatKey(isNotIn.key(), isNotIn.comparisonValues())) - .terms(terms -> { - List values = isNotIn.comparisonValues().stream() - .map(it -> FieldValue.of(JsonData.of(it))).collect(toList()); - return terms.value(values); - })))) - .build(); - } - - private static Query mapAnd(And and) { - BoolQuery boolQuery = new BoolQuery.Builder().must(map(and.left())) - .must(map(and.right())).build(); - return new Query.Builder().bool(boolQuery).build(); - } - - private static Query mapNot(Not not) { - BoolQuery boolQuery = new BoolQuery.Builder().mustNot(map(not.expression())) - .build(); - return new Query.Builder().bool(boolQuery).build(); - } - - private static Query mapOr(Or or) { - BoolQuery boolQuery = new BoolQuery.Builder().should(map(or.left())) - .should(map(or.right())).build(); - return new Query.Builder().bool(boolQuery).build(); - } - - private static String formatKey(String key, Object comparisonValue) { - if (comparisonValue instanceof String || comparisonValue instanceof UUID) { - return "metadata." + key + ".keyword"; - } else { - return "metadata." + key; - } - } - - private static String formatKey(String key, Collection comparisonValues) { - return formatKey(key, comparisonValues.iterator().next()); - } -} \ No newline at end of file diff --git a/ai-assistant/src/com/axonivy/utils/aiassistant/core/embedding/EsDocument.java b/ai-assistant/src/com/axonivy/utils/aiassistant/core/embedding/EmbeddingDocument.java similarity index 71% rename from ai-assistant/src/com/axonivy/utils/aiassistant/core/embedding/EsDocument.java rename to ai-assistant/src/com/axonivy/utils/aiassistant/core/embedding/EmbeddingDocument.java index 0de3e03..d5a842a 100644 --- a/ai-assistant/src/com/axonivy/utils/aiassistant/core/embedding/EsDocument.java +++ b/ai-assistant/src/com/axonivy/utils/aiassistant/core/embedding/EmbeddingDocument.java @@ -2,10 +2,10 @@ import java.util.Map; -public class EsDocument { +public class EmbeddingDocument { private float[] vector; private String text; - private Map metadata; + private Map metadata; public float[] getVector() { return vector; @@ -23,11 +23,11 @@ public void setText(String text) { this.text = text; } - public Map getMetadata() { + public Map getMetadata() { return metadata; } - public void setMetadata(Map metadata) { + public void setMetadata(Map metadata) { this.metadata = metadata; } } \ No newline at end of file diff --git a/ai-assistant/src/com/axonivy/utils/aiassistant/core/embedding/IvyElasticSearchEmbeddingStore.java b/ai-assistant/src/com/axonivy/utils/aiassistant/core/embedding/IvyElasticSearchEmbeddingStore.java deleted file mode 100644 index 2e4cde4..0000000 --- a/ai-assistant/src/com/axonivy/utils/aiassistant/core/embedding/IvyElasticSearchEmbeddingStore.java +++ /dev/null @@ -1,574 +0,0 @@ -package com.axonivy.utils.aiassistant.core.embedding; - -import static dev.langchain4j.internal.Utils.isNullOrBlank; -import static dev.langchain4j.internal.Utils.isNullOrEmpty; -import static dev.langchain4j.internal.Utils.randomUUID; -import static dev.langchain4j.internal.ValidationUtils.ensureGreaterThanZero; -import static dev.langchain4j.internal.ValidationUtils.ensureNotEmpty; -import static dev.langchain4j.internal.ValidationUtils.ensureNotNull; -import static dev.langchain4j.internal.ValidationUtils.ensureTrue; -import static java.util.Collections.singletonList; -import static java.util.stream.Collectors.toList; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -import org.apache.http.Header; -import org.apache.http.HttpHost; -import org.apache.http.auth.AuthScope; -import org.apache.http.auth.UsernamePasswordCredentials; -import org.apache.http.client.CredentialsProvider; -import org.apache.http.impl.client.BasicCredentialsProvider; -import org.apache.http.message.BasicHeader; -import org.elasticsearch.client.RestClient; -import org.elasticsearch.client.RestClientBuilder; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.axonivy.portal.components.persistence.converter.BusinessEntityConverter; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; - -import co.elastic.clients.elasticsearch.ElasticsearchClient; -import co.elastic.clients.elasticsearch._types.BulkIndexByScrollFailure; -import co.elastic.clients.elasticsearch._types.ErrorCause; -import co.elastic.clients.elasticsearch._types.InlineScript; -import co.elastic.clients.elasticsearch._types.KnnQuery; -import co.elastic.clients.elasticsearch._types.Time; -import co.elastic.clients.elasticsearch._types.mapping.DenseVectorProperty; -import co.elastic.clients.elasticsearch._types.mapping.Property; -import co.elastic.clients.elasticsearch._types.mapping.TextProperty; -import co.elastic.clients.elasticsearch._types.mapping.TypeMapping; -import co.elastic.clients.elasticsearch._types.query_dsl.Query; -import co.elastic.clients.elasticsearch._types.query_dsl.ScriptScoreQuery; -import co.elastic.clients.elasticsearch.core.BulkRequest; -import co.elastic.clients.elasticsearch.core.BulkResponse; -import co.elastic.clients.elasticsearch.core.DeleteByQueryResponse; -import co.elastic.clients.elasticsearch.core.SearchResponse; -import co.elastic.clients.elasticsearch.core.bulk.BulkResponseItem; -import co.elastic.clients.elasticsearch.indices.DeleteIndexRequest; -import co.elastic.clients.elasticsearch.indices.DeleteIndexResponse; -import co.elastic.clients.elasticsearch.indices.ExistsRequest; -import co.elastic.clients.json.JsonData; -import co.elastic.clients.json.JsonpMapper; -import co.elastic.clients.json.jackson.JacksonJsonpMapper; -import co.elastic.clients.transport.ElasticsearchTransport; -import co.elastic.clients.transport.endpoints.BooleanResponse; -import co.elastic.clients.transport.rest_client.RestClientTransport; -import dev.langchain4j.data.document.Metadata; -import dev.langchain4j.data.embedding.Embedding; -import dev.langchain4j.data.segment.TextSegment; -import dev.langchain4j.store.embedding.EmbeddingMatch; -import dev.langchain4j.store.embedding.EmbeddingSearchRequest; -import dev.langchain4j.store.embedding.EmbeddingSearchResult; -import dev.langchain4j.store.embedding.EmbeddingStore; -import dev.langchain4j.store.embedding.elasticsearch.ElasticsearchEmbeddingStore; -import dev.langchain4j.store.embedding.elasticsearch.ElasticsearchRequestFailedException; -import dev.langchain4j.store.embedding.filter.Filter; - -/** - * Represents an Elasticsearch index as an - * embedding store. Current implementation assumes the index uses the cosine - * distance metric.
- * Supports storing {@link Metadata} and filtering by it using {@link Filter} - * (provided inside {@link EmbeddingSearchRequest}). - */ -public class IvyElasticSearchEmbeddingStore - implements EmbeddingStore { - - private static final Logger log = LoggerFactory - .getLogger(ElasticsearchEmbeddingStore.class); - - private final ElasticsearchClient client; - private final String indexName; - private final ObjectMapper objectMapper; - - /** - * Creates an instance of ElasticsearchEmbeddingStore. - * - * @param serverUrl Elasticsearch Server URL (mandatory) - * @param apiKey Elasticsearch API key (optional) - * @param userName Elasticsearch userName (optional) - * @param password Elasticsearch password (optional) - * @param indexName Elasticsearch index name (optional). Default value: - * "default". Index will be created automatically if not - * exists. - * @param dimension Embedding vector dimension (mandatory when index does not - * exist yet). - */ - public IvyElasticSearchEmbeddingStore(String serverUrl, String apiKey, - String userName, String password, String indexName, Integer dimension) { - - RestClientBuilder restClientBuilder = RestClient - .builder(HttpHost.create(ensureNotNull(serverUrl, "serverUrl"))); - - if (!isNullOrBlank(userName)) { - CredentialsProvider provider = new BasicCredentialsProvider(); - provider.setCredentials(AuthScope.ANY, - new UsernamePasswordCredentials(userName, password)); - restClientBuilder - .setHttpClientConfigCallback(httpClientBuilder -> httpClientBuilder - .setDefaultCredentialsProvider(provider)); - } - - if (!isNullOrBlank(apiKey)) { - restClientBuilder.setDefaultHeaders(new Header[] { - new BasicHeader("Authorization", "Apikey " + apiKey) }); - } - - ElasticsearchTransport transport = new RestClientTransport( - restClientBuilder.build(), new JacksonJsonpMapper()); - - this.client = new ElasticsearchClient(transport); - this.indexName = ensureNotNull(indexName, "indexName"); - this.objectMapper = new ObjectMapper(); - - createIndexIfNotExist(indexName, dimension); - } - - public IvyElasticSearchEmbeddingStore(RestClient restClient, String indexName, - Integer dimension) { - JsonpMapper mapper = new JacksonJsonpMapper(); - ElasticsearchTransport transport = new RestClientTransport(restClient, - mapper); - - this.client = new ElasticsearchClient(transport); - this.indexName = ensureNotNull(indexName, "indexName"); - this.objectMapper = new ObjectMapper(); - - createIndexIfNotExist(indexName, dimension); - } - - public static Builder builder() { - return new Builder(); - } - - public static class Builder { - - private String serverUrl; - private String apiKey; - private String userName; - private String password; - private RestClient restClient; - private String indexName = "default"; - private Integer dimension; - - /** - * @param serverUrl Elasticsearch Server URL - * @return builder - */ - public Builder serverUrl(String serverUrl) { - this.serverUrl = serverUrl; - return this; - } - - /** - * @param apiKey Elasticsearch API key (optional) - * @return builder - */ - public Builder apiKey(String apiKey) { - this.apiKey = apiKey; - return this; - } - - /** - * @param userName Elasticsearch userName (optional) - * @return builder - */ - public Builder userName(String userName) { - this.userName = userName; - return this; - } - - /** - * @param password Elasticsearch password (optional) - * @return builder - */ - public Builder password(String password) { - this.password = password; - return this; - } - - /** - * @param restClient Elasticsearch RestClient (optional). Effectively - * overrides all other connection parameters like - * serverUrl, etc. - * @return builder - */ - public Builder restClient(RestClient restClient) { - this.restClient = restClient; - return this; - } - - /** - * @param indexName Elasticsearch index name (optional). Default value: - * "default". Index will be created automatically if not - * exists. - * @return builder - */ - public Builder indexName(String indexName) { - this.indexName = indexName; - return this; - } - - /** - * @param dimension Embedding vector dimension (mandatory when index does - * not exist yet). - * @return builder - */ - public Builder dimension(Integer dimension) { - this.dimension = dimension; - return this; - } - - public IvyElasticSearchEmbeddingStore build() { - if (restClient != null) { - return new IvyElasticSearchEmbeddingStore(restClient, indexName, - dimension); - } else { - return new IvyElasticSearchEmbeddingStore(serverUrl, apiKey, userName, - password, indexName, dimension); - } - } - } - - @Override - public String add(Embedding embedding) { - String id = randomUUID(); - add(id, embedding); - return id; - } - - @Override - public void add(String id, Embedding embedding) { - addInternal(id, embedding, null); - } - - @Override - public String add(Embedding embedding, TextSegment textSegment) { - String id = randomUUID(); - addInternal(id, embedding, textSegment); - return id; - } - - @Override - public List addAll(List embeddings) { - List ids = embeddings.stream().map(ignored -> randomUUID()) - .collect(toList()); - addAllInternal(ids, embeddings, null); - return ids; - } - - @Override - public List addAll(List embeddings, - List embedded) { - List ids = embeddings.stream().map(ignored -> randomUUID()) - .collect(toList()); - addAllInternal(ids, embeddings, embedded); - return ids; - } - - @Override - public EmbeddingSearchResult search( - EmbeddingSearchRequest embeddingSearchRequest) { - try { - // Use Script Score and cosineSimilarity to calculate - // see - // https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-script-score-query.html#vector-functions-cosine - ScriptScoreQuery scriptScoreQuery = buildScriptScoreQuery( - embeddingSearchRequest.queryEmbedding().vector(), - (float) embeddingSearchRequest.minScore(), - embeddingSearchRequest.filter()); - SearchResponse response = client - .search( - co.elastic.clients.elasticsearch.core.SearchRequest.of(s -> s - .index(indexName).query(q -> q.scriptScore(scriptScoreQuery)) - .size(embeddingSearchRequest.maxResults())), - EsDocument.class); - - return new EmbeddingSearchResult<>(toMatches(response)); - } catch (IOException e) { - throw new ElasticsearchRequestFailedException(e.getMessage()); - } - } - - @Override - public void removeAll(Collection ids) { - ensureNotEmpty(ids, "ids"); - removeByIds(ids); - } - - @Override - public void removeAll(Filter filter) { - ensureNotNull(filter, "filter"); - Query query = ElasticsearchMetadataFilterMapper.map(filter); - removeByQuery(query); - } - - @Override - public void removeAll() { - Query query = Query.of(q -> q.matchAll(m -> m)); - removeByQuery(query); - } - - private ScriptScoreQuery buildScriptScoreQuery(float[] vector, float minScore, - Filter filter) throws JsonProcessingException { - - Query query; - if (filter == null) { - query = Query.of(q -> q.matchAll(m -> m)); - } else { - query = ElasticsearchMetadataFilterMapper.map(filter); - } - - return ScriptScoreQuery.of(q -> q.minScore(minScore).query(query) - .script(s -> s.inline(InlineScript.of(i -> i - // The script adds 1.0 to the cosine similarity to prevent the score - // from being negative. - // divided by 2 to keep score in the range [0, 1] - .source( - "(cosineSimilarity(params.query_vector, 'vector') + 1.0) / 2") - .params("query_vector", toJsonData(vector)))))); - } - - private JsonData toJsonData(T rawData) { - try { - return JsonData.fromJson(objectMapper.writeValueAsString(rawData)); - } catch (JsonProcessingException e) { - throw new RuntimeException(e); - } - } - - private void addInternal(String id, Embedding embedding, - TextSegment embedded) { - addAllInternal(singletonList(id), singletonList(embedding), - embedded == null ? null : singletonList(embedded)); - } - - private void addAllInternal(List ids, List embeddings, - List embedded) { - if (isNullOrEmpty(ids) || isNullOrEmpty(embeddings)) { - log.info("[do not add empty embeddings to elasticsearch]"); - return; - } - ensureTrue(ids.size() == embeddings.size(), - "ids size is not equal to embeddings size"); - ensureTrue(embedded == null || embeddings.size() == embedded.size(), - "embeddings size is not equal to embedded size"); - - try { - bulkIndex(ids, embeddings, embedded); - } catch (IOException e) { - throw new ElasticsearchRequestFailedException(e.getMessage()); - } - } - - private void createIndexIfNotExist(String indexName, Integer dimension) { - try { - BooleanResponse response = client.indices() - .exists(c -> c.index(indexName)); - if (!response.value()) { - ensureGreaterThanZero(dimension, "dimension"); - client.indices().create( - c -> c.index(indexName).mappings(getDefaultMappings(dimension))); - } - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - private TypeMapping getDefaultMappings(int dimension) { - Map properties = new HashMap<>(4); - properties.put("text", Property.of(p -> p.text(TextProperty.of(t -> t)))); - properties.put("vector", Property.of(p -> p.denseVector(DenseVectorProperty - .of(d -> d.dims(dimension).index(true).similarity("cosine"))))); - return TypeMapping.of(c -> c.properties(properties)); - } - - private void bulkIndex(List ids, List embeddings, - List embedded) throws IOException { - int size = ids.size(); - BulkRequest.Builder bulkBuilder = new BulkRequest.Builder(); - for (int i = 0; i < size; i++) { - int finalI = i; - EsDocument document = new EsDocument(); - document.setVector(embeddings.get(i).vector()); - document.setText(embedded == null ? null : embedded.get(i).text()); - document.setMetadata( - embedded == null ? null : embedded.get(i).metadata().toMap()); - bulkBuilder.operations(op -> op.index( - idx -> idx.index(indexName).id(ids.get(finalI)).document(document))); - } - - BulkResponse response = client.bulk(bulkBuilder.build()); - handleBulkResponseErrors(response); - } - - private void handleBulkResponseErrors(BulkResponse response) { - if (response.errors()) { - for (BulkResponseItem item : response.items()) { - throwIfError(item.error()); - } - } - } - - private void throwIfError(ErrorCause errorCause) { - if (errorCause != null) { - throw new ElasticsearchRequestFailedException( - "error:\n" + BusinessEntityConverter.entityToJsonValue(errorCause)); - } - } - - private void removeByQuery(Query query) { - try { - DeleteByQueryResponse response = client - .deleteByQuery(delete -> delete.index(indexName).query(query)); - if (!response.failures().isEmpty()) { - for (BulkIndexByScrollFailure item : response.failures()) { - throwIfError(item.cause()); - } - } - } catch (IOException e) { - throw new ElasticsearchRequestFailedException(e.getMessage()); - } - } - - private void removeByIds(Collection ids) { - try { - bulkRemove(ids); - } catch (IOException e) { - throw new ElasticsearchRequestFailedException(e.getMessage()); - } - } - - private void bulkRemove(Collection ids) throws IOException { - BulkRequest.Builder bulkBuilder = new BulkRequest.Builder(); - for (String id : ids) { - bulkBuilder - .operations(op -> op.delete(dlt -> dlt.index(indexName).id(id))); - } - BulkResponse response = client.bulk(bulkBuilder.build()); - handleBulkResponseErrors(response); - } - - private List> toMatches( - SearchResponse response) { - return response.hits().hits().stream() - .map(hit -> Optional.ofNullable(hit.source()) - .map(document -> new EmbeddingMatch<>(hit.score(), hit.id(), - new Embedding(document.getVector()), - document.getText() == null ? null - : TextSegment.from(document.getText(), - new Metadata(document.getMetadata())))) - .orElse(null)) - .collect(toList()); - } - - // Methods added by mnhnam - - public boolean removeIndex() { - try { - DeleteIndexRequest request = new DeleteIndexRequest.Builder() - .index(indexName).timeout(new Time.Builder().time("1m").build()) - .build(); - DeleteIndexResponse response = client.indices().delete(request); - return response.acknowledged(); - } catch (IOException e) { - throw new ElasticsearchRequestFailedException(e.getMessage()); - } - } - - public boolean isIndexActive() { - ExistsRequest request = new ExistsRequest.Builder().index(indexName) - .build(); - try { - BooleanResponse existsResponse = client.indices().exists(request); - return existsResponse.value(); - } catch (IOException e) { - throw new ElasticsearchRequestFailedException(e.getMessage()); - } - } - - public EmbeddingSearchResult searchExactKnn( - EmbeddingSearchRequest embeddingSearchRequest) { - try { - // Use the Knn query - // see - // https://www.elastic.co/guide/en/elasticsearch/reference/current/knn-search.html#approximate-knn - - KnnQuery knnQuery = buildKnnQuery( - embeddingSearchRequest.queryEmbedding().vector(), - embeddingSearchRequest.maxResults(), embeddingSearchRequest.filter()); - - // Use Script Score and cosineSimilarity to calculate - // see - // https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-script-score-query.html#vector-functions-cosine - ScriptScoreQuery scriptScoreQuery = buildScriptScoreQuery( - embeddingSearchRequest.queryEmbedding().vector(), - (float) embeddingSearchRequest.minScore(), - embeddingSearchRequest.filter()); - - // Combined Script Score query and Knn query to produce an Exact Knn - // search query - // see - // https://www.elastic.co/guide/en/elasticsearch/reference/current/knn-search.html#exact-knn - SearchResponse response = client.search( - co.elastic.clients.elasticsearch.core.SearchRequest - .of(s -> s.index(indexName).knn(knnQuery) - .query(q -> q.scriptScore(scriptScoreQuery)) - .size(embeddingSearchRequest.maxResults())), - EsDocument.class); - - return new EmbeddingSearchResult<>(toMatches(response)); - } catch (IOException e) { - throw new ElasticsearchRequestFailedException(e.getMessage()); - } - } - - public EmbeddingSearchResult searchApproximateKnn( - EmbeddingSearchRequest embeddingSearchRequest) { - try { - // Use the approximate Knn query to search - // see - // https://www.elastic.co/guide/en/elasticsearch/reference/current/knn-search.html#approximate-knn - - KnnQuery knnQuery = buildKnnQuery( - embeddingSearchRequest.queryEmbedding().vector(), - embeddingSearchRequest.maxResults(), embeddingSearchRequest.filter()); - - SearchResponse response = client.search( - co.elastic.clients.elasticsearch.core.SearchRequest - .of(s -> s.index(indexName).knn(knnQuery) - .size(embeddingSearchRequest.maxResults())), - EsDocument.class); - - return new EmbeddingSearchResult<>(toMatches(response)); - } catch (IOException e) { - throw new ElasticsearchRequestFailedException(e.getMessage()); - } - } - - private KnnQuery buildKnnQuery(float[] vectors, int numberOfCandidates, - Filter filter) { - Query query; - if (filter == null) { - query = Query.of(q -> q.matchAll(m -> m)); - } else { - query = ElasticsearchMetadataFilterMapper.map(filter); - } - - List vectorList = new ArrayList<>(); - for (float vector : vectors) { - vectorList.add(vector); - } - - return KnnQuery.of(s -> s.field("vector").numCandidates(numberOfCandidates) - .k(numberOfCandidates).queryVector(vectorList).filter(query)); - } - -} \ No newline at end of file diff --git a/ai-assistant/src/com/axonivy/utils/aiassistant/core/embedding/IvyOpenSearchEmbeddingStore.java b/ai-assistant/src/com/axonivy/utils/aiassistant/core/embedding/IvyOpenSearchEmbeddingStore.java new file mode 100644 index 0000000..e6f8272 --- /dev/null +++ b/ai-assistant/src/com/axonivy/utils/aiassistant/core/embedding/IvyOpenSearchEmbeddingStore.java @@ -0,0 +1,354 @@ +package com.axonivy.utils.aiassistant.core.embedding; + +import static dev.langchain4j.internal.Utils.isNullOrBlank; +import static dev.langchain4j.internal.ValidationUtils.ensureNotNull; +import static java.util.Collections.singletonList; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; +import org.apache.hc.client5.http.auth.AuthScope; +import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; +import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; +import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.message.BasicHeader; +import org.opensearch.client.json.JsonData; +import org.opensearch.client.json.jackson.JacksonJsonpMapper; +import org.opensearch.client.opensearch.OpenSearchClient; +import org.opensearch.client.opensearch._types.FieldValue; +import org.opensearch.client.opensearch._types.InlineScript; +import org.opensearch.client.opensearch._types.Time; +import org.opensearch.client.opensearch._types.mapping.Property; +import org.opensearch.client.opensearch._types.mapping.TextProperty; +import org.opensearch.client.opensearch._types.mapping.TypeMapping; +import org.opensearch.client.opensearch._types.query_dsl.Query; +import org.opensearch.client.opensearch._types.query_dsl.ScriptScoreQuery; +import org.opensearch.client.opensearch._types.query_dsl.TermQuery; +import org.opensearch.client.opensearch.core.BulkRequest; +import org.opensearch.client.opensearch.core.BulkResponse; +import org.opensearch.client.opensearch.core.DeleteByQueryRequest; +import org.opensearch.client.opensearch.core.DeleteByQueryResponse; +import org.opensearch.client.opensearch.core.SearchRequest; +import org.opensearch.client.opensearch.core.SearchResponse; +import org.opensearch.client.opensearch.indices.DeleteIndexRequest; +import org.opensearch.client.opensearch.indices.DeleteIndexResponse; +import org.opensearch.client.opensearch.indices.ExistsRequest; +import org.opensearch.client.transport.OpenSearchTransport; +import org.opensearch.client.transport.aws.AwsSdk2Transport; +import org.opensearch.client.transport.aws.AwsSdk2TransportOptions; +import org.opensearch.client.transport.endpoints.BooleanResponse; +import org.opensearch.client.transport.httpclient5.ApacheHttpClient5TransportBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.core.JsonProcessingException; + +import dev.langchain4j.data.embedding.Embedding; +import dev.langchain4j.store.embedding.opensearch.OpenSearchEmbeddingStore; +import dev.langchain4j.store.embedding.opensearch.OpenSearchRequestFailedException; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.apache.ApacheHttpClient; +import software.amazon.awssdk.regions.Region; + +public class IvyOpenSearchEmbeddingStore { + private static final Logger log = LoggerFactory + .getLogger(OpenSearchEmbeddingStore.class); + + private final String indexName; + private final OpenSearchClient client; + private final String serverUrl; + + private static final String INDEX_NOT_EXIST_ERROR = "Cannot find vector store [%s]"; + private static final String CANNOT_CONNECT_TO_OPEN_SEARCH = "Cannot connect to the OpenSearch server with URL %s"; + + /** + * Creates an instance of OpenSearchEmbeddingStore to connect with + * OpenSearch clusters running locally and network reachable. + * + * @param serverUrl OpenSearch Server URL. + * @param apiKey OpenSearch API key (optional) + * @param userName OpenSearch username (optional) + * @param password OpenSearch password (optional) + * @param indexName OpenSearch index name. + */ + public IvyOpenSearchEmbeddingStore(String serverUrl, + String apiKey, + String userName, + String password, + String indexName) { + HttpHost openSearchHost; + try { + openSearchHost = HttpHost.create(serverUrl); + } catch (URISyntaxException se) { + log.error("[I/O OpenSearch Exception]", se); + throw new OpenSearchRequestFailedException(se.getMessage()); + } + + OpenSearchTransport transport = ApacheHttpClient5TransportBuilder + .builder(openSearchHost) + .setMapper(new JacksonJsonpMapper()) + .setHttpClientConfigCallback(httpClientBuilder -> { + + if (!isNullOrBlank(apiKey)) { + httpClientBuilder.setDefaultHeaders(singletonList( + new BasicHeader("Authorization", "ApiKey " + apiKey) + )); + } + + if (!isNullOrBlank(userName) && !isNullOrBlank(password)) { + BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider(); + credentialsProvider.setCredentials(new AuthScope(openSearchHost), + new UsernamePasswordCredentials(userName, password.toCharArray())); + httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider); + } + + httpClientBuilder.setConnectionManager(PoolingAsyncClientConnectionManagerBuilder.create().build()); + + return httpClientBuilder; + }) + .build(); + + this.serverUrl = serverUrl; + this.client = new OpenSearchClient(transport); + this.indexName = ensureNotNull(indexName, "indexName"); + } + + /** + * Creates an instance of OpenSearchEmbeddingStore to connect with + * OpenSearch clusters running as a fully managed service at AWS. + * + * @param serverUrl OpenSearch Server URL. + * @param serviceName The AWS signing service name, one of `es` (Amazon OpenSearch) or `aoss` (Amazon OpenSearch Serverless). + * @param region The AWS region for which requests will be signed. This should typically match the region in `serverUrl`. + * @param options The options to establish connection with the service. It must include which credentials should be used. + * @param indexName OpenSearch index name. + */ + public IvyOpenSearchEmbeddingStore(String serverUrl, + String serviceName, + String region, + AwsSdk2TransportOptions options, + String indexName) { + + Region selectedRegion = Region.of(region); + + SdkHttpClient httpClient = ApacheHttpClient.builder().build(); + OpenSearchTransport transport = new AwsSdk2Transport(httpClient, serverUrl, serviceName, selectedRegion, options); + + this.serverUrl = serverUrl; + this.client = new OpenSearchClient(transport); + this.indexName = ensureNotNull(indexName, "indexName"); + } + + /** + * Creates an instance of OpenSearchEmbeddingStore using provided OpenSearchClient + * + * @param openSearchClient OpenSearch client provided + * @param indexName OpenSearch index name. + */ + public IvyOpenSearchEmbeddingStore(OpenSearchClient openSearchClient, + String indexName) { + + this.serverUrl = StringUtils.EMPTY; + this.client = ensureNotNull(openSearchClient, "openSearchClient"); + this.indexName = ensureNotNull(indexName, "indexName"); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String serverUrl; + private String apiKey; + private String userName; + private String password; + private String serviceName; + private String region; + private AwsSdk2TransportOptions options; + private String indexName = "default"; + private OpenSearchClient openSearchClient; + + public Builder serverUrl(String serverUrl) { + this.serverUrl = serverUrl; + return this; + } + + public Builder apiKey(String apiKey) { + this.apiKey = apiKey; + return this; + } + + public Builder userName(String userName) { + this.userName = userName; + return this; + } + + public Builder password(String password) { + this.password = password; + return this; + } + + public Builder serviceName(String serviceName) { + this.serviceName = serviceName; + return this; + } + + public Builder region(String region) { + this.region = region; + return this; + } + + public Builder options(AwsSdk2TransportOptions options) { + this.options = options; + return this; + } + + public Builder indexName(String indexName) { + this.indexName = indexName; + return this; + } + + public Builder openSearchClient(OpenSearchClient openSearchClient) { + this.openSearchClient = openSearchClient; + return this; + } + + public IvyOpenSearchEmbeddingStore build() { + if (openSearchClient != null) { + return new IvyOpenSearchEmbeddingStore(openSearchClient, indexName); + } + if (!isNullOrBlank(serviceName) && !isNullOrBlank(region) + && options != null) { + return new IvyOpenSearchEmbeddingStore(serverUrl, serviceName, region, + options, indexName); + } + return new IvyOpenSearchEmbeddingStore(serverUrl, apiKey, userName, + password, + indexName); + } + + } + + /** + * This implementation uses the exact k-NN with scoring script to calculate + * See https://opensearch.org/docs/latest/search-plugins/knn/knn-score-script/ + */ + + public SearchResponse findRelevantDocuments( + Embedding referenceEmbedding, int maxResults, double minScore) { + try { + ScriptScoreQuery scriptScoreQuery = buildDefaultScriptScoreQuery( + referenceEmbedding.vector(), (float) minScore); + SearchResponse response = client.search( + SearchRequest.of(s -> s.index(indexName) + .query(n -> n.scriptScore(scriptScoreQuery)).size(maxResults)), + EmbeddingDocument.class); + return response; + } catch (IOException ex) { + return null; + } + } + + private ScriptScoreQuery buildDefaultScriptScoreQuery(float[] vector, + float minScore) throws JsonProcessingException { + + return ScriptScoreQuery.of(q -> q.minScore(minScore) + .query(Query.of(qu -> qu.matchAll(m -> m))) + .script(s -> s.inline(InlineScript.of(i -> i.source("knn_score") + .lang("knn").params("field", JsonData.of("vector")) + .params("query_value", JsonData.of(vector)) + .params("space_type", JsonData.of("cosinesimil"))))) + .boost(0.5f)); + + // ===> From the OpenSearch documentation: + // "Cosine similarity returns a number between -1 and 1, and because + // OpenSearch + // relevance scores can't be below 0, the k-NN plugin adds 1 to get the + // final score." + // See + // https://opensearch.org/docs/latest/search-plugins/knn/knn-score-script + // Thus, the query applies a boost of `0.5` to keep score in the range [0, + // 1] + } + + public void createIndexIfNotExist(int dimension) throws IOException { + BooleanResponse response; + response = client.indices().exists(c -> c.index(indexName)); + if (!response.value()) { + client.indices().create(c -> c.index(indexName).settings(s -> s.knn(true)) + .mappings(getDefaultMappings(dimension))); + } + } + + private TypeMapping getDefaultMappings(int dimension) { + Map properties = new HashMap<>(4); + properties.put("text", Property.of(p -> p.text(TextProperty.of(t -> t)))); + properties.put("vector", + Property.of(p -> p.knnVector(k -> k.dimension(dimension)))); + return TypeMapping.of(c -> c.properties(properties)); + } + + public BulkResponse bulkIndex(String indexName, + List docs) throws IOException { + + BulkRequest.Builder bulkBuilder = new BulkRequest.Builder(); + for (int i = 0; i < docs.size(); i++) { + int finalI = i; + bulkBuilder.operations(op -> op.index(idx -> idx.index(indexName) + .id(String.join("_", indexName, Integer.toString(finalI))) + .document(docs.get(finalI)))); + } + + return client.bulk(bulkBuilder.build()); + } + + public boolean removeIndex() throws IOException { + if (StringUtils.isNotBlank(isIndexActive())) { + return false; + } + DeleteIndexRequest request = new DeleteIndexRequest.Builder() + .index(indexName).timeout(new Time.Builder().time("1m").build()) + .build(); + DeleteIndexResponse response = client.indices().delete(request); + return response.acknowledged(); + } + + public String isIndexActive() { + try { + ExistsRequest request = new ExistsRequest.Builder().index(indexName) + .build(); + BooleanResponse existsResponse = client.indices().exists(request); + return existsResponse.value() ? StringUtils.EMPTY + : String.format(INDEX_NOT_EXIST_ERROR, indexName); + } catch (Exception e) { + return String.format(CANNOT_CONNECT_TO_OPEN_SEARCH, serverUrl); + } + + } + + public BulkResponse bulkEmbeddingDocumentById(String indexName, + String documentId, EmbeddingDocument doc) throws IOException { + + BulkRequest.Builder bulkBuilder = new BulkRequest.Builder(); + bulkBuilder.operations(op -> op + .index(idx -> idx.index(indexName).id(documentId).document(doc))); + return client.bulk(bulkBuilder.build()); + } + + public boolean removeEmbeddingDocumentById(String id) throws IOException { + DeleteByQueryRequest request = new DeleteByQueryRequest.Builder() + .index(indexName) + .query(TermQuery.of(t -> t.field("id").value(FieldValue.of(id))).toQuery()) + .timeout(new Time.Builder().time("1m").build()) + .build(); + + DeleteByQueryResponse response = client.deleteByQuery(request); + return response.deleted() != 0; + } +} \ No newline at end of file diff --git a/ai-assistant/src/com/axonivy/utils/aiassistant/dto/flow/AiStep.java b/ai-assistant/src/com/axonivy/utils/aiassistant/dto/flow/AiStep.java index 3b98ba1..4c4ac4c 100644 --- a/ai-assistant/src/com/axonivy/utils/aiassistant/dto/flow/AiStep.java +++ b/ai-assistant/src/com/axonivy/utils/aiassistant/dto/flow/AiStep.java @@ -4,8 +4,6 @@ import java.util.List; import java.util.Map; import java.util.Map.Entry; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; @@ -128,44 +126,6 @@ public static String getFormattedMetadatas(Map metadatas) { return result; } - public static String extractTextInsideTag(String text) { - String tagPattern = "<([^>]+)>"; // Regex pattern to match characters inside - // <> - Pattern pattern = Pattern.compile(tagPattern); - Matcher matcher = pattern.matcher(text); - - if (matcher.find()) { - return matcher.group(1); // Return the first captured group - } - return StringUtils.EMPTY; - } - - public static String extractJsonArray(String text) { - String tagPattern = "\\[([^\\]]+)]"; // Regex pattern to match characters - // inside [] - Pattern pattern = Pattern.compile(tagPattern); - Matcher matcher = pattern.matcher(text); - - if (matcher.find()) { - return "[" + matcher.group(1) + "]"; // Return the first captured group - // inside array characters - } - return StringUtils.EMPTY; - } - - - public static String extractTextInsideDoubleTag(String text) { - String tagPattern = "<<([^>]+)>>"; // Regex pattern to match characters - // inside <<>> - Pattern pattern = Pattern.compile(tagPattern); - Matcher matcher = pattern.matcher(text); - - if (matcher.find()) { - return matcher.group(1); // Return the first captured group - } - return StringUtils.EMPTY; - } - public String getCustomInstruction() { return customInstruction; } diff --git a/ai-assistant/src/com/axonivy/utils/aiassistant/dto/flow/RephraseStep.java b/ai-assistant/src/com/axonivy/utils/aiassistant/dto/flow/RephraseStep.java index 61c6119..eae1ec1 100644 --- a/ai-assistant/src/com/axonivy/utils/aiassistant/dto/flow/RephraseStep.java +++ b/ai-assistant/src/com/axonivy/utils/aiassistant/dto/flow/RephraseStep.java @@ -19,6 +19,7 @@ import com.axonivy.utils.aiassistant.enums.StepType; import com.axonivy.utils.aiassistant.prompts.AiFlowPromptTemplates; import com.axonivy.utils.aiassistant.service.AiFunctionService; +import com.axonivy.utils.aiassistant.utils.AiFunctionUtils; public class RephraseStep extends AiStep { private static final long serialVersionUID = -4106563714989416129L; @@ -57,7 +58,8 @@ public void run(String message, List memory, String resultFromAI = assistant.getAiModel().getAiBot().chat(params, AiFlowPromptTemplates.RE_PHRASE_STEP); - String extractedRephrasedText = extractTextInsideTag(resultFromAI); + String extractedRephrasedText = AiFunctionUtils + .extractTextInsideTag(resultFromAI); AiResultDTO resultDto = new AiResultDTO(); if (StringUtils.isNotBlank(extractedRephrasedText)) { diff --git a/ai-assistant/src/com/axonivy/utils/aiassistant/dto/flow/SwitchStep.java b/ai-assistant/src/com/axonivy/utils/aiassistant/dto/flow/SwitchStep.java index d7668ff..60f3e92 100644 --- a/ai-assistant/src/com/axonivy/utils/aiassistant/dto/flow/SwitchStep.java +++ b/ai-assistant/src/com/axonivy/utils/aiassistant/dto/flow/SwitchStep.java @@ -15,6 +15,7 @@ import com.axonivy.utils.aiassistant.dto.tool.AiFunction; import com.axonivy.utils.aiassistant.enums.StepType; import com.axonivy.utils.aiassistant.prompts.AiFlowPromptTemplates; +import com.axonivy.utils.aiassistant.utils.AiFunctionUtils; import com.fasterxml.jackson.annotation.JsonProperty; public class SwitchStep extends AiStep { @@ -56,7 +57,8 @@ public void run(String message, List memory, String resultFromAI = assistant.getAiModel().getAiBot().chat(params, AiFlowPromptTemplates.FULFILL_CONDITIONAL_STEP); - setOnSuccess(NumberUtils.toInt(extractTextInsideTag(resultFromAI), -1)); + setOnSuccess(NumberUtils + .toInt(AiFunctionUtils.extractTextInsideTag(resultFromAI), -1)); } private String generateConditionsString() { diff --git a/ai-assistant/src/com/axonivy/utils/aiassistant/dto/flow/TextStep.java b/ai-assistant/src/com/axonivy/utils/aiassistant/dto/flow/TextStep.java index 500f9ba..1a6eb6b 100644 --- a/ai-assistant/src/com/axonivy/utils/aiassistant/dto/flow/TextStep.java +++ b/ai-assistant/src/com/axonivy/utils/aiassistant/dto/flow/TextStep.java @@ -17,6 +17,7 @@ import com.axonivy.utils.aiassistant.dto.tool.AiFunction; import com.axonivy.utils.aiassistant.enums.StepType; import com.axonivy.utils.aiassistant.prompts.AiFlowPromptTemplates; +import com.axonivy.utils.aiassistant.utils.AiFunctionUtils; public class TextStep extends AiStep { @@ -72,7 +73,7 @@ private void getResultUsingAI(AiResultDTO resultToDisplay, params.put(AiConstants.CUSTOM_INSTRUCTION, Optional.ofNullable(getCustomInstruction()).orElse(StringUtils.EMPTY)); - String extractedText = extractTextInsideTag( + String extractedText = AiFunctionUtils.extractTextInsideTag( bot.chat(params, AiFlowPromptTemplates.TEXT_STEP_USE_AI)) .concat(System.lineSeparator()).concat(System.lineSeparator()) .concat(Optional.ofNullable(resultToDisplay).map(AiResultDTO::getResult) diff --git a/ai-assistant/src/com/axonivy/utils/aiassistant/dto/tool/IvyTool.java b/ai-assistant/src/com/axonivy/utils/aiassistant/dto/tool/IvyTool.java index 21d377b..09accc4 100644 --- a/ai-assistant/src/com/axonivy/utils/aiassistant/dto/tool/IvyTool.java +++ b/ai-assistant/src/com/axonivy/utils/aiassistant/dto/tool/IvyTool.java @@ -17,7 +17,6 @@ import com.axonivy.utils.aiassistant.constant.AiConstants; import com.axonivy.utils.aiassistant.core.AbstractAIBot; import com.axonivy.utils.aiassistant.dto.Assistant; -import com.axonivy.utils.aiassistant.dto.flow.AiStep; import com.axonivy.utils.aiassistant.dto.history.ChatMessage; import com.axonivy.utils.aiassistant.dto.history.Conversation; import com.axonivy.utils.aiassistant.dto.history.StreamingMessage; @@ -157,8 +156,8 @@ private boolean fulfillNormalAttributes(List memory, try { fulfilled = BusinessEntityConverter - .jsonValueToEntities( - AiStep.extractJsonArray(AiStep.extractTextInsideTag( + .jsonValueToEntities(AiFunctionUtils + .extractJsonArray(AiFunctionUtils.extractTextInsideTag( bot.chat(params, BasicPromptTemplates.FULFILL_IVY_TOOL))), IvyToolAttribute.class); } catch (Exception e) { @@ -206,7 +205,7 @@ public IvyToolAttribute fulfillIvyToolAttribute(List memory, params.put(AiConstants.METADATA, metadata); return BusinessEntityConverter.jsonValueToEntity( - AiStep.extractTextInsideTag( + AiFunctionUtils.extractTextInsideTag( bot.chat(params, BasicPromptTemplates.FULFILL_IVY_ATTRIBUTE)), IvyToolAttribute.class); diff --git a/ai-assistant/src/com/axonivy/utils/aiassistant/dto/tool/RetrievalQATool.java b/ai-assistant/src/com/axonivy/utils/aiassistant/dto/tool/RetrievalQATool.java index 884cbc8..7f6e7f8 100644 --- a/ai-assistant/src/com/axonivy/utils/aiassistant/dto/tool/RetrievalQATool.java +++ b/ai-assistant/src/com/axonivy/utils/aiassistant/dto/tool/RetrievalQATool.java @@ -5,12 +5,14 @@ import java.util.Optional; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.math.NumberUtils; import com.axonivy.utils.aiassistant.constant.AiConstants; import com.axonivy.utils.aiassistant.dto.Assistant; import com.axonivy.utils.aiassistant.enums.ToolType; import com.axonivy.utils.aiassistant.prompts.BasicPromptTemplates; import com.axonivy.utils.aiassistant.prompts.RagPromptTemplates; +import com.axonivy.utils.aiassistant.utils.AiFunctionUtils; import ch.ivyteam.ivy.environment.Ivy; @@ -65,7 +67,18 @@ public String answer(Assistant assistant, String message) { BasicPromptTemplates.generateContactPrompt( assistant.getContactEmail(), assistant.getContactWebsite())); + int requestType = analyzeRequestType(assistant, message); + params.put("structureGuidelines", + RagPromptTemplates.getStructuredOutputInstruction(requestType)); + return assistant.getAiModel().getAiBot().chat(params, RagPromptTemplates.RAG_PROMPT_TEMPLATE); } + + public static int analyzeRequestType(Assistant assistant, String message) { + String messageFromAI = assistant.getAiModel().getAiBot() + .chat(RagPromptTemplates.getAnalyzeRequestTypePromptTemplate(message)); + return NumberUtils + .toInt(AiFunctionUtils.extractTextInsideTag(messageFromAI), 1); + } } \ No newline at end of file diff --git a/ai-assistant/src/com/axonivy/utils/aiassistant/enums/AiVariable.java b/ai-assistant/src/com/axonivy/utils/aiassistant/enums/AiVariable.java index cd502e5..662a7fe 100644 --- a/ai-assistant/src/com/axonivy/utils/aiassistant/enums/AiVariable.java +++ b/ai-assistant/src/com/axonivy/utils/aiassistant/enums/AiVariable.java @@ -3,7 +3,7 @@ public enum AiVariable { AI_ASSISTANT("AiAssistant.Assistants"), AI_FUNCTIONS("AiAssistant.AiFunctions"), - ELASTIC_SEARCH_URL("AiAssistant.ElasticSearchUrl"), + OPEN_SEARCH_VECTOR_STORE_URL("AiAssistant.OpenSearchVectorStoreUrl"), SUGGESTIONS("AiAssistant.Suggestions"), AI_ASSISTANT_TEMPLATES("AiAssistant.AssistantTemplates"); diff --git a/ai-assistant/src/com/axonivy/utils/aiassistant/prompts/RagPromptTemplates.java b/ai-assistant/src/com/axonivy/utils/aiassistant/prompts/RagPromptTemplates.java index 7d414c0..fe0b304 100644 --- a/ai-assistant/src/com/axonivy/utils/aiassistant/prompts/RagPromptTemplates.java +++ b/ai-assistant/src/com/axonivy/utils/aiassistant/prompts/RagPromptTemplates.java @@ -12,6 +12,12 @@ public class RagPromptTemplates { + private static final int QUESTION_TYPE_WHAT = 1; + private static final int QUESTION_TYPE_HOW = 2; + private static final int QUESTION_TYPE_LIST = 3; + private static final int QUESTION_TYPE_COMPARE = 4; + private static final int QUESTION_TYPE_WHY_CANNOT = 5; + public static final String RAG_PROMPT_TEMPLATE = """ Context: ************************* @@ -29,9 +35,14 @@ public class RagPromptTemplates { Instruction: - Don't try to create the answer from your own knowledge - Restructure the answer to make it easier to understand - - Prefer the context has higher matching score - - Prefer to answer with image from the Context part + - Prioritize to answer with contexts have higher score - MUST answer in this language regardless the language of user message: {{language}} + - Criticize the answer: + + if you found something incorrect, Tell user that you don't know the answer for his question, please ask something else or try to contact provided contact info. + + otherwise, show the answer + ************************* + How to structure the answer: + {{structureGuidelines}} ************************* Query: {{request}}"""; @@ -55,6 +66,87 @@ public class RagPromptTemplates { {{context}} --------------------------"""; + public static final String ANALYZE_QUESTION_TYPE_TEMPLATE = """ + Query: {{request}} + + Conditions: + - 1: Question about what is something + - 2: Question about how to do something + - 3: Request to list out something + - 4: Request to compare something + - 5: Question why cannot do something + + Instruction: + 1. Analyze the query + 2. Choose the right condition. + 3. Show the number of the selected condition as a tag <>. + Example: If the correct condition is "2", then you should show "<2>" + """; + + private static final String QUESTION_TYPE_WHAT_INSTRUCTION = """ + The query is about the definition. Structure your format as follow: + + The definition of the subject + + --- + + List out aspects of the subject such as its subtypes, use case, example,... + List out aspect of details of features or subtypes + + --- + + List out restrictions or limitations of the subject + + --- + + Show all images related to the subject + """; + + private static final String QUESTION_TYPE_HOW_INSTRUCTION = """ + The query is about how to do something. Structure your format as follow: + + List out steps to archive the goal, analyze and explain them. Show related images for each step as much as possible + + --- + + List out limitations or risks if any + + --- + + Summary + """; + + private static final String QUESTION_TYPE_COMPARE_INSTRUCTION = """ + The query is about compare something. Structure your format as follow: + + The header about the comparison + + Then show a table about the all the differences and their details or related information + Highlight the important differences points + + --- + + Give a brief summary + """; + + private static final String QUESTION_TYPE_LIST_INSTRUCTION = """ + The query is about list out something. Structure your format as follow: + + List out details of the related list. Even sub lists if you can found. + --- + + a brief summary of the list above + """; + + private static final String QUESTION_TYPE_WHY_CANNOT_INSTRUCTION = """ + The query is about asking why something cannot be achived. Structure your format as follow: + + List out found solutions and their details, how to perform the solution + --- + + If the contact point above has website or email, tell user he can contact if the answer does not help + """; + public static String formatRetrievedDocuments( List> retrievedDocuments) { String result = ""; @@ -76,4 +168,22 @@ public static String formatRetrievedDocuments( } return result; } + + public static String getAnalyzeRequestTypePromptTemplate(String request) { + Map params = new HashMap<>(); + params.put(AiConstants.REQUEST, request); + return PromptTemplate.from(ANALYZE_QUESTION_TYPE_TEMPLATE).apply(params) + .text(); + } + + public static String getStructuredOutputInstruction(int requestType) { + return switch (requestType) { + case QUESTION_TYPE_WHAT -> QUESTION_TYPE_WHAT_INSTRUCTION; + case QUESTION_TYPE_HOW -> QUESTION_TYPE_HOW_INSTRUCTION; + case QUESTION_TYPE_LIST -> QUESTION_TYPE_LIST_INSTRUCTION; + case QUESTION_TYPE_COMPARE -> QUESTION_TYPE_COMPARE_INSTRUCTION; + case QUESTION_TYPE_WHY_CANNOT -> QUESTION_TYPE_WHY_CANNOT_INSTRUCTION; + default -> ""; + }; + } } diff --git a/ai-assistant/src/com/axonivy/utils/aiassistant/rest/AssistantRestService.java b/ai-assistant/src/com/axonivy/utils/aiassistant/rest/AssistantRestService.java index c84a41e..7e76341 100644 --- a/ai-assistant/src/com/axonivy/utils/aiassistant/rest/AssistantRestService.java +++ b/ai-assistant/src/com/axonivy/utils/aiassistant/rest/AssistantRestService.java @@ -369,6 +369,10 @@ private void handleRetrievalQATool(AsyncResponse response, String message, params.put(AiConstants.CONTACT_PART, BasicPromptTemplates.generateContactPrompt( assistant.getContactEmail(), assistant.getContactWebsite())); + + int requestType = RetrievalQATool.analyzeRequestType(assistant, message); + params.put("structureGuidelines", + RagPromptTemplates.getStructuredOutputInstruction(requestType)); response.resume(BusinessEntityConverter.entityToJsonValue( new StreamingMessage(conversation.getId(), AIState.IN_PROGRESS, diff --git a/ai-assistant/src/com/axonivy/utils/aiassistant/service/PortalDocService.java b/ai-assistant/src/com/axonivy/utils/aiassistant/service/PortalDocService.java index 3f42e11..3179970 100644 --- a/ai-assistant/src/com/axonivy/utils/aiassistant/service/PortalDocService.java +++ b/ai-assistant/src/com/axonivy/utils/aiassistant/service/PortalDocService.java @@ -1,6 +1,7 @@ package com.axonivy.utils.aiassistant.service; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -28,8 +29,13 @@ public class PortalDocService { private static final String HEADER_PREFIX = "# "; private static final String SUB_HEADER_PREFIX = "## "; + private static final String SMALL_SUB_HEADER_PREFIX = "#### "; + private static final String TWO_LEVELS_PATH_PREFIX = "../../"; - private static final String IMAGE_FORMAT = "![%s](%s)"; + private static final String LINK_FORMAT = "[%s](%s)"; + private static final String IMAGE_FORMAT = "!" + LINK_FORMAT; + private static final String CODE_BLOCK = "```"; + private static final String UNKNOWN_CHARACTER_CODE = "?"; public static void createTextIndex(AbstractAIBot bot, String index, List contents) throws IOException { @@ -76,19 +82,25 @@ private static List proceedEachContent(String content) { if (line.startsWith(HEADER_PREFIX)) { headerKeyword = line.strip(); + keyword = headerKeyword; handleBlockText(headerKeyword, keyword, blockText, result); continue; } if (line.startsWith(SUB_HEADER_PREFIX)) { - keyword = line.strip(); handleBlockText(headerKeyword, keyword, blockText, result); + keyword = line.strip(); continue; } // If a line does not contain keywords, append them to the block text blockText.add(line); } + + if (!blockText.isEmpty()) { + handleBlockText(headerKeyword, keyword, blockText, result); + } + return result; } @@ -97,8 +109,8 @@ private static void handleBlockText(String headerKeyword, String keyword, if (CollectionUtils.isNotEmpty(blockText)) { Metadata meta = Metadata.from("keywords", String.join(" ", Arrays.asList(headerKeyword, keyword))); - String keywords = headerKeyword.concat(System.lineSeparator()) - .concat(keyword).concat(System.lineSeparator()); + String keywords = String.format("The main topic of this context: %s, %s", + headerKeyword, keyword).concat(System.lineSeparator()); List blockTextWithHeader = new ArrayList<>(); blockTextWithHeader.add(keywords); @@ -116,6 +128,7 @@ public static String convertPortalDocument(String raw) { Document document = Jsoup.parse(raw); // Remove specific elements by tag name + removeElements(document, "head"); removeElements(document, "div[role=search]"); removeElements(document, "ul.current"); removeElements(document, "footer"); @@ -123,12 +136,14 @@ public static String convertPortalDocument(String raw) { removeElements(document, "div.wy-side-nav-search"); removeElements(document, "p.caption[role=heading]"); removeElements(document, "nav.wy-nav-top"); + removeElements(document, "nav.wy-nav-side"); removeElements(document, "ul.wy-breadcrumbs"); removeElements(document, "script"); removeElements(document, "link[rel=stylesheet]"); removeElements(document, "hr"); removeElements(document, "div.toctree-wrapper"); removeElements(document, "a.headerlink"); + removeElements(document, "div.toctree-wrapper"); // Set h1 tags as header will be used as metadata keyword for (Element h1Tag : document.select("h1")) { @@ -144,7 +159,13 @@ public static String convertPortalDocument(String raw) { // Replace image tags with their source links for (Element imgTag : document.select("img")) { - String alt = imgTag.attr("alt"); + String alt = StringUtils.defaultIfBlank(imgTag.attr("alt"), + imgTag.attr("title")); + if (StringUtils.isBlank(alt)) { + alt = Optional.ofNullable(imgTag.select("span.std-ref")) + .map(Elements::first).map(Element::text).orElse(""); + } + String srcLink = imgTag.attr("src").replace(TWO_LEVELS_PATH_PREFIX, IVY_DOC_HOST); if (srcLink.startsWith("screenshots/")) { @@ -157,7 +178,7 @@ public static String convertPortalDocument(String raw) { .replaceWith(new TextNode(String.format(IMAGE_FORMAT, alt, srcLink))); } - // Transform tags to " url here " format + // Transform tags to markdown link format for (Element anchorTag : document.select("a")) { String hrefLink = anchorTag.attr("href"); if (hrefLink.startsWith("#")) { @@ -168,51 +189,38 @@ public static String convertPortalDocument(String raw) { // If a link is a svg file, remove it anchorTag.remove(); } else { - anchorTag.replaceWith(new TextNode("" + hrefLink + "")); + String alt = StringUtils.defaultIfBlank(anchorTag.attr("alt"), anchorTag.attr("title")); + if (StringUtils.isBlank(alt)) { + alt = Optional.ofNullable(anchorTag.select("span.std-ref")) + .map(Elements::first).map(Element::text).orElse(""); + byte[] ptext = alt.getBytes(StandardCharsets.ISO_8859_1); + alt = new String(ptext, StandardCharsets.UTF_8); + // remove the key icon + alt.replace(UNKNOWN_CHARACTER_CODE, ""); + } + anchorTag.replaceWith(new TextNode( + String.format(LINK_FORMAT, alt, hrefLink))); } } } + // Transform notes title to sub header + for (Element noteTag : document.select("div.admonition")) { + Element title = noteTag.select(".admonition-title").first(); + title.replaceWith(new TextNode( + StringUtils.LF + SMALL_SUB_HEADER_PREFIX + title.text() + + StringUtils.LF)); + } + // Transform tables into readable format for (Element tableTag : document.select("table")) { - Elements rows = tableTag.select("tr"); - - Elements headers = rows.get(0).select("thead"); - if (headers.size() > 0 && rows.size() > 1) { // Ensure there are headers - // and at least one row of - // data - - StringBuilder tableBuilder = new StringBuilder("Table content:") - .append(StringUtils.LF); - - for (int rowIndex = 1; rowIndex < rows.size(); rowIndex++) { // Skip the - // header - // row - Elements dataCols = rows.get(rowIndex).select("td"); - tableBuilder.append("row ").append(rowIndex).append(":") - .append(StringUtils.LF); - - for (int colIndex = 0; colIndex < headers.size() - && colIndex < dataCols.size(); colIndex++) { - tableBuilder.append(headers.get(colIndex).text()).append(": ") - .append(dataCols.get(colIndex).text()); - - if (colIndex < headers.size() - 1) { - tableBuilder.append(", "); - } - } - - tableBuilder.append(StringUtils.LF); - } - - tableTag.replaceWith(Jsoup.parse(tableBuilder.toString())); - } - + tableTag.replaceWith(Jsoup.parse(convertTableToMarkdown(tableTag))); } - // Added "Code block: " tag for all code blocks + // Transformed code block to markdown format for (Element codeBlockDiv : document.select("div.notranslate")) { - codeBlockDiv.before("__code_block: "); + codeBlockDiv.before(CODE_BLOCK); + codeBlockDiv.after(CODE_BLOCK); } // Add a new line after each tag for better readability @@ -242,4 +250,34 @@ private static void removeElements(Document document, String cssQuery) { element.remove(); } } + + private static String convertTableToMarkdown(Element table) { + StringBuilder markdown = new StringBuilder(); + + // Convert table headers + Elements headers = table.select("thead tr th"); + if (!headers.isEmpty()) { + for (Element header : headers) { + markdown.append("| ").append(header.text()).append(" "); + } + markdown.append("|\n"); + + // Add separator line for headers + headers.forEach(header -> markdown.append("|---")); + markdown.append("|\n"); + } + + // Convert table rows + Elements rows = table.select("tbody tr"); + for (Element row : rows) { + Elements cells = row.select("td"); + for (Element cell : cells) { + markdown.append("| ").append(cell.text()).append(" "); + } + markdown.append("|\n"); + } + + return markdown.toString(); + } + } \ No newline at end of file diff --git a/ai-assistant/src/com/axonivy/utils/aiassistant/utils/AiFunctionUtils.java b/ai-assistant/src/com/axonivy/utils/aiassistant/utils/AiFunctionUtils.java index 9a8f57a..94a3f8a 100644 --- a/ai-assistant/src/com/axonivy/utils/aiassistant/utils/AiFunctionUtils.java +++ b/ai-assistant/src/com/axonivy/utils/aiassistant/utils/AiFunctionUtils.java @@ -1,6 +1,10 @@ package com.axonivy.utils.aiassistant.utils; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; import ch.ivyteam.ivy.process.call.SubProcessCallStart; import ch.ivyteam.ivy.process.call.SubProcessSearchFilter; @@ -25,4 +29,41 @@ public static Boolean checkIvyToolInSecurityContext(String signature) { return CollectionUtils.isNotEmpty(subProcessStartList); }); } + + public static String extractTextInsideTag(String text) { + String tagPattern = "<([^>]+)>"; // Regex pattern to match characters inside + // <> + Pattern pattern = Pattern.compile(tagPattern); + Matcher matcher = pattern.matcher(text); + + if (matcher.find()) { + return matcher.group(1); // Return the first captured group + } + return StringUtils.EMPTY; + } + + public static String extractJsonArray(String text) { + String tagPattern = "\\[([^\\]]+)]"; // Regex pattern to match characters + // inside [] + Pattern pattern = Pattern.compile(tagPattern); + Matcher matcher = pattern.matcher(text); + + if (matcher.find()) { + return "[" + matcher.group(1) + "]"; // Return the first captured group + // inside array characters + } + return StringUtils.EMPTY; + } + + public static String extractTextInsideDoubleTag(String text) { + String tagPattern = "<<([^>]+)>>"; // Regex pattern to match characters + // inside <<>> + Pattern pattern = Pattern.compile(tagPattern); + Matcher matcher = pattern.matcher(text); + + if (matcher.find()) { + return matcher.group(1); // Return the first captured group + } + return StringUtils.EMPTY; + } } diff --git a/ai-assistant/src/com/axonivy/utils/aiassistant/utils/BotUtils.java b/ai-assistant/src/com/axonivy/utils/aiassistant/utils/BotUtils.java index 5c22249..9cc9e6e 100644 --- a/ai-assistant/src/com/axonivy/utils/aiassistant/utils/BotUtils.java +++ b/ai-assistant/src/com/axonivy/utils/aiassistant/utils/BotUtils.java @@ -7,6 +7,6 @@ public class BotUtils { public static AbstractAIBot getBot() { - return new OpenAIBot(AiModelService.getSecondaryOpenAIModel()); + return new OpenAIBot(AiModelService.getPrimaryOpenAIModel()); } } \ No newline at end of file diff --git a/ai-assistant/webContent/resources/css/chatbot.css b/ai-assistant/webContent/resources/css/chatbot.css index cc59771..2e9703b 100644 --- a/ai-assistant/webContent/resources/css/chatbot.css +++ b/ai-assistant/webContent/resources/css/chatbot.css @@ -508,11 +508,9 @@ body .chatbot-send-form .chat-send-form-button button.ui-button.ui-button-icon-o /* Theme for elements inside message */ .chat-message table { border-spacing: 0; -} - -.chat-message table { margin-top: 1em; margin-bottom: 1em; + word-break: auto-phrase; } .chat-message table > thead > tr > th { diff --git a/ai-assistant/webContent/resources/js/ai-assistant.js b/ai-assistant/webContent/resources/js/ai-assistant.js index 497c27c..811d54c 100644 --- a/ai-assistant/webContent/resources/js/ai-assistant.js +++ b/ai-assistant/webContent/resources/js/ai-assistant.js @@ -6,6 +6,7 @@ const IVY = 'IVY'; const RETRIEVAL_QA = 'RETRIEVAL_QA'; const VALIDATE_ERROR = 'ERROR'; var streamingValue = ''; +var streamingText = ''; var workingFlow = null; @@ -300,9 +301,13 @@ function Assistant(ivyUri, uri, view, assistantId, conversationId, username) { if (result.status == 'in_progress') { streaming = true; + if (result.token != null) { + streamingText += result.token; + } if (result.token != null) { if (result.token.startsWith(result.conversationId)) { streaming = false; + streamingText = ''; streamingValue = result.token.replace(result.conversationId, '').trim(); view.removeStreamingClassFromMessage(); view.enableSendButton(); @@ -761,6 +766,12 @@ function ViewAI(uri) { // Update existing streaming message streamingMessage.get(0).innerHTML = cloneTemplate.innerHTML; + + if (!isIFrame(streamingMessage.get(0).innerHTML)) { + streamingMessage.find('.js-message').get(0).innerHTML = marked.parse(streamingText); + streamingMessage.find('.js-message').find('img').addClass('w-full'); + streamingMessage.find('.js-message').find('a').attr('target', '_blank').addClass('underline'); + } } // Function to remove the 'streaming' class from a message @@ -779,10 +790,12 @@ function ViewAI(uri) { streamingMessage.removeClass('streaming'); $(streamingMessage).find('.js-message').get(0).innerHTML = converted; $($(streamingMessage).find('.js-message').get(0)).find('img').addClass('w-full'); + $($(streamingMessage).find('.js-message').get(0)).find('a').attr('target', '_blank').addClass('underline'); } else { const messages = messageList.find('.chat-message-container').not('.my-message').find('.js-message'); messages.get(messages.length - 1).innerHTML = converted; $(messages.get(messages.length - 1)).find('img').addClass('w-full'); + $(messages.get(messages.length - 1)).find('a').attr('target', '_blank').addClass('underline'); } if (!isDisableChat) { From 15e1401b1808a23d54ecf7bf3dd6dbe34aa7e52c Mon Sep 17 00:00:00 2001 From: mnhnam-axonivy Date: Tue, 12 Nov 2024 15:35:01 +0700 Subject: [PATCH 2/3] IVYPORTAL-17135 Stage 2: Improve the quality of answers from OpenSearch - Improve answer quality - Implement export conversation feature --- ai-assistant/cms/cms_de.yaml | 3 +- ai-assistant/cms/cms_en.yaml | 3 +- ai-assistant/cms/cms_es.yaml | 9 +- ai-assistant/cms/cms_fr.yaml | 4 +- .../variables/AiAssistant/AiFunctions.json | 290 ++++++++++-------- .../utils/aiassistant/bean/AssistantBean.java | 21 ++ .../bean/UploadPortalDocumentBean.java | 6 +- .../utils/aiassistant/core/OpenAIBot.java | 19 +- .../core/embedding/EmbeddingDocument.java | 6 +- .../IvyOpenSearchEmbeddingStore.java | 21 +- .../prompts/AiFlowPromptTemplates.java | 5 +- .../prompts/RagPromptTemplates.java | 8 +- .../aiassistant/service/PortalDocService.java | 36 ++- .../ChatDashboard/ChatDashboard.xhtml | 37 ++- .../webContent/resources/css/chatbot.css | 17 + .../webContent/resources/js/ai-assistant.js | 23 +- 16 files changed, 322 insertions(+), 186 deletions(-) diff --git a/ai-assistant/cms/cms_de.yaml b/ai-assistant/cms/cms_de.yaml index 62f8fd8..0c4da61 100644 --- a/ai-assistant/cms/cms_de.yaml +++ b/ai-assistant/cms/cms_de.yaml @@ -18,7 +18,8 @@ Dialogs: ToggleNonStartableAiFunction: Nicht startbare KI-Funktionen anzeigen ChatDashboard: AiManagement: AI-Management - ClearConversationHistory: Gesprächsverlauf löschen + ClearConversationHistory: Verlauf löschen + ExportHistory: Verlauf exportieren Title: Assistent Dashboard helper: UploadPortalDocument: diff --git a/ai-assistant/cms/cms_en.yaml b/ai-assistant/cms/cms_en.yaml index d5007d8..8ded2ac 100644 --- a/ai-assistant/cms/cms_en.yaml +++ b/ai-assistant/cms/cms_en.yaml @@ -18,7 +18,8 @@ Dialogs: ToggleNonStartableAiFunction: Show non-startable AI functions ChatDashboard: AiManagement: AI Management - ClearConversationHistory: Clear conversation history + ClearConversationHistory: Clear history + ExportHistory: Export history Title: Assistant Dashboard helper: UploadPortalDocument: diff --git a/ai-assistant/cms/cms_es.yaml b/ai-assistant/cms/cms_es.yaml index 351653d..89e3cc4 100644 --- a/ai-assistant/cms/cms_es.yaml +++ b/ai-assistant/cms/cms_es.yaml @@ -18,7 +18,14 @@ Dialogs: ToggleNonStartableAiFunction: Mostrar funciones de IA no iniciables ChatDashboard: AiManagement: Gestión de la IA - ClearConversationHistory: Borrar el historial de conversaciones + ClearConversationHistory: Borrar historial + ExportHistory: |+ + Exportar historial + + + + + Title: Cuadro de mandos auxiliar helper: UploadPortalDocument: diff --git a/ai-assistant/cms/cms_fr.yaml b/ai-assistant/cms/cms_fr.yaml index 2999d78..b100aa1 100644 --- a/ai-assistant/cms/cms_fr.yaml +++ b/ai-assistant/cms/cms_fr.yaml @@ -18,7 +18,9 @@ Dialogs: ToggleNonStartableAiFunction: Afficher les fonctions d'IA non démarrables ChatDashboard: AiManagement: Gestion de l'IA - ClearConversationHistory: Effacer l'historique de conversation + ClearConversationHistory: | + Effacer l'historique + ExportHistory: Exporter l'historique Title: Tableau de bord de l'assistant helper: UploadPortalDocument: diff --git a/ai-assistant/config/variables/AiAssistant/AiFunctions.json b/ai-assistant/config/variables/AiAssistant/AiFunctions.json index 1475c27..6f0c419 100644 --- a/ai-assistant/config/variables/AiAssistant/AiFunctions.json +++ b/ai-assistant/config/variables/AiAssistant/AiFunctions.json @@ -21,23 +21,31 @@ }, { "action": 2, - "case": "User want to find, list out, show, or look up for process" + "case": "Latest message of user is a request to find, list out, show, or look up for process" + }, + { + "action": 2, + "case": "Latest message of user is a normal sentence about his conditions, for example: got sick, pregnant,..." + }, + { + "action": 2, + "case": "Latest message of user is a normal sentence about his changes, for example: buy new house, just married,..." }, { "action": 3, - "case": "User has a question related to process" + "case": "Latest message of user is a question process. Question could be end with question mark or not" }, { "action": 3, - "case": "User has a question related to process widget" + "case": "Latest message of user is a question process widget. Question could be end with question mark or not" }, { "action": 3, - "case": "User has a question related to process list" + "case": "Latest message of user is a question process list. Question could be end with question mark or not" }, { "action": 3, - "case": "User want to do something different from above conditions" + "case": "Latest message of user about something different from above conditions" } ] }, @@ -90,15 +98,15 @@ }, { "action": 3, - "case": "User has a question related to task" + "case": "Latest message of user is a question task. Question could be end with question mark or not" }, { "action": 3, - "case": "User has a question related to task widget" + "case": "Latest message of user is a question task widget. Question could be end with question mark or not" }, { "action": 3, - "case": "User has a question related to task list" + "case": "Latest message of user is a question task list. Question could be end with question mark or not" }, { "action": 3, @@ -151,15 +159,15 @@ }, { "action": 2, - "case": "User has a question related to case" + "case": "Latest message of user is a question case. Question could be end with question mark or not" }, { "action": 2, - "case": "User has a question related to case widget" + "case": "Latest message of user is a question case widget. Question could be end with question mark or not" }, { "action": 2, - "case": "User has a question related to case list" + "case": "Latest message of user is a question case list. Question could be end with question mark or not" }, { "action": 2, @@ -196,11 +204,33 @@ "steps": [ { "stepNo": 0, + "type": "SWITCH", + "cases": [ + { + "action": 5, + "case": "Latest message of user is a question about why the find case function didn't work" + }, + { + "action": 5, + "case": "Latest message of user is a question about what he should do if he cannot find a case" + }, + { + "action": 1, + "case": "User want to do something different from above conditions" + }, + { + "action": 1, + "case": "User want to find case" + } + ] + }, + { + "stepNo": 1, "type": "RE_PHRASE", "useConversationMemory": true, "toolId": "find-cases", - "onRephrase": 1, - "onSuccess": 1, + "onRephrase": 2, + "onSuccess": 2, "saveToHistory": false, "examples": [ { @@ -214,58 +244,32 @@ ] }, { - "stepNo": 1, + "stepNo": 2, "type": "IVY_TOOL", "toolId": "find-cases", - "onSuccess": -1, - "onError": 4 - }, - { - "stepNo": 2, - "type": "TEXT", - "useAI": true, - "customInstruction": "Generate a question similar to 'I have rephrased your request to find case as follows. Could you please confirm if it is correct?'", - "showResultOfStep": 0, - "onSuccess": 3 + "onSuccess": 4, + "onError": 3, + "saveToHistory": false }, { "stepNo": 3, - "type": "SWITCH", - "cases": [ - { - "action": 1, - "case": "User agree" - }, - { - "action": 0, - "case": "User don't agree and refine the condition to find case" - }, - { - "action": -1, - "case": "User don't want to find case anymore" - } - ] + "type": "TEXT", + "text": "Sorry, I cannot find any case matched your request.", + "onSuccess": -1 }, { "stepNo": 4, "type": "TEXT", - "useAI": true, - "customInstruction": "Generate a question similar to 'Sorry, I cannot find any cases matched your request. Could you please provide more details?'", - "onSuccess": 5 + "text": "I found cases matched your request.", + "onSuccess": -1, + "showResultOfStep": 2 }, { "stepNo": 5, - "type": "SWITCH", - "cases": [ - { - "action": 1, - "case": "User clarify or make another request to find case" - }, - { - "action": -1, - "case": "User say he want to cancel or he make another request that irrelevant to the find case function" - } - ] + "type": "KNOWLEDGE_BASE", + "toolId": "portal-support", + "onSuccess": -1, + "onError": -1 } ] }, @@ -282,11 +286,33 @@ "steps": [ { "stepNo": 0, + "type": "SWITCH", + "cases": [ + { + "action": 5, + "case": "Latest message of user is a question about why the find task function didn't work" + }, + { + "action": 5, + "case": "Latest message of user is a question about what he should do if he cannot find a task" + }, + { + "action": 1, + "case": "User want to do something different from above conditions" + }, + { + "action": 1, + "case": "User want to find task" + } + ] + }, + { + "stepNo": 1, "type": "RE_PHRASE", "useConversationMemory": false, "toolId": "find-tasks", - "onRephrase": 1, - "onSuccess": 1, + "onRephrase": 2, + "onSuccess": 2, "saveToHistory": false, "examples": [ { @@ -297,87 +323,59 @@ "before": "find top priority task", "after": "find task has high priority" }, + { + "before": "find working tasks", + "after": "find task has state 'in_progress','open'" + }, + { + "before": "find finished tasks", + "after": "find task has state 'done'" + }, { "before": "find my running task", - "after": "find task has state 'in_progress'" + "after": "find task has state 'in_progress' and the responsible is " }, { - "before": "please find tasks I need to finish this week", - "after": "find task has state 'in_progress','open', expiry date from to , and the responsible is " + "before": "please find tasks I need to finish", + "after": "find task has state 'in_progress','open', and the responsible is " }, { - "before": "List me all the tasks that need completion within the current week", + "before": "List me all the tasks that need completion this week", "after": "find task has state 'in_progress','open', expiry date from to " + }, + { + "before": "List me all the tasks that need completion this month", + "after": "find task has state 'in_progress','open', expiry date from to " } ] }, { - "stepNo": 1, + "stepNo": 2, "type": "IVY_TOOL", "toolId": "find-tasks", - "onSuccess": 6, - "onError": 4, + "onSuccess": 4, + "onError": 3, "saveToHistory": false }, - { - "stepNo": 2, - "type": "TEXT", - "text": "I have rephrased your request as follows. Could you please confirm if it is correct?", - "showResultOfStep": 0, - "onSuccess": 3 - }, { "stepNo": 3, - "type": "SWITCH", - "cases": [ - { - "action": 1, - "case": "User agree" - }, - { - "action": 0, - "case": "User don't agree and suggested another condition to find task" - }, - { - "action": -1, - "case": "User don't want to find task anymore" - } - ] + "type": "TEXT", + "text": "Sorry, I cannot find any task matched your request.", + "onSuccess": -1 }, { "stepNo": 4, "type": "TEXT", - "text": "Sorry, I cannot find any task matched your request. Could you please provide more details or clarify your request?", - "onSuccess": 5 + "text": "I found tasks matched your request.", + "onSuccess": -1, + "showResultOfStep": 2 }, { "stepNo": 5, - "type": "SWITCH", - "cases": [ - { - "action": 0, - "case": "User update the condition to find task" - }, - { - "action": 0, - "case": "User make another request to find task" - }, - { - "action": -1, - "case": "User say he want to cancel or he make another request that irrelevant to the find task function" - }, - { - "action": -1, - "case": "User say good or thank you or seem that he don't want to find task anymore." - } - ] - }, - { - "stepNo": 6, - "type": "TEXT", - "text": "I found tasks matched your request.", + "type": "KNOWLEDGE_BASE", + "toolId": "portal-support", "onSuccess": -1, - "showResultOfStep": 1 + "onError": -1 } ] }, @@ -541,17 +539,39 @@ "steps": [ { "stepNo": 0, + "type": "SWITCH", + "cases": [ + { + "action": 7, + "case": "Latest message of user is a question about why the find process function didn't work" + }, + { + "action": 7, + "case": "Latest message of user is a question about what he should do if he cannot find a process" + }, + { + "action": 1, + "case": "User want to do something different from above conditions" + }, + { + "action": 1, + "case": "User want to find process" + } + ] + }, + { + "stepNo": 1, "type": "IVY_TOOL", "toolId": "find-processes", "onSuccess": -1, - "onError": 1 + "onError": 2 }, { - "stepNo": 1, + "stepNo": 2, "type": "RE_PHRASE", "toolId": "find-processes", - "onRephrase": 2, - "onSuccess": 0, + "onRephrase": 3, + "onSuccess": 1, "examples": [ { "before": "find leave request process", @@ -572,18 +592,18 @@ ] }, { - "stepNo": 2, + "stepNo": 3, "type": "TEXT", "text": "I have rephrased your request as follows. Could you please confirm if it is correct?", - "showResultOfStep": 1, - "onSuccess": 3 + "showResultOfStep": 2, + "onSuccess": 4 }, { - "stepNo": 3, + "stepNo": 4, "type": "SWITCH", "cases": [ { - "action": 4, + "action": 5, "case": "User agree" }, { @@ -591,7 +611,7 @@ "case": "User don't agree" }, { - "action": 0, + "action": 1, "case": "User suggest other processes" }, { @@ -601,17 +621,24 @@ ] }, { - "stepNo": 4, + "stepNo": 5, "type": "IVY_TOOL", "toolId": "find-processes", "onSuccess": -1, - "onError": 5 + "onError": 6 }, { - "stepNo": 5, + "stepNo": 6, "type": "TEXT", "text": "Sorry I cannot find any process matched your request", "onSuccess": -1 + }, + { + "stepNo": 7, + "type": "KNOWLEDGE_BASE", + "toolId": "portal-support", + "onSuccess": -1, + "onError": -1 } ] }, @@ -638,6 +665,14 @@ { "action": 26, "case": "In the chat history, AI didn't metion any process" + }, + { + "action": 30, + "case": "Latest message of user is a question about why the start process function didn't work" + }, + { + "action": 30, + "case": "Latest message of user is a question about what he should do if he cannot start a process" } ] }, @@ -987,6 +1022,13 @@ "case": "User has other request" } ] + }, + { + "stepNo": 30, + "type": "KNOWLEDGE_BASE", + "toolId": "portal-support", + "onSuccess": -1, + "onError": -1 } ] }, diff --git a/ai-assistant/src/com/axonivy/utils/aiassistant/bean/AssistantBean.java b/ai-assistant/src/com/axonivy/utils/aiassistant/bean/AssistantBean.java index 1064f7c..4769108 100644 --- a/ai-assistant/src/com/axonivy/utils/aiassistant/bean/AssistantBean.java +++ b/ai-assistant/src/com/axonivy/utils/aiassistant/bean/AssistantBean.java @@ -2,15 +2,20 @@ import static com.axonivy.utils.aiassistant.enums.SessionAttribute.SELECTED_ASSISTANT_ID; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.Serializable; +import java.nio.charset.StandardCharsets; import java.util.List; import javax.annotation.PostConstruct; import javax.faces.bean.ManagedBean; import javax.faces.bean.ViewScoped; +import javax.ws.rs.core.MediaType; import org.apache.commons.collections4.CollectionUtils; +import org.primefaces.model.DefaultStreamedContent; +import org.primefaces.model.StreamedContent; import com.axonivy.portal.components.persistence.converter.BusinessEntityConverter; import com.axonivy.utils.aiassistant.dto.Assistant; @@ -29,6 +34,8 @@ public class AssistantBean implements Serializable { private static final long serialVersionUID = 1683098437048122830L; + private static final String CONVERSATION_FILE_PATTERN = "conversation_%s.json"; + private Assistant assistant; private String assistantId; private List availableAssistants; @@ -88,6 +95,20 @@ public void clearHistory() throws IOException { ChatMessageManager.loadConversation(assistant.getId(), conversationId); } + public StreamedContent exportHistory() { + Conversation conversation = ChatMessageManager + .loadConversation(assistant.getId(), conversationId); + var inputStream = new ByteArrayInputStream( + BusinessEntityConverter.prettyPrintEntityToJsonValue(conversation) + .getBytes(StandardCharsets.UTF_8)); + return DefaultStreamedContent + .builder() + .stream(() -> inputStream) + .contentType(MediaType.APPLICATION_JSON) + .name(String.format(CONVERSATION_FILE_PATTERN, conversationId)) + .build(); + } + public void navigateToAIManagement() { AiNavigator.navigateToAIManagement(); } diff --git a/ai-assistant/src/com/axonivy/utils/aiassistant/bean/UploadPortalDocumentBean.java b/ai-assistant/src/com/axonivy/utils/aiassistant/bean/UploadPortalDocumentBean.java index 4328168..15710e2 100644 --- a/ai-assistant/src/com/axonivy/utils/aiassistant/bean/UploadPortalDocumentBean.java +++ b/ai-assistant/src/com/axonivy/utils/aiassistant/bean/UploadPortalDocumentBean.java @@ -26,6 +26,7 @@ public class UploadPortalDocumentBean { private static final String PORTAL_USER_GUIDE = "portal-user-guide"; + private static final String PERMISSIONS_DOC = "portal-developer-guide/permissions/index.html"; public boolean handlePortalDocumentUpload(FileUploadEvent event) throws IOException { @@ -45,8 +46,9 @@ public boolean handlePortalDocumentUpload(FileUploadEvent event) while (zipEntry != null) { String fileName = zipEntry.getName(); // Only handle files within "portal-user-guide" folder and ending with ".html" - if (fileName.startsWith(PORTAL_USER_GUIDE + "/") - && fileName.endsWith(".html")) { + if ((fileName.startsWith(PORTAL_USER_GUIDE + "/") + && fileName.endsWith(".html")) + || fileName.contentEquals(PERMISSIONS_DOC)) { // Get data from the XHTML file String fileContent = extractFileContent(buffer, fileStream); diff --git a/ai-assistant/src/com/axonivy/utils/aiassistant/core/OpenAIBot.java b/ai-assistant/src/com/axonivy/utils/aiassistant/core/OpenAIBot.java index 39b6005..13f2585 100644 --- a/ai-assistant/src/com/axonivy/utils/aiassistant/core/OpenAIBot.java +++ b/ai-assistant/src/com/axonivy/utils/aiassistant/core/OpenAIBot.java @@ -166,7 +166,7 @@ public void embed(String collectionName, List textSegments) { List embeddings = new ArrayList<>(); for (TextSegment segment : textSegments) { EmbeddingDocument doc = new EmbeddingDocument(); - doc.setMetadata(segment.metadata().asMap()); + doc.setMetadata(segment.metadata().toMap()); doc.setText(segment.text()); doc.setVector(getEmbeddingModel().embed(segment).content().vector()); embeddings.add(doc); @@ -196,12 +196,7 @@ public String chat(Map variables, String promptTemplate) { @Override public String chat(String message) { try { - Ivy.log().error(message); - - String x = getModel().generate(message); - Ivy.log().error(x); - - return x; + return getModel().generate(message); } catch (Exception e) { OpenAIErrorResponse error = BusinessEntityConverter.jsonValueToEntity( e.getCause().getMessage(), OpenAIErrorResponse.class); @@ -213,8 +208,6 @@ public String chat(String message) { public String streamChat(Map variables, String promptTemplate, StreamingResponseHandler handler) { try { - Ivy.log() - .error(PromptTemplate.from(promptTemplate).apply(variables).text()); getChatModel().generate( PromptTemplate.from(promptTemplate).apply(variables).text(), handler); @@ -233,10 +226,14 @@ public String retrieveDocumentsAsString(String collectionName, String query) { Embedding queryEmbedding = embeddingModel.embed(query).content(); List> relevant = toEmbeddingMatch( - getEmbeddingStore().findRelevantDocuments(queryEmbedding, 10, 0.6)); + getEmbeddingStore().findRelevantDocuments(queryEmbedding, 10, 0.7)); relevant.sort(Comparator.comparing(EmbeddingMatch::score)); - return RagPromptTemplates.formatRetrievedDocuments(relevant); + + String formattedRetrievedDocuments = RagPromptTemplates + .formatRetrievedDocuments(relevant); + + return formattedRetrievedDocuments; } @Override diff --git a/ai-assistant/src/com/axonivy/utils/aiassistant/core/embedding/EmbeddingDocument.java b/ai-assistant/src/com/axonivy/utils/aiassistant/core/embedding/EmbeddingDocument.java index d5a842a..ad3b12a 100644 --- a/ai-assistant/src/com/axonivy/utils/aiassistant/core/embedding/EmbeddingDocument.java +++ b/ai-assistant/src/com/axonivy/utils/aiassistant/core/embedding/EmbeddingDocument.java @@ -5,7 +5,7 @@ public class EmbeddingDocument { private float[] vector; private String text; - private Map metadata; + private Map metadata; public float[] getVector() { return vector; @@ -23,11 +23,11 @@ public void setText(String text) { this.text = text; } - public Map getMetadata() { + public Map getMetadata() { return metadata; } - public void setMetadata(Map metadata) { + public void setMetadata(Map metadata) { this.metadata = metadata; } } \ No newline at end of file diff --git a/ai-assistant/src/com/axonivy/utils/aiassistant/core/embedding/IvyOpenSearchEmbeddingStore.java b/ai-assistant/src/com/axonivy/utils/aiassistant/core/embedding/IvyOpenSearchEmbeddingStore.java index e6f8272..96de313 100644 --- a/ai-assistant/src/com/axonivy/utils/aiassistant/core/embedding/IvyOpenSearchEmbeddingStore.java +++ b/ai-assistant/src/com/axonivy/utils/aiassistant/core/embedding/IvyOpenSearchEmbeddingStore.java @@ -23,6 +23,7 @@ import org.opensearch.client.opensearch._types.FieldValue; import org.opensearch.client.opensearch._types.InlineScript; import org.opensearch.client.opensearch._types.Time; +import org.opensearch.client.opensearch._types.mapping.KnnVectorMethod; import org.opensearch.client.opensearch._types.mapping.Property; import org.opensearch.client.opensearch._types.mapping.TextProperty; import org.opensearch.client.opensearch._types.mapping.TypeMapping; @@ -66,6 +67,13 @@ public class IvyOpenSearchEmbeddingStore { private static final String INDEX_NOT_EXIST_ERROR = "Cannot find vector store [%s]"; private static final String CANNOT_CONNECT_TO_OPEN_SEARCH = "Cannot connect to the OpenSearch server with URL %s"; + // Possible values: 'faiss' 'lucene', 'nmslib' + // Use Meta's FAISS by default + private static final String SEARCH_ENGINE = "faiss"; + + // Possible values: 'hnsw', 'ivf' (only for FAISS) + private static final String SEARCH_METHOD = "hnsw"; + /** * Creates an instance of OpenSearchEmbeddingStore to connect with * OpenSearch clusters running locally and network reachable. @@ -281,16 +289,25 @@ public void createIndexIfNotExist(int dimension) throws IOException { BooleanResponse response; response = client.indices().exists(c -> c.index(indexName)); if (!response.value()) { - client.indices().create(c -> c.index(indexName).settings(s -> s.knn(true)) + client.indices() + .create(c -> c.index(indexName) + .settings(s -> s.knn(true).knnAlgoParamEfSearch(100)) .mappings(getDefaultMappings(dimension))); } } private TypeMapping getDefaultMappings(int dimension) { + + KnnVectorMethod.Builder builder = new KnnVectorMethod.Builder(); + builder.engine(SEARCH_ENGINE); + builder.name(SEARCH_METHOD); + Map properties = new HashMap<>(4); properties.put("text", Property.of(p -> p.text(TextProperty.of(t -> t)))); properties.put("vector", - Property.of(p -> p.knnVector(k -> k.dimension(dimension)))); + Property + .of(p -> p.knnVector( + k -> k.dimension(dimension).method(builder.build())))); return TypeMapping.of(c -> c.properties(properties)); } diff --git a/ai-assistant/src/com/axonivy/utils/aiassistant/prompts/AiFlowPromptTemplates.java b/ai-assistant/src/com/axonivy/utils/aiassistant/prompts/AiFlowPromptTemplates.java index b42d626..1161d39 100644 --- a/ai-assistant/src/com/axonivy/utils/aiassistant/prompts/AiFlowPromptTemplates.java +++ b/ai-assistant/src/com/axonivy/utils/aiassistant/prompts/AiFlowPromptTemplates.java @@ -12,9 +12,8 @@ public class AiFlowPromptTemplates { Instruction: 1. Read the chat history carefully. The last message of "User" is the request. - 2. Choose the right condition. - 3. ONLY show the value of the "action" field from the selected condition as a tag <>. - Example: If the correct action is "2", then you should show "<2>" + 2. Analyze the request and all conditions then choose the right condition. + 3. Put the value of the "action" field from the selected condition as a tag <>. Example <2> """; public static final String RE_PHRASE_STEP = """ diff --git a/ai-assistant/src/com/axonivy/utils/aiassistant/prompts/RagPromptTemplates.java b/ai-assistant/src/com/axonivy/utils/aiassistant/prompts/RagPromptTemplates.java index fe0b304..7560c76 100644 --- a/ai-assistant/src/com/axonivy/utils/aiassistant/prompts/RagPromptTemplates.java +++ b/ai-assistant/src/com/axonivy/utils/aiassistant/prompts/RagPromptTemplates.java @@ -34,9 +34,11 @@ public class RagPromptTemplates { ************************* Instruction: - Don't try to create the answer from your own knowledge + - If the contexts are not related to the query, just say you don't know - Restructure the answer to make it easier to understand - Prioritize to answer with contexts have higher score - MUST answer in this language regardless the language of user message: {{language}} + - When show image, please also show its explanation - Criticize the answer: + if you found something incorrect, Tell user that you don't know the answer for his question, please ask something else or try to contact provided contact info. + otherwise, show the answer @@ -180,9 +182,9 @@ public static String getStructuredOutputInstruction(int requestType) { return switch (requestType) { case QUESTION_TYPE_WHAT -> QUESTION_TYPE_WHAT_INSTRUCTION; case QUESTION_TYPE_HOW -> QUESTION_TYPE_HOW_INSTRUCTION; - case QUESTION_TYPE_LIST -> QUESTION_TYPE_LIST_INSTRUCTION; - case QUESTION_TYPE_COMPARE -> QUESTION_TYPE_COMPARE_INSTRUCTION; - case QUESTION_TYPE_WHY_CANNOT -> QUESTION_TYPE_WHY_CANNOT_INSTRUCTION; + case QUESTION_TYPE_LIST -> QUESTION_TYPE_LIST_INSTRUCTION; + case QUESTION_TYPE_COMPARE -> QUESTION_TYPE_COMPARE_INSTRUCTION; + case QUESTION_TYPE_WHY_CANNOT -> QUESTION_TYPE_WHY_CANNOT_INSTRUCTION; default -> ""; }; } diff --git a/ai-assistant/src/com/axonivy/utils/aiassistant/service/PortalDocService.java b/ai-assistant/src/com/axonivy/utils/aiassistant/service/PortalDocService.java index 3179970..907be00 100644 --- a/ai-assistant/src/com/axonivy/utils/aiassistant/service/PortalDocService.java +++ b/ai-assistant/src/com/axonivy/utils/aiassistant/service/PortalDocService.java @@ -1,7 +1,6 @@ package com.axonivy.utils.aiassistant.service; import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -22,20 +21,17 @@ import dev.langchain4j.data.segment.TextSegment; public class PortalDocService { - private static final String IVY_DOC_HOST = "https://market.axonivy.com/market-cache/portal/portal-guide/11.3.1/doc/"; - public static final String USER_GUIDE_DIR = "portal-user-guide"; - public static final String DEVELOPER_GUIDE_DIR = "portal-developer-guide"; - public static final String PORTAL_COMPONENT_GUIDE_DIR = "portal-components"; + private static final String IVY_DOC_HOST = "https://market.axonivy.com/market-cache/portal/portal-guide/12.0.0-m266/doc/"; private static final String HEADER_PREFIX = "# "; private static final String SUB_HEADER_PREFIX = "## "; private static final String SMALL_SUB_HEADER_PREFIX = "#### "; + private static final String LIST_ITEM_PREFIX = "- "; private static final String TWO_LEVELS_PATH_PREFIX = "../../"; private static final String LINK_FORMAT = "[%s](%s)"; private static final String IMAGE_FORMAT = "!" + LINK_FORMAT; private static final String CODE_BLOCK = "```"; - private static final String UNKNOWN_CHARACTER_CODE = "?"; public static void createTextIndex(AbstractAIBot bot, String index, List contents) throws IOException { @@ -109,7 +105,7 @@ private static void handleBlockText(String headerKeyword, String keyword, if (CollectionUtils.isNotEmpty(blockText)) { Metadata meta = Metadata.from("keywords", String.join(" ", Arrays.asList(headerKeyword, keyword))); - String keywords = String.format("The main topic of this context: %s, %s", + String keywords = String.format("Keywords: %s, %s", headerKeyword, keyword).concat(System.lineSeparator()); List blockTextWithHeader = new ArrayList<>(); @@ -193,10 +189,6 @@ public static String convertPortalDocument(String raw) { if (StringUtils.isBlank(alt)) { alt = Optional.ofNullable(anchorTag.select("span.std-ref")) .map(Elements::first).map(Element::text).orElse(""); - byte[] ptext = alt.getBytes(StandardCharsets.ISO_8859_1); - alt = new String(ptext, StandardCharsets.UTF_8); - // remove the key icon - alt.replace(UNKNOWN_CHARACTER_CODE, ""); } anchorTag.replaceWith(new TextNode( String.format(LINK_FORMAT, alt, hrefLink))); @@ -223,6 +215,18 @@ public static String convertPortalDocument(String raw) { codeBlockDiv.after(CODE_BLOCK); } + // Replace '\n' inside

tags with a single space + for (Element paragraphTag : document.select("p")) { + String converted = paragraphTag.text().replace(StringUtils.LF, + StringUtils.SPACE); + paragraphTag.replaceWith(new TextNode(converted)); + } + + // Add '- ' before

  • tags + for (Element listTag : document.select("li")) { + listTag.replaceWith(new TextNode(LIST_ITEM_PREFIX + listTag.text())); + } + // Add a new line after each tag for better readability for (Element tag : document.getAllElements()) { if (!Optional.ofNullable(tag).map(Element::parents).isPresent()) { @@ -258,13 +262,13 @@ private static String convertTableToMarkdown(Element table) { Elements headers = table.select("thead tr th"); if (!headers.isEmpty()) { for (Element header : headers) { - markdown.append("| ").append(header.text()).append(" "); + markdown.append("| ").append(header.text()).append(StringUtils.SPACE); } - markdown.append("|\n"); + markdown.append("|").append(StringUtils.LF); // Add separator line for headers headers.forEach(header -> markdown.append("|---")); - markdown.append("|\n"); + markdown.append("|").append(StringUtils.LF); } // Convert table rows @@ -272,9 +276,9 @@ private static String convertTableToMarkdown(Element table) { for (Element row : rows) { Elements cells = row.select("td"); for (Element cell : cells) { - markdown.append("| ").append(cell.text()).append(" "); + markdown.append("| ").append(cell.text()).append(StringUtils.SPACE); } - markdown.append("|\n"); + markdown.append("|").append(StringUtils.LF); } return markdown.toString(); diff --git a/ai-assistant/src_hd/com/axonivy/utils/aiassistant/component/ChatDashboard/ChatDashboard.xhtml b/ai-assistant/src_hd/com/axonivy/utils/aiassistant/component/ChatDashboard/ChatDashboard.xhtml index a79a45a..e68d9a8 100644 --- a/ai-assistant/src_hd/com/axonivy/utils/aiassistant/component/ChatDashboard/ChatDashboard.xhtml +++ b/ai-assistant/src_hd/com/axonivy/utils/aiassistant/component/ChatDashboard/ChatDashboard.xhtml @@ -87,23 +87,32 @@
    - - - - + + + + + + + + +
    - diff --git a/ai-assistant/webContent/resources/css/chatbot.css b/ai-assistant/webContent/resources/css/chatbot.css index 2e9703b..5f04cf5 100644 --- a/ai-assistant/webContent/resources/css/chatbot.css +++ b/ai-assistant/webContent/resources/css/chatbot.css @@ -642,3 +642,20 @@ body.dark .chatbot-panel .chat-message.system-response-expand-button { color: var(--gray-100); } +body .ui-button.ui-widget.icon-more-menu-button { + background: var(--surface-a); + border-color: var(--surface-900); +} + +body .ui-button.ui-widget.icon-more-menu-button { + color: var(--widget-header-icon); +} + +.ui-menu-items, body .ui-menu .ui-menu-list .ui-menuitem:not(:last-child), .action-step-item:not(:last-child) { + border-bottom: solid 1px var(--gray-50); +} + +body .ui-menu .ui-menu-list .ui-menuitem .action-step-item.red .ui-menuitem-text, +body .ui-menu .ui-menu-list .ui-menuitem .action-step-item.red .si { + color: var(--red-500); +} diff --git a/ai-assistant/webContent/resources/js/ai-assistant.js b/ai-assistant/webContent/resources/js/ai-assistant.js index 811d54c..fe1d27c 100644 --- a/ai-assistant/webContent/resources/js/ai-assistant.js +++ b/ai-assistant/webContent/resources/js/ai-assistant.js @@ -580,7 +580,7 @@ function ViewAI(uri) { break; case 'IVY_TOOL': icon = 'si si-lg si-cog-double-2'; - header = 'Processing Ivy tool'; + header = 'Run Ivy tool'; break; case 'RE_PHRASE': icon = 'si si-lg si-messages-bubble-check'; @@ -767,9 +767,18 @@ function ViewAI(uri) { // Update existing streaming message streamingMessage.get(0).innerHTML = cloneTemplate.innerHTML; + // While streaming the response, show text instead of image + const renderer = new marked.Renderer(); + renderer.image = function(text) { + return text; + } + + marked.use({ renderer }); + if (!isIFrame(streamingMessage.get(0).innerHTML)) { streamingMessage.find('.js-message').get(0).innerHTML = marked.parse(streamingText); - streamingMessage.find('.js-message').find('img').addClass('w-full'); + streamingMessage.find('.js-message').find('img').remove(); + streamingMessage.find('.js-message').find('em').addClass('block'); streamingMessage.find('.js-message').find('a').attr('target', '_blank').addClass('underline'); } } @@ -781,6 +790,10 @@ function ViewAI(uri) { return; } + // User default renderer + const renderer = new marked.Renderer(); + marked.use({ renderer }); + let converted = isIFrame(streamingValue) ? convertIFrame(streamingValue) : marked.parse(streamingValue); if (typeof jsMessageList !== 'undefined') { @@ -789,12 +802,14 @@ function ViewAI(uri) { if (streamingMessage.length > 0) { streamingMessage.removeClass('streaming'); $(streamingMessage).find('.js-message').get(0).innerHTML = converted; - $($(streamingMessage).find('.js-message').get(0)).find('img').addClass('w-full'); + $($(streamingMessage).find('.js-message').get(0)).find('img').addClass('max-w-full'); + $($(streamingMessage).find('.js-message').get(0)).find('em').addClass('block'); $($(streamingMessage).find('.js-message').get(0)).find('a').attr('target', '_blank').addClass('underline'); } else { const messages = messageList.find('.chat-message-container').not('.my-message').find('.js-message'); messages.get(messages.length - 1).innerHTML = converted; - $(messages.get(messages.length - 1)).find('img').addClass('w-full'); + $(messages.get(messages.length - 1)).find('img').addClass('max-w-full'); + $(messages.get(messages.length - 1)).find('em').addClass('block'); $(messages.get(messages.length - 1)).find('a').attr('target', '_blank').addClass('underline'); } From 3b3a164d855f8ced5ec9f626145ac9c6bb106abb Mon Sep 17 00:00:00 2001 From: mnhnam-axonivy Date: Wed, 13 Nov 2024 17:24:58 +0700 Subject: [PATCH 3/3] IVYPORTAL-17135 Stage 2: Improve the quality of answers from OpenSearch - Handled feedback: Fix grammar error - Handled feedback: Improve rephrase quality of the find tasks function --- .../config/variables/AiAssistant/AiFunctions.json | 10 +++++++++- ai-assistant/webContent/resources/js/ai-assistant.js | 4 ++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/ai-assistant/config/variables/AiAssistant/AiFunctions.json b/ai-assistant/config/variables/AiAssistant/AiFunctions.json index 6f0c419..22ebddb 100644 --- a/ai-assistant/config/variables/AiAssistant/AiFunctions.json +++ b/ai-assistant/config/variables/AiAssistant/AiFunctions.json @@ -315,6 +315,14 @@ "onSuccess": 2, "saveToHistory": false, "examples": [ + { + "before": "show me all tasks", + "after": "find all tasks" + }, + { + "before": "show all my tasks", + "after": "find tasks which the responsible is " + }, { "before": "find my fix car task", "after": "find task has name 'fix car'" @@ -664,7 +672,7 @@ }, { "action": 26, - "case": "In the chat history, AI didn't metion any process" + "case": "In the chat history, AI didn't mention any process" }, { "action": 30, diff --git a/ai-assistant/webContent/resources/js/ai-assistant.js b/ai-assistant/webContent/resources/js/ai-assistant.js index fe1d27c..7c25cac 100644 --- a/ai-assistant/webContent/resources/js/ai-assistant.js +++ b/ai-assistant/webContent/resources/js/ai-assistant.js @@ -791,8 +791,8 @@ function ViewAI(uri) { } // User default renderer - const renderer = new marked.Renderer(); - marked.use({ renderer }); + marked.use({ renderer: new marked.Renderer() }); + marked.setOptions(marked.getDefaults()); let converted = isIFrame(streamingValue) ? convertIFrame(streamingValue) : marked.parse(streamingValue);