001/* 002 * Copyright 2026 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 */ 016package org.ametys.plugins.ai.search; 017 018import java.io.IOException; 019import java.io.InputStreamReader; 020import java.io.Reader; 021import java.time.ZonedDateTime; 022import java.util.ArrayList; 023import java.util.Arrays; 024import java.util.Collection; 025import java.util.HashMap; 026import java.util.List; 027import java.util.Map; 028import java.util.Set; 029 030import javax.jcr.LoginException; 031import javax.jcr.RepositoryException; 032 033import org.apache.avalon.framework.context.Context; 034import org.apache.avalon.framework.context.ContextException; 035import org.apache.avalon.framework.context.Contextualizable; 036import org.apache.avalon.framework.service.ServiceException; 037import org.apache.avalon.framework.service.ServiceManager; 038import org.apache.cocoon.components.ContextHelper; 039import org.apache.cocoon.components.source.impl.SitemapSource; 040import org.apache.cocoon.environment.Request; 041import org.apache.commons.io.IOUtils; 042import org.apache.commons.lang3.StringUtils; 043import org.apache.commons.lang3.Strings; 044import org.apache.excalibur.source.SourceResolver; 045import org.apache.solr.common.SolrInputDocument; 046 047import org.ametys.cms.content.indexing.solr.SolrFieldNames; 048import org.ametys.cms.content.indexing.solr.SolrIndexer; 049import org.ametys.cms.repository.Content; 050import org.ametys.cms.search.query.AndQuery; 051import org.ametys.cms.search.query.DocumentTypeQuery; 052import org.ametys.cms.search.query.Query; 053import org.ametys.cms.search.solr.schema.FieldDefinition; 054import org.ametys.cms.search.solr.schema.FieldTypeDefinition; 055import org.ametys.cms.search.solr.schema.SchemaDefinition; 056import org.ametys.core.authentication.AuthenticateAction; 057import org.ametys.core.user.UserIdentity; 058import org.ametys.core.user.population.UserPopulationDAO; 059import org.ametys.core.util.URIUtils; 060import org.ametys.plugins.ai.AIHelper; 061import org.ametys.plugins.ai.provider.impl.DefaultTokenCountEstimator; 062import org.ametys.plugins.repository.AmetysObject; 063import org.ametys.plugins.repository.AmetysRepositoryException; 064import org.ametys.plugins.repository.jcr.DefaultTraversableAmetysObject; 065import org.ametys.plugins.repository.provider.RequestAttributeWorkspaceSelector; 066import org.ametys.plugins.repository.version.VersionableAmetysObject; 067import org.ametys.runtime.plugin.component.AbstractLogEnabled; 068import org.ametys.runtime.plugin.component.DeferredServiceable; 069import org.ametys.web.WebConstants; 070import org.ametys.web.indexing.solr.AdditionalWebDataIndexer; 071import org.ametys.web.indexing.solr.SolrWebFieldNames; 072import org.ametys.web.repository.content.WebContent; 073import org.ametys.web.search.query.SiteQuery; 074import org.ametys.web.search.query.SitemapQuery; 075 076import dev.langchain4j.data.document.Document; 077import dev.langchain4j.data.document.DocumentSplitter; 078import dev.langchain4j.data.document.splitter.DocumentSplitters; 079import dev.langchain4j.data.segment.TextSegment; 080import dev.langchain4j.model.TokenCountEstimator; 081 082/** 083 * Indexer for AI additional data. 084 */ 085public class AIAdditionalDataIndexer extends AbstractLogEnabled implements AdditionalWebDataIndexer, DeferredServiceable, Contextualizable 086{ 087 /** The solr type for chunk embedding */ 088 public static final String SOLR_TYPE = "chunk_embedding"; 089 /** The solr field name for embedding vector */ 090 public static final String SOLR_EMBEDDING = "chunk_embedding"; 091 /** The solr field name with the content id */ 092 public static final String SOLR_CONTENT_ID = "chunk_object_s_dv"; // s_dv required for Ametys join queries 093 094 /** The content attribute name for a chunk */ 095 public static final String DATA_CHUNK_CONTENT = "content"; 096 /** The embedding attribute name for a chunk */ 097 public static final String DATA_CHUNK_EMBDEDING = "embedding"; 098 /** The creation datetime attribute name for a chunk */ 099 public static final String DATA_CHUNK_CREATION_DATETIME = "creation"; 100 /** The identifier of the embedding used */ 101 public static final String DATA_CHUNK_CREATOR = "creator"; 102 103 104 private static final int __DOCUMENT_CHUNK_MAX_SIZE = 800; 105 private static final int __DOCUMENT_CHUNK_MAX_OVERLAP = 400; 106 107 private AIHelper _aiHelper; 108 109 private SourceResolver _sourceResolver; 110 private Context _context; 111 private CacheDAO _cacheDAO; 112 private SolrIndexer _solrIndexer; 113 114 public void deferredService(ServiceManager manager) throws ServiceException 115 { 116 _aiHelper = (AIHelper) manager.lookup(AIHelper.ROLE); 117 _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE); 118 _cacheDAO = (CacheDAO) manager.lookup(CacheDAO.ROLE); 119 _solrIndexer = (SolrIndexer) manager.lookup(SolrIndexer.ROLE); 120 } 121 122 public void contextualize(Context context) throws ContextException 123 { 124 _context = context; 125 } 126 127 public Collection<String> getSupportedTypes() 128 { 129 return Set.of("content"); 130 } 131 132 public List<SolrInputDocument> indexAdditionalDocuments(AmetysObject object, SolrInputDocument document) 133 { 134 if (!(object instanceof Content content)) 135 { 136 throw new IllegalArgumentException("Object should be a Content and not a " + object.getClass().getName()); 137 } 138 139 if (!_aiHelper.isEmbeddingSupported() 140 || content instanceof WebContent webContent && !_aiHelper.isSemanticIndexationActivated(webContent.getSiteName())) 141 { 142 return List.of(); 143 } 144 145 try 146 { 147 DefaultTraversableAmetysObject cacheContentVersionNode = _updateAndGetCache(content); 148 149 return _getDocumentsFromCache(content, cacheContentVersionNode); 150 } 151 catch (IOException | AmetysRepositoryException | RepositoryException e) 152 { 153 throw new RuntimeException("An error occurred while preparing content " + content.getId(), e); 154 } 155 } 156 157 private DefaultTraversableAmetysObject _updateAndGetCache(Content content) throws IOException, AmetysRepositoryException, LoginException, RepositoryException 158 { 159 String embeddingGeneratorKey = _aiHelper.getEmbeddingGeneratorKey(); 160 161 // Convert to md and clean anchors 162 List<TextSegment> segments = _contentToSegments(content); 163 String revision = _getContentRevision(content); 164 165 DefaultTraversableAmetysObject nearestCacheContentVersionNode = _cacheDAO.getNearestCacheContentVersionNode(content, revision); 166 167 DefaultTraversableAmetysObject cacheContentVersionNode = _checkCacheValidity(embeddingGeneratorKey, segments, nearestCacheContentVersionNode); 168 if (cacheContentVersionNode == null) 169 { 170 // Create new cache content version node 171 cacheContentVersionNode = _cacheDAO.getCacheContentVersionNode(content, revision); 172 173 // Compare and replace segments under segments size 174 for (int i = 0; i < segments.size(); i++) 175 { 176 TextSegment textSegment = segments.get(i); 177 String newSegment = textSegment.text(); 178 179 CacheContentVersionChunk existingChunkNode = _cacheDAO.getChunk(cacheContentVersionNode, i, true); 180 String existingSegment = existingChunkNode.getValueOrDefault(DATA_CHUNK_CONTENT, null); 181 String existingCreator = existingChunkNode.getValueOrDefault(DATA_CHUNK_CREATOR, null); 182 183 CacheContentVersionChunk newChunkNode = _cacheDAO.getChunk(cacheContentVersionNode, i, true); 184 185 if (!Strings.CS.equals(newSegment, existingSegment) 186 || !Strings.CS.equals(embeddingGeneratorKey, existingCreator)) 187 { 188 // compute and store embedding 189 List<Float> embedding = _aiHelper.computeEmbedding(newSegment); 190 if (embedding != null && !embedding.isEmpty()) 191 { 192 newChunkNode.setValue(DATA_CHUNK_CONTENT, newSegment); 193 newChunkNode.setValue(DATA_CHUNK_EMBDEDING, embedding.toArray(new Float[0])); 194 newChunkNode.setValue(DATA_CHUNK_CREATION_DATETIME, ZonedDateTime.now()); 195 newChunkNode.setValue(DATA_CHUNK_CREATOR, embeddingGeneratorKey); 196 } 197 } 198 else 199 { 200 // simply copy old values 201 newChunkNode.setValue(DATA_CHUNK_CONTENT, existingChunkNode.getValue(DATA_CHUNK_CONTENT)); 202 newChunkNode.setValue(DATA_CHUNK_EMBDEDING, existingChunkNode.getValue(DATA_CHUNK_EMBDEDING)); 203 newChunkNode.setValue(DATA_CHUNK_CREATION_DATETIME, existingChunkNode.getValue(DATA_CHUNK_CREATION_DATETIME)); 204 newChunkNode.setValue(DATA_CHUNK_CREATOR, existingChunkNode.getValue(DATA_CHUNK_CREATOR)); 205 } 206 } 207 208 if (cacheContentVersionNode.needsSave()) 209 { 210 cacheContentVersionNode.saveChanges(); 211 } 212 } 213 214 return cacheContentVersionNode; 215 } 216 217 private List<SolrInputDocument> _getDocumentsFromCache(Content content, DefaultTraversableAmetysObject cacheContentVersionNode) 218 { 219 List<SolrInputDocument> sids = new ArrayList<>(); 220 221 // Read what is stored as vector and send it to solr 222 for (int i = 0; ; i++) 223 { 224 CacheContentVersionChunk chunk = _cacheDAO.getChunk(cacheContentVersionNode, i, false); 225 if (chunk == null) 226 { 227 break; 228 } 229 230 Double[] v = chunk.getValueOrDefault(DATA_CHUNK_EMBDEDING, new Double[0]); 231 232 SolrInputDocument doc = new SolrInputDocument(); 233 doc.addField("id", chunk.getId()); 234 doc.addField(SolrFieldNames.DOCUMENT_TYPE, SOLR_TYPE); 235 doc.addField(SOLR_EMBEDDING, Arrays.asList(v)); 236 doc.addField(SOLR_CONTENT_ID, content.getId()); 237 if (content instanceof WebContent webContent) 238 { 239 doc.addField(SolrWebFieldNames.SITE_NAME, webContent.getSiteName()); 240 } 241 if (content.getLanguage() != null) 242 { 243 doc.addField(SolrFieldNames.CONTENT_LANGUAGES, content.getLanguage()); 244 } 245 246 _solrIndexer.indexAclInitValues(chunk, doc); 247 248 sids.add(doc); 249 } 250 251 return sids; 252 } 253 254 private DefaultTraversableAmetysObject _checkCacheValidity(String embeddingGeneratorKey, List<TextSegment> segments, DefaultTraversableAmetysObject cacheContentVersionNode) 255 { 256 if (cacheContentVersionNode != null) 257 { 258 // Compare segments and segments size 259 for (int i = 0; i < segments.size(); i++) 260 { 261 TextSegment textSegment = segments.get(i); 262 String newSegment = textSegment.text(); 263 264 CacheContentVersionChunk chunkNode = _cacheDAO.getChunk(cacheContentVersionNode, i, false); 265 if (chunkNode == null) 266 { 267 // Nearest has less segments, we will create a new one 268 return null; 269 } 270 271 String existingSegment = chunkNode.getValueOrDefault(DATA_CHUNK_CONTENT, null); 272 String creator = chunkNode.getValueOrDefault(DATA_CHUNK_CREATOR, null); 273 274 if (!Strings.CS.equals(newSegment, existingSegment) 275 || !Strings.CS.equals(embeddingGeneratorKey, creator)) 276 { 277 // Nearest is not matching, we will create a new one 278 return null; 279 } 280 } 281 } 282 283 // If cache is bigger that expected... that not a problem, we will ignore it 284 return cacheContentVersionNode; 285 } 286 287 private String _getContentRevision(Content content) 288 { 289 if (!(content instanceof VersionableAmetysObject versionableContent)) 290 { 291 throw new IllegalArgumentException("Object should be a VersionableAmetysObject " + content.getId()); 292 } 293 294 String revision = versionableContent.getRevision(); 295 if (revision != null) 296 { 297 return revision; 298 } 299 300 Request request = ContextHelper.getRequest(_context); 301 String currentWsp = RequestAttributeWorkspaceSelector.getForcedWorkspace(request); 302 if (currentWsp.equals(WebConstants.LIVE_WORKSPACE)) 303 { 304 String[] allLabels = versionableContent.getAllLabels(); 305 if (Arrays.binarySearch(allLabels, "Live") >= 0) 306 { 307 versionableContent.switchToLabel("Live"); 308 revision = versionableContent.getRevision(); 309 versionableContent.switchToLabel(null); 310 return revision; 311 } 312 else 313 { 314 throw new IllegalArgumentException("Cannot determine live revision version of " + content.getId()); 315 } 316 } 317 else 318 { 319 // get the last version 320 String[] allRevisions = versionableContent.getAllRevisions(); 321 return allRevisions[allRevisions.length - 1]; 322 } 323 } 324 325 public List<Query> getUnindexObjectQuery(String ametysObjectId) throws Exception 326 { 327 return List.of(new ContentChunkQuery(ametysObjectId)); 328 } 329 330 public Query getUnindexAllQuery(String type) throws Exception 331 { 332 return new DocumentTypeQuery(SOLR_TYPE); 333 } 334 335 public Query getUnindexSiteQuery(String type, String sitename) throws Exception 336 { 337 return new AndQuery(getUnindexAllQuery(type), new SiteQuery(sitename)); 338 } 339 340 public Query getUnindexSitemapQuery(String type, String siteName, String sitemapName) throws Exception 341 { 342 return new AndQuery(getUnindexSiteQuery(type, siteName), new SitemapQuery(sitemapName)); 343 } 344 345 public Collection<SchemaDefinition> getSchemaDefinitions() 346 { 347 if (!_aiHelper.isEmbeddingSupported()) 348 { 349 return List.of(); 350 } 351 352 org.apache.solr.client.solrj.request.schema.FieldTypeDefinition knnVectorFieldTypeDefinition = new org.apache.solr.client.solrj.request.schema.FieldTypeDefinition(); 353 knnVectorFieldTypeDefinition.setAttributes(Map.of( 354 "name", "knn_vector", 355 "class", "solr.DenseVectorField", 356 "vectorDimension", _aiHelper.getEmbeddingDimension(), 357 "similarityFunction", "cosine" 358 )); 359 360 return List.of( 361 new FieldTypeDefinition(knnVectorFieldTypeDefinition), 362 new FieldDefinition(SOLR_EMBEDDING, "knn_vector", false, false, true, false), 363 new FieldDefinition(SOLR_CONTENT_ID, "string", false, true, true, true) 364 ); 365 } 366 367 private List<TextSegment> _contentToSegments(Content content) throws IOException 368 { 369 String contentTextMarkdown = _contentToMD(content, ""); 370 371 TokenCountEstimator estimator = new DefaultTokenCountEstimator(); 372 DocumentSplitter splitter = DocumentSplitters.recursive(__DOCUMENT_CHUNK_MAX_SIZE, __DOCUMENT_CHUNK_MAX_OVERLAP, estimator); 373 return StringUtils.isNotBlank(contentTextMarkdown) ? splitter.split(Document.from(contentTextMarkdown)) : List.of(); 374 } 375 376 private String _contentToMD (Content content, String version) throws IOException 377 { 378 if (!(content instanceof WebContent webContent)) 379 { 380 return ""; 381 } 382 383 Request request = ContextHelper.getRequest(_context); 384 UserIdentity currentUser = AuthenticateAction.getUserIdentityFromSession(request); 385 Object currentInternalAllowed = request.getAttribute(AuthenticateAction.REQUEST_ATTRIBUTE_INTERNAL_ALLOWED); 386 387 try 388 { 389 request.getSession(true).setAttribute(AuthenticateAction.SESSION_USERIDENTITY, UserPopulationDAO.SYSTEM_USER_IDENTITY); 390 request.setAttribute(AuthenticateAction.REQUEST_ATTRIBUTE_INTERNAL_ALLOWED, true); 391 392 String uri = URIUtils.buildURI("cocoon://_wrapped-content-data.md", getContentViewUrlParameters(webContent, "ai", version)); 393 SitemapSource src = null; 394 try 395 { 396 src = (SitemapSource) _sourceResolver.resolveURI(uri); 397 Reader reader = new InputStreamReader(src.getInputStream(), "UTF-8"); 398 return IOUtils.toString(reader); 399 } 400 finally 401 { 402 _sourceResolver.release(src); 403 } 404 } 405 finally 406 { 407 request.setAttribute(AuthenticateAction.SESSION_USERIDENTITY, currentUser); 408 request.setAttribute(AuthenticateAction.REQUEST_ATTRIBUTE_INTERNAL_ALLOWED, currentInternalAllowed); 409 } 410 } 411 412 private Map<String, String> getContentViewUrlParameters(WebContent content, String viewName, String version) 413 { 414 Map<String, String> params = new HashMap<>(); 415 params.put("contentId", content.getId()); 416 params.put("viewName", viewName); 417 params.put("fallbackViewName", "main"); 418 params.put("contentVersion", version); 419 params.put("showMissing", "false"); 420 params.put("showDisableValues", "true"); 421 422 return params; 423 } 424}