From 98d49499304a3302b32ffdbd7fa2bca0d2864577 Mon Sep 17 00:00:00 2001 From: DarioGii Date: Thu, 6 Nov 2025 11:31:46 +0000 Subject: [PATCH] wip --- .../common/model/ApplicationProperties.java | 1 + app/proprietary/build.gradle | 1 + .../controller/ChatbotController.java | 16 +++- .../chatbot/ChatbotDocumentCacheEntry.java | 2 + .../model/chatbot/ChatbotSession.java | 2 + .../chatbot/ChatbotSessionCreateRequest.java | 1 + .../model/chatbot/ChatbotSessionResponse.java | 2 + .../service/chatbot/ChatbotCacheService.java | 6 +- .../chatbot/ChatbotConversationService.java | 81 ++++++++++++++----- .../chatbot/ChatbotFeatureProperties.java | 31 +++++-- .../chatbot/ChatbotIngestionService.java | 30 +++++-- .../service/chatbot/ChatbotService.java | 4 +- .../chatbot/ChatbotCacheServiceTest.java | 20 ++++- 13 files changed, 159 insertions(+), 38 deletions(-) diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index ce810851e..06ca59f06 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -631,6 +631,7 @@ public class ApplicationProperties { @Data public static class Models { + private String provider = "openai"; private String primary = "gpt-5-nano"; private String fallback = "gpt-5-mini"; private String embedding = "text-embedding-3-small"; diff --git a/app/proprietary/build.gradle b/app/proprietary/build.gradle index a45958098..2646c42d1 100644 --- a/app/proprietary/build.gradle +++ b/app/proprietary/build.gradle @@ -53,6 +53,7 @@ dependencies { api 'com.github.ben-manes.caffeine:caffeine' api 'io.swagger.core.v3:swagger-core-jakarta:2.2.38' implementation 'org.springframework.ai:spring-ai-openai' + implementation 'org.springframework.ai:spring-ai-ollama' implementation 'com.bucket4j:bucket4j_jdk17-core:8.15.0' // https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17 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 b099a5dfb..6f8ffd48b 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 @@ -54,9 +54,11 @@ public class ChatbotController { .documentId(session.getDocumentId()) .alphaWarning(settings.alphaWarning()) .ocrRequested(session.isOcrRequested()) + .imageContentDetected(session.isImageContentDetected()) + .textCharacters(session.getTextCharacters()) .maxCachedCharacters(cacheService.getMaxDocumentCharacters()) .createdAt(session.getCreatedAt()) - .warnings(defaultWarnings(settings)) + .warnings(sessionWarnings(settings, session)) .metadata(session.getMetadata()) .build(); return ResponseEntity.status(HttpStatus.CREATED).body(response); @@ -81,9 +83,11 @@ public class ChatbotController { .documentId(session.getDocumentId()) .alphaWarning(settings.alphaWarning()) .ocrRequested(session.isOcrRequested()) + .imageContentDetected(session.isImageContentDetected()) + .textCharacters(session.getTextCharacters()) .maxCachedCharacters(cacheService.getMaxDocumentCharacters()) .createdAt(session.getCreatedAt()) - .warnings(defaultWarnings(settings)) + .warnings(sessionWarnings(settings, session)) .metadata(session.getMetadata()) .build(); return ResponseEntity.ok(response); @@ -95,13 +99,19 @@ public class ChatbotController { return ResponseEntity.noContent().build(); } - private List defaultWarnings(ChatbotSettings settings) { + private List sessionWarnings(ChatbotSettings settings, ChatbotSession session) { List warnings = new ArrayList<>(); if (settings.alphaWarning()) { warnings.add("Chatbot feature is in alpha and may change."); } warnings.add("Image-based content is not supported yet."); + if (session != null && session.isImageContentDetected()) { + warnings.add("Detected images will be ignored until image support ships."); + } warnings.add("Only extracted text is sent for analysis."); + if (session != null && session.isOcrRequested()) { + warnings.add("OCR was requested – extra processing charges may apply."); + } return warnings; } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/chatbot/ChatbotDocumentCacheEntry.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/chatbot/ChatbotDocumentCacheEntry.java index 24aaccc7f..44b317435 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/chatbot/ChatbotDocumentCacheEntry.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/chatbot/ChatbotDocumentCacheEntry.java @@ -23,6 +23,8 @@ public class ChatbotDocumentCacheEntry { private String text; private List chunks; private boolean ocrApplied; + private boolean imageContentDetected; + private long textCharacters; private String vectorStoreId; private Instant storedAt; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/chatbot/ChatbotSession.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/chatbot/ChatbotSession.java index 1d7ff9cc1..b4e6f3d21 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/chatbot/ChatbotSession.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/chatbot/ChatbotSession.java @@ -19,6 +19,8 @@ public class ChatbotSession { private boolean ocrRequested; private boolean warningsAccepted; private boolean alphaWarningRequired; + private boolean imageContentDetected; + private long textCharacters; private String cacheKey; private String vectorStoreId; private Instant createdAt; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/chatbot/ChatbotSessionCreateRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/chatbot/ChatbotSessionCreateRequest.java index 9bf5e55e0..abb53ddc6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/chatbot/ChatbotSessionCreateRequest.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/chatbot/ChatbotSessionCreateRequest.java @@ -20,4 +20,5 @@ public class ChatbotSessionCreateRequest { private Map metadata; private boolean ocrRequested; private boolean warningsAccepted; + private boolean imagesDetected; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/chatbot/ChatbotSessionResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/chatbot/ChatbotSessionResponse.java index 7504fa6c1..e1f3276bd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/chatbot/ChatbotSessionResponse.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/chatbot/ChatbotSessionResponse.java @@ -20,7 +20,9 @@ public class ChatbotSessionResponse { private String documentId; private boolean alphaWarning; private boolean ocrRequested; + private boolean imageContentDetected; private long maxCachedCharacters; + private long textCharacters; private Instant createdAt; private List warnings; private Map metadata; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotCacheService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotCacheService.java index 935e95b69..a12659ef0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotCacheService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotCacheService.java @@ -64,7 +64,9 @@ public class ChatbotCacheService { String documentId, String rawText, Map metadata, - boolean ocrApplied) { + boolean ocrApplied, + boolean imageContentDetected, + long textCharacters) { Objects.requireNonNull(sessionId, "sessionId must not be null"); Objects.requireNonNull(documentId, "documentId must not be null"); Objects.requireNonNull(rawText, "rawText must not be null"); @@ -82,6 +84,8 @@ public class ChatbotCacheService { .metadata(metadata) .text(rawText) .ocrApplied(ocrApplied) + .imageContentDetected(imageContentDetected) + .textCharacters(textCharacters) .storedAt(Instant.now()) .build(); documentCache.put(cacheKey, entry); 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 136664fa9..368b7be6a 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 @@ -14,6 +14,8 @@ import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.ollama.OllamaChatModel; +import org.springframework.ai.ollama.api.OllamaOptions; import org.springframework.ai.openai.OpenAiChatModel; import org.springframework.ai.openai.OpenAiChatOptions; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; @@ -33,6 +35,7 @@ import stirling.software.proprietary.model.chatbot.ChatbotResponse; import stirling.software.proprietary.model.chatbot.ChatbotSession; import stirling.software.proprietary.model.chatbot.ChatbotTextChunk; import stirling.software.proprietary.service.chatbot.ChatbotFeatureProperties.ChatbotSettings; +import stirling.software.proprietary.service.chatbot.ChatbotFeatureProperties.ChatbotSettings.ModelProvider; import stirling.software.proprietary.service.chatbot.exception.ChatbotException; @Service @@ -66,7 +69,7 @@ public class ChatbotConversationService { .findById(request.getSessionId()) .orElseThrow(() -> new ChatbotException("Unknown chatbot session")); - ensureModelSwitchCapability(); + ensureModelSwitchCapability(settings); ChatbotDocumentCacheEntry cacheEntry = cacheService @@ -81,6 +84,7 @@ public class ChatbotConversationService { ModelReply nanoReply = invokeModel( + settings, settings.models().primary(), request.getPrompt(), session, @@ -100,6 +104,7 @@ public class ChatbotConversationService { List expandedContext = ensureMinimumContext(context, cacheEntry); finalReply = invokeModel( + settings, settings.models().fallback(), request.getPrompt(), session, @@ -118,7 +123,7 @@ public class ChatbotConversationService { .cacheHit(true) .respondedAt(Instant.now()) .warnings(warnings) - .metadata(buildMetadata(finalReply, context.size(), escalated)) + .metadata(buildMetadata(settings, session, finalReply, context.size(), escalated)) .build(); } @@ -126,6 +131,10 @@ public class ChatbotConversationService { List warnings = new ArrayList<>(); warnings.add("Chatbot is in alpha – behaviour may change."); warnings.add("Image content is not yet supported in answers."); + if (session.isImageContentDetected()) { + warnings.add( + "Detected document images will be ignored until image support is available."); + } if (session.isOcrRequested()) { warnings.add("OCR costs may apply for this session."); } @@ -133,30 +142,44 @@ public class ChatbotConversationService { } private Map buildMetadata( - ModelReply reply, int contextSize, boolean escalated) { + ChatbotSettings settings, + ChatbotSession session, + ModelReply reply, + int contextSize, + boolean escalated) { Map metadata = new HashMap<>(); metadata.put("contextSize", contextSize); metadata.put("requiresEscalation", reply.requiresEscalation()); metadata.put("escalated", escalated); metadata.put("rationale", reply.rationale()); + metadata.put("modelProvider", settings.models().provider().name()); + metadata.put("imageContentDetected", session.isImageContentDetected()); + metadata.put("charactersCached", session.getTextCharacters()); return metadata; } - private void ensureModelSwitchCapability() { - if (!(chatModel instanceof OpenAiChatModel)) { - throw new ChatbotException( - "Chatbot requires OpenAI chat model to support runtime model switching"); + private void ensureModelSwitchCapability(ChatbotSettings settings) { + ModelProvider provider = settings.models().provider(); + switch (provider) { + case OPENAI -> { + if (!(chatModel instanceof OpenAiChatModel)) { + throw new ChatbotException( + "Chatbot requires an OpenAI chat model to support runtime model switching."); + } + } + case OLLAMA -> { + if (!(chatModel instanceof OllamaChatModel)) { + throw new ChatbotException( + "Chatbot is configured for Ollama but no Ollama chat model bean is available."); + } + } } if (modelSwitchVerified.compareAndSet(false, true)) { - ChatbotSettings settings = featureProperties.current(); - OpenAiChatOptions primary = - OpenAiChatOptions.builder().model(settings.models().primary()).build(); - OpenAiChatOptions fallback = - OpenAiChatOptions.builder().model(settings.models().fallback()).build(); log.info( - "Verified runtime model override support ({} -> {})", - primary.getModel(), - fallback.getModel()); + "Verified runtime model override support for provider {} ({} -> {})", + provider, + settings.models().primary(), + settings.models().fallback()); } } @@ -178,12 +201,13 @@ public class ChatbotConversationService { } private ModelReply invokeModel( + ChatbotSettings settings, String model, String prompt, ChatbotSession session, List context, Map metadata) { - Prompt requestPrompt = buildPrompt(model, prompt, session, context, metadata); + Prompt requestPrompt = buildPrompt(settings, model, prompt, session, context, metadata); ChatResponse response = chatModel.call(requestPrompt); String content = Optional.ofNullable(response) @@ -195,6 +219,7 @@ public class ChatbotConversationService { } private Prompt buildPrompt( + ChatbotSettings settings, String model, String question, ChatbotSession session, @@ -215,28 +240,46 @@ public class ChatbotConversationService { .reduce((left, right) -> left + ", " + right) .orElse("none"); + String imageDirective = + session.isImageContentDetected() + ? "Images were detected in this PDF. You must explain that image analysis is not available." + : "No images detected in this PDF."; + String systemPrompt = "You are Stirling PDF Bot. Use provided context strictly. " + "Respond in compact JSON with fields answer (string), confidence (0..1), requiresEscalation (boolean), rationale (string). " - + "Explain limitations when context insufficient."; + + "Explain limitations when context insufficient. Always note that image analysis is not supported yet."; String userPrompt = "Document metadata: " + metadataSummary + "\nOCR applied: " + session.isOcrRequested() + + "\n" + + imageDirective + "\nContext:\n" + contextBuilder + "Question: " + question; - OpenAiChatOptions options = - OpenAiChatOptions.builder().model(model).temperature(0.2).build(); + Object options = buildChatOptions(settings, model); return new Prompt( List.of(new SystemMessage(systemPrompt), new UserMessage(userPrompt)), options); } + private Object buildChatOptions(ChatbotSettings settings, String model) { + return switch (settings.models().provider()) { + case OPENAI -> + OpenAiChatOptions.builder() + .model(model) + .temperature(0.2) + .responseFormat("json_object") + .build(); + case OLLAMA -> OllamaOptions.builder().model(model).temperature(0.2).build(); + }; + } + private ModelReply parseModelResponse(String raw) { if (!StringUtils.hasText(raw)) { throw new ChatbotException("Model returned empty response"); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotFeatureProperties.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotFeatureProperties.java index 653254822..e3a00274a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotFeatureProperties.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotFeatureProperties.java @@ -3,6 +3,7 @@ package stirling.software.proprietary.service.chatbot; import java.util.Optional; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.ApplicationProperties.Premium; @@ -20,15 +21,18 @@ public class ChatbotFeatureProperties { public ChatbotSettings current() { Chatbot chatbot = resolveChatbot(); + ChatbotSettings.ModelSettings modelSettings = + new ChatbotSettings.ModelSettings( + resolveProvider(chatbot.getModels().getProvider()), + chatbot.getModels().getPrimary(), + chatbot.getModels().getFallback(), + chatbot.getModels().getEmbedding()); return new ChatbotSettings( chatbot.isEnabled(), chatbot.isAlphaWarning(), chatbot.getMaxPromptCharacters(), chatbot.getMinConfidenceNano(), - new ChatbotSettings.ModelSettings( - chatbot.getModels().getPrimary(), - chatbot.getModels().getFallback(), - chatbot.getModels().getEmbedding()), + modelSettings, new ChatbotSettings.RagSettings( chatbot.getRag().getChunkSizeTokens(), chatbot.getRag().getChunkOverlapTokens(), @@ -53,6 +57,17 @@ public class ChatbotFeatureProperties { .orElseGet(Chatbot::new); } + private ChatbotSettings.ModelProvider resolveProvider(String configuredProvider) { + if (!StringUtils.hasText(configuredProvider)) { + return ChatbotSettings.ModelProvider.OPENAI; + } + try { + return ChatbotSettings.ModelProvider.valueOf(configuredProvider.trim().toUpperCase()); + } catch (IllegalArgumentException ignored) { + return ChatbotSettings.ModelProvider.OPENAI; + } + } + public record ChatbotSettings( boolean enabled, boolean alphaWarning, @@ -64,7 +79,8 @@ public class ChatbotFeatureProperties { OcrSettings ocr, AuditSettings audit) { - public record ModelSettings(String primary, String fallback, String embedding) {} + public record ModelSettings( + ModelProvider provider, String primary, String fallback, String embedding) {} public record RagSettings(int chunkSizeTokens, int chunkOverlapTokens, int topK) {} @@ -73,5 +89,10 @@ public class ChatbotFeatureProperties { public record OcrSettings(boolean enabledByDefault) {} public record AuditSettings(boolean enabled) {} + + public enum ModelProvider { + OPENAI, + OLLAMA + } } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotIngestionService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotIngestionService.java index 63bd24587..1c0c2f8d7 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotIngestionService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotIngestionService.java @@ -2,6 +2,7 @@ package stirling.software.proprietary.service.chatbot; import java.time.Instant; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; @@ -43,25 +44,38 @@ public class ChatbotIngestionService { if (!request.isWarningsAccepted() && settings.alphaWarning()) { throw new ChatbotException("Alpha warning must be accepted before use"); } - if (!StringUtils.hasText(request.getText())) { - throw new NoTextDetectedException("No text detected in document payload"); + boolean hasText = StringUtils.hasText(request.getText()); + if (!hasText) { + throw new NoTextDetectedException( + "No text detected in document payload. Images are currently unsupported – enable OCR to continue."); } String sessionId = StringUtils.hasText(request.getSessionId()) ? request.getSessionId() : ChatbotSession.randomSessionId(); - Map metadata = - request.getMetadata() == null ? Map.of() : Map.copyOf(request.getMetadata()); + boolean imagesDetected = request.isImagesDetected(); + long textCharacters = request.getText().length(); boolean ocrApplied = request.isOcrRequested(); + Map metadata = new HashMap<>(); + if (request.getMetadata() != null) { + metadata.putAll(request.getMetadata()); + } + metadata.put("content.imagesDetected", Boolean.toString(imagesDetected)); + metadata.put("content.characterCount", String.valueOf(textCharacters)); + metadata.put( + "content.extractionSource", ocrApplied ? "ocr-text-layer" : "embedded-text-layer"); + Map immutableMetadata = Map.copyOf(metadata); String cacheKey = cacheService.register( sessionId, request.getDocumentId(), request.getText(), - metadata, - ocrApplied); + immutableMetadata, + ocrApplied, + imagesDetected, + textCharacters); List chunkTexts = chunkText( @@ -76,8 +90,10 @@ public class ChatbotIngestionService { .sessionId(sessionId) .documentId(request.getDocumentId()) .userId(request.getUserId()) - .metadata(metadata) + .metadata(immutableMetadata) .ocrRequested(ocrApplied) + .imageContentDetected(imagesDetected) + .textCharacters(textCharacters) .warningsAccepted(request.isWarningsAccepted()) .alphaWarningRequired(settings.alphaWarning()) .cacheKey(cacheKey) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotService.java index 1a2205037..27c0762eb 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotService.java @@ -39,7 +39,9 @@ public class ChatbotService { session.getSessionId(), Map.of( "documentId", session.getDocumentId(), - "ocrRequested", session.isOcrRequested())); + "ocrRequested", session.isOcrRequested(), + "imagesDetected", session.isImageContentDetected(), + "textCharacters", session.getTextCharacters())); return session; } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/chatbot/ChatbotCacheServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/chatbot/ChatbotCacheServiceTest.java index bd1f8a7fc..dceb46f50 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/service/chatbot/ChatbotCacheServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/chatbot/ChatbotCacheServiceTest.java @@ -40,7 +40,15 @@ class ChatbotCacheServiceTest { String longText = "a".repeat(51); assertThrows( ChatbotException.class, - () -> cacheService.register("session", "doc", longText, Map.of(), false)); + () -> + cacheService.register( + "session", + "doc", + longText, + Map.of(), + false, + false, + longText.length())); } @Test @@ -48,10 +56,18 @@ class ChatbotCacheServiceTest { ChatbotCacheService cacheService = new ChatbotCacheService(properties); String cacheKey = cacheService.register( - "session1", "doc1", "hello world", Map.of("title", "Sample"), false); + "session1", + "doc1", + "hello world", + Map.of("title", "Sample"), + false, + false, + "hello world".length()); assertTrue(cacheService.resolveBySessionId("session1").isPresent()); ChatbotDocumentCacheEntry entry = cacheService.resolveByCacheKey(cacheKey).orElseThrow(); assertEquals("doc1", entry.getDocumentId()); assertEquals("Sample", entry.getMetadata().get("title")); + assertEquals("hello world".length(), entry.getTextCharacters()); + assertTrue(!entry.isImageContentDetected()); } }