001/* 002 * Copyright 2025 Anyware Services 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package org.ametys.plugins.ai; 018import java.io.IOException; 019import java.io.InputStream; 020import java.nio.file.Files; 021import java.nio.file.Path; 022import java.util.ArrayList; 023import java.util.HashMap; 024import java.util.List; 025import java.util.Map; 026import java.util.Optional; 027 028import javax.jcr.Repository; 029 030import org.apache.avalon.framework.activity.Initializable; 031import org.apache.avalon.framework.component.Component; 032import org.apache.avalon.framework.context.Context; 033import org.apache.avalon.framework.context.ContextException; 034import org.apache.avalon.framework.context.Contextualizable; 035import org.apache.avalon.framework.service.ServiceException; 036import org.apache.avalon.framework.service.ServiceManager; 037import org.apache.avalon.framework.service.Serviceable; 038import org.apache.cocoon.components.ContextHelper; 039import org.apache.cocoon.environment.Request; 040import org.apache.commons.lang3.StringUtils; 041import org.apache.commons.lang3.Strings; 042import org.apache.commons.lang3.tuple.Pair; 043import org.apache.tika.Tika; 044 045import org.ametys.cms.content.indexing.solr.SolrFieldNames; 046import org.ametys.cms.search.query.DocumentTypeQuery; 047import org.ametys.cms.search.query.Query; 048import org.ametys.cms.search.solr.SearcherFactory; 049import org.ametys.cms.search.solr.SearcherFactory.Searcher; 050import org.ametys.cms.search.solr.SolrClientProvider; 051import org.ametys.cms.transformation.URIResolver; 052import org.ametys.cms.transformation.URIResolverExtensionPoint; 053import org.ametys.core.cache.AbstractCacheManager; 054import org.ametys.core.cache.Cache; 055import org.ametys.core.ui.Callable; 056import org.ametys.core.upload.Upload; 057import org.ametys.core.upload.UploadManager; 058import org.ametys.core.user.CurrentUserProvider; 059import org.ametys.core.util.JSONUtils; 060import org.ametys.plugins.ai.provider.AIProvider; 061import org.ametys.plugins.ai.provider.AIProviderExtensionPoint; 062import org.ametys.plugins.ai.search.AIAdditionalDataIndexer; 063import org.ametys.plugins.ai.search.CacheContentVersionChunk; 064import org.ametys.plugins.ai.search.VectorQuery; 065import org.ametys.plugins.explorer.resources.Resource; 066import org.ametys.plugins.repository.AmetysObject; 067import org.ametys.plugins.repository.AmetysObjectIterable; 068import org.ametys.plugins.repository.AmetysObjectResolver; 069import org.ametys.plugins.repository.UnknownAmetysObjectException; 070import org.ametys.plugins.repository.provider.WorkspaceSelector; 071import org.ametys.runtime.config.Config; 072import org.ametys.runtime.i18n.I18nizableText; 073import org.ametys.runtime.plugin.component.AbstractLogEnabled; 074import org.ametys.runtime.plugin.component.DeferredServiceable; 075import org.ametys.web.WebHelper; 076import org.ametys.web.indexing.solr.SolrWebFieldNames; 077import org.ametys.web.renderingcontext.RenderingContext; 078import org.ametys.web.renderingcontext.RenderingContextHandler; 079import org.ametys.web.repository.content.WebContent; 080import org.ametys.web.repository.page.Page; 081import org.ametys.web.repository.site.Site; 082import org.ametys.web.repository.site.SiteManager; 083 084import dev.langchain4j.data.message.AiMessage; 085import dev.langchain4j.data.message.SystemMessage; 086import dev.langchain4j.data.message.UserMessage; 087import dev.langchain4j.memory.ChatMemory; 088import dev.langchain4j.memory.chat.MessageWindowChatMemory; 089 090 091/** 092 * This is helper to use AI as image or text generation. 093 */ 094public class AIHelper extends AbstractLogEnabled implements Component, DeferredServiceable, Contextualizable, Serviceable, Initializable 095{ 096 /** Avalon role */ 097 public static final String ROLE = AIHelper.class.getName(); 098 /** The site configuration parameter for the a specific image prompt */ 099 public static final String SITE_CONFIG_IMAGE_PROMPT = "aiImagePrompt"; 100 /** The site configuration parameter for the a specific text prompt */ 101 public static final String SITE_CONFIG_TEXT_PROMPT = "aiTextPrompt"; 102 /** The site configuration parameter for the semantic indexation activation */ 103 public static final String SITE_CONFIG_SEMANTIC_INDEXATION_ACTIVATE = "aiSemanticIndexationActivate"; 104 /** The site configuration parameter for the chatbot activation */ 105 public static final String SITE_CONFIG_CHAT_ACTIVATE = "aiChatActivate"; 106 /** The site configuration parameter for the chatbot prompt */ 107 public static final String SITE_CONFIG_CHAT_PROMPT = "aiChatPrompt"; 108 /** The site configuration parameter for the chatbot name */ 109 public static final String SITE_CONFIG_CHAT_NAME = "aiChatName"; 110 111 112 /** A security hardcoded max for the text summarize */ 113 private static final int __TEXT_SUMMARIZE_MAXLENGTH = 1000; 114 /** The prompt used to summarize a text. It contains one parameter: {maxLength}. */ 115 private static final String __TEXT_SUMMARIZE_PROMPT_PREFIX = 116 "Create a summary of the following user provided text in **{maxLength} characters maximum**. Do not exceed this limit." 117 + "The summary is to be used in a search results page. " 118 + "Provide the answer in the language of the text to be summarized. Not another language. " 119 + "Provide the answer without any formating, title, or quotes, just raw text. " 120 + "If the text is incomprehensible, or if it's just a few words: just return the same text."; 121 122 /** The prompt to generate an image. It contains no parameter. */ 123 private static final String __IMAGE_GENERATION_PROMPT_PREFIX = 124 "Generate an image/illustration/photo to illustrate the following user provided text. The image must have no text (if any it should be in the same language as the text)."; // No text in the image, since generated text are always unreadable. 125 126 /** The activate param */ 127 private static final String CONFIG_ACTIVATE = "ai.active"; 128 /** The provider configuration param */ 129 private static final String CONFIG_PROVIDER = "ai.provider"; 130 131 private static final String __CHAT_SUMMARY_PREFIX = "Here is a context:"; 132 private static final String __CHAT_SUMMARY_SUFFIX = "With the preceding context and the following sentence, tell me what I have to search in a semantic search engine ? don't add any fluff, just the keywords. Here is the senence:"; 133 134 135 private static final String __CHAT_PROMPT_SUFFIX = "\n" 136 + "For the user, you should never refer to a \"document\" but rather a \"page on the website {url}\".\n" 137 + "The correspondence between the document and the page is established by looking at the first two lines of each document.\n" 138 + "The first line contains the page title.\n" 139 + "The second line contains the URL of the page directly behind the text in brackets [URL to view the page online].\n" 140 + "The following lines correspond to the content of the page.\n" 141 + "\n" 142 + "To do this:\n" 143 + "1. Analyze the user's request.\n" 144 + "2. Search the pages provided in the store.\n" 145 + "3. Select relevant passages and extract them.\n" 146 + "4. Provide a clear and concise response based on this information.\n" 147 + "5. Systematically retrieve the exact URLs from the beginning lines of each page and provide them as clickable links.\n" 148 + " - Strict extraction after the marker [URL to view the page online].\n" 149 + " - Validation: the page title must match the context of the request.\n" 150 + "\n" 151 + "You can also use certain tools if necessary:\n" 152 + "- **highlightArea**: to visually mark an area in the interface.\n" 153 + "- **browseToPage**: to navigate to a page, only after explicit confirmation from the user.\n" 154 + "\n" 155 + "# Output format\n" 156 + "- Provide a brief paragraph response.\n" 157 + "- Avoid unnecessary technical jargon.\n" 158 + "- Provide a mandatory list of clickable links to the corresponding pages.\n" 159 + "- A clickable link should use the following format: [link text](URL).\n" 160 + "- If no link can be provided, explain why.\n" 161 + "\n" 162 + "# Additional suggestions\n" 163 + "Propose 1 to 2 questions that the user might ask next. Phrase them as the user would speak to the assistant.\n" 164 + "Expected format:\n" 165 + "```suggestions\n" 166 + "Tell me more about...\n" 167 + "What is the procedure for...\n" 168 + "```\n" 169 + "\n" 170 + "# Additional rules\n" 171 + "- If no exact match is found, provide the closest information while specifying the divergence.\n" 172 + "- If nothing relevant is found, simply say: \"I found no relevant information on the site\".\n" 173 + "- Never mention the word \"document\" in front of the user.\n" 174 + "- Be attentive to sensitive information.\n"; 175 176 /** The prompt to add context to the embedding. It contains one parameter: {chunks} */ 177 private static final String __CHAT_PROMPT_CONTEXT = 178 "The context is structured in JSON format, as an array containing the documents to be used. " 179 + "Each document have:\n" 180 + "- a \"chunk_content\" field corresponding to the text to be used to formulate the response. This content is divided in fields and each field introduced by \"**label of the field**:\" : the field labels are not part of the content it self but help you understand its value,\n" 181 + "- a \"chunk_uri\" field corresponding to the URL of the page. If \"chunk_uri\" is empty, do not invent a link, just do not put a link.\n\n" 182 + "Here are the chunks: {chunks}"; 183 184 /** The prompt introduction. It contains one parameter: {name} */ 185 private static final String __CHAT_PROMPT_INTRODUCTION = "You are a siteweb assistant named {name}"; 186 187 private static final String _CACHE_ID = AIHelper.class.getName(); 188 189 private static final int __MAX_NUMBER_OF_CHUNKS = 20; 190 191 /** The extension point providing access to registered AI providers.*/ 192 protected AIProviderExtensionPoint _aiProviderEP; 193 194 /** The upload manager used to upload images */ 195 protected UploadManager _uploadManager; 196 197 /** The current user provider */ 198 protected CurrentUserProvider _currentUserProvider; 199 200 /** The JSON utils used to convert JSON to objects */ 201 protected JSONUtils _jsonUtils; 202 203 /** The searcher factory*/ 204 protected SearcherFactory _searcherFactory; 205 206 /** The ametys uri resolver */ 207 protected URIResolverExtensionPoint _uriResolverEP; 208 209 /** The ametys object resolver */ 210 protected AmetysObjectResolver _ametysObjectResolver; 211 /** The solr client provider */ 212 protected SolrClientProvider _solrClientProvider; 213 /** The site manager */ 214 protected SiteManager _siteManager; 215 /** The avalon context */ 216 protected Context _context; 217 /** The workspace selector */ 218 protected WorkspaceSelector _workspaceSelector; 219 /** The jcr repository */ 220 protected Repository _repository; 221 /** The Ametys cache manager */ 222 protected AbstractCacheManager _cacheManager; 223 /** The rendering context handler */ 224 protected RenderingContextHandler _renderingContextHandler; 225 226 public void service(ServiceManager manager) throws ServiceException 227 { 228 _cacheManager = (AbstractCacheManager) manager.lookup(AbstractCacheManager.ROLE); 229 } 230 231 public void initialize() throws Exception 232 { 233 _cacheManager.createRequestCache( 234 _CACHE_ID, 235 new I18nizableText("plugin.ai", "PLUGINS_AI_CACHE_EMBEDDING_LABEL"), 236 new I18nizableText("plugin.ai", "PLUGINS_AI_CACHE_EMBEDDING_DESCRIPTION"), 237 true 238 ); 239 } 240 241 public void deferredService(ServiceManager manager) throws ServiceException 242 { 243 _aiProviderEP = (AIProviderExtensionPoint) manager.lookup(AIProviderExtensionPoint.ROLE); 244 _uploadManager = (UploadManager) manager.lookup(UploadManager.ROLE); 245 _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE); 246 _siteManager = (SiteManager) manager.lookup(SiteManager.ROLE); 247 _jsonUtils = (JSONUtils) manager.lookup(JSONUtils.ROLE); 248 _searcherFactory = (SearcherFactory) manager.lookup(SearcherFactory.ROLE); 249 _solrClientProvider = (SolrClientProvider) manager.lookup(SolrClientProvider.ROLE); 250 _uriResolverEP = (URIResolverExtensionPoint) manager.lookup(URIResolverExtensionPoint.ROLE); 251 _ametysObjectResolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE); 252 _workspaceSelector = (WorkspaceSelector) manager.lookup(WorkspaceSelector.ROLE); 253 _repository = (Repository) manager.lookup(Repository.class.getName()); 254 _renderingContextHandler = (RenderingContextHandler) manager.lookup(RenderingContextHandler.ROLE); 255 } 256 257 public void contextualize(Context context) throws ContextException 258 { 259 _context = context; 260 } 261 262 /** 263 * Checks if a configured AI provider is available and supports text summarization. 264 * This includes verifying the provider exists, has a valid text model, and is properly configured. 265 * @return true if all is good, false otherwise 266 */ 267 public boolean isEnabled() 268 { 269 return getCurrentAIProvider() != null; 270 } 271 272 /** 273 * Summarize a given text 274 * @param text The input text to be summarized. 275 * @param maxLength The maximum number of characters for the summary. 276 * @return A map with the Ai-generated summary under the key "summary". 277 */ 278 @Callable(rights = "AI_Rights_Use") 279 public Map<String, String> textToSummary(String text, int maxLength) 280 { 281 try 282 { 283 if (isEnabled()) 284 { 285 return Map.of("summary", getCurrentAIProvider().textToSummary(text, maxLength)); 286 } 287 } 288 catch (Exception e) 289 { 290 getLogger().error("Error while summarizing text", e); 291 } 292 return Map.of("error", "error"); 293 } 294 295 /** 296 * Checks if a configured AI provider is available and supports image generation. 297 * This includes verifying the provider exists, has a valid text model, and is properly configured. 298 * @return true if all is good, false otherwise 299 */ 300 public boolean isImageGenerationSupported() 301 { 302 AIProvider aiProvider = getCurrentAIProvider(); 303 if (aiProvider == null) 304 { 305 return false; 306 } 307 return aiProvider.isImageGenerationSupported(); 308 } 309 310 /** 311 * Checks if a configured AI provider is available and supports embedding. 312 * @return true if supported, false otherwise 313 */ 314 public boolean isEmbeddingSupported() 315 { 316 AIProvider aiProvider = getCurrentAIProvider(); 317 return aiProvider != null && aiProvider.isEmbeddingSupported(); 318 } 319 320 /** 321 * Checks if a configured AI provider is available and supports embedding and semantic indexation is activated on the current site. 322 * @param siteName The site to consider 323 * @return true if activated 324 */ 325 public boolean isSemanticIndexationActivated(String siteName) 326 { 327 AIProvider aiProvider = getCurrentAIProvider(); 328 if (aiProvider == null || !aiProvider.isEmbeddingSupported()) 329 { 330 return false; 331 } 332 333 try 334 { 335 Site site = _siteManager.getSite(siteName); 336 return site.getValue(SITE_CONFIG_SEMANTIC_INDEXATION_ACTIVATE) == Boolean.TRUE; 337 } 338 catch (UnknownAmetysObjectException e) 339 { 340 getLogger().warn("There is no site '{}'", siteName); 341 return false; 342 } 343 } 344 345 /** 346 * Checks if a configured AI provider is available and supports embedding and semantic indexation and chatbot are activated on the current site. 347 * This includes verifying the provider exists, has a valid text model, and is properly configured. 348 * @param siteName The site to consider 349 * @return true if all is good, false otherwise 350 */ 351 public boolean isChatbotEnabled(String siteName) 352 { 353 AIProvider aiProvider = getCurrentAIProvider(); 354 if (aiProvider == null || !aiProvider.isEmbeddingSupported()) 355 { 356 return false; 357 } 358 359 try 360 { 361 Site site = _siteManager.getSite(siteName); 362 return site.getValue(SITE_CONFIG_CHAT_ACTIVATE) == Boolean.TRUE && site.getValue(SITE_CONFIG_SEMANTIC_INDEXATION_ACTIVATE) == Boolean.TRUE; 363 } 364 catch (UnknownAmetysObjectException e) 365 { 366 getLogger().warn("There is not site '{}'", siteName); 367 return false; 368 } 369 } 370 371 /** 372 * Generate an image with a given text 373 * @param text The input text to generate the image with. 374 * @param name The name for the generated image file. 375 * @return A map with the URL of the Ai-generated image under the key "image". 376 */ 377 @Callable(rights = "AI_Rights_Use") 378 public Map<String, Object> textToImage(String text, String name) 379 { 380 try 381 { 382 if (isImageGenerationSupported()) 383 { 384 Map<String, Object> imageAsJson = new HashMap<>(); 385 386 Path image = getCurrentAIProvider().textToImage(text); 387 try (InputStream is = Files.newInputStream(image)) 388 { 389 Upload storeUpload = _uploadManager.storeUpload(_currentUserProvider.getUser(), name + "-AI-generated." + StringUtils.substringAfterLast(image.toString(), "."), is); 390 _fillSuccess(storeUpload, imageAsJson); 391 return imageAsJson; 392 } 393 catch (Exception e) 394 { 395 getLogger().error("Error while accessing to the image", e); 396 return Map.of("error", "error"); 397 } 398 finally 399 { 400 Files.delete(image); 401 } 402 } 403 } 404 catch (Exception e) 405 { 406 getLogger().error("Error while generating the image", e); 407 } 408 return Map.of("error", "error"); 409 } 410 411 /** 412 * Summarize a resource 413 * @param resource the resource to be summarized. 414 * @param maxLength The maximum number of characters for the summary. 415 * @return the summary. 416 * @throws Exception if an error occurred 417 */ 418 public String resourceToSummary(Resource resource, int maxLength) throws Exception 419 { 420 if (isEnabled()) 421 { 422 try (InputStream is = resource.getInputStream()) 423 { 424 Tika tika = new Tika(); 425 tika.setMaxStringLength(-1); 426 String text = tika.parseToString(is); 427 428 return getCurrentAIProvider().textToSummary(text, maxLength); 429 } 430 } 431 return null; 432 } 433 434 /** 435 * Fill the result map. 436 * @param upload The upload 437 * @param result The result map to fill 438 */ 439 private void _fillSuccess(Upload upload, Map<String, Object> result) 440 { 441 result.put("success", true); 442 result.put("id", upload.getId()); 443 result.put("filename", upload.getFilename()); 444 result.put("size", upload.getLength()); 445 result.put("viewHref", _getUrlForView(upload)); 446 result.put("downloadHref", _getUrlForDownload(upload)); 447 } 448 449 /** 450 * Get the url for view the uploaded file 451 * @param upload The file uploaded 452 * @return The url for view 453 */ 454 private String _getUrlForView(Upload upload) 455 { 456 return "/plugins/core/upload/file?id=" + upload.getId(); 457 } 458 459 /** 460 * Get the url for download the uploaded file 461 * @param upload The file uploaded 462 * @return The url for view 463 */ 464 private String _getUrlForDownload(Upload upload) 465 { 466 return "/plugins/core/upload/file?id=" + upload.getId() + "&download=true"; 467 } 468 /** 469 * Get the current AI Provider class 470 * @return the AI provider 471 */ 472 protected AIProvider getCurrentAIProvider() 473 { 474 if (Config.getInstance().getValue(CONFIG_ACTIVATE) == Boolean.TRUE) 475 { 476 String providerID = Config.getInstance().getValue(CONFIG_PROVIDER); 477 return _aiProviderEP.getExtension(providerID); 478 } 479 else 480 { 481 return null; 482 } 483 } 484 485 /** 486 * Initialize the prompt with a given maximum length minus 10 characters to prevent the LLM to exceed the limit. 487 * @param maxLength the number of characters allowed 488 * @return the text of the prompt 489 */ 490 public String getTextSummaryPrompt(int maxLength) 491 { 492 String siteName = null; 493 494 Request request = ContextHelper.getRequest(_context); 495 if (request != null) 496 { 497 siteName = WebHelper.getSiteName(request); 498 } 499 500 String prompt = ""; 501 502 if (StringUtils.isNotBlank(siteName)) 503 { 504 try 505 { 506 Site site = _siteManager.getSite(siteName); 507 prompt = site.getValue(SITE_CONFIG_TEXT_PROMPT); 508 Strings.CS.appendIfMissing(prompt, "."); 509 } 510 catch (UnknownAmetysObjectException e) 511 { 512 getLogger().warn("There is not site '{}' to generate an image prompt", siteName); 513 } 514 515 } 516 517 return (__TEXT_SUMMARIZE_PROMPT_PREFIX + " " + prompt).replace("{maxLength}", Integer.toString(Math.min(maxLength, __TEXT_SUMMARIZE_MAXLENGTH))); 518 } 519 520 521 /** 522 * Initialize the prompt for image generation. 523 * @return the text of the prompt 524 */ 525 public String getImageGenerationPrompt() 526 { 527 String siteName = null; 528 529 Request request = ContextHelper.getRequest(_context); 530 if (request != null) 531 { 532 siteName = WebHelper.getSiteName(request); 533 } 534 535 String prompt = ""; 536 537 if (StringUtils.isNotBlank(siteName)) 538 { 539 try 540 { 541 Site site = _siteManager.getSite(siteName); 542 prompt = site.getValue(SITE_CONFIG_IMAGE_PROMPT); 543 Strings.CS.appendIfMissing(prompt, "."); 544 } 545 catch (UnknownAmetysObjectException e) 546 { 547 getLogger().warn("There is not site '{}' to generate an image prompt", siteName); 548 } 549 550 } 551 return __IMAGE_GENERATION_PROMPT_PREFIX + " " + prompt; 552 } 553 554 /** 555 * Get the chat prompt. 556 * @return the text of the prompt 557 */ 558 private Pair<String, String> getChatPromptAndName() 559 { 560 String siteName = null; 561 562 Request request = ContextHelper.getRequest(_context); 563 if (request != null) 564 { 565 siteName = WebHelper.getSiteName(request); 566 } 567 568 String prompt = ""; 569 String name = ""; 570 571 if (StringUtils.isNotBlank(siteName)) 572 { 573 try 574 { 575 Site site = _siteManager.getSite(siteName); 576 prompt = site.getValue(SITE_CONFIG_CHAT_PROMPT); 577 prompt = Strings.CS.appendIfMissing(prompt, ".") 578 + __CHAT_PROMPT_SUFFIX.replace("{url}", site.getUrl()); 579 name = site.getValue(SITE_CONFIG_CHAT_NAME); 580 } 581 catch (UnknownAmetysObjectException e) 582 { 583 getLogger().warn("There is not site '{}' to get the chat prompt", siteName); 584 } 585 586 } 587 588 return Pair.of(prompt, name); 589 } 590 591 /** 592 * Get the chatbot response according to the current provider. 593 * @param userMessage text 594 * @param historyJson the history of the conversation in JSON format 595 * @param siteName The current site name 596 * @param sitemapName The current sitemap name 597 * @return the chatbot response message 598 * @throws IOException if an error occurs 599 */ 600 @Callable (rights = Callable.NO_CHECK_REQUIRED) 601 public String askChatbot(String userMessage, String historyJson, String siteName, String sitemapName) throws IOException 602 { 603 if (!isChatbotEnabled(siteName)) 604 { 605 return null; 606 } 607 608 AIProvider provider = getCurrentAIProvider(); 609 610 List<Object> historyList = _jsonUtils.convertJsonToList(historyJson); 611 612 try 613 { 614 List<Float> queryVector = null; 615 616 if (historyList != null && historyList.size() > 0) 617 { 618 ChatMemory memory = MessageWindowChatMemory.withMaxMessages(500); 619 memory.add(SystemMessage.from(__CHAT_SUMMARY_PREFIX)); 620 _addHistoryToChatMemory(historyList, memory); 621 memory.add(SystemMessage.from(__CHAT_SUMMARY_SUFFIX)); 622 memory.add(UserMessage.from(userMessage)); 623 624 String aiResponse = provider.askChatbot(memory); 625 if (aiResponse != null) 626 { 627 queryVector = computeEmbedding(aiResponse); 628 } 629 } 630 if (queryVector == null) 631 { 632 queryVector = computeEmbedding(userMessage); 633 } 634 635 List<Map<String, Object>> contextChunks = new ArrayList<>(); 636 637 Query q = new VectorQuery(AIAdditionalDataIndexer.SOLR_EMBEDDING, queryVector); 638 639 Searcher searcher = _searcherFactory.create() 640 .withQuery(q) 641 .addFilterQuery(new DocumentTypeQuery(AIAdditionalDataIndexer.SOLR_TYPE)) 642 .addFilterQueryString(SolrWebFieldNames.SITE_NAME + ":" + siteName) 643 .addFilterQueryString(SolrFieldNames.CONTENT_LANGUAGES + ":" + sitemapName) 644 .withLimits(0, __MAX_NUMBER_OF_CHUNKS) 645 .setCheckRights(true); 646 AmetysObjectIterable<CacheContentVersionChunk> chunks = searcher.search(); 647 for (CacheContentVersionChunk chunk : chunks) 648 { 649 String uri = _getContentURL(chunk.getContentId()); 650 651 if (StringUtils.isNotBlank(uri)) 652 { 653 String chunkContent = chunk.getValue(AIAdditionalDataIndexer.DATA_CHUNK_CONTENT); 654 655 Map<String, Object> chunkMap = new HashMap<>(); 656 chunkMap.put("chunk_content", chunkContent); 657 chunkMap.put("chunk_uri", uri); 658 contextChunks.add(chunkMap); 659 } 660 } 661 662 String contextJsonString = _jsonUtils.convertObjectToJson(contextChunks); 663 String context = __CHAT_PROMPT_CONTEXT.replace("{chunks}", contextJsonString); 664 665 ChatMemory memory = MessageWindowChatMemory.withMaxMessages(500); 666 667 Pair<String, String> promptAndName = getChatPromptAndName(); 668 memory.add(SystemMessage.from(__CHAT_PROMPT_INTRODUCTION.replace("{name}", promptAndName.getRight()) + "\n" + promptAndName.getLeft() + "\n" + context)); 669 670 _addHistoryToChatMemory(historyList, memory); 671 memory.add(UserMessage.from(userMessage)); 672 673 String aiResponse = provider.askChatbot(memory); 674 return aiResponse != null ? aiResponse : null; 675 } 676 catch (Exception e) 677 { 678 getLogger().error("Error while getting chatbot response", e); 679 return null; 680 } 681 } 682 683 private void _addHistoryToChatMemory(List<Object> historyList, ChatMemory memory) 684 { 685 if (historyList != null) 686 { 687 for (Object o : historyList) 688 { 689 @SuppressWarnings("unchecked") 690 Map<String, Object> map = (Map<String, Object>) o; 691 String role = (String) map.get("role"); 692 String text = (String) map.get("text"); 693 694 if ("user".equals(role)) 695 { 696 memory.add(UserMessage.from(text)); 697 } 698 else if ("ai".equals(role)) 699 { 700 memory.add(AiMessage.from(text)); 701 } 702 } 703 } 704 705 } 706 707 private String _getContentURL(String contentId) 708 { 709 RenderingContext currentRenderingContext = _renderingContextHandler.getRenderingContext(); 710 if (currentRenderingContext == RenderingContext.BACK) 711 { 712 // We do not resolve uri in the BACK context since it can links to javascript: for pages, and this does not work in the chatbot rendering. 713 _renderingContextHandler.setRenderingContext(RenderingContext.PREVIEW); 714 } 715 716 try 717 { 718 AmetysObject obj = _ametysObjectResolver.resolveById(contentId); 719 720 if (obj instanceof WebContent webContent) 721 { 722 Optional<Page> firstPage = webContent.getReferencingPages().stream().findFirst(); 723 if (firstPage.isPresent()) 724 { 725 URIResolver uriResolver = _uriResolverEP.getResolverForType("page"); 726 if (uriResolver != null) 727 { 728 return uriResolver.resolve(firstPage.get().getId(), false, true, false); 729 } 730 } 731 } 732 else if (obj instanceof Resource resource) 733 { 734 URIResolver uriResolver = _uriResolverEP.getResolverForType("attachment-content"); 735 if (uriResolver != null) 736 { 737 return uriResolver.resolve(resource.getId(), false, true, false); 738 } 739 } 740 } 741 catch (Exception e) 742 { 743 getLogger().warn("The embedding vector references an unexisting ametys object : " + contentId, e); 744 } 745 finally 746 { 747 _renderingContextHandler.setRenderingContext(currentRenderingContext); 748 } 749 750 return ""; 751 } 752 753 /** 754 * Get the embedding of a text 755 * @param text The text to get the embedding for 756 * @return The embedding vector for the given text or null if an error occurred or embedding is not supported 757 */ 758 public List<Float> computeEmbedding(String text) 759 { 760 return _getCache().get(text, t -> { 761 AIProvider provider = getCurrentAIProvider(); 762 if (provider != null && provider.isEmbeddingSupported()) 763 { 764 return provider.computeEmbedding(t); 765 } 766 return null; 767 }); 768 } 769 770 /** 771 * Get the dimension of the embedding vector 772 * @return The embedding dimension or -1 if not supported 773 */ 774 public int getEmbeddingDimension() 775 { 776 AIProvider provider = getCurrentAIProvider(); 777 if (provider != null && provider.isEmbeddingSupported()) 778 { 779 return provider.getEmbeddingDimension(); 780 } 781 return -1; 782 } 783 784 /** 785 * Get a string key identifying the embedding generator. Comparing this key between two calls allows to know if an existing embedding is still valid. 786 * @return The embedding generator key or null if embedding is not supported 787 */ 788 public String getEmbeddingGeneratorKey() 789 { 790 AIProvider provider = getCurrentAIProvider(); 791 if (provider != null) 792 { 793 return provider.getEmbeddingKey(); 794 } 795 return null; 796 } 797 798 private Cache<String, List<Float>> _getCache() 799 { 800 return _cacheManager.get(_CACHE_ID); 801 } 802}