View Javadoc
1   /*
2    * Copyright 2012-2025 CodeLibs Project and the Others.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *     http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13   * either express or implied. See the License for the specific language
14   * governing permissions and limitations under the License.
15   */
16  package org.codelibs.fess.helper;
17  
18  import static org.codelibs.core.stream.StreamUtil.split;
19  import static org.codelibs.core.stream.StreamUtil.stream;
20  
21  import java.time.LocalDateTime;
22  import java.time.ZoneId;
23  import java.time.format.DateTimeFormatter;
24  import java.util.ArrayList;
25  import java.util.Arrays;
26  import java.util.Collections;
27  import java.util.HashMap;
28  import java.util.HashSet;
29  import java.util.List;
30  import java.util.Map;
31  import java.util.Set;
32  import java.util.function.Consumer;
33  
34  import org.apache.logging.log4j.LogManager;
35  import org.apache.logging.log4j.Logger;
36  import org.codelibs.core.lang.StringUtil;
37  import org.codelibs.core.lang.ThreadUtil;
38  import org.codelibs.core.misc.Pair;
39  import org.codelibs.fess.Constants;
40  import org.codelibs.fess.mylasta.direction.FessConfig;
41  import org.codelibs.fess.opensearch.client.SearchEngineClient;
42  import org.codelibs.fess.opensearch.config.exbhv.BadWordBhv;
43  import org.codelibs.fess.opensearch.config.exbhv.ElevateWordBhv;
44  import org.codelibs.fess.opensearch.config.exentity.BadWord;
45  import org.codelibs.fess.opensearch.config.exentity.ElevateWord;
46  import org.codelibs.fess.opensearch.log.exbhv.SearchLogBhv;
47  import org.codelibs.fess.opensearch.log.exentity.SearchLog;
48  import org.codelibs.fess.suggest.Suggester;
49  import org.codelibs.fess.suggest.constants.FieldNames;
50  import org.codelibs.fess.suggest.entity.SuggestItem;
51  import org.codelibs.fess.suggest.exception.SuggestSettingsException;
52  import org.codelibs.fess.suggest.index.SuggestDeleteResponse;
53  import org.codelibs.fess.suggest.index.contents.document.ESSourceReader;
54  import org.codelibs.fess.suggest.settings.SuggestSettings;
55  import org.codelibs.fess.suggest.settings.SuggestSettingsBuilder;
56  import org.codelibs.fess.suggest.util.SuggestUtil;
57  import org.codelibs.fess.util.ComponentUtil;
58  import org.opensearch.common.lucene.search.function.CombineFunction;
59  import org.opensearch.index.query.BoolQueryBuilder;
60  import org.opensearch.index.query.QueryBuilders;
61  import org.opensearch.index.query.functionscore.FunctionScoreQueryBuilder;
62  import org.opensearch.index.query.functionscore.ScoreFunctionBuilders;
63  import org.opensearch.search.sort.SortBuilders;
64  
65  import jakarta.annotation.PostConstruct;
66  
67  /**
68   * Helper class for managing the suggest feature.
69   * This class provides methods for initializing the suggester, indexing data from
70   * various sources, and managing elevate words and bad words.
71   */
72  public class SuggestHelper {
73      /**
74       * Constructs a new suggest helper.
75       */
76      public SuggestHelper() {
77          // do nothing
78      }
79  
80      private static final Logger logger = LogManager.getLogger(SuggestHelper.class);
81  
82      /** The separator for text content. */
83      protected static final String TEXT_SEP = " ";
84  
85      /** The suggester instance for handling suggest operations. */
86      protected Suggester suggester;
87  
88      /** The Fess configuration for accessing system settings. */
89      protected FessConfig fessConfig;
90  
91      /** The set of field names for content. */
92      protected final Set<String> contentFieldNameSet = new HashSet<>();
93  
94      /** The set of field names for tags. */
95      protected final Set<String> tagFieldNameSet = new HashSet<>();
96  
97      /** The set of field names for roles. */
98      protected final Set<String> roleFieldNameSet = new HashSet<>();
99  
100     /** The list of content field names. */
101     protected List<String> contentFieldList;
102 
103     /** The popular word helper for handling popular words. */
104     protected PopularWordHelper popularWordHelper = null;
105 
106     /** The interval for storing search logs. */
107     protected long searchStoreInterval = 1; // min
108 
109     /**
110      * Initializes the SuggestHelper.
111      * This method sets up the suggester, configures field names, and initializes
112      * the connection to the search engine.
113      */
114     @PostConstruct
115     public void init() {
116         if (logger.isDebugEnabled()) {
117             logger.debug("Initializing {}", this.getClass().getSimpleName());
118         }
119         fessConfig = ComponentUtil.getFessConfig();
120         split(fessConfig.getSuggestFieldContents(), ",")
121                 .of(stream -> stream.filter(StringUtil::isNotBlank).forEach(contentFieldNameSet::add));
122         split(fessConfig.getSuggestFieldTags(), ",").of(stream -> stream.filter(StringUtil::isNotBlank).forEach(tagFieldNameSet::add));
123         split(fessConfig.getSuggestFieldRoles(), ",").of(stream -> stream.filter(StringUtil::isNotBlank).forEach(roleFieldNameSet::add));
124         contentFieldList = Arrays.asList(stream(fessConfig.getSuggestFieldContents()).get(stream -> stream.toArray(n -> new String[n])));
125 
126         final SearchEngineClient searchEngineClient = ComponentUtil.getSearchEngineClient();
127         searchEngineClient.admin()
128                 .cluster()
129                 .prepareHealth()
130                 .setWaitForYellowStatus()
131                 .execute()
132                 .actionGet(fessConfig.getIndexHealthTimeout());
133 
134         final SuggestSettingsBuilder settingsBuilder = SuggestSettings.builder();
135         settingsBuilder.addInitialSettings("search_engine.type", fessConfig.getFesenType());
136         settingsBuilder.bulkTimeout(fessConfig.getIndexBulkTimeout());
137         settingsBuilder.clusterTimeout(fessConfig.getIndexHealthTimeout());
138         settingsBuilder.indexTimeout(fessConfig.getIndexIndexTimeout());
139         settingsBuilder.indicesTimeout(fessConfig.getIndexIndicesTimeout());
140         settingsBuilder.searchTimeout(fessConfig.getIndexSearchTimeout());
141         settingsBuilder.setSettingsIndexName(fessConfig.getIndexDocumentSuggestIndex() + "_suggest");
142         suggester = Suggester.builder().settings(settingsBuilder).build(searchEngineClient, fessConfig.getIndexDocumentSuggestIndex());
143         if (ComponentUtil.hasPopularWordHelper()) {
144             popularWordHelper = ComponentUtil.getPopularWordHelper();
145         }
146         try {
147             suggester.settings().array().delete(SuggestSettings.DefaultKeys.SUPPORTED_FIELDS);
148             split(fessConfig.getSuggestFieldIndexContents(), ",").of(stream -> stream.filter(StringUtil::isNotBlank).forEach(field -> {
149                 try {
150                     suggester.settings().array().add(SuggestSettings.DefaultKeys.SUPPORTED_FIELDS, field);
151                 } catch (final SuggestSettingsException e) {
152                     logger.warn("Failed to add suggest field: field={}", field, e);
153                 }
154             }));
155             suggester.createIndexIfNothing();
156         } catch (final Exception e) {
157             logger.warn("Failed to initialize Suggester.", e);
158         }
159     }
160 
161     /**
162      * Gets the suggester instance.
163      *
164      * @return The suggester instance.
165      */
166     public Suggester suggester() {
167         return suggester;
168     }
169 
170     /**
171      * Stores search logs in the suggest index.
172      */
173     public void storeSearchLog() {
174         final SearchLogBhv searchLogBhv = ComponentUtil.getComponent(SearchLogBhv.class);
175 
176         searchLogBhv.selectBulk(cb -> {
177             final String from = LocalDateTime.now().minusDays(fessConfig.getPurgeSuggestSearchLogDay()).format(DateTimeFormatter.ISO_DATE);
178             cb.query().addQuery(QueryBuilders.rangeQuery("requestedAt").gte(from));
179             cb.query().addOrderBy_RequestedAt_Asc();
180             cb.specify().columnAccessType();
181             cb.specify().columnUserSessionId();
182             cb.specify().columnHitCount();
183             cb.specify().columnClientIp();
184             cb.specify().columnSearchWord();
185             cb.specify().columnRequestedAt();
186             cb.specify().columnHitCount();
187             cb.specify().columnVirtualHost();
188             cb.specify().columnLanguages();
189             cb.specify().columnRoles();
190             cb.specify().doColumn("searchField.*");
191         }, this::indexFromSearchLog);
192     }
193 
194     /**
195      * Indexes suggest data from a list of search logs.
196      *
197      * @param searchLogList The list of search logs to index.
198      */
199     public void indexFromSearchLog(final List<SearchLog> searchLogList) {
200         final Map<String, LocalDateTime> duplicateSessionMap = new HashMap<>();
201         searchLogList.stream().forEach(searchLog -> {
202             if (searchLog.getHitCount() == null
203                     || searchLog.getHitCount().longValue() < fessConfig.getSuggestMinHitCountAsInteger().longValue()) {
204                 return;
205             }
206 
207             final String sessionId;
208             if (searchLog.getUserSessionId() != null) {
209                 sessionId = searchLog.getUserSessionId();
210             } else if (Constants.SEARCH_LOG_ACCESS_TYPE_WEB.equals(searchLog.getAccessType())) {
211                 sessionId = searchLog.getClientIp();
212             } else {
213                 sessionId = searchLog.getClientIp() + '_' + searchLog.getSearchWord();
214             }
215 
216             final LocalDateTime requestedAt = searchLog.getRequestedAt();
217             if (sessionId == null || duplicateSessionMap.containsKey(sessionId)
218                     && duplicateSessionMap.get(sessionId).plusMinutes(searchStoreInterval).isAfter(requestedAt)) {
219                 return;
220             }
221 
222             final StringBuilder sb = new StringBuilder(100);
223             final List<String> fields = new ArrayList<>();
224             final List<String> tags = new ArrayList<>();
225             final List<String> roles = new ArrayList<>();
226 
227             for (final Pair<String, String> searchFieldLog : searchLog.getSearchFieldLogList()) {
228                 final String name = searchFieldLog.getFirst();
229                 if (contentFieldNameSet.contains(name)) {
230                     if (sb.length() > 0) {
231                         sb.append(TEXT_SEP);
232                     }
233                     sb.append(searchFieldLog.getSecond());
234                     fields.add(name);
235                 } else if (tagFieldNameSet.contains(name)) {
236                     tags.add(searchFieldLog.getSecond());
237                 } else if (roleFieldNameSet.contains(name)) {
238                     roles.add(searchFieldLog.getSecond());
239                 }
240             }
241 
242             final String virtualHost = searchLog.getVirtualHost();
243             if (virtualHost != null) {
244                 tags.add(virtualHost);
245             }
246 
247             if (sb.length() > 0) {
248                 final String[] langs = searchLog.getLanguages() == null ? new String[] {} : searchLog.getLanguages().split(",");
249                 stream(searchLog.getRoles()).of(stream -> stream.forEach(role -> roles.add(role)));
250                 if (fessConfig.isValidSearchLogPermissions(roles.toArray(new String[roles.size()]))) {
251                     suggester.indexer()
252                             .indexFromSearchWord(sb.toString(), fields.toArray(new String[fields.size()]),
253                                     tags.toArray(new String[tags.size()]), roles.toArray(new String[roles.size()]), 1, langs);
254                     duplicateSessionMap.put(sessionId, requestedAt);
255                 }
256             }
257         });
258         refresh();
259     }
260 
261     /**
262      * Indexes suggest data from documents.
263      *
264      * @param success A callback for successful indexing.
265      * @param error   A callback for indexing errors.
266      */
267     public void indexFromDocuments(final Consumer<Boolean> success, final Consumer<Throwable> error) {
268         final FessConfig fessConfig = ComponentUtil.getFessConfig();
269         final long interval = fessConfig.getSuggestUpdateRequestIntervalAsInteger().longValue();
270         final int docPerReq = fessConfig.getSuggestUpdateDocPerRequestAsInteger();
271         final SystemHelper systemHelper = ComponentUtil.getSystemHelper();
272         suggester.indexer().indexFromDocument(() -> {
273             final ESSourceReader reader = new ESSourceReader(ComponentUtil.getSearchEngineClient(), suggester.settings(),
274                     fessConfig.getIndexDocumentSearchIndex());
275             reader.setScrollSize(fessConfig.getSuggestSourceReaderScrollSizeAsInteger());
276             reader.setLimitDocNumPercentage(fessConfig.getSuggestUpdateContentsLimitNumPercentage());
277             reader.setLimitNumber(fessConfig.getSuggestUpdateContentsLimitNumAsInteger());
278             reader.setLimitOfDocumentSize(fessConfig.getSuggestUpdateContentsLimitDocSizeAsInteger());
279 
280             final List<FunctionScoreQueryBuilder.FilterFunctionBuilder> flist = new ArrayList<>();
281             flist.add(new FunctionScoreQueryBuilder.FilterFunctionBuilder(ScoreFunctionBuilders.randomFunction()
282                     .seed(ComponentUtil.getSystemHelper().getCurrentTimeAsLong())
283                     .setField(fessConfig.getIndexFieldDocId())));
284             reader.setQuery(QueryBuilders
285                     .functionScoreQuery(QueryBuilders.matchAllQuery(),
286                             flist.toArray(new FunctionScoreQueryBuilder.FilterFunctionBuilder[flist.size()]))
287                     .boostMode(CombineFunction.MULTIPLY));
288             reader.addSort(SortBuilders.fieldSort(fessConfig.getIndexFieldClickCount()));
289             reader.addSort(SortBuilders.scoreSort());
290             return reader;
291         }, docPerReq, () -> {
292             systemHelper.calibrateCpuLoad();
293             ThreadUtil.sleep(interval);
294         }).then(response -> {
295             refresh();
296             success.accept(true);
297         }).error(t -> error.accept(t));
298     }
299 
300     /**
301      * Purges old suggest data from documents.
302      *
303      * @param time The timestamp to purge data before.
304      */
305     public void purgeDocumentSuggest(final LocalDateTime time) {
306         final BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
307         boolQueryBuilder
308                 .must(QueryBuilders.rangeQuery(FieldNames.TIMESTAMP).lt(time.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()));
309 
310         boolQueryBuilder.must(QueryBuilders.termQuery(FieldNames.KINDS, SuggestItem.Kind.DOCUMENT.toString()));
311         boolQueryBuilder.mustNot(QueryBuilders.termQuery(FieldNames.KINDS, SuggestItem.Kind.QUERY.toString()));
312         boolQueryBuilder.mustNot(QueryBuilders.termQuery(FieldNames.KINDS, SuggestItem.Kind.USER.toString()));
313 
314         SuggestUtil.deleteByQuery(ComponentUtil.getSearchEngineClient(), suggester.settings(), suggester.getIndex(), boolQueryBuilder);
315     }
316 
317     /**
318      * Purges old suggest data from search logs.
319      *
320      * @param time The timestamp to purge data before.
321      */
322     public void purgeSearchlogSuggest(final LocalDateTime time) {
323         final BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
324         boolQueryBuilder
325                 .must(QueryBuilders.rangeQuery(FieldNames.TIMESTAMP).lt(time.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()));
326 
327         boolQueryBuilder.mustNot(QueryBuilders.termQuery(FieldNames.KINDS, SuggestItem.Kind.DOCUMENT.toString()));
328         boolQueryBuilder.must(QueryBuilders.termQuery(FieldNames.KINDS, SuggestItem.Kind.QUERY.toString()));
329         boolQueryBuilder.mustNot(QueryBuilders.termQuery(FieldNames.KINDS, SuggestItem.Kind.USER.toString()));
330 
331         SuggestUtil.deleteByQuery(ComponentUtil.getSearchEngineClient(), suggester.settings(), suggester.getIndex(), boolQueryBuilder);
332     }
333 
334     /**
335      * Gets the total number of words in the suggest index.
336      *
337      * @return The total number of words.
338      */
339     public long getAllWordsNum() {
340         return suggester.getAllWordsNum();
341     }
342 
343     /**
344      * Gets the number of words from documents in the suggest index.
345      *
346      * @return The number of document words.
347      */
348     public long getDocumentWordsNum() {
349         return suggester.getDocumentWordsNum();
350     }
351 
352     /**
353      * Gets the number of words from queries in the suggest index.
354      *
355      * @return The number of query words.
356      */
357     public long getQueryWordsNum() {
358         return suggester.getQueryWordsNum();
359     }
360 
361     /**
362      * Deletes all words from the suggest index.
363      *
364      * @return true if the operation was successful, false otherwise.
365      */
366     public boolean deleteAllWords() {
367         final SuggestDeleteResponse response = suggester.indexer().deleteAll();
368         if (response.hasError()) {
369             logger.warn("Failed to delete all words.", response.getErrors().get(0));
370             return false;
371         }
372         refresh();
373         return true;
374     }
375 
376     /**
377      * Deletes all document-based words from the suggest index.
378      *
379      * @return true if the operation was successful, false otherwise.
380      */
381     public boolean deleteDocumentWords() {
382         final SuggestDeleteResponse response = suggester.indexer().deleteDocumentWords();
383         if (response.hasError()) {
384             logger.warn("Failed to delete document words.", response.getErrors().get(0));
385             return false;
386         }
387         refresh();
388         return true;
389     }
390 
391     /**
392      * Deletes all query-based words from the suggest index.
393      *
394      * @return true if the operation was successful, false otherwise.
395      */
396     public boolean deleteQueryWords() {
397         final SuggestDeleteResponse response = suggester.indexer().deleteQueryWords();
398         if (response.hasError()) {
399             logger.warn("Failed to delete query words.", response.getErrors().get(0));
400             return false;
401         }
402         refresh();
403         return true;
404     }
405 
406     /**
407      * Stores all elevate words in the suggest index.
408      *
409      * @param apply true to apply the changes immediately.
410      */
411     public void storeAllElevateWords(final boolean apply) {
412         deleteAllElevateWord(apply);
413 
414         final List<ElevateWord> list = ComponentUtil.getComponent(ElevateWordBhv.class).selectList(cb -> {
415             cb.query().matchAll();
416             cb.fetchFirst(ComponentUtil.getFessConfig().getPageElevateWordMaxFetchSizeAsInteger());
417         });
418 
419         for (final ElevateWord elevateWord : list) {
420             addElevateWord(elevateWord.getSuggestWord(), elevateWord.getReading(), elevateWord.getLabelTypeValues(),
421                     elevateWord.getPermissions(), elevateWord.getBoost(), apply);
422         }
423         refresh();
424     }
425 
426     /**
427      * Deletes all elevate words from the suggest index.
428      *
429      * @param apply true to apply the changes immediately.
430      */
431     public void deleteAllElevateWord(final boolean apply) {
432         final List<ElevateWord> list = ComponentUtil.getComponent(ElevateWordBhv.class).selectList(cb -> {
433             cb.query().matchAll();
434             cb.fetchFirst(ComponentUtil.getFessConfig().getPageElevateWordMaxFetchSizeAsInteger());
435         });
436 
437         for (final ElevateWord elevateWord : list) {
438             suggester.indexer().deleteElevateWord(elevateWord.getSuggestWord(), apply);
439         }
440         refresh();
441     }
442 
443     /**
444      * Deletes a specific elevate word from the suggest index.
445      *
446      * @param word  The elevate word to delete.
447      * @param apply true to apply the changes immediately.
448      */
449     public void deleteElevateWord(final String word, final boolean apply) {
450         suggester.indexer().deleteElevateWord(word, apply);
451         refresh();
452     }
453 
454     /**
455      * Adds an elevate word to the suggest index.
456      *
457      * @param word        The elevate word.
458      * @param reading     The reading of the word.
459      * @param tags        The tags associated with the word.
460      * @param permissions The permissions for the word.
461      * @param boost       The boost value for the word.
462      * @param apply       true to apply the changes immediately.
463      */
464     public void addElevateWord(final String word, final String reading, final String[] tags, final String[] permissions, final Float boost,
465             final boolean apply) {
466         final String[] readings;
467         if (StringUtil.isBlank(reading)) {
468             readings = word.replace(" ", TEXT_SEP).replaceAll(TEXT_SEP + "+", TEXT_SEP).split(TEXT_SEP);
469         } else {
470             readings = reading.replace(" ", TEXT_SEP).replaceAll(TEXT_SEP + "+", TEXT_SEP).split(TEXT_SEP);
471         }
472 
473         final List<String> labelList = new ArrayList<>();
474         if (tags != null) {
475             Collections.addAll(labelList, tags);
476         }
477         final List<String> roleList = new ArrayList<>();
478         if (permissions != null) {
479             Collections.addAll(roleList, permissions);
480         }
481 
482         suggester.indexer()
483                 .addElevateWord(new org.codelibs.fess.suggest.entity.ElevateWord(word, boost, Arrays.asList(readings), contentFieldList,
484                         labelList, roleList), apply);
485 
486         refresh();
487     }
488 
489     /**
490      * Deletes all bad words from the suggest index.
491      */
492     protected void deleteAllBadWords() {
493         suggester.settings().badword().deleteAll();
494     }
495 
496     /**
497      * Stores all bad words in the suggest index.
498      *
499      * @param apply true to apply the changes immediately.
500      */
501     public void storeAllBadWords(final boolean apply) {
502         deleteAllBadWords();
503         final List<BadWord> list = ComponentUtil.getComponent(BadWordBhv.class).selectList(cb -> {
504             cb.query().matchAll();
505             cb.fetchFirst(ComponentUtil.getFessConfig().getPageBadWordMaxFetchSizeAsInteger());
506         });
507         for (final BadWord badWord : list) {
508             final String word = badWord.getSuggestWord();
509             suggester.indexer().addBadWord(word, apply);
510         }
511         refresh();
512     }
513 
514     /**
515      * Adds a bad word to the suggest index.
516      *
517      * @param badWord The bad word to add.
518      * @param apply   true to apply the changes immediately.
519      */
520     public void addBadWord(final String badWord, final boolean apply) {
521         suggester.indexer().addBadWord(badWord, apply);
522         refresh();
523     }
524 
525     /**
526      * Deletes a bad word from the suggest index.
527      *
528      * @param badWord The bad word to delete.
529      */
530     public void deleteBadWord(final String badWord) {
531         suggester.indexer().deleteBadWord(badWord);
532         refresh();
533     }
534 
535     /**
536      * Refreshes the suggest index.
537      */
538     public synchronized void refresh() {
539         suggester.refresh();
540         if (popularWordHelper != null) {
541             popularWordHelper.clearCache();
542         }
543     }
544 
545     /**
546      * Sets the interval for storing search logs.
547      *
548      * @param searchStoreInterval The search store interval in minutes.
549      */
550     public void setSearchStoreInterval(final long searchStoreInterval) {
551         this.searchStoreInterval = searchStoreInterval;
552     }
553 }