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.chat;
17  
18  import java.net.URLEncoder;
19  import java.nio.charset.StandardCharsets;
20  import java.util.ArrayList;
21  import java.util.Collections;
22  import java.util.LinkedHashMap;
23  import java.util.List;
24  import java.util.Locale;
25  import java.util.Map;
26  import java.util.regex.Pattern;
27  import java.util.stream.Collectors;
28  
29  import org.apache.logging.log4j.LogManager;
30  import org.apache.logging.log4j.Logger;
31  import org.codelibs.core.lang.StringUtil;
32  import org.codelibs.fess.entity.ChatMessage;
33  import org.codelibs.fess.entity.ChatMessage.ChatSource;
34  import org.codelibs.fess.entity.ChatSession;
35  import org.codelibs.fess.entity.FacetInfo;
36  import org.codelibs.fess.entity.GeoInfo;
37  import org.codelibs.fess.entity.HighlightInfo;
38  import org.codelibs.fess.entity.SearchRenderData;
39  import org.codelibs.fess.entity.SearchRequestParams;
40  import org.codelibs.fess.helper.MarkdownRenderer;
41  import org.codelibs.fess.llm.ChatIntent;
42  import org.codelibs.fess.llm.IntentDetectionResult;
43  import org.codelibs.fess.llm.LlmChatResponse;
44  import org.codelibs.fess.llm.LlmClient;
45  import org.codelibs.fess.llm.LlmClientManager;
46  import org.codelibs.fess.llm.LlmException;
47  import org.codelibs.fess.llm.LlmMessage;
48  import org.codelibs.fess.llm.LlmStreamCallback;
49  import org.codelibs.fess.llm.RelevanceEvaluationResult;
50  import org.codelibs.fess.mylasta.direction.FessConfig;
51  import org.codelibs.fess.util.ComponentUtil;
52  import org.dbflute.optional.OptionalThing;
53  import org.lastaflute.web.util.LaRequestUtil;
54  
55  import jakarta.annotation.Resource;
56  import jakarta.servlet.http.HttpServletRequest;
57  
58  /**
59   * Client class for RAG (Retrieval-Augmented Generation) chat functionality.
60   *
61   * Orchestrates the multi-phase RAG workflow including session management,
62   * document search, and delegation to LlmClientManager for LLM operations.
63   * Prompt construction and LLM-specific logic is handled by LlmClient implementations.
64   *
65   * @author FessProject
66   */
67  public class ChatClient {
68  
69      private static final Logger logger = LogManager.getLogger(ChatClient.class);
70  
71      /** The session manager for managing chat sessions. */
72      @Resource
73      protected ChatSessionManager chatSessionManager;
74  
75      /** The LLM client manager for language model interactions. */
76      @Resource
77      protected LlmClientManager llmClientManager;
78  
79      /** The markdown renderer for converting markdown to safe HTML. */
80      @Resource
81      protected MarkdownRenderer markdownRenderer;
82  
83      /**
84       * Default constructor.
85       */
86      public ChatClient() {
87          // Default constructor
88      }
89  
90      /**
91       * Checks if RAG chat is available.
92       *
93       * @return true if RAG chat is available
94       */
95      public boolean isAvailable() {
96          final boolean available = llmClientManager.available();
97          if (logger.isTraceEnabled()) {
98              logger.trace("[RAG] ChatClient availability check. available={}", available);
99          }
100         return available;
101     }
102 
103     /**
104      * Performs a chat request with RAG.
105      *
106      * @param sessionId the session ID (can be null for new sessions)
107      * @param userMessage the user's message
108      * @param userId the user ID (can be null for anonymous users)
109      * @return the chat response including session info and sources
110      */
111     public ChatResult chat(final String sessionId, final String userMessage, final String userId) {
112         return chat(sessionId, userMessage, userId, Collections.emptyMap(), new String[0]);
113     }
114 
115     /**
116      * Performs a chat request with RAG and search filters.
117      *
118      * @param sessionId the session ID (can be null for new sessions)
119      * @param userMessage the user's message
120      * @param userId the user ID (can be null for anonymous users)
121      * @param fields the field filters (e.g., label)
122      * @param extraQueries the extra query filters (e.g., filetype, timestamp)
123      * @return the chat response including session info and sources
124      */
125     public ChatResult chat(final String sessionId, final String userMessage, final String userId, final Map<String, String[]> fields,
126             final String[] extraQueries) {
127         final Map<String, String[]> safeFields = fields != null ? fields : Collections.emptyMap();
128         final String[] safeExtraQueries = extraQueries != null ? extraQueries : new String[0];
129         final long startTime = System.currentTimeMillis();
130         final String contextPath = resolveContextPath();
131         if (logger.isDebugEnabled()) {
132             logger.debug("[RAG] Starting chat request. sessionId={}, userId={}, userMessage={}", sessionId, userId, userMessage);
133         }
134 
135         final ChatSession session = chatSessionManager.getOrCreateSession(sessionId, userId);
136         // Extract history snapshot before adding current user message to avoid duplication
137         final List<LlmMessage> history = extractHistory(session);
138         // Add user message immediately for session integrity under concurrent access
139         final ChatMessage userChatMessage = ChatMessage.userMessage(userMessage);
140         session.addMessage(userChatMessage);
141 
142         try {
143             // Intent detection
144             final IntentDetectionResult intentResult = llmClientManager.detectIntent(userMessage, history);
145             if (logger.isDebugEnabled()) {
146                 logger.debug("[RAG] Intent detected. intent={}, query={}", intentResult.getIntent(), intentResult.getQuery());
147             }
148 
149             if (intentResult.getIntent() == ChatIntent.UNCLEAR) {
150                 // Unclear intent - generate answer with empty documents to ask for clarification
151                 final LlmChatResponse llmResponse = llmClientManager.generateAnswer(userMessage, Collections.emptyList(), history);
152                 final ChatMessage assistantMessage = ChatMessage.assistantMessage(llmResponse.getContent());
153                 session.addMessage(assistantMessage);
154                 logger.info("[RAG] Chat completed (unclear). sessionId={}, elapsedTime={}ms", session.getSessionId(),
155                         System.currentTimeMillis() - startTime);
156                 return new ChatResult(session.getSessionId(), assistantMessage, Collections.emptyList());
157             }
158 
159             // For SUMMARY intent, search by URL; for SEARCH/FAQ, search with query
160             ChatSearchResult searchResult;
161             if (intentResult.getIntent() == ChatIntent.SUMMARY && StringUtil.isNotBlank(intentResult.getDocumentUrl())) {
162                 searchResult = searchByUrl(intentResult.getDocumentUrl());
163             } else {
164                 final String query = StringUtil.isBlank(intentResult.getQuery()) ? userMessage : intentResult.getQuery();
165                 searchResult = searchWithQueryAndMetadata(query, safeFields, safeExtraQueries);
166 
167                 // Fallback: regenerate query if no results
168                 if (searchResult.getDocuments().isEmpty()) {
169                     logger.info("[RAG] Primary search returned 0 results, regenerating query. originalQuery={}", query);
170                     final String newQuery = llmClientManager.regenerateQuery(userMessage, query, "no_results", history);
171                     if (StringUtil.isNotBlank(newQuery) && !newQuery.equals(query)) {
172                         logger.info("[RAG] Regenerated query. newQuery={}", newQuery);
173                         searchResult = searchWithQueryAndMetadata(newQuery, safeFields, safeExtraQueries);
174                     }
175                 }
176             }
177 
178             final List<Map<String, Object>> searchResults = searchResult.getDocuments();
179             final LlmChatResponse llmResponse = llmClientManager.generateAnswer(userMessage, searchResults, history);
180 
181             final ChatMessage assistantMessage = ChatMessage.assistantMessage(llmResponse.getContent());
182             addSourcesToMessage(assistantMessage, searchResults, contextPath, searchResult.getQueryId(), searchResult.getRequestedTime());
183 
184             session.addMessage(assistantMessage);
185 
186             logger.info("[RAG] Chat completed. sessionId={}, intent={}, sourcesCount={}, elapsedTime={}ms", session.getSessionId(),
187                     intentResult.getIntent(), searchResults.size(), System.currentTimeMillis() - startTime);
188 
189             return new ChatResult(session.getSessionId(), assistantMessage, searchResults);
190         } catch (final Exception e) {
191             if (e instanceof LlmException) {
192                 logger.warn("[RAG] LLM error during chat. sessionId={}, error={}", session.getSessionId(), e.getMessage());
193             } else {
194                 logger.warn("[RAG] Unexpected error during chat. sessionId={}, error={}", session.getSessionId(), e.getMessage(), e);
195             }
196             throw e;
197         } finally {
198             session.trimHistory(getMaxHistoryMessages());
199         }
200     }
201 
202     /**
203      * Performs an enhanced streaming chat request with multi-phase RAG flow.
204      *
205      * @param sessionId the session ID (can be null for new sessions)
206      * @param userMessage the user's message
207      * @param userId the user ID (can be null for anonymous users)
208      * @param callback the callback to receive phase notifications and streaming chunks
209      * @return the chat result with session info, sources, and HTML content
210      */
211     public ChatResult streamChatEnhanced(final String sessionId, final String userMessage, final String userId,
212             final ChatPhaseCallback callback) {
213         return streamChatEnhanced(sessionId, userMessage, userId, Collections.emptyMap(), new String[0], callback);
214     }
215 
216     /**
217      * Performs an enhanced streaming chat request with multi-phase RAG flow and search filters.
218      * This flow includes: intent detection, keyword search, result evaluation,
219      * content retrieval, answer generation, and markdown rendering.
220      *
221      * @param sessionId the session ID (can be null for new sessions)
222      * @param userMessage the user's message
223      * @param userId the user ID (can be null for anonymous users)
224      * @param fields the field filters (e.g., label)
225      * @param extraQueries the extra query filters (e.g., filetype, timestamp)
226      * @param callback the callback to receive phase notifications and streaming chunks
227      * @return the chat result with session info, sources, and HTML content
228      */
229     public ChatResult streamChatEnhanced(final String sessionId, final String userMessage, final String userId,
230             final Map<String, String[]> fields, final String[] extraQueries, final ChatPhaseCallback callback) {
231         final Map<String, String[]> safeFields = fields != null ? fields : Collections.emptyMap();
232         final String[] safeExtraQueries = extraQueries != null ? extraQueries : new String[0];
233         final long startTime = System.currentTimeMillis();
234         // Capture context path early before request context may become unavailable during SSE processing
235         final String contextPath = resolveContextPath();
236         // Note: Locale is resolved via LaRequestUtil in LlmClient. During long SSE processing,
237         // the request context may become unavailable, falling back to Locale.getDefault().
238         if (logger.isDebugEnabled()) {
239             logger.debug("[RAG] Starting enhanced streaming chat request. sessionId={}, userId={}, userMessage={}", sessionId, userId,
240                     userMessage);
241         }
242 
243         final ChatSession session = chatSessionManager.getOrCreateSession(sessionId, userId);
244         // Extract history snapshot before adding current user message to avoid duplication
245         final List<LlmMessage> history = extractHistory(session);
246         // Add user message immediately for session integrity under concurrent access
247         final ChatMessage userChatMessage = ChatMessage.userMessage(userMessage);
248         session.addMessage(userChatMessage);
249         final StringBuilder fullResponse = new StringBuilder();
250         List<Map<String, Object>> sources = new ArrayList<>();
251         String searchQueryId = null;
252         long searchRequestedTime = 0L;
253 
254         try {
255             // Phase 1: Intent Detection
256             long phaseStartTime = System.currentTimeMillis();
257             callback.onPhaseStart(ChatPhaseCallback.PHASE_INTENT, "Analyzing your question...");
258             final IntentDetectionResult intentResult = llmClientManager.detectIntent(userMessage, history);
259             callback.onPhaseComplete(ChatPhaseCallback.PHASE_INTENT);
260 
261             if (logger.isDebugEnabled()) {
262                 logger.debug("[RAG] Phase {} completed. intent={}, query={}, reasoning={}, phaseElapsedTime={}ms",
263                         ChatPhaseCallback.PHASE_INTENT, intentResult.getIntent(), intentResult.getQuery(), intentResult.getReasoning(),
264                         System.currentTimeMillis() - phaseStartTime);
265             }
266 
267             if (intentResult.getIntent() == ChatIntent.UNCLEAR) {
268                 // Intent is unclear - ask user for clarification
269                 phaseStartTime = System.currentTimeMillis();
270                 callback.onPhaseStart(ChatPhaseCallback.PHASE_ANSWER, "Generating response...");
271                 llmClientManager.generateUnclearIntentResponse(userMessage, history, (chunk, done) -> {
272                     fullResponse.append(chunk);
273                     callback.onChunk(chunk, done);
274                 });
275                 callback.onPhaseComplete(ChatPhaseCallback.PHASE_ANSWER);
276                 if (logger.isDebugEnabled()) {
277                     logger.debug("[RAG] Phase {} completed. responseLength={}, phaseElapsedTime={}ms", ChatPhaseCallback.PHASE_ANSWER,
278                             fullResponse.length(), System.currentTimeMillis() - phaseStartTime);
279                 }
280             } else if (intentResult.getIntent() == ChatIntent.SUMMARY) {
281                 // Summary intent - search by URL and generate summary
282                 final String documentUrl = intentResult.getDocumentUrl();
283                 phaseStartTime = System.currentTimeMillis();
284                 callback.onPhaseStart(ChatPhaseCallback.PHASE_SEARCH, "Searching for document...", documentUrl);
285                 final ChatSearchResult urlSearchResult = searchByUrl(documentUrl);
286                 final List<Map<String, Object>> urlResults = urlSearchResult.getDocuments();
287                 searchQueryId = urlSearchResult.getQueryId();
288                 searchRequestedTime = urlSearchResult.getRequestedTime();
289                 callback.onPhaseComplete(ChatPhaseCallback.PHASE_SEARCH);
290                 if (logger.isDebugEnabled()) {
291                     logger.debug("[RAG] Phase {} completed. documentUrl={}, resultCount={}, phaseElapsedTime={}ms",
292                             ChatPhaseCallback.PHASE_SEARCH, documentUrl, urlResults.size(), System.currentTimeMillis() - phaseStartTime);
293                 }
294 
295                 if (urlResults.isEmpty()) {
296                     // URL not found - inform user
297                     phaseStartTime = System.currentTimeMillis();
298                     callback.onPhaseStart(ChatPhaseCallback.PHASE_ANSWER, "Generating response...");
299                     llmClientManager.generateDocumentNotFoundResponse(userMessage, documentUrl, history, (chunk, done) -> {
300                         fullResponse.append(chunk);
301                         callback.onChunk(chunk, done);
302                     });
303                     callback.onPhaseComplete(ChatPhaseCallback.PHASE_ANSWER);
304                     if (logger.isDebugEnabled()) {
305                         logger.debug("[RAG] Phase {} completed. responseLength={}, phaseElapsedTime={}ms", ChatPhaseCallback.PHASE_ANSWER,
306                                 fullResponse.length(), System.currentTimeMillis() - phaseStartTime);
307                     }
308                 } else {
309                     // Fetch full content and generate summary
310                     phaseStartTime = System.currentTimeMillis();
311                     callback.onPhaseStart(ChatPhaseCallback.PHASE_FETCH, "Retrieving document content...");
312                     final List<String> docIds = urlResults.stream()
313                             .map(doc -> (String) doc.get("doc_id"))
314                             .filter(id -> id != null)
315                             .collect(Collectors.toList());
316                     final List<Map<String, Object>> fullDocs = fetchFullContent(docIds);
317                     callback.onPhaseComplete(ChatPhaseCallback.PHASE_FETCH);
318                     sources = fullDocs;
319                     if (logger.isDebugEnabled()) {
320                         logger.debug("[RAG] Phase {} completed. docIds={}, fetchedCount={}, phaseElapsedTime={}ms",
321                                 ChatPhaseCallback.PHASE_FETCH, docIds, fullDocs.size(), System.currentTimeMillis() - phaseStartTime);
322                     }
323 
324                     phaseStartTime = System.currentTimeMillis();
325                     callback.onPhaseStart(ChatPhaseCallback.PHASE_ANSWER, "Generating summary...");
326                     llmClientManager.generateSummaryResponse(userMessage, fullDocs, history, (chunk, done) -> {
327                         fullResponse.append(chunk);
328                         callback.onChunk(chunk, done);
329                     });
330                     callback.onPhaseComplete(ChatPhaseCallback.PHASE_ANSWER);
331                     if (logger.isDebugEnabled()) {
332                         logger.debug("[RAG] Phase {} completed. responseLength={}, phaseElapsedTime={}ms", ChatPhaseCallback.PHASE_ANSWER,
333                                 fullResponse.length(), System.currentTimeMillis() - phaseStartTime);
334                     }
335                 }
336             } else {
337                 // Phase 2: Search with query
338                 final String query = StringUtil.isBlank(intentResult.getQuery()) ? userMessage : intentResult.getQuery();
339                 phaseStartTime = System.currentTimeMillis();
340                 callback.onPhaseStart(ChatPhaseCallback.PHASE_SEARCH, "Searching documents...", query);
341                 ChatSearchResult querySearchResult = searchWithQueryAndMetadata(query, safeFields, safeExtraQueries);
342                 List<Map<String, Object>> searchResults = querySearchResult.getDocuments();
343                 searchQueryId = querySearchResult.getQueryId();
344                 searchRequestedTime = querySearchResult.getRequestedTime();
345                 callback.onPhaseComplete(ChatPhaseCallback.PHASE_SEARCH);
346 
347                 logger.info("[RAG] Search completed. query={}, resultCount={}, elapsedTime={}ms", query, searchResults.size(),
348                         System.currentTimeMillis() - phaseStartTime);
349                 if (logger.isDebugEnabled()) {
350                     logger.debug("[RAG] Phase {} completed. query={}, resultCount={}, phaseElapsedTime={}ms",
351                             ChatPhaseCallback.PHASE_SEARCH, query, searchResults.size(), System.currentTimeMillis() - phaseStartTime);
352                 }
353 
354                 // Fallback: regenerate query if no results
355                 if (searchResults.isEmpty()) {
356                     logger.info("[RAG] Primary search returned 0 results, regenerating query. originalQuery={}", query);
357                     final String newQuery = llmClientManager.regenerateQuery(userMessage, query, "no_results", history);
358                     if (StringUtil.isNotBlank(newQuery) && !newQuery.equals(query)) {
359                         logger.info("[RAG] Regenerated query. newQuery={}", newQuery);
360                         callback.onPhaseStart(ChatPhaseCallback.PHASE_SEARCH, "Searching with refined query...", newQuery);
361                         final ChatSearchResult fallbackResult = searchWithQueryAndMetadata(newQuery, safeFields, safeExtraQueries);
362                         searchResults = fallbackResult.getDocuments();
363                         searchQueryId = fallbackResult.getQueryId();
364                         searchRequestedTime = fallbackResult.getRequestedTime();
365                         callback.onPhaseComplete(ChatPhaseCallback.PHASE_SEARCH);
366                     }
367                 }
368 
369                 if (searchResults.isEmpty()) {
370                     // No results even after fallback - generate no-results response
371                     phaseStartTime = System.currentTimeMillis();
372                     callback.onPhaseStart(ChatPhaseCallback.PHASE_ANSWER, "Generating response...");
373                     llmClientManager.generateNoResultsResponse(userMessage, history, (chunk, done) -> {
374                         fullResponse.append(chunk);
375                         callback.onChunk(chunk, done);
376                     });
377                     callback.onPhaseComplete(ChatPhaseCallback.PHASE_ANSWER);
378                     if (logger.isDebugEnabled()) {
379                         logger.debug("[RAG] Phase {} completed. responseLength={}, phaseElapsedTime={}ms", ChatPhaseCallback.PHASE_ANSWER,
380                                 fullResponse.length(), System.currentTimeMillis() - phaseStartTime);
381                     }
382                 } else {
383                     // Phase 3: Evaluate results
384                     phaseStartTime = System.currentTimeMillis();
385                     callback.onPhaseStart(ChatPhaseCallback.PHASE_EVALUATE, "Evaluating relevance...");
386                     RelevanceEvaluationResult evalResult = llmClientManager.evaluateResults(userMessage, query, searchResults);
387                     callback.onPhaseComplete(ChatPhaseCallback.PHASE_EVALUATE);
388 
389                     if (logger.isDebugEnabled()) {
390                         logger.debug("[RAG] Phase {} completed. hasRelevant={}, relevantDocIds={}, phaseElapsedTime={}ms",
391                                 ChatPhaseCallback.PHASE_EVALUATE, evalResult.isHasRelevantResults(), evalResult.getRelevantDocIds(),
392                                 System.currentTimeMillis() - phaseStartTime);
393                     }
394 
395                     // Fallback: regenerate query if no relevant results
396                     if (!evalResult.isHasRelevantResults()) {
397                         logger.info("[RAG] No relevant results in evaluation, regenerating query. originalQuery={}", query);
398                         final String newQuery = llmClientManager.regenerateQuery(userMessage, query, "no_relevant_results", history);
399 
400                         boolean fallbackSucceeded = false;
401                         if (StringUtil.isNotBlank(newQuery) && !newQuery.equals(query)) {
402                             callback.onPhaseStart(ChatPhaseCallback.PHASE_SEARCH, "Searching with refined query...", newQuery);
403                             final ChatSearchResult fallbackResult = searchWithQueryAndMetadata(newQuery, safeFields, safeExtraQueries);
404                             final List<Map<String, Object>> fallbackSearchResults = fallbackResult.getDocuments();
405                             callback.onPhaseComplete(ChatPhaseCallback.PHASE_SEARCH);
406 
407                             if (!fallbackSearchResults.isEmpty()) {
408                                 // Re-evaluate fallback results
409                                 callback.onPhaseStart(ChatPhaseCallback.PHASE_EVALUATE, "Evaluating relevance...");
410                                 final RelevanceEvaluationResult fallbackEvalResult =
411                                         llmClientManager.evaluateResults(userMessage, newQuery, fallbackSearchResults);
412                                 callback.onPhaseComplete(ChatPhaseCallback.PHASE_EVALUATE);
413 
414                                 if (fallbackEvalResult.isHasRelevantResults()) {
415                                     searchResults = fallbackSearchResults;
416                                     searchQueryId = fallbackResult.getQueryId();
417                                     searchRequestedTime = fallbackResult.getRequestedTime();
418                                     evalResult = fallbackEvalResult;
419                                     fallbackSucceeded = true;
420                                 }
421                             }
422                         }
423 
424                         if (!fallbackSucceeded) {
425                             // All fallbacks failed - generate no-results response
426                             phaseStartTime = System.currentTimeMillis();
427                             callback.onPhaseStart(ChatPhaseCallback.PHASE_ANSWER, "Generating response...");
428                             llmClientManager.generateNoResultsResponse(userMessage, history, (chunk, done) -> {
429                                 fullResponse.append(chunk);
430                                 callback.onChunk(chunk, done);
431                             });
432                             callback.onPhaseComplete(ChatPhaseCallback.PHASE_ANSWER);
433                             if (logger.isDebugEnabled()) {
434                                 logger.debug("[RAG] Phase {} completed. responseLength={}, phaseElapsedTime={}ms",
435                                         ChatPhaseCallback.PHASE_ANSWER, fullResponse.length(), System.currentTimeMillis() - phaseStartTime);
436                             }
437                         }
438                     }
439 
440                     if (evalResult.isHasRelevantResults()) {
441                         // Phase 4: Fetch full content
442                         phaseStartTime = System.currentTimeMillis();
443                         callback.onPhaseStart(ChatPhaseCallback.PHASE_FETCH, "Retrieving document content...");
444                         final List<Map<String, Object>> fullDocs = fetchFullContent(evalResult.getRelevantDocIds());
445                         callback.onPhaseComplete(ChatPhaseCallback.PHASE_FETCH);
446                         sources = fullDocs;
447 
448                         if (logger.isDebugEnabled()) {
449                             logger.debug("[RAG] Phase {} completed. docIds={}, fetchedCount={}, phaseElapsedTime={}ms",
450                                     ChatPhaseCallback.PHASE_FETCH, evalResult.getRelevantDocIds(), fullDocs.size(),
451                                     System.currentTimeMillis() - phaseStartTime);
452                         }
453 
454                         // Phase 5: Generate answer
455                         phaseStartTime = System.currentTimeMillis();
456                         callback.onPhaseStart(ChatPhaseCallback.PHASE_ANSWER, "Generating response...");
457                         final LlmStreamCallback answerCallback = (chunk, done) -> {
458                             fullResponse.append(chunk);
459                             callback.onChunk(chunk, done);
460                         };
461                         if (intentResult.getIntent() == ChatIntent.FAQ) {
462                             llmClientManager.generateFaqAnswerResponse(userMessage, fullDocs, history, answerCallback);
463                         } else {
464                             llmClientManager.streamGenerateAnswer(userMessage, fullDocs, history, answerCallback);
465                         }
466                         callback.onPhaseComplete(ChatPhaseCallback.PHASE_ANSWER);
467                         if (logger.isDebugEnabled()) {
468                             logger.debug("[RAG] Phase {} completed. responseLength={}, sourceCount={}, phaseElapsedTime={}ms",
469                                     ChatPhaseCallback.PHASE_ANSWER, fullResponse.length(), fullDocs.size(),
470                                     System.currentTimeMillis() - phaseStartTime);
471                         }
472                     }
473                 }
474             }
475 
476             // Phase 6: Render markdown to safe HTML
477             final long renderStartTime = System.currentTimeMillis();
478             final String htmlContent = renderMarkdownToHtml(fullResponse.toString());
479             if (logger.isDebugEnabled()) {
480                 logger.debug("[RAG] Markdown rendering completed. markdownLength={}, htmlLength={}, renderElapsedTime={}ms",
481                         fullResponse.length(), htmlContent.length(), System.currentTimeMillis() - renderStartTime);
482             }
483 
484             // Create and save assistant message (user message was already added at the start)
485             final ChatMessage assistantMessage = ChatMessage.assistantMessage(fullResponse.toString());
486             assistantMessage.setHtmlContent(htmlContent);
487 
488             for (int i = 0; i < sources.size(); i++) {
489                 populateUrlLink(sources.get(i));
490             }
491             addSourcesToMessage(assistantMessage, sources, contextPath, searchQueryId, searchRequestedTime);
492 
493             session.addMessage(assistantMessage);
494 
495             logger.info(
496                     "[RAG] Enhanced chat completed. sessionId={}, userId={}, intent={}, sourcesCount={}, responseLength={}, elapsedTime={}ms",
497                     session.getSessionId(), userId, intentResult.getIntent(), sources.size(), fullResponse.length(),
498                     System.currentTimeMillis() - startTime);
499 
500             return new ChatResult(session.getSessionId(), assistantMessage, sources);
501 
502         } catch (final LlmException e) {
503             logger.warn("[RAG] LLM error during enhanced chat. sessionId={}, errorCode={}, error={}, elapsedTime={}ms",
504                     session.getSessionId(), e.getErrorCode(), e.getMessage(), System.currentTimeMillis() - startTime, e);
505             callback.onError("llm", e.getErrorCode());
506             throw e;
507         } catch (final Exception e) {
508             logger.warn("[RAG] Unexpected error during enhanced chat. sessionId={}, error={}, elapsedTime={}ms", session.getSessionId(),
509                     e.getMessage(), System.currentTimeMillis() - startTime, e);
510             callback.onError("unknown", LlmException.ERROR_UNKNOWN);
511             throw e;
512         } finally {
513             session.trimHistory(getMaxHistoryMessages());
514         }
515     }
516 
517     /**
518      * Extracts conversation history from a chat session as LlmMessage list.
519      * The assistant message content in history is controlled by the
520      * {@code rag.chat.history.assistant.content} configuration property.
521      *
522      * @param session the chat session
523      * @return the list of LlmMessages representing the conversation history
524      */
525     protected List<LlmMessage> extractHistory(final ChatSession session) {
526         final FessConfig fessConfig = ComponentUtil.getFessConfig();
527         final String assistantContentMode = fessConfig.getOrDefault("rag.chat.history.assistant.content", "smart_summary");
528 
529         final LlmClient client = llmClientManager.getClient();
530         final int assistantMaxChars = client != null ? client.getHistoryAssistantMaxChars() : 800;
531         final int summaryMaxChars = client != null ? client.getHistoryAssistantSummaryMaxChars() : 800;
532 
533         final List<LlmMessage> history = new ArrayList<>();
534         for (final ChatMessage msg : session.getMessages()) {
535             if (msg.isUser()) {
536                 history.add(LlmMessage.user(msg.getContent()));
537             } else if (msg.isAssistant()) {
538                 final String content = buildAssistantHistoryContent(msg, assistantContentMode, assistantMaxChars, summaryMaxChars);
539                 if (content != null) {
540                     history.add(LlmMessage.assistant(content));
541                 }
542             }
543         }
544         return history;
545     }
546 
547     /**
548      * Builds the assistant message content for history based on the specified mode.
549      *
550      * @param msg the assistant chat message
551      * @param mode the content mode (full, smart_summary, source_titles, source_titles_and_urls, truncated, none)
552      * @param assistantMaxChars the maximum characters for truncated mode
553      * @param summaryMaxChars the maximum characters for summary modes
554      * @return the content string for history, or null if the message should be excluded
555      */
556     protected String buildAssistantHistoryContent(final ChatMessage msg, final String mode, final int assistantMaxChars,
557             final int summaryMaxChars) {
558         switch (mode) {
559         case "full":
560             return msg.getContent();
561         case "smart_summary":
562             return buildSmartSummaryContent(msg, summaryMaxChars);
563         case "source_titles":
564             return buildSourceTitlesContent(msg, summaryMaxChars);
565         case "source_titles_and_urls":
566             return buildSourceTitlesAndUrlsContent(msg);
567         case "truncated":
568             return buildTruncatedContent(msg, assistantMaxChars);
569         case "none":
570             return null;
571         default:
572             return msg.getContent();
573         }
574     }
575 
576     /**
577      * Builds a summary string from source document titles.
578      *
579      * @param msg the assistant chat message
580      * @param summaryMaxChars the maximum characters for the content summary
581      * @return a string listing referenced document titles
582      */
583     protected String buildSourceTitlesContent(final ChatMessage msg, final int summaryMaxChars) {
584         final List<ChatSource> sources = msg.getSources();
585         if (sources == null || sources.isEmpty()) {
586             return buildTruncatedContent(msg, summaryMaxChars);
587         }
588         final int maxSuffixLen = Math.max(0, summaryMaxChars / 4);
589         final String suffix = buildSourceTitlesSuffix(sources, maxSuffixLen);
590         if (suffix.isEmpty()) {
591             return buildTruncatedContent(msg, summaryMaxChars);
592         }
593         final String content = msg.getContent();
594         if (content == null || content.isEmpty()) {
595             return suffix;
596         }
597         final String truncMarker = "... [truncated]";
598         final int bodyBudget = Math.max(0, summaryMaxChars - suffix.length() - truncMarker.length());
599         if (content.length() <= bodyBudget) {
600             return content + suffix;
601         }
602         if (bodyBudget <= 0) {
603             return suffix;
604         }
605         return content.substring(0, bodyBudget) + truncMarker + suffix;
606     }
607 
608     /**
609      * Builds a summary string from source document titles and URLs.
610      *
611      * @param msg the assistant chat message
612      * @return a string listing referenced document titles and URLs
613      */
614     protected String buildSourceTitlesAndUrlsContent(final ChatMessage msg) {
615         final List<ChatSource> sources = msg.getSources();
616         if (sources == null || sources.isEmpty()) {
617             return msg.getContent();
618         }
619         final String refs = sources.stream().map(s -> {
620             final String title = s.getTitle();
621             final String url = s.getUrl();
622             if (title != null && !title.isEmpty() && url != null && !url.isEmpty()) {
623                 return title + " (" + url + ")";
624             } else if (title != null && !title.isEmpty()) {
625                 return title;
626             } else if (url != null && !url.isEmpty()) {
627                 return url;
628             }
629             return null;
630         }).filter(s -> s != null).collect(Collectors.joining(", "));
631         if (refs.isEmpty()) {
632             return msg.getContent();
633         }
634         return "[References: " + refs + "]";
635     }
636 
637     /**
638      * Builds a truncated version of the assistant message content.
639      *
640      * @param msg the assistant chat message
641      * @param maxChars the maximum characters for the content
642      * @return the truncated content
643      */
644     protected String buildTruncatedContent(final ChatMessage msg, final int maxChars) {
645         final String content = msg.getContent();
646         if (content == null) {
647             return null;
648         }
649         if (content.length() <= maxChars) {
650             return content;
651         }
652         return content.substring(0, maxChars) + "...";
653     }
654 
655     /**
656      * Builds a smart summary of the assistant message for history.
657      * Preserves the beginning (direct answer) and end (conclusion) of long responses,
658      * omitting the middle section, and appends source titles.
659      *
660      * @param msg the assistant chat message
661      * @param maxChars the maximum characters for the summary
662      * @return the summarized content with source titles
663      */
664     protected String buildSmartSummaryContent(final ChatMessage msg, final int maxChars) {
665         final String content = msg.getContent();
666         if (content == null) {
667             return null;
668         }
669         final String omitMarker = "\n...[omitted]...\n";
670         final int maxSuffixLen = Math.max(0, maxChars / 4);
671         final String suffix = buildSourceTitlesSuffix(msg.getSources(), maxSuffixLen);
672         final int bodyBudget = Math.max(0, maxChars - suffix.length() - omitMarker.length());
673         if (content.length() <= bodyBudget) {
674             return content + suffix;
675         }
676         if (bodyBudget <= 0) {
677             return suffix.isEmpty() ? content.substring(0, Math.min(content.length(), maxChars)) : suffix;
678         }
679         final int headChars = (int) (bodyBudget * 0.6);
680         final int tailChars = bodyBudget - headChars;
681         final String head = content.substring(0, headChars);
682         final String tail = content.substring(content.length() - tailChars);
683         return head + omitMarker + tail + suffix;
684     }
685 
686     /**
687      * Builds the source titles suffix string (e.g., "\n[Referenced documents: Title1, Title2]").
688      * Returns an empty string if no sources or titles are available.
689      *
690      * @param sources the source documents
691      * @return the source titles suffix, or empty string
692      */
693     private String buildSourceTitlesSuffix(final List<ChatSource> sources) {
694         return buildSourceTitlesSuffix(sources, Integer.MAX_VALUE);
695     }
696 
697     private String buildSourceTitlesSuffix(final List<ChatSource> sources, final int maxSuffixLength) {
698         if (sources == null || sources.isEmpty()) {
699             return "";
700         }
701         final String titles =
702                 sources.stream().map(ChatSource::getTitle).filter(t -> t != null && !t.isEmpty()).collect(Collectors.joining(", "));
703         if (titles.isEmpty()) {
704             return "";
705         }
706         final String suffix = "\n[Referenced documents: " + titles + "]";
707         if (suffix.length() <= maxSuffixLength) {
708             return suffix;
709         }
710         if (maxSuffixLength <= 0) {
711             return "";
712         }
713         return suffix.substring(0, maxSuffixLength);
714     }
715 
716     private static final int MAX_QUERY_LENGTH = 1000;
717 
718     private static final Pattern DANGEROUS_QUERY_PATTERN = Pattern.compile("\\*:\\*");
719 
720     /**
721      * Searches documents using a Fess query.
722      *
723      * @param query the Fess query string
724      * @return the list of search result documents
725      */
726     protected List<Map<String, Object>> searchWithQuery(final String query) {
727         return searchWithQueryAndMetadata(query).getDocuments();
728     }
729 
730     private ChatSearchResult searchWithQueryAndMetadata(final String query) {
731         return searchWithQueryAndMetadata(query, Collections.emptyMap(), new String[0]);
732     }
733 
734     /**
735      * Searches documents using a Fess query with filters.
736      *
737      * @param query the Fess query string
738      * @param fields the field filters (e.g., label)
739      * @param extraQueries the extra query filters (e.g., filetype, timestamp)
740      * @return the list of search result documents
741      */
742     protected List<Map<String, Object>> searchWithQuery(final String query, final Map<String, String[]> fields,
743             final String[] extraQueries) {
744         return searchWithQueryAndMetadata(query, fields, extraQueries).getDocuments();
745     }
746 
747     private ChatSearchResult searchWithQueryAndMetadata(final String query, final Map<String, String[]> fields,
748             final String[] extraQueries) {
749         final ChatSearchResult rejected = validateQuery(query);
750         if (rejected != null) {
751             return rejected;
752         }
753         return searchDocuments(query, fields, extraQueries);
754     }
755 
756     /**
757      * Fetches full document content for the given document IDs.
758      *
759      * @param docIds the document IDs to fetch
760      * @return list of documents with full content
761      */
762     protected List<Map<String, Object>> fetchFullContent(final List<String> docIds) {
763         if (docIds.isEmpty()) {
764             if (logger.isDebugEnabled()) {
765                 logger.debug("[RAG] Fetch full content called with empty docIds.");
766             }
767             return Collections.emptyList();
768         }
769 
770         final long startTime = System.currentTimeMillis();
771         final FessConfig fessConfig = ComponentUtil.getFessConfig();
772         final String[] fields = fessConfig.getRagChatContentFields().split(",");
773 
774         if (logger.isDebugEnabled()) {
775             logger.debug("[RAG] Fetching full content. docIds={}, fields={}", docIds, String.join(",", fields));
776         }
777 
778         try {
779             final List<Map<String, Object>> results = ComponentUtil.getSearchHelper()
780                     .getDocumentListByDocIds(docIds.toArray(new String[0]), fields, OptionalThing.empty(),
781                             SearchRequestParams.SearchRequestType.JSON);
782 
783             // Reorder results to match original docIds order (preserve search score order)
784             final Map<String, Map<String, Object>> resultMap = new LinkedHashMap<>();
785             for (final Map<String, Object> doc : results) {
786                 final String docId = (String) doc.get("doc_id");
787                 if (docId != null) {
788                     resultMap.put(docId, doc);
789                 }
790             }
791             final List<Map<String, Object>> orderedResults = new ArrayList<>();
792             for (final String docId : docIds) {
793                 final Map<String, Object> doc = resultMap.get(docId);
794                 if (doc != null) {
795                     orderedResults.add(doc);
796                 }
797             }
798 
799             if (logger.isDebugEnabled()) {
800                 logger.debug("[RAG] Full content fetched. docIdCount={}, fetchedCount={}, elapsedTime={}ms", docIds.size(),
801                         orderedResults.size(), System.currentTimeMillis() - startTime);
802             }
803             return orderedResults;
804         } catch (final Exception e) {
805             logger.warn("Failed to fetch full content for docIds={}. error={}, elapsedTime={}ms", docIds, e.getMessage(),
806                     System.currentTimeMillis() - startTime);
807             return Collections.emptyList();
808         }
809     }
810 
811     /**
812      * Escapes special characters in the value for use in Fess queries.
813      *
814      * @param value the value to escape
815      * @return the escaped value
816      */
817     protected String escapeQueryValue(final String value) {
818         if (value == null) {
819             return "";
820         }
821         final StringBuilder sb = new StringBuilder(value.length() + 16);
822         for (int i = 0; i < value.length(); i++) {
823             final char c = value.charAt(i);
824             if (c == '\0') {
825                 continue; // Skip NULL characters
826             }
827             if (c == '\\' || c == '"') {
828                 sb.append('\\');
829             }
830             sb.append(c);
831         }
832         return sb.toString();
833     }
834 
835     /**
836      * Renders markdown text to sanitized HTML.
837      *
838      * @param markdown the markdown text
839      * @return sanitized HTML
840      */
841     protected String renderMarkdownToHtml(final String markdown) {
842         if (markdownRenderer == null || !markdownRenderer.isInitialized()) {
843             logger.warn("MarkdownRenderer is not initialized, returning escaped text");
844             return escapeHtml(markdown);
845         }
846         return markdownRenderer.render(markdown);
847     }
848 
849     /**
850      * Escapes HTML special characters.
851      *
852      * @param text the text to escape
853      * @return the escaped text
854      */
855     protected String escapeHtml(final String text) {
856         if (text == null) {
857             return "";
858         }
859         return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;").replace("'", "&#39;");
860     }
861 
862     /**
863      * Gets the maximum number of history messages to retain.
864      *
865      * @return the maximum number of history messages
866      */
867     protected int getMaxHistoryMessages() {
868         return ComponentUtil.getFessConfig().getRagChatHistoryMaxMessagesAsInteger();
869     }
870 
871     /**
872      * Searches for documents relevant to the user's query.
873      * Delegates to the multi-argument variant with empty filters.
874      *
875      * @param query the search query
876      * @return a ChatSearchResult with documents and search metadata
877      */
878     protected ChatSearchResult searchDocuments(final String query) {
879         return searchDocuments(query, Collections.emptyMap(), new String[0]);
880     }
881 
882     /**
883      * Searches for documents by URL.
884      *
885      * @param url the URL to search for
886      * @return a ChatSearchResult with documents and search metadata
887      */
888     protected ChatSearchResult searchByUrl(final String url) {
889         if (StringUtil.isBlank(url)) {
890             return new ChatSearchResult(Collections.emptyList(), null, 0L);
891         }
892 
893         final FessConfig fessConfig = ComponentUtil.getFessConfig();
894         final int maxDocs = fessConfig.getRagChatContextMaxDocumentsAsInteger();
895 
896         try {
897             final SearchRenderData data = new SearchRenderData();
898             final ChatSearchRequestParams params =
899                     new ChatSearchRequestParams("url:\"" + escapeQueryValue(url) + "\"", maxDocs, fessConfig);
900 
901             ComponentUtil.getSearchHelper().search(params, data, OptionalThing.empty());
902 
903             @SuppressWarnings("unchecked")
904             final List<Map<String, Object>> docs = (List<Map<String, Object>>) data.getDocumentItems();
905             if (docs != null) {
906                 return new ChatSearchResult(docs, data.getQueryId(), data.getRequestedTime());
907             }
908         } catch (final Exception e) {
909             logger.warn("Failed to search documents by URL: url={}", url, e);
910         }
911 
912         return new ChatSearchResult(Collections.emptyList(), null, 0L);
913     }
914 
915     /**
916      * Validates a query and returns an empty result if invalid, or null if validation passed.
917      */
918     private ChatSearchResult validateQuery(final String query) {
919         if (StringUtil.isBlank(query)) {
920             return new ChatSearchResult(Collections.emptyList(), null, 0L);
921         }
922         if (query.length() > MAX_QUERY_LENGTH) {
923             logger.warn("[RAG] Rejected LLM-generated query exceeding max length. length={}", query.length());
924             return new ChatSearchResult(Collections.emptyList(), null, 0L);
925         }
926         if (DANGEROUS_QUERY_PATTERN.matcher(query).find()) {
927             logger.warn("[RAG] Rejected LLM-generated query with dangerous pattern. query={}", query);
928             return new ChatSearchResult(Collections.emptyList(), null, 0L);
929         }
930         return null;
931     }
932 
933     /**
934      * Searches for documents relevant to the user's query.
935      * SearchHelper applies role-based access control filtering through
936      * SearchRequestType.JSON and the role filter mechanism, ensuring
937      * users only see documents they are authorized to access.
938      * <p>
939      * This is the primary extension point for subclasses to customize search behavior.
940      *
941      * @param query the search query
942      * @param fields the field filters (e.g., label)
943      * @param extraQueries the extra query filters (e.g., filetype, timestamp)
944      * @return a ChatSearchResult with documents and search metadata
945      */
946     protected ChatSearchResult searchDocuments(final String query, final Map<String, String[]> fields, final String[] extraQueries) {
947         final long startTime = System.currentTimeMillis();
948         final FessConfig fessConfig = ComponentUtil.getFessConfig();
949         final int maxDocs = fessConfig.getRagChatContextMaxDocumentsAsInteger();
950 
951         if (logger.isDebugEnabled()) {
952             logger.debug("[RAG] Starting document search. query={}, maxDocs={}", query, maxDocs);
953         }
954 
955         try {
956             final SearchRenderData data = new SearchRenderData();
957             final ChatSearchRequestParams params = new ChatSearchRequestParams(query, maxDocs, fessConfig, fields, extraQueries);
958 
959             ComponentUtil.getSearchHelper().search(params, data, OptionalThing.empty());
960 
961             @SuppressWarnings("unchecked")
962             final List<Map<String, Object>> docs = (List<Map<String, Object>>) data.getDocumentItems();
963             if (docs != null) {
964                 if (logger.isDebugEnabled()) {
965                     logger.debug("[RAG] Document search completed. query={}, resultCount={}, elapsedTime={}ms", query, docs.size(),
966                             System.currentTimeMillis() - startTime);
967                 }
968                 return new ChatSearchResult(docs, data.getQueryId(), data.getRequestedTime());
969             }
970         } catch (final Exception e) {
971             logger.warn("Failed to search documents for RAG: query={}, elapsedTime={}ms", query, System.currentTimeMillis() - startTime, e);
972         }
973 
974         if (logger.isDebugEnabled()) {
975             logger.debug("[RAG] Document search returned no results. query={}, elapsedTime={}ms", query,
976                     System.currentTimeMillis() - startTime);
977         }
978         return new ChatSearchResult(new ArrayList<>(), null, 0L);
979     }
980 
981     /**
982      * Resolves the context path from the current request, or empty string if unavailable.
983      *
984      * @return the context path
985      */
986     protected String resolveContextPath() {
987         return LaRequestUtil.getOptionalRequest().map(HttpServletRequest::getContextPath).orElse("");
988     }
989 
990     /**
991      * Builds a go URL for the given document.
992      *
993      * @param contextPath the application context path
994      * @param docId the document ID
995      * @param queryId the query ID from the search
996      * @param requestedTime the requested time from the search
997      * @param order the order index of the document
998      * @return the go URL, or null if docId or queryId is null
999      */
1000     protected String buildGoUrl(final String contextPath, final String docId, final String queryId, final long requestedTime,
1001             final int order) {
1002         if (docId == null || queryId == null) {
1003             return null;
1004         }
1005         return contextPath + "/go/?rt=" + requestedTime + "&docId=" + URLEncoder.encode(docId, StandardCharsets.UTF_8) + "&queryId="
1006                 + URLEncoder.encode(queryId, StandardCharsets.UTF_8) + "&order=" + order;
1007     }
1008 
1009     /**
1010      * Creates ChatSource objects from search results and adds them to the assistant message.
1011      *
1012      * @param assistantMessage the message to add sources to
1013      * @param sourceList the search result documents
1014      * @param contextPath the application context path
1015      * @param queryId the query ID from the search
1016      * @param requestedTime the requested time from the search
1017      */
1018     protected void addSourcesToMessage(final ChatMessage assistantMessage, final List<Map<String, Object>> sourceList,
1019             final String contextPath, final String queryId, final long requestedTime) {
1020         for (int i = 0; i < sourceList.size(); i++) {
1021             final ChatSource source = new ChatSource(i + 1, sourceList.get(i));
1022             source.setGoUrl(buildGoUrl(contextPath, source.getDocId(), queryId, requestedTime, i));
1023             assistantMessage.addSource(source);
1024         }
1025     }
1026 
1027     /**
1028      * Populates the url_link field in the document map if not already present.
1029      *
1030      * @param doc the document map
1031      */
1032     protected void populateUrlLink(final Map<String, Object> doc) {
1033         final FessConfig fessConfig = ComponentUtil.getFessConfig();
1034         if (doc.get(fessConfig.getResponseFieldUrlLink()) == null) {
1035             doc.put(fessConfig.getResponseFieldUrlLink(), ComponentUtil.getViewHelper().getUrlLink(doc));
1036         }
1037     }
1038 
1039     /**
1040      * Result of a search operation, including queryId and requestedTime.
1041      */
1042     protected static class ChatSearchResult {
1043         private final List<Map<String, Object>> documents;
1044         private final String queryId;
1045         private final long requestedTime;
1046 
1047         /**
1048          * Creates a new chat search result.
1049          *
1050          * @param documents the search result documents
1051          * @param queryId the query ID
1052          * @param requestedTime the requested time
1053          */
1054         public ChatSearchResult(final List<Map<String, Object>> documents, final String queryId, final long requestedTime) {
1055             this.documents = documents;
1056             this.queryId = queryId;
1057             this.requestedTime = requestedTime;
1058         }
1059 
1060         /**
1061          * Gets the search result documents.
1062          *
1063          * @return the list of documents
1064          */
1065         public List<Map<String, Object>> getDocuments() {
1066             return documents;
1067         }
1068 
1069         /**
1070          * Gets the query ID.
1071          *
1072          * @return the query ID
1073          */
1074         public String getQueryId() {
1075             return queryId;
1076         }
1077 
1078         /**
1079          * Gets the requested time.
1080          *
1081          * @return the requested time
1082          */
1083         public long getRequestedTime() {
1084             return requestedTime;
1085         }
1086     }
1087 
1088     /**
1089      * Result of a chat request.
1090      */
1091     public static class ChatResult {
1092         private final String sessionId;
1093         private final ChatMessage message;
1094         private final List<Map<String, Object>> sources;
1095 
1096         /**
1097          * Creates a new chat result.
1098          *
1099          * @param sessionId the session ID
1100          * @param message the chat message
1101          * @param sources the list of source documents
1102          */
1103         public ChatResult(final String sessionId, final ChatMessage message, final List<Map<String, Object>> sources) {
1104             this.sessionId = sessionId;
1105             this.message = message;
1106             this.sources = sources;
1107         }
1108 
1109         /**
1110          * Gets the session ID.
1111          *
1112          * @return the session ID
1113          */
1114         public String getSessionId() {
1115             return sessionId;
1116         }
1117 
1118         /**
1119          * Gets the chat message.
1120          *
1121          * @return the chat message
1122          */
1123         public ChatMessage getMessage() {
1124             return message;
1125         }
1126 
1127         /**
1128          * Gets the source documents.
1129          *
1130          * @return the list of source documents
1131          */
1132         public List<Map<String, Object>> getSources() {
1133             return sources;
1134         }
1135     }
1136 
1137     /**
1138      * Search request parameters for RAG chat context retrieval.
1139      */
1140     protected static class ChatSearchRequestParams extends SearchRequestParams {
1141         private final String query;
1142         private final int pageSize;
1143         private final FessConfig fessConfig;
1144         private final Map<String, String[]> fields;
1145         private final String[] extraQueries;
1146 
1147         /**
1148          * Creates new chat search request parameters.
1149          *
1150          * @param query the search query
1151          * @param pageSize the page size
1152          * @param fessConfig the Fess configuration
1153          */
1154         public ChatSearchRequestParams(final String query, final int pageSize, final FessConfig fessConfig) {
1155             this(query, pageSize, fessConfig, Collections.emptyMap(), new String[0]);
1156         }
1157 
1158         /**
1159          * Creates new chat search request parameters with filter support.
1160          *
1161          * @param query the search query
1162          * @param pageSize the page size
1163          * @param fessConfig the Fess configuration
1164          * @param fields the field filters (e.g., label)
1165          * @param extraQueries the extra query filters (e.g., filetype, timestamp)
1166          */
1167         public ChatSearchRequestParams(final String query, final int pageSize, final FessConfig fessConfig,
1168                 final Map<String, String[]> fields, final String[] extraQueries) {
1169             this.query = query;
1170             this.pageSize = pageSize;
1171             this.fessConfig = fessConfig;
1172             this.fields = fields;
1173             this.extraQueries = extraQueries;
1174         }
1175 
1176         @Override
1177         public String getQuery() {
1178             return query;
1179         }
1180 
1181         @Override
1182         public Map<String, String[]> getFields() {
1183             return fields;
1184         }
1185 
1186         @Override
1187         public Map<String, String[]> getConditions() {
1188             return Collections.emptyMap();
1189         }
1190 
1191         @Override
1192         public String[] getLanguages() {
1193             return new String[0];
1194         }
1195 
1196         @Override
1197         public GeoInfo getGeoInfo() {
1198             return null;
1199         }
1200 
1201         @Override
1202         public FacetInfo getFacetInfo() {
1203             return null;
1204         }
1205 
1206         @Override
1207         public HighlightInfo getHighlightInfo() {
1208             return new HighlightInfo().fragmentSize(Integer.parseInt(fessConfig.getOrDefault("rag.chat.highlight.fragment.size", "500")))
1209                     .numOfFragments(Integer.parseInt(fessConfig.getOrDefault("rag.chat.highlight.number.of.fragments", "3")))
1210                     .preTags(StringUtil.EMPTY)
1211                     .postTags(StringUtil.EMPTY);
1212         }
1213 
1214         @Override
1215         public String getSort() {
1216             return null;
1217         }
1218 
1219         @Override
1220         public int getStartPosition() {
1221             return fessConfig.getPagingSearchPageStartAsInteger();
1222         }
1223 
1224         @Override
1225         public int getPageSize() {
1226             return pageSize;
1227         }
1228 
1229         @Override
1230         public int getOffset() {
1231             return 0;
1232         }
1233 
1234         @Override
1235         public String[] getExtraQueries() {
1236             return extraQueries;
1237         }
1238 
1239         @Override
1240         public Object getAttribute(final String name) {
1241             return null;
1242         }
1243 
1244         @Override
1245         public Locale getLocale() {
1246             return Locale.getDefault();
1247         }
1248 
1249         @Override
1250         public SearchRequestType getType() {
1251             return SearchRequestType.JSON;
1252         }
1253 
1254         @Override
1255         public String getSimilarDocHash() {
1256             return null;
1257         }
1258     }
1259 }