1
2
3
4
5
6
7
8
9
10
11
12
13
14
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
60
61
62
63
64
65
66
67 public class ChatClient {
68
69 private static final Logger logger = LogManager.getLogger(ChatClient.class);
70
71
72 @Resource
73 protected ChatSessionManager chatSessionManager;
74
75
76 @Resource
77 protected LlmClientManager llmClientManager;
78
79
80 @Resource
81 protected MarkdownRenderer markdownRenderer;
82
83
84
85
86 public ChatClient() {
87
88 }
89
90
91
92
93
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
105
106
107
108
109
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
117
118
119
120
121
122
123
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
137 final List<LlmMessage> history = extractHistory(session);
138
139 final ChatMessage userChatMessage = ChatMessage.userMessage(userMessage);
140 session.addMessage(userChatMessage);
141
142 try {
143
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
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
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
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
204
205
206
207
208
209
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
218
219
220
221
222
223
224
225
226
227
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
235 final String contextPath = resolveContextPath();
236
237
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
245 final List<LlmMessage> history = extractHistory(session);
246
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
519
520
521
522
523
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
549
550
551
552
553
554
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
578
579
580
581
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
610
611
612
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
639
640
641
642
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
657
658
659
660
661
662
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
688
689
690
691
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
722
723
724
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
736
737
738
739
740
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
758
759
760
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
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
813
814
815
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;
826 }
827 if (c == '\\' || c == '"') {
828 sb.append('\\');
829 }
830 sb.append(c);
831 }
832 return sb.toString();
833 }
834
835
836
837
838
839
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
851
852
853
854
855 protected String escapeHtml(final String text) {
856 if (text == null) {
857 return "";
858 }
859 return text.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """).replace("'", "'");
860 }
861
862
863
864
865
866
867 protected int getMaxHistoryMessages() {
868 return ComponentUtil.getFessConfig().getRagChatHistoryMaxMessagesAsInteger();
869 }
870
871
872
873
874
875
876
877
878 protected ChatSearchResult searchDocuments(final String query) {
879 return searchDocuments(query, Collections.emptyMap(), new String[0]);
880 }
881
882
883
884
885
886
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
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
935
936
937
938
939
940
941
942
943
944
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
983
984
985
986 protected String resolveContextPath() {
987 return LaRequestUtil.getOptionalRequest().map(HttpServletRequest::getContextPath).orElse("");
988 }
989
990
991
992
993
994
995
996
997
998
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
1011
1012
1013
1014
1015
1016
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
1029
1030
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
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
1049
1050
1051
1052
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
1062
1063
1064
1065 public List<Map<String, Object>> getDocuments() {
1066 return documents;
1067 }
1068
1069
1070
1071
1072
1073
1074 public String getQueryId() {
1075 return queryId;
1076 }
1077
1078
1079
1080
1081
1082
1083 public long getRequestedTime() {
1084 return requestedTime;
1085 }
1086 }
1087
1088
1089
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
1098
1099
1100
1101
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
1111
1112
1113
1114 public String getSessionId() {
1115 return sessionId;
1116 }
1117
1118
1119
1120
1121
1122
1123 public ChatMessage getMessage() {
1124 return message;
1125 }
1126
1127
1128
1129
1130
1131
1132 public List<Map<String, Object>> getSources() {
1133 return sources;
1134 }
1135 }
1136
1137
1138
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
1149
1150
1151
1152
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
1160
1161
1162
1163
1164
1165
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 }