From 9ffbede49a2cf112a70ba25297ab9bed1ac36cd1 Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Fri, 14 Nov 2025 17:58:12 +0000 Subject: [PATCH] improving context --- .../config/ChatbotVectorStoreConfig.java | 3 + .../controller/ChatbotController.java | 2 +- .../chatbot/ChatbotContextCompressor.java | 49 +++++++ .../chatbot/ChatbotConversationService.java | 51 ++++++-- .../service/chatbot/ChatbotMemoryService.java | 52 ++++++++ .../public/locales/en-GB/translation.json | 13 +- .../core/components/chatbot/ChatbotDrawer.tsx | 123 ++++++++---------- .../viewer/useViewerRightRailButtons.tsx | 4 - 8 files changed, 209 insertions(+), 88 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotContextCompressor.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotMemoryService.java diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/config/ChatbotVectorStoreConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/config/ChatbotVectorStoreConfig.java index 2ff78ca45..f3277b7db 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/config/ChatbotVectorStoreConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/config/ChatbotVectorStoreConfig.java @@ -28,10 +28,12 @@ public class ChatbotVectorStoreConfig { public VectorStore chatbotVectorStore( ObjectProvider jedisProvider, EmbeddingModel embeddingModel) { JedisPooled jedis = jedisProvider.getIfAvailable(); + if (jedis != null) { try { jedis.ping(); log.info("Initialising Redis vector store for chatbot usage"); + return RedisVectorStore.builder(jedis, embeddingModel) .indexName(DEFAULT_INDEX) .prefix(DEFAULT_PREFIX) @@ -45,6 +47,7 @@ public class ChatbotVectorStoreConfig { } else { log.info("No Redis connection detected; using SimpleVectorStore for chatbot."); } + return SimpleVectorStore.builder(embeddingModel).build(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/ChatbotController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/ChatbotController.java index 7a5e10e96..9dbf49c6f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/ChatbotController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/ChatbotController.java @@ -107,7 +107,7 @@ public class ChatbotController { warnings.add("Images detected - Images are not currently supported."); } - warnings.add("Only extracted text is sent for analysis."); + warnings.add("Images are not yet supported. Only extracted text is sent for analysis."); if (session != null && session.isOcrRequested()) { warnings.add("OCR requested – uses credits ."); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotContextCompressor.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotContextCompressor.java new file mode 100644 index 000000000..67b8c04d0 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotContextCompressor.java @@ -0,0 +1,49 @@ +package stirling.software.proprietary.service.chatbot; + +import java.util.List; + +import org.springframework.ai.document.Document; +import org.springframework.stereotype.Component; +import org.springframework.util.CollectionUtils; + +@Component +public class ChatbotContextCompressor { + + private static final int DEFAULT_SUMMARY_LIMIT = 3000; + private static final int MIN_CHUNK_SNIPPET = 160; + + public String summarize(List documents, int requestedLimit) { + if (CollectionUtils.isEmpty(documents)) { + return "No contextual snippets available for this session."; + } + int maxChars = + requestedLimit > 0 + ? Math.min(requestedLimit, DEFAULT_SUMMARY_LIMIT) + : DEFAULT_SUMMARY_LIMIT; + StringBuilder builder = new StringBuilder(); + int perChunkLimit = Math.max(MIN_CHUNK_SNIPPET, maxChars / Math.max(documents.size(), 1)); + for (Document doc : documents) { + if (builder.length() >= maxChars) { + break; + } + String chunkOrder = doc.getMetadata().getOrDefault("chunkOrder", "?").toString(); + String text = trimContent(doc.getText(), perChunkLimit); + builder.append("Chunk ").append(chunkOrder).append(": ").append(text).append('\n'); + } + if (builder.length() == 0) { + return "Unable to summarise context; original content unavailable."; + } + return builder.substring(0, Math.min(builder.length(), maxChars)).trim(); + } + + private String trimContent(String content, int perChunkLimit) { + if (content == null || content.isBlank()) { + return "(empty chunk)"; + } + String normalized = content.replaceAll("\\s+", " ").trim(); + if (normalized.length() <= perChunkLimit) { + return normalized; + } + return normalized.substring(0, Math.max(0, perChunkLimit - 3)) + "..."; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotConversationService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotConversationService.java index 8d363efef..c74d2fead 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotConversationService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotConversationService.java @@ -47,6 +47,8 @@ public class ChatbotConversationService { private final ChatbotCacheService cacheService; private final ChatbotFeatureProperties featureProperties; private final ChatbotRetrievalService retrievalService; + private final ChatbotContextCompressor contextCompressor; + private final ChatbotMemoryService memoryService; private final ChatbotUsageService usageService; private final ObjectMapper objectMapper; private final AtomicBoolean modelSwitchVerified = new AtomicBoolean(false); @@ -79,6 +81,9 @@ public class ChatbotConversationService { List context = retrievalService.retrieveTopK( request.getSessionId(), request.getPrompt(), settings); + String contextSummary = + contextCompressor.summarize( + context, (int) Math.max(settings.maxPromptCharacters() / 2, 1000)); ModelReply nanoReply = invokeModel( @@ -87,6 +92,7 @@ public class ChatbotConversationService { request.getPrompt(), session, context, + contextSummary, cacheEntry.getMetadata()); boolean shouldEscalate = @@ -106,6 +112,7 @@ public class ChatbotConversationService { request.getPrompt(), session, context, + contextSummary, cacheEntry.getMetadata()); } @@ -116,6 +123,8 @@ public class ChatbotConversationService { finalReply.completionTokens()); session.setUsageSummary(usageSummary); + memoryService.recordTurn(session, request.getPrompt(), finalReply.answer()); + return ChatbotResponse.builder() .sessionId(request.getSessionId()) .modelUsed( @@ -200,8 +209,10 @@ public class ChatbotConversationService { String prompt, ChatbotSession session, List context, + String contextSummary, Map metadata) { - Prompt requestPrompt = buildPrompt(settings, model, prompt, session, context, metadata); + Prompt requestPrompt = + buildPrompt(settings, model, prompt, session, context, contextSummary, metadata); ChatResponse response; try { response = chatModel.call(requestPrompt); @@ -244,16 +255,9 @@ public class ChatbotConversationService { String question, ChatbotSession session, List context, + String contextSummary, Map metadata) { - StringBuilder contextBuilder = new StringBuilder(); - for (Document chunk : context) { - contextBuilder - .append("[Chunk ") - .append(chunk.getMetadata().getOrDefault("chunkOrder", "?")) - .append("]\n") - .append(chunk.getText()) - .append("\n\n"); - } + String chunkOutline = buildChunkOutline(context); String metadataSummary = metadata.entrySet().stream() .map(entry -> entry.getKey() + ": " + entry.getValue()) @@ -277,8 +281,10 @@ public class ChatbotConversationService { + session.isOcrRequested() + "\n" + imageDirective - + "\nContext:\n" - + contextBuilder + + "\nContext summary:\n" + + contextSummary + + "\nContext outline:\n" + + chunkOutline + "Question: " + question; @@ -298,6 +304,27 @@ public class ChatbotConversationService { return builder.build(); } + private String buildChunkOutline(List context) { + if (context == null || context.isEmpty()) { + return "No chunks retrieved for this question."; + } + StringBuilder outline = new StringBuilder(); + for (Document chunk : context) { + String order = chunk.getMetadata().getOrDefault("chunkOrder", "?").toString(); + String snippet = chunk.getText(); + if (snippet != null) { + snippet = snippet.replaceAll("\\s+", " ").trim(); + if (snippet.length() > 240) { + snippet = snippet.substring(0, 237) + "..."; + } + } else { + snippet = "(empty)"; + } + outline.append("- Chunk ").append(order).append(": ").append(snippet).append("\n"); + } + return outline.toString(); + } + private ModelReply parseModelResponse( String raw, long promptTokens, long completionTokens, long totalTokens) { if (!StringUtils.hasText(raw)) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotMemoryService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotMemoryService.java new file mode 100644 index 000000000..b54fe7422 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotMemoryService.java @@ -0,0 +1,52 @@ +package stirling.software.proprietary.service.chatbot; + +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.ai.document.Document; +import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.model.chatbot.ChatbotSession; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ChatbotMemoryService { + + private final VectorStore vectorStore; + + public void recordTurn(ChatbotSession session, String prompt, String answer) { + if (session == null) { + return; + } + if (!StringUtils.hasText(prompt) && !StringUtils.hasText(answer)) { + return; + } + Map metadata = new HashMap<>(); + metadata.put("sessionId", session.getSessionId()); + metadata.put("documentId", session.getDocumentId()); + metadata.put("turnType", "conversation"); + metadata.put("turnTimestamp", Instant.now().toString()); + metadata.put("userId", session.getUserId()); + + StringBuilder contentBuilder = new StringBuilder(); + if (StringUtils.hasText(prompt)) { + contentBuilder.append("User: ").append(prompt.trim()).append("\n"); + } + if (StringUtils.hasText(answer)) { + contentBuilder.append("Assistant: ").append(answer.trim()); + } + try { + vectorStore.add(List.of(new Document(contentBuilder.toString(), metadata))); + } catch (RuntimeException ex) { + log.warn("Failed to persist chatbot conversation turn: {}", ex.getMessage()); + } + } +} diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 1e37c3513..e8d262f08 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -5145,8 +5145,7 @@ "title": "Stirling PDF Bot", "alphaBadge": "Alpha", "alphaTitle": "Experimental feature", - "alphaDescription": "This Chatbot feature is in currently in alpha and is subject to change. Image-based content is not supported yet. Responses may be imperfect, so double-check important answers.", - "acceptAlphaLabel": "I understand this feature is experimental and image content is not supported yet.", + "alphaDescription": "Chatbot is in currently in alpha and is subject to change. Responses may be imperfect, please check responses.", "fileLabel": "Document to query", "filePlaceholder": "Select an uploaded PDF", "noFiles": "Upload a PDF from File Manager to start chatting.", @@ -5193,6 +5192,14 @@ "noTextTitle": "No text detected in this PDF", "noTextBody": "We could not find selectable text in this document. Would you like to run OCR to convert scanned pages into text?", "noTextDismiss": "Maybe later", - "noTextRunOcr": "Run OCR and retry" + "noTextRunOcr": "Run OCR and retry", + "usage": { + "limitReachedTitle": "Chatbot limit reached", + "limitReachedBody": "You have exceeded the current monthly allocation for the chatbot. Further responses may be throttled.", + "nearingLimitTitle": "Approaching usage limit", + "nearingLimitBody": "You are nearing your monthly chatbot allocation. Consider limiting very large requests." + }, + "autoSyncInfo": "Selected documents are synced automatically when the chatbot opens.", + "autoSyncPrompt": "Acknowledge the alpha notice to start syncing automatically." } } diff --git a/frontend/src/core/components/chatbot/ChatbotDrawer.tsx b/frontend/src/core/components/chatbot/ChatbotDrawer.tsx index 68906e144..4d0c99848 100644 --- a/frontend/src/core/components/chatbot/ChatbotDrawer.tsx +++ b/frontend/src/core/components/chatbot/ChatbotDrawer.tsx @@ -1,6 +1,5 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'; import { - ActionIcon, Badge, Box, Button, @@ -13,14 +12,12 @@ import { Switch, Text, Textarea, - Tooltip, } from '@mantine/core'; import { useMediaQuery, useViewportSize } from '@mantine/hooks'; import { useTranslation } from 'react-i18next'; import SmartToyRoundedIcon from '@mui/icons-material/SmartToyRounded'; import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded'; import SendRoundedIcon from '@mui/icons-material/SendRounded'; -import RefreshRoundedIcon from '@mui/icons-material/RefreshRounded'; import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; import { useChatbot } from '@app/contexts/ChatbotContext'; @@ -47,8 +44,6 @@ interface ChatMessage { createdAt: Date; } -const ALPHA_ACK_KEY = 'stirling.chatbot.alphaAck'; - function createMessageId() { if (typeof crypto !== 'undefined' && crypto.randomUUID) { return crypto.randomUUID(); @@ -68,7 +63,6 @@ const ChatbotDrawer = () => { const { show } = useToast(); const files = selectors.getFiles(); const [selectedFileId, setSelectedFileId] = useState(); - const [alphaAccepted, setAlphaAccepted] = useState(false); const [runOcr, setRunOcr] = useState(false); const [isStartingSession, setIsStartingSession] = useState(false); const [isSendingMessage, setIsSendingMessage] = useState(false); @@ -94,11 +88,6 @@ const ChatbotDrawer = () => { return; } - const storedAck = typeof window !== 'undefined' - ? window.localStorage.getItem(ALPHA_ACK_KEY) === 'true' - : false; - setAlphaAccepted(storedAck); - if (preferredFileId) { setSelectedFileId(preferredFileId); setPreferredFileId(undefined); @@ -161,6 +150,8 @@ const ChatbotDrawer = () => { setContextStats(null); setMessages([]); setWarnings([]); + setPendingOcrRetry(false); + setNoTextModalOpen(false); } }, [sessionInfo, selectedFileId]); @@ -192,17 +183,6 @@ const ChatbotDrawer = () => { }; }, [isMobile, isOpen, sidebarRefs.toolPanelRef]); - const handleAlphaAccept = (checked: boolean) => { - setAlphaAccepted(checked); - if (typeof window !== 'undefined') { - if (checked) { - window.localStorage.setItem(ALPHA_ACK_KEY, 'true'); - } else { - window.localStorage.removeItem(ALPHA_ACK_KEY); - } - } - }; - const withStatus = async (label: string, fn: () => Promise): Promise => { setStatusMessage(label); try { @@ -228,14 +208,6 @@ const ChatbotDrawer = () => { if (!ensureFileSelected() || !selectedFile) { return; } - if (!alphaAccepted) { - show({ - alertType: 'neutral', - title: t('chatbot.toasts.ackTitle', 'Accept alpha notice'), - body: t('chatbot.toasts.ackBody', 'Please acknowledge the alpha warning before starting.'), - }); - return; - } setIsStartingSession(true); try { let workingFile: File = selectedFile; @@ -271,7 +243,7 @@ const ChatbotDrawer = () => { text: extractionResult.text, metadata, ocrRequested: shouldRunOcr, - warningsAccepted: alphaAccepted, + warningsAccepted: true, }; const response = await withStatus( @@ -302,6 +274,36 @@ const ChatbotDrawer = () => { } }; + useEffect(() => { + if ( + !isOpen || + !selectedFile || + sessionInfo || + isStartingSession || + pendingOcrRetry || + noTextModalOpen + ) { + return; + } + let cancelled = false; + handleSessionStart().catch((error) => { + if (!cancelled) { + console.error('[Chatbot] Auto-sync failed', error); + } + }); + return () => { + cancelled = true; + }; + }, [isOpen, selectedFile, sessionInfo, isStartingSession, pendingOcrRetry, noTextModalOpen, runOcr]); + + useEffect(() => { + if (!sessionInfo) { + return; + } + setSessionInfo(null); + setContextStats(null); + }, [runOcr]); + const handleSendMessage = async () => { if (!sessionInfo) { show({ @@ -495,25 +497,6 @@ const ChatbotDrawer = () => { transitionProps={{ transition: 'slide-left', duration: 200 }} > - - - - {t('chatbot.alphaTitle', 'Experimental feature')} - - {t('chatbot.alphaDescription', 'This chatbot is in alpha. It currently ignores images and may produce inaccurate answers. Your PDF text stays local until you confirm you want to chat.')} - - -