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.util.ArrayList; 019import java.util.List; 020 021import javax.jcr.LoginException; 022import javax.jcr.Repository; 023import javax.jcr.RepositoryException; 024import javax.jcr.Session; 025 026import org.apache.avalon.framework.activity.Disposable; 027import org.apache.avalon.framework.component.Component; 028import org.apache.avalon.framework.context.Context; 029import org.apache.avalon.framework.context.ContextException; 030import org.apache.avalon.framework.context.Contextualizable; 031import org.apache.avalon.framework.service.ServiceException; 032import org.apache.avalon.framework.service.ServiceManager; 033import org.apache.cocoon.components.ContextHelper; 034import org.apache.cocoon.environment.Request; 035import org.apache.commons.lang3.StringUtils; 036 037import org.ametys.cms.repository.Content; 038import org.ametys.core.util.StringUtils.AlphanumComparator; 039import org.ametys.plugins.repository.AmetysObject; 040import org.ametys.plugins.repository.AmetysObjectIterable; 041import org.ametys.plugins.repository.AmetysObjectResolver; 042import org.ametys.plugins.repository.AmetysRepositoryException; 043import org.ametys.plugins.repository.RepositoryConstants; 044import org.ametys.plugins.repository.collection.AmetysObjectCollection; 045import org.ametys.plugins.repository.jcr.DefaultTraversableAmetysObject; 046import org.ametys.plugins.repository.jcr.JCRTraversableAmetysObject; 047import org.ametys.plugins.repository.provider.AbstractRepository; 048import org.ametys.plugins.repository.provider.RequestAttributeWorkspaceSelector; 049import org.ametys.runtime.plugin.component.AbstractLogEnabled; 050import org.ametys.runtime.plugin.component.DeferredServiceable; 051import org.ametys.runtime.plugin.component.PluginAware; 052import org.ametys.web.repository.content.WebContent; 053 054/** 055 * DAO for AI cache management. 056 */ 057public class CacheDAO extends AbstractLogEnabled implements DeferredServiceable, PluginAware, Contextualizable, Component, Disposable 058{ 059 /** The avalon role */ 060 public static final String ROLE = CacheDAO.class.getName(); 061 062 /** The property name to store content id on Content node of the cache */ 063 public static final String CACHEROOT_CONTENT_DATA_CONTENTID = "id"; 064 065 private static final String __CACHEROOT_NODENAME = "ai_cache"; 066 067 private static final String __CACHEROOT_NODETYPE = "ametys:collection"; 068 private static final String __CACHEROOT_CONTENT_NODETYPE = "ametys:ai_cache_content"; 069 private static final String __CACHEROOT_CONTENT_VERSION_NODETYPE = "ametys:ai_cache_content_version"; 070 private static final String __CACHEROOT_CONTENT_VERSION_CHUNK_NODETYPE = "ametys:ai_cache_content_version_chunk"; 071 072 private static final String __CACHEROOT_CONTENT_DATA_SITE = "site"; 073 private static final String __CACHEROOT_CONTENT_DATA_LANGUAGE = "lang"; 074 075 private String _pluginName; 076 private AmetysObjectResolver _ametysObjectResolver; 077 private Context _context; 078 079 private Repository _repository; 080 081 // This DAO use its own session to prevent sessions overlap due to parallel indexation 082 private Session _cacheSession; 083 084 public void setPluginInfo(String pluginName, String featureName, String id) 085 { 086 _pluginName = pluginName; 087 } 088 089 public void deferredService(ServiceManager manager) throws ServiceException 090 { 091 _ametysObjectResolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE); 092 _repository = (Repository) manager.lookup(AbstractRepository.ROLE); 093 try 094 { 095 _cacheSession = _repository.login(RepositoryConstants.DEFAULT_WORKSPACE); 096 } 097 catch (RepositoryException e) 098 { 099 throw new ServiceException(AbstractRepository.ROLE, "Unable to get a JCR session for cache DAO", e); 100 } 101 } 102 103 public void contextualize(Context context) throws ContextException 104 { 105 _context = context; 106 } 107 108 public void dispose() 109 { 110 _cacheSession.logout(); 111 } 112 113 /** 114 * Get the root plugin storage object. 115 * @return the root plugin storage object. 116 * @throws AmetysRepositoryException if a repository error occurs. 117 * @throws RepositoryException if a repository error occurs. 118 * @throws LoginException if a repository error occurs. 119 */ 120 public AmetysObjectCollection getCacheRootNode() throws AmetysRepositoryException, LoginException, RepositoryException 121 { 122 return _getCacheRootNode(_repository.login()); 123 } 124 125 private AmetysObjectCollection _getCacheRootNode() throws RepositoryException 126 { 127 _cacheSession.refresh(true); 128 return _getCacheRootNode(_cacheSession); 129 } 130 131 private AmetysObjectCollection _getCacheRootNode(Session session) throws AmetysRepositoryException 132 { 133 // Always switch to the default workspace, because data can be written from live too 134 Request request = ContextHelper.getRequest(_context); 135 String currentWsp = RequestAttributeWorkspaceSelector.getForcedWorkspace(request); 136 137 try 138 { 139 // Force the workspace. 140 RequestAttributeWorkspaceSelector.setForcedWorkspace(request, RepositoryConstants.DEFAULT_WORKSPACE); 141 142 DefaultTraversableAmetysObject pluginsNode = _ametysObjectResolver.resolveByPath("/ametys:plugins", session); 143 144 DefaultTraversableAmetysObject pluginNode = _getOrCreateNode(pluginsNode, _pluginName, "ametys:unstructured"); 145 146 return _getOrCreateNode(pluginNode, __CACHEROOT_NODENAME, __CACHEROOT_NODETYPE); 147 } 148 catch (AmetysRepositoryException e) 149 { 150 throw new AmetysRepositoryException("Unable to get the thesaurus root node", e); 151 } 152 finally 153 { 154 // Restore the workspace. 155 RequestAttributeWorkspaceSelector.setForcedWorkspace(request, currentWsp); 156 } 157 } 158 159 /** 160 * Get the cache node for a given content id 161 * @param content the content 162 * @param creates Create the node if not existing 163 * @return the cache node or null if not existing and not created 164 * @throws AmetysRepositoryException if an error occurs when manipulating the repository 165 * @throws RepositoryException if an error occurs when manipulating the repository 166 */ 167 public DefaultTraversableAmetysObject getCacheContentNode(Content content, boolean creates) throws AmetysRepositoryException, RepositoryException 168 { 169 String uuid = StringUtils.substringAfter(content.getId(), "://"); 170 171 AmetysObjectCollection cacheRootNode = _getCacheRootNode(); 172 boolean willCreate = !cacheRootNode.hasChild(uuid); 173 174 DefaultTraversableAmetysObject ao = _getOrCreateNode(cacheRootNode, uuid, __CACHEROOT_CONTENT_NODETYPE); 175 if (willCreate) 176 { 177 try 178 { 179 if (content instanceof WebContent webContent) 180 { 181 ao.getNode().setProperty("ametys-internal:" + __CACHEROOT_CONTENT_DATA_SITE, webContent.getSiteName()); 182 } 183 184 if (content.getLanguage() != null) 185 { 186 ao.getNode().setProperty("ametys-internal:" + __CACHEROOT_CONTENT_DATA_LANGUAGE, content.getLanguage()); 187 } 188 189 ao.getNode().setProperty("ametys-internal:" + CACHEROOT_CONTENT_DATA_CONTENTID, content.getId()); 190 } 191 catch (RepositoryException e) 192 { 193 throw new AmetysRepositoryException(e); 194 } 195 ao.saveChanges(); 196 } 197 return ao; 198 } 199 200 /** 201 * Get the cache node for a given content id 202 * @param content the content 203 * @param version the content version 204 * @return the cache node 205 * @throws AmetysRepositoryException if an error occurs when manipulating the repository 206 * @throws RepositoryException if an error occurs when manipulating the repository 207 * @throws LoginException if an error occurs when manipulating the repository 208 */ 209 public DefaultTraversableAmetysObject getNearestCacheContentVersionNode(Content content, String version) throws AmetysRepositoryException, LoginException, RepositoryException 210 { 211 DefaultTraversableAmetysObject cacheContentNode = getCacheContentNode(content, true); 212 if (!cacheContentNode.hasChild(version)) 213 { 214 AmetysObjectIterable<DefaultTraversableAmetysObject> children = cacheContentNode.getChildren(); 215 List<String> revisions = children.stream().map(AmetysObject::getName) 216 .toList(); 217 List<String> revisionsPlusNewVersion = new ArrayList<>(revisions); 218 revisionsPlusNewVersion.add(version); 219 revisionsPlusNewVersion.sort(new AlphanumComparator()); 220 221 int i = revisionsPlusNewVersion.indexOf(version); 222 223 if (i > 0) 224 { 225 String precedingRevision = revisionsPlusNewVersion.get(i - 1); 226 return _getOrCreateNode(cacheContentNode, precedingRevision, __CACHEROOT_CONTENT_VERSION_NODETYPE); 227 } 228 else 229 { 230 return null; 231 } 232 } 233 return getCacheContentVersionNode(content, version); 234 } 235 236 /** 237 * Get the cache node for a given content id 238 * @param content the content 239 * @param version the content version 240 * @return the cache node 241 * @throws AmetysRepositoryException if an error occurs when manipulating the repository 242 * @throws RepositoryException if an error occurs when manipulating the repository 243 * @throws LoginException if an error occurs when manipulating the repository 244 */ 245 public DefaultTraversableAmetysObject getCacheContentVersionNode(Content content, String version) throws AmetysRepositoryException, LoginException, RepositoryException 246 { 247 DefaultTraversableAmetysObject cacheContentNode = getCacheContentNode(content, false); 248 if (cacheContentNode == null) 249 { 250 return null; 251 } 252 253 boolean copyPreceding = !cacheContentNode.hasChild(version); 254 DefaultTraversableAmetysObject ao = _getOrCreateNode(cacheContentNode, version, __CACHEROOT_CONTENT_VERSION_NODETYPE); 255 if (copyPreceding) 256 { 257 AmetysObjectIterable<DefaultTraversableAmetysObject> children = cacheContentNode.getChildren(); 258 List<String> revisions = children.stream().map(AmetysObject::getName) 259 .sorted(new AlphanumComparator()) 260 .toList(); 261 int i = revisions.indexOf(version); 262 263 if (i > 0) 264 { 265 String precedingRevision = revisions.get(i - 1); 266 DefaultTraversableAmetysObject ao2 = _getOrCreateNode(cacheContentNode, precedingRevision, __CACHEROOT_CONTENT_VERSION_NODETYPE); 267 AmetysObjectIterable<CacheContentVersionChunk> chunksToCopy = ao2.getChildren(); 268 chunksToCopy.forEach(child -> 269 { 270 try 271 { 272 CacheContentVersionChunk newChunk = _getOrCreateNode(ao, child.getName(), __CACHEROOT_CONTENT_VERSION_CHUNK_NODETYPE); 273 newChunk.setValue(AIAdditionalDataIndexer.DATA_CHUNK_CONTENT, child.getValueOrDefault(AIAdditionalDataIndexer.DATA_CHUNK_CONTENT, null)); 274 newChunk.setValue(AIAdditionalDataIndexer.DATA_CHUNK_EMBDEDING, child.getValueOrDefault(AIAdditionalDataIndexer.DATA_CHUNK_EMBDEDING, null)); 275 newChunk.setValue(AIAdditionalDataIndexer.DATA_CHUNK_CREATION_DATETIME, child.getValueOrDefault(AIAdditionalDataIndexer.DATA_CHUNK_CREATION_DATETIME, null)); 276 newChunk.setValue(AIAdditionalDataIndexer.DATA_CHUNK_CREATOR, child.getValueOrDefault(AIAdditionalDataIndexer.DATA_CHUNK_CREATOR, null)); 277 newChunk.saveChanges(); 278 } 279 catch (AmetysRepositoryException e) 280 { 281 getLogger().error("Unable to copy chunk {} from revision {} to revision {}", child.getName(), precedingRevision, version, e); 282 } 283 }); 284 } 285 } 286 287 return ao; 288 } 289 290 /** 291 * Get or create a chunk node 292 * @param cacheContentVersionNode The cache content version node 293 * @param number the chunk number 294 * @param createIfNotExist true to create the chunk if it does not exist 295 * @return The retrieved or created chunk node, or null otherwise 296 * @throws AmetysRepositoryException if an error occurs when manipulating the repository 297 */ 298 public CacheContentVersionChunk getChunk(DefaultTraversableAmetysObject cacheContentVersionNode, int number, boolean createIfNotExist) throws AmetysRepositoryException 299 { 300 if (!createIfNotExist && !cacheContentVersionNode.hasChild(Integer.toString(number))) 301 { 302 return null; 303 } 304 305 return (CacheContentVersionChunk) _getOrCreateNode(cacheContentVersionNode, Integer.toString(number), __CACHEROOT_CONTENT_VERSION_CHUNK_NODETYPE); 306 } 307 308 /** 309 * Get or create a node 310 * @param <T> The type of the node 311 * @param parentNode the parent node 312 * @param nodeName the name of the node 313 * @param nodeType the type of the node 314 * @return The retrieved or created node 315 * @throws AmetysRepositoryException if an error occurs when manipulating the repository 316 */ 317 @SuppressWarnings("unchecked") 318 protected synchronized <T extends AmetysObject> T _getOrCreateNode(JCRTraversableAmetysObject parentNode, String nodeName, String nodeType) throws AmetysRepositoryException 319 { 320 T definitionsNode; 321 if (parentNode.hasChild(nodeName)) 322 { 323 definitionsNode = (T) parentNode.getChild(nodeName); 324 } 325 else 326 { 327 definitionsNode = (T) parentNode.createChild(nodeName, nodeType); 328 parentNode.saveChanges(); 329 } 330 return definitionsNode; 331 } 332}