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}