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.llm;
17  
18  import java.util.List;
19  import java.util.Map;
20  import java.util.concurrent.CopyOnWriteArrayList;
21  
22  import org.apache.logging.log4j.LogManager;
23  import org.apache.logging.log4j.Logger;
24  import org.codelibs.fess.Constants;
25  import org.codelibs.fess.util.ComponentUtil;
26  
27  /**
28   * Manager class for coordinating LLM (Large Language Model) client operations.
29   *
30   * This class serves as the central coordinator for LLM operations in Fess.
31   * It manages registered LLM clients and provides access to the configured
32   * LLM provider based on the current configuration.
33   */
34  public class LlmClientManager {
35  
36      private static final Logger logger = LogManager.getLogger(LlmClientManager.class);
37  
38      /** The list of registered LLM clients. */
39      protected final List<LlmClient> clientList = new CopyOnWriteArrayList<>();
40  
41      /**
42       * Default constructor.
43       */
44      public LlmClientManager() {
45          // Default constructor
46      }
47  
48      /**
49       * Checks whether LLM chat functionality is available and configured.
50       *
51       * @return true if LLM chat is configured and available, false otherwise
52       */
53      public boolean available() {
54          final String llmType = getLlmType();
55          if (Constants.NONE.equals(llmType)) {
56              if (logger.isTraceEnabled()) {
57                  logger.trace("[LLM] LLM not available. llmType=none");
58              }
59              return false;
60          }
61          if (!isRagChatEnabled()) {
62              if (logger.isTraceEnabled()) {
63                  logger.trace("[LLM] LLM not available. ragChatEnabled=false");
64              }
65              return false;
66          }
67          final LlmClient client = getClient();
68          final boolean isAvailable = client != null && client.isAvailable();
69          if (logger.isTraceEnabled()) {
70              logger.trace("[LLM] LLM availability check. llmType={}, clientFound={}, isAvailable={}", llmType, client != null, isAvailable);
71          }
72          return isAvailable;
73      }
74  
75      /**
76       * Gets the LLM client instance for the configured LLM type.
77       *
78       * @return The LLM client instance, or null if not found
79       */
80      public LlmClient getClient() {
81          final String llmType = getLlmType();
82          final String name = llmType + "LlmClient";
83          if (ComponentUtil.hasComponent(name)) {
84              final LlmClient client = ComponentUtil.getComponent(name);
85              if (logger.isTraceEnabled()) {
86                  logger.trace("[LLM] LlmClient found via DI. componentName={}, clientName={}, available={}", name, client.getName(),
87                          client.isAvailable());
88              }
89              return client;
90          }
91          // Fallback: search registered clients
92          for (final LlmClient client : clientList) {
93              if (llmType.equals(client.getName())) {
94                  if (logger.isTraceEnabled()) {
95                      logger.trace("[LLM] LlmClient found via registration. name={}", client.getName());
96                  }
97                  return client;
98              }
99          }
100         logger.warn("[LLM] LlmClient not found. componentName={}", name);
101         return null;
102     }
103 
104     /**
105      * Gets the configured LLM type from the system configuration.
106      *
107      * @return The LLM type string from configuration (e.g., "ollama", "openai", "gemini")
108      */
109     protected String getLlmType() {
110         return ComponentUtil.getFessConfig().getSystemProperty("rag.llm.name", "ollama");
111     }
112 
113     /**
114      * Checks if RAG chat feature is enabled.
115      *
116      * @return true if RAG chat is enabled, false otherwise
117      */
118     protected boolean isRagChatEnabled() {
119         return ComponentUtil.getFessConfig().isRagChatEnabled();
120     }
121 
122     /**
123      * Gets all registered LLM clients.
124      *
125      * @return Array of all registered LLM clients
126      */
127     public LlmClient[] getClients() {
128         return clientList.toArray(new LlmClient[clientList.size()]);
129     }
130 
131     /**
132      * Registers an LLM client with this manager.
133      *
134      * @param client The LLM client to register
135      */
136     public void register(final LlmClient client) {
137         if (logger.isDebugEnabled()) {
138             logger.debug("Loaded LlmClient: {}", client.getClass().getSimpleName());
139         }
140         clientList.add(client);
141     }
142 
143     /**
144      * Performs a chat completion request using the configured LLM client.
145      *
146      * @param request the chat request
147      * @return the chat response
148      * @throws LlmException if LLM is not available or an error occurs
149      */
150     public LlmChatResponse chat(final LlmChatRequest request) {
151         final long startTime = System.currentTimeMillis();
152         final String llmType = getLlmType();
153         if (logger.isDebugEnabled()) {
154             logger.debug("[LLM] Starting LLM chat request. llmType={}, messageCount={}", llmType, request.getMessages().size());
155             for (final LlmMessage msg : request.getMessages()) {
156                 logger.debug("[LLM] message: role={}, content={}", msg.getRole(), msg.getContent());
157             }
158         }
159         try {
160             final LlmClient client = getAvailableClient();
161             if (logger.isDebugEnabled()) {
162                 logger.debug("[LLM] Using LLM client. clientName={}", client.getName());
163             }
164             final LlmChatResponse response = client.chat(request);
165             if (logger.isDebugEnabled()) {
166                 logger.debug("[LLM] LLM chat request completed. llmType={}", llmType);
167             }
168             return response;
169         } catch (final LlmException e) {
170             logger.warn("[LLM] Chat request failed. llmType={}, error={}, elapsedTime={}ms", llmType, e.getMessage(),
171                     System.currentTimeMillis() - startTime);
172             throw e;
173         } catch (final Exception e) {
174             logger.warn("[LLM] Chat request failed with unexpected error. llmType={}, error={}, elapsedTime={}ms", llmType, e.getMessage(),
175                     System.currentTimeMillis() - startTime, e);
176             throw new LlmException("LLM chat request failed", e);
177         }
178     }
179 
180     /**
181      * Performs a streaming chat completion request using the configured LLM client.
182      *
183      * @param request the chat request
184      * @param callback the callback to receive streaming chunks
185      * @throws LlmException if LLM is not available or an error occurs
186      */
187     public void streamChat(final LlmChatRequest request, final LlmStreamCallback callback) {
188         final long startTime = System.currentTimeMillis();
189         final String llmType = getLlmType();
190         if (logger.isDebugEnabled()) {
191             logger.debug("[LLM] Starting LLM streaming chat request. llmType={}, messageCount={}", llmType, request.getMessages().size());
192             for (final LlmMessage msg : request.getMessages()) {
193                 logger.debug("[LLM] message: role={}, content={}", msg.getRole(), msg.getContent());
194             }
195         }
196         try {
197             final LlmClient client = getAvailableClient();
198             if (logger.isDebugEnabled()) {
199                 logger.debug("[LLM] Using LLM client for streaming. clientName={}", client.getName());
200             }
201             client.streamChat(request, callback);
202             if (logger.isDebugEnabled()) {
203                 logger.debug("[LLM] LLM streaming chat request completed. llmType={}", llmType);
204             }
205         } catch (final LlmException e) {
206             logger.warn("[LLM] Stream chat request failed. llmType={}, error={}, elapsedTime={}ms", llmType, e.getMessage(),
207                     System.currentTimeMillis() - startTime);
208             throw e;
209         } catch (final Exception e) {
210             logger.warn("[LLM] Stream chat request failed with unexpected error. llmType={}, error={}, elapsedTime={}ms", llmType,
211                     e.getMessage(), System.currentTimeMillis() - startTime, e);
212             throw new LlmException("LLM streaming chat request failed", e);
213         }
214     }
215 
216     /**
217      * Gets the available LLM client, performing a single lookup.
218      *
219      * @return the available LLM client
220      * @throws LlmException if LLM client is not available
221      */
222     protected LlmClient getAvailableClient() {
223         final String llmType = getLlmType();
224         if (Constants.NONE.equals(llmType)) {
225             throw new LlmException("LLM client is not available");
226         }
227         if (!isRagChatEnabled()) {
228             throw new LlmException("LLM client is not available");
229         }
230         final LlmClient client = getClient();
231         if (client == null || !client.isAvailable()) {
232             throw new LlmException("LLM client is not available");
233         }
234         return client;
235     }
236 
237     // RAG workflow delegation methods
238 
239     /**
240      * Detects the intent of a user message using the configured LLM client.
241      *
242      * @param userMessage the user's message
243      * @return the detected intent with extracted keywords
244      * @throws LlmException if LLM is not available
245      */
246     public IntentDetectionResult detectIntent(final String userMessage) {
247         if (logger.isDebugEnabled()) {
248             logger.debug("[LLM] Delegating detectIntent. llmType={}", getLlmType());
249         }
250         return getAvailableClient().detectIntent(userMessage);
251     }
252 
253     /**
254      * Detects the intent of a user message with conversation history context.
255      *
256      * @param userMessage the user's message
257      * @param history the conversation history for context
258      * @return the detected intent with extracted keywords
259      * @throws LlmException if LLM is not available
260      */
261     public IntentDetectionResult detectIntent(final String userMessage, final List<LlmMessage> history) {
262         if (logger.isDebugEnabled()) {
263             logger.debug("[LLM] Delegating detectIntent with history. llmType={}, historySize={}", getLlmType(),
264                     history != null ? history.size() : 0);
265         }
266         return getAvailableClient().detectIntent(userMessage, history);
267     }
268 
269     /**
270      * Evaluates search results for relevance using the configured LLM client.
271      *
272      * @param userMessage the original user message
273      * @param query the search query used
274      * @param searchResults the search results to evaluate
275      * @return evaluation result with relevant document IDs
276      * @throws LlmException if LLM is not available
277      */
278     public RelevanceEvaluationResult evaluateResults(final String userMessage, final String query,
279             final List<Map<String, Object>> searchResults) {
280         if (logger.isDebugEnabled()) {
281             logger.debug("[LLM] Delegating evaluateResults. llmType={}", getLlmType());
282         }
283         return getAvailableClient().evaluateResults(userMessage, query, searchResults);
284     }
285 
286     /**
287      * Generates an answer using document content (synchronous).
288      *
289      * @param userMessage the user's message
290      * @param documents the documents with content
291      * @param history the conversation history
292      * @return the chat response
293      * @throws LlmException if LLM is not available
294      */
295     public LlmChatResponse generateAnswer(final String userMessage, final List<Map<String, Object>> documents,
296             final List<LlmMessage> history) {
297         if (logger.isDebugEnabled()) {
298             logger.debug("[LLM] Delegating generateAnswer. llmType={}", getLlmType());
299         }
300         return getAvailableClient().generateAnswer(userMessage, documents, history);
301     }
302 
303     /**
304      * Regenerates a search query when the previous query failed.
305      *
306      * @param userMessage the user's original message
307      * @param failedQuery the query that failed
308      * @param failureReason the reason for failure
309      * @param history the conversation history
310      * @return a new query string
311      * @throws LlmException if LLM is not available
312      */
313     public String regenerateQuery(final String userMessage, final String failedQuery, final String failureReason,
314             final List<LlmMessage> history) {
315         if (logger.isDebugEnabled()) {
316             logger.debug("[LLM] Delegating regenerateQuery. llmType={}", getLlmType());
317         }
318         return getAvailableClient().regenerateQuery(userMessage, failedQuery, failureReason, history);
319     }
320 
321     /**
322      * Generates an answer using document content (streaming).
323      *
324      * @param userMessage the user's message
325      * @param documents the documents with content
326      * @param history the conversation history
327      * @param callback the streaming callback
328      * @throws LlmException if LLM is not available
329      */
330     public void streamGenerateAnswer(final String userMessage, final List<Map<String, Object>> documents, final List<LlmMessage> history,
331             final LlmStreamCallback callback) {
332         if (logger.isDebugEnabled()) {
333             logger.debug("[LLM] Delegating streamGenerateAnswer. llmType={}", getLlmType());
334         }
335         getAvailableClient().streamGenerateAnswer(userMessage, documents, history, callback);
336     }
337 
338     /**
339      * Generates a response asking user for clarification.
340      *
341      * @param userMessage the user's message
342      * @param history the conversation history
343      * @param callback the streaming callback
344      * @throws LlmException if LLM is not available
345      */
346     public void generateUnclearIntentResponse(final String userMessage, final List<LlmMessage> history, final LlmStreamCallback callback) {
347         if (logger.isDebugEnabled()) {
348             logger.debug("[LLM] Delegating generateUnclearIntentResponse. llmType={}", getLlmType());
349         }
350         getAvailableClient().generateUnclearIntentResponse(userMessage, history, callback);
351     }
352 
353     /**
354      * Generates a response when no relevant documents are found.
355      *
356      * @param userMessage the user's message
357      * @param history the conversation history
358      * @param callback the streaming callback
359      * @throws LlmException if LLM is not available
360      */
361     public void generateNoResultsResponse(final String userMessage, final List<LlmMessage> history, final LlmStreamCallback callback) {
362         if (logger.isDebugEnabled()) {
363             logger.debug("[LLM] Delegating generateNoResultsResponse. llmType={}", getLlmType());
364         }
365         getAvailableClient().generateNoResultsResponse(userMessage, history, callback);
366     }
367 
368     /**
369      * Generates a response when the specified document URL is not found.
370      *
371      * @param userMessage the user's message
372      * @param documentUrl the URL that was not found
373      * @param history the conversation history
374      * @param callback the streaming callback
375      * @throws LlmException if LLM is not available
376      */
377     public void generateDocumentNotFoundResponse(final String userMessage, final String documentUrl, final List<LlmMessage> history,
378             final LlmStreamCallback callback) {
379         if (logger.isDebugEnabled()) {
380             logger.debug("[LLM] Delegating generateDocumentNotFoundResponse. llmType={}", getLlmType());
381         }
382         getAvailableClient().generateDocumentNotFoundResponse(userMessage, documentUrl, history, callback);
383     }
384 
385     /**
386      * Generates a summary of the specified documents.
387      *
388      * @param userMessage the user's message
389      * @param documents the documents to summarize
390      * @param history the conversation history
391      * @param callback the streaming callback
392      * @throws LlmException if LLM is not available
393      */
394     public void generateSummaryResponse(final String userMessage, final List<Map<String, Object>> documents, final List<LlmMessage> history,
395             final LlmStreamCallback callback) {
396         if (logger.isDebugEnabled()) {
397             logger.debug("[LLM] Delegating generateSummaryResponse. llmType={}", getLlmType());
398         }
399         getAvailableClient().generateSummaryResponse(userMessage, documents, history, callback);
400     }
401 
402     /**
403      * Generates an FAQ answer using document content (streaming).
404      *
405      * @param userMessage the user's message
406      * @param documents the documents with content
407      * @param history the conversation history
408      * @param callback the streaming callback
409      * @throws LlmException if LLM is not available
410      */
411     public void generateFaqAnswerResponse(final String userMessage, final List<Map<String, Object>> documents,
412             final List<LlmMessage> history, final LlmStreamCallback callback) {
413         if (logger.isDebugEnabled()) {
414             logger.debug("[LLM] Delegating generateFaqAnswerResponse. llmType={}", getLlmType());
415         }
416         getAvailableClient().generateFaqAnswerResponse(userMessage, documents, history, callback);
417     }
418 
419     /**
420      * Generates a direct answer without document search.
421      *
422      * @param userMessage the user's message
423      * @param history the conversation history
424      * @param callback the streaming callback
425      * @throws LlmException if LLM is not available
426      */
427     public void generateDirectAnswer(final String userMessage, final List<LlmMessage> history, final LlmStreamCallback callback) {
428         if (logger.isDebugEnabled()) {
429             logger.debug("[LLM] Delegating generateDirectAnswer. llmType={}", getLlmType());
430         }
431         getAvailableClient().generateDirectAnswer(userMessage, history, callback);
432     }
433 }