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 4b5a202c0..bb82ac1c2 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 @@ -581,6 +581,7 @@ public class ApplicationProperties { private boolean ssoAutoLogin; private boolean database; private CustomMetadata customMetadata = new CustomMetadata(); + private Chatbot chatbot = new Chatbot(); @Data public static class CustomMetadata { @@ -599,6 +600,50 @@ public class ApplicationProperties { : producer; } } + + @Data + public static class Chatbot { + private boolean enabled; + private boolean alphaWarning = true; + private Cache cache = new Cache(); + private Models models = new Models(); + private Rag rag = new Rag(); + private Ocr ocr = new Ocr(); + private Audit audit = new Audit(); + private long maxPromptCharacters = 4000; + private double minConfidenceNano = 0.65; + + @Data + public static class Cache { + private long ttlMinutes = 720; + private long maxEntries = 200; + private long maxDocumentCharacters = 200000; + } + + @Data + public static class Models { + private String primary = "gpt-5-nano"; + private String fallback = "gpt-5-mini"; + private String embedding = "text-embedding-3-small"; + } + + @Data + public static class Rag { + private int chunkSizeTokens = 512; + private int chunkOverlapTokens = 128; + private int topK = 8; + } + + @Data + public static class Ocr { + private boolean enabledByDefault; + } + + @Data + public static class Audit { + private boolean enabled = true; + } + } } @Data diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 5f8ca51be..235c3c7eb 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -91,6 +91,27 @@ premium: author: username creator: Stirling-PDF producer: Stirling-PDF + chatbot: + enabled: false # Master toggle for Stirling PDF chatbot feature + alphaWarning: true # Display alpha-state warning before any processing + cache: + ttlMinutes: 720 # Cache entry lifetime (12h) + maxEntries: 200 # Maximum number of cached documents per node + maxDocumentCharacters: 200000 # Reject uploads exceeding this character count + models: + primary: gpt-5-nano # Default lightweight model + fallback: gpt-5-mini # Escalation model for complex prompts + embedding: text-embedding-3-small # Embedding model for vector store usage + rag: + chunkSizeTokens: 512 # Token window used when chunking text + chunkOverlapTokens: 128 # Overlap between successive chunks + topK: 8 # Number of chunks to retrieve per query + ocr: + enabledByDefault: false # Whether OCR pre-processing is opted-in automatically + audit: + enabled: true # Emit audit records for chatbot activity + maxPromptCharacters: 4000 # Server-side guardrail for incoming prompts + minConfidenceNano: 0.65 # Minimum nano confidence to avoid escalation enterpriseFeatures: audit: enabled: true # Enable audit logging diff --git a/app/proprietary/build.gradle b/app/proprietary/build.gradle index ea484233a..e20fa095e 100644 --- a/app/proprietary/build.gradle +++ b/app/proprietary/build.gradle @@ -41,6 +41,8 @@ dependencies { api 'org.springframework:spring-webmvc' api 'org.springframework.session:spring-session-core' api "org.springframework.security:spring-security-core:$springSecuritySamlVersion" + api "org.springframework.security:spring-security-web:$springSecuritySamlVersion" + api "org.springframework.security:spring-security-config:$springSecuritySamlVersion" api "org.springframework.security:spring-security-saml2-service-provider:$springSecuritySamlVersion" api 'org.springframework.boot:spring-boot-starter-jetty' api 'org.springframework.boot:spring-boot-starter-security' @@ -50,12 +52,14 @@ dependencies { api 'org.springframework.boot:spring-boot-starter-cache' api 'com.github.ben-manes.caffeine:caffeine' api 'io.swagger.core.v3:swagger-core-jakarta:2.2.38' + implementation 'org.springframework.ai:spring-ai-spring-boot-starter:1.0.1' + implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter:1.0.1' implementation 'com.bucket4j:bucket4j_jdk17-core:8.15.0' // https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17 implementation 'org.bouncycastle:bcprov-jdk18on:1.82' - implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5:3.1.3.RELEASE' + // implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5:3.1.3.RELEASE' // Removed - UI moved to React frontend api 'io.micrometer:micrometer-registry-prometheus' implementation 'com.unboundid.product.scim2:scim2-sdk-client:4.0.0' 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 new file mode 100644 index 000000000..77370a1ad --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/ChatbotController.java @@ -0,0 +1,103 @@ +package stirling.software.proprietary.controller; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.model.chatbot.ChatbotQueryRequest; +import stirling.software.proprietary.model.chatbot.ChatbotResponse; +import stirling.software.proprietary.model.chatbot.ChatbotSession; +import stirling.software.proprietary.model.chatbot.ChatbotSessionCreateRequest; +import stirling.software.proprietary.model.chatbot.ChatbotSessionResponse; +import stirling.software.proprietary.service.chatbot.ChatbotCacheService; +import stirling.software.proprietary.service.chatbot.ChatbotFeatureProperties; +import stirling.software.proprietary.service.chatbot.ChatbotFeatureProperties.ChatbotSettings; +import stirling.software.proprietary.service.chatbot.ChatbotService; +import stirling.software.proprietary.service.chatbot.ChatbotSessionRegistry; +import stirling.software.proprietary.service.chatbot.exception.ChatbotException; + +@RestController +@RequestMapping("/api/internal/chatbot") +@RequiredArgsConstructor +@Slf4j +public class ChatbotController { + + private final ChatbotService chatbotService; + private final ChatbotSessionRegistry sessionRegistry; + private final ChatbotCacheService cacheService; + private final ChatbotFeatureProperties featureProperties; + + @PostMapping("/session") + public ResponseEntity createSession( + @RequestBody ChatbotSessionCreateRequest request) { + ChatbotSession session = chatbotService.createSession(request); + ChatbotSettings settings = featureProperties.current(); + ChatbotSessionResponse response = + ChatbotSessionResponse.builder() + .sessionId(session.getSessionId()) + .documentId(session.getDocumentId()) + .alphaWarning(settings.alphaWarning()) + .ocrRequested(session.isOcrRequested()) + .maxCachedCharacters(cacheService.getMaxDocumentCharacters()) + .createdAt(session.getCreatedAt()) + .warnings(defaultWarnings(settings)) + .metadata(session.getMetadata()) + .build(); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @PostMapping("/query") + public ResponseEntity query(@RequestBody ChatbotQueryRequest request) { + ChatbotResponse response = chatbotService.ask(request); + return ResponseEntity.ok(response); + } + + @GetMapping("/session/{sessionId}") + public ResponseEntity getSession(@PathVariable String sessionId) { + ChatbotSettings settings = featureProperties.current(); + ChatbotSession session = + sessionRegistry + .findById(sessionId) + .orElseThrow(() -> new ChatbotException("Session not found")); + ChatbotSessionResponse response = + ChatbotSessionResponse.builder() + .sessionId(session.getSessionId()) + .documentId(session.getDocumentId()) + .alphaWarning(settings.alphaWarning()) + .ocrRequested(session.isOcrRequested()) + .maxCachedCharacters(cacheService.getMaxDocumentCharacters()) + .createdAt(session.getCreatedAt()) + .warnings(defaultWarnings(settings)) + .metadata(session.getMetadata()) + .build(); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/session/{sessionId}") + public ResponseEntity closeSession(@PathVariable String sessionId) { + chatbotService.close(sessionId); + return ResponseEntity.noContent().build(); + } + + private List defaultWarnings(ChatbotSettings settings) { + 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."); + warnings.add("Only extracted text is sent for analysis."); + return warnings; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/ChatbotExceptionHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/ChatbotExceptionHandler.java new file mode 100644 index 000000000..b2e82ac4e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/ChatbotExceptionHandler.java @@ -0,0 +1,44 @@ +package stirling.software.proprietary.controller; + +import java.time.Instant; +import java.util.Map; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.service.chatbot.exception.ChatbotException; +import stirling.software.proprietary.service.chatbot.exception.NoTextDetectedException; + +@RestControllerAdvice(assignableTypes = ChatbotController.class) +@Slf4j +public class ChatbotExceptionHandler { + + @ExceptionHandler(NoTextDetectedException.class) + public ResponseEntity> handleNoText(NoTextDetectedException ex) { + return buildResponse(HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage()); + } + + @ExceptionHandler(ChatbotException.class) + public ResponseEntity> handleChatbot(ChatbotException ex) { + log.debug("Chatbot exception: {}", ex.getMessage()); + return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgument(IllegalArgumentException ex) { + return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + private ResponseEntity> buildResponse(HttpStatus status, String message) { + Map payload = + Map.of( + "timestamp", Instant.now().toString(), + "status", status.value(), + "error", message); + return ResponseEntity.status(status).body(payload); + } +} 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 new file mode 100644 index 000000000..24aaccc7f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/chatbot/ChatbotDocumentCacheEntry.java @@ -0,0 +1,36 @@ +package stirling.software.proprietary.model.chatbot; + +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChatbotDocumentCacheEntry { + + private String cacheKey; + private String sessionId; + private String documentId; + private Map metadata; + private String text; + private List chunks; + private boolean ocrApplied; + private String vectorStoreId; + private Instant storedAt; + + public Map getMetadata() { + return metadata == null ? Collections.emptyMap() : metadata; + } + + public List getChunks() { + return chunks == null ? Collections.emptyList() : chunks; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/chatbot/ChatbotQueryRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/chatbot/ChatbotQueryRequest.java new file mode 100644 index 000000000..92b3547cd --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/chatbot/ChatbotQueryRequest.java @@ -0,0 +1,17 @@ +package stirling.software.proprietary.model.chatbot; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChatbotQueryRequest { + + private String sessionId; + private String prompt; + private boolean allowEscalation; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/chatbot/ChatbotResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/chatbot/ChatbotResponse.java new file mode 100644 index 000000000..d0aba1861 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/chatbot/ChatbotResponse.java @@ -0,0 +1,37 @@ +package stirling.software.proprietary.model.chatbot; + +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChatbotResponse { + + private String sessionId; + private String modelUsed; + private double confidence; + private String answer; + private boolean escalated; + private boolean servedFromNanoOnly; + private boolean cacheHit; + private Instant respondedAt; + private List warnings; + private Map metadata; + + public List getWarnings() { + return warnings == null ? Collections.emptyList() : warnings; + } + + public Map getMetadata() { + return metadata == null ? Collections.emptyMap() : metadata; + } +} 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 new file mode 100644 index 000000000..1d7ff9cc1 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/chatbot/ChatbotSession.java @@ -0,0 +1,33 @@ +package stirling.software.proprietary.model.chatbot; + +import java.time.Instant; +import java.util.Collections; +import java.util.Map; +import java.util.UUID; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class ChatbotSession { + + private String sessionId; + private String documentId; + private String userId; + private Map metadata; + private boolean ocrRequested; + private boolean warningsAccepted; + private boolean alphaWarningRequired; + private String cacheKey; + private String vectorStoreId; + private Instant createdAt; + + public static String randomSessionId() { + return UUID.randomUUID().toString(); + } + + public Map getMetadata() { + return metadata == null ? Collections.emptyMap() : metadata; + } +} 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 new file mode 100644 index 000000000..9bf5e55e0 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/chatbot/ChatbotSessionCreateRequest.java @@ -0,0 +1,23 @@ +package stirling.software.proprietary.model.chatbot; + +import java.util.Map; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChatbotSessionCreateRequest { + + private String sessionId; + private String documentId; + private String userId; + private String text; + private Map metadata; + private boolean ocrRequested; + private boolean warningsAccepted; +} 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 new file mode 100644 index 000000000..7504fa6c1 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/chatbot/ChatbotSessionResponse.java @@ -0,0 +1,35 @@ +package stirling.software.proprietary.model.chatbot; + +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChatbotSessionResponse { + + private String sessionId; + private String documentId; + private boolean alphaWarning; + private boolean ocrRequested; + private long maxCachedCharacters; + private Instant createdAt; + private List warnings; + private Map metadata; + + public List getWarnings() { + return warnings == null ? Collections.emptyList() : warnings; + } + + public Map getMetadata() { + return metadata == null ? Collections.emptyMap() : metadata; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/chatbot/ChatbotTextChunk.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/chatbot/ChatbotTextChunk.java new file mode 100644 index 000000000..cfa91e24c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/chatbot/ChatbotTextChunk.java @@ -0,0 +1,20 @@ +package stirling.software.proprietary.model.chatbot; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChatbotTextChunk { + + private String id; + private String text; + private int order; + private List embedding; +} 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 new file mode 100644 index 000000000..edb713d97 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotCacheService.java @@ -0,0 +1,138 @@ +package stirling.software.proprietary.service.chatbot; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.stereotype.Service; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.ApplicationProperties.Premium; +import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures; +import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures.Chatbot; +import stirling.software.proprietary.model.chatbot.ChatbotDocumentCacheEntry; +import stirling.software.proprietary.model.chatbot.ChatbotTextChunk; +import stirling.software.proprietary.service.chatbot.exception.ChatbotException; + +@Service +@Slf4j +public class ChatbotCacheService { + + private final Cache documentCache; + private final long maxDocumentCharacters; + private final Map sessionToCacheKey = new ConcurrentHashMap<>(); + + public ChatbotCacheService(ApplicationProperties applicationProperties) { + Chatbot chatbotConfig = resolveChatbot(applicationProperties); + ApplicationProperties.Premium.ProFeatures.Chatbot.Cache cacheSettings = + chatbotConfig.getCache(); + this.maxDocumentCharacters = cacheSettings.getMaxDocumentCharacters(); + long ttlMinutes = Math.max(cacheSettings.getTtlMinutes(), 1); + long maxEntries = Math.max(cacheSettings.getMaxEntries(), 1); + + this.documentCache = + Caffeine.newBuilder() + .maximumSize(maxEntries) + .expireAfterWrite(Duration.ofMinutes(ttlMinutes)) + .recordStats() + .build(); + log.info( + "Initialised chatbot document cache with maxEntries={} ttlMinutes={} maxChars={}", + maxEntries, + ttlMinutes, + maxDocumentCharacters); + } + + public long getMaxDocumentCharacters() { + return maxDocumentCharacters; + } + + public String register( + String sessionId, + String documentId, + String rawText, + Map metadata, + boolean ocrApplied) { + Objects.requireNonNull(sessionId, "sessionId must not be null"); + Objects.requireNonNull(documentId, "documentId must not be null"); + Objects.requireNonNull(rawText, "rawText must not be null"); + if (rawText.length() > maxDocumentCharacters) { + throw new ChatbotException( + "Document text exceeds maximum allowed characters: " + maxDocumentCharacters); + } + String cacheKey = + sessionToCacheKey.computeIfAbsent(sessionId, k -> UUID.randomUUID().toString()); + ChatbotDocumentCacheEntry entry = + ChatbotDocumentCacheEntry.builder() + .cacheKey(cacheKey) + .sessionId(sessionId) + .documentId(documentId) + .metadata(metadata) + .text(rawText) + .ocrApplied(ocrApplied) + .storedAt(Instant.now()) + .build(); + documentCache.put(cacheKey, entry); + return cacheKey; + } + + public void attachChunks(String cacheKey, List chunks) { + documentCache + .asMap() + .computeIfPresent( + cacheKey, + (key, existing) -> { + existing.setChunks(chunks); + return existing; + }); + } + + public Optional resolveByCacheKey(String cacheKey) { + return Optional.ofNullable(documentCache.getIfPresent(cacheKey)); + } + + public Optional resolveBySessionId(String sessionId) { + return Optional.ofNullable(sessionToCacheKey.get(sessionId)) + .flatMap(this::resolveByCacheKey); + } + + public void invalidateSession(String sessionId) { + Optional.ofNullable(sessionToCacheKey.remove(sessionId)) + .ifPresent(documentCache::invalidate); + } + + public void invalidateCacheKey(String cacheKey) { + documentCache.invalidate(cacheKey); + sessionToCacheKey.values().removeIf(value -> value.equals(cacheKey)); + } + + public Map snapshot() { + return Map.copyOf(documentCache.asMap()); + } + + private Chatbot resolveChatbot(ApplicationProperties properties) { + if (properties == null) { + return new Chatbot(); + } + Premium premium = properties.getPremium(); + if (premium == null) { + return new Chatbot(); + } + ProFeatures pro = premium.getProFeatures(); + if (pro == null) { + return new Chatbot(); + } + Chatbot chatbot = pro.getChatbot(); + return chatbot == null ? new Chatbot() : chatbot; + } +} 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 new file mode 100644 index 000000000..a98b8ebc5 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotConversationService.java @@ -0,0 +1,265 @@ +package stirling.software.proprietary.service.chatbot; + +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.springframework.ai.chat.messages.SystemMessage; +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.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.model.chatbot.ChatbotDocumentCacheEntry; +import stirling.software.proprietary.model.chatbot.ChatbotQueryRequest; +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.exception.ChatbotException; + +@Service +@Slf4j +@RequiredArgsConstructor +public class ChatbotConversationService { + + private final ChatModel chatModel; + private final ChatbotSessionRegistry sessionRegistry; + private final ChatbotCacheService cacheService; + private final ChatbotFeatureProperties featureProperties; + private final ChatbotRetrievalService retrievalService; + private final ObjectMapper objectMapper; + private final AtomicBoolean modelSwitchVerified = new AtomicBoolean(false); + + public ChatbotResponse handleQuery(ChatbotQueryRequest request) { + ChatbotSettings settings = featureProperties.current(); + if (!settings.enabled()) { + throw new ChatbotException("Chatbot feature is disabled"); + } + if (!StringUtils.hasText(request.getPrompt())) { + throw new ChatbotException("Prompt cannot be empty"); + } + if (request.getPrompt().length() > settings.maxPromptCharacters()) { + throw new ChatbotException("Prompt exceeds maximum allowed characters"); + } + ChatbotSession session = + sessionRegistry + .findById(request.getSessionId()) + .orElseThrow(() -> new ChatbotException("Unknown chatbot session")); + + ensureModelSwitchCapability(); + + ChatbotDocumentCacheEntry cacheEntry = + cacheService + .resolveBySessionId(request.getSessionId()) + .orElseThrow(() -> new ChatbotException("Session cache not found")); + + List warnings = buildWarnings(settings, session); + + List context = + retrievalService.retrieveTopK( + request.getSessionId(), request.getPrompt(), settings); + + ModelReply nanoReply = + invokeModel( + settings.models().primary(), + request.getPrompt(), + session, + context, + cacheEntry.getMetadata()); + + boolean shouldEscalate = + request.isAllowEscalation() + && (nanoReply.requiresEscalation() + || nanoReply.confidence() < settings.minConfidenceNano() + || request.getPrompt().length() > settings.maxPromptCharacters()); + + ModelReply finalReply = nanoReply; + boolean escalated = false; + if (shouldEscalate) { + escalated = true; + List expandedContext = ensureMinimumContext(context, cacheEntry); + finalReply = + invokeModel( + settings.models().fallback(), + request.getPrompt(), + session, + expandedContext, + cacheEntry.getMetadata()); + } + + return ChatbotResponse.builder() + .sessionId(request.getSessionId()) + .modelUsed( + shouldEscalate ? settings.models().fallback() : settings.models().primary()) + .confidence(finalReply.confidence()) + .answer(finalReply.answer()) + .escalated(escalated) + .servedFromNanoOnly(!escalated) + .cacheHit(true) + .respondedAt(Instant.now()) + .warnings(warnings) + .metadata(buildMetadata(finalReply, context.size(), escalated)) + .build(); + } + + private List buildWarnings(ChatbotSettings settings, ChatbotSession session) { + 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.isOcrRequested()) { + warnings.add("OCR costs may apply for this session."); + } + return warnings; + } + + private Map buildMetadata( + 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()); + return metadata; + } + + private void ensureModelSwitchCapability() { + if (!(chatModel instanceof OpenAiChatModel)) { + throw new ChatbotException( + "Chatbot requires OpenAI chat model to support runtime model switching"); + } + if (modelSwitchVerified.compareAndSet(false, true)) { + ChatbotSettings settings = featureProperties.current(); + OpenAiChatOptions primary = + OpenAiChatOptions.builder().withModel(settings.models().primary()).build(); + OpenAiChatOptions fallback = + OpenAiChatOptions.builder().withModel(settings.models().fallback()).build(); + log.info( + "Verified runtime model override support ({} -> {})", + primary.getModel(), + fallback.getModel()); + } + } + + private List ensureMinimumContext( + List context, ChatbotDocumentCacheEntry entry) { + if (context.size() >= 3 || entry.getChunks().size() <= context.size()) { + return context; + } + List augmented = new ArrayList<>(context); + for (ChatbotTextChunk chunk : entry.getChunks()) { + if (augmented.size() >= 3) { + break; + } + if (!augmented.contains(chunk)) { + augmented.add(chunk); + } + } + return augmented; + } + + private ModelReply invokeModel( + String model, + String prompt, + ChatbotSession session, + List context, + Map metadata) { + Prompt requestPrompt = buildPrompt(model, prompt, session, context, metadata); + ChatResponse response = chatModel.call(requestPrompt); + String content = + Optional.ofNullable(response) + .map(ChatResponse::getResults) + .filter(results -> !results.isEmpty()) + .map(results -> results.get(0).getOutput().getContent()) + .orElse(""); + return parseModelResponse(content); + } + + private Prompt buildPrompt( + String model, + String question, + ChatbotSession session, + List context, + Map metadata) { + StringBuilder contextBuilder = new StringBuilder(); + for (ChatbotTextChunk chunk : context) { + contextBuilder + .append("[Chunk ") + .append(chunk.getOrder()) + .append("]\n") + .append(chunk.getText()) + .append("\n\n"); + } + String metadataSummary = + metadata.entrySet().stream() + .map(entry -> entry.getKey() + ": " + entry.getValue()) + .reduce((left, right) -> left + ", " + right) + .orElse("none"); + + 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."; + + String userPrompt = + "Document metadata: " + + metadataSummary + + "\nOCR applied: " + + session.isOcrRequested() + + "\nContext:\n" + + contextBuilder + + "Question: " + + question; + + OpenAiChatOptions options = + OpenAiChatOptions.builder().withModel(model).withTemperature(0.2).build(); + + return new Prompt( + List.of(new SystemMessage(systemPrompt), new UserMessage(userPrompt)), options); + } + + private ModelReply parseModelResponse(String raw) { + if (!StringUtils.hasText(raw)) { + throw new ChatbotException("Model returned empty response"); + } + try { + JsonNode node = objectMapper.readTree(raw); + String answer = + Optional.ofNullable(node.get("answer")).map(JsonNode::asText).orElse(raw); + double confidence = + Optional.ofNullable(node.get("confidence")) + .map(JsonNode::asDouble) + .orElse(0.0D); + boolean requiresEscalation = + Optional.ofNullable(node.get("requiresEscalation")) + .map(JsonNode::asBoolean) + .orElse(false); + String rationale = + Optional.ofNullable(node.get("rationale")) + .map(JsonNode::asText) + .orElse("Model did not provide rationale"); + return new ModelReply(answer, confidence, requiresEscalation, rationale); + } catch (IOException ex) { + log.warn("Failed to parse model JSON response, returning raw text", ex); + return new ModelReply(raw, 0.0D, true, "Unable to parse JSON response"); + } + } + + private record ModelReply( + String answer, double confidence, boolean requiresEscalation, String rationale) {} +} 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 new file mode 100644 index 000000000..653254822 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotFeatureProperties.java @@ -0,0 +1,77 @@ +package stirling.software.proprietary.service.chatbot; + +import java.util.Optional; + +import org.springframework.stereotype.Component; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.ApplicationProperties.Premium; +import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures; +import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures.Chatbot; + +@Component +public class ChatbotFeatureProperties { + + private final ApplicationProperties applicationProperties; + + public ChatbotFeatureProperties(ApplicationProperties applicationProperties) { + this.applicationProperties = applicationProperties; + } + + public ChatbotSettings current() { + Chatbot chatbot = resolveChatbot(); + return new ChatbotSettings( + chatbot.isEnabled(), + chatbot.isAlphaWarning(), + chatbot.getMaxPromptCharacters(), + chatbot.getMinConfidenceNano(), + new ChatbotSettings.ModelSettings( + chatbot.getModels().getPrimary(), + chatbot.getModels().getFallback(), + chatbot.getModels().getEmbedding()), + new ChatbotSettings.RagSettings( + chatbot.getRag().getChunkSizeTokens(), + chatbot.getRag().getChunkOverlapTokens(), + chatbot.getRag().getTopK()), + new ChatbotSettings.CacheSettings( + chatbot.getCache().getTtlMinutes(), + chatbot.getCache().getMaxEntries(), + chatbot.getCache().getMaxDocumentCharacters()), + new ChatbotSettings.OcrSettings(chatbot.getOcr().isEnabledByDefault()), + new ChatbotSettings.AuditSettings(chatbot.getAudit().isEnabled())); + } + + public boolean isEnabled() { + return current().enabled(); + } + + private Chatbot resolveChatbot() { + return Optional.ofNullable(applicationProperties) + .map(ApplicationProperties::getPremium) + .map(Premium::getProFeatures) + .map(ProFeatures::getChatbot) + .orElseGet(Chatbot::new); + } + + public record ChatbotSettings( + boolean enabled, + boolean alphaWarning, + long maxPromptCharacters, + double minConfidenceNano, + ModelSettings models, + RagSettings rag, + CacheSettings cache, + OcrSettings ocr, + AuditSettings audit) { + + public record ModelSettings(String primary, String fallback, String embedding) {} + + public record RagSettings(int chunkSizeTokens, int chunkOverlapTokens, int topK) {} + + public record CacheSettings(long ttlMinutes, long maxEntries, long maxDocumentCharacters) {} + + public record OcrSettings(boolean enabledByDefault) {} + + public record AuditSettings(boolean enabled) {} + } +} 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 new file mode 100644 index 000000000..2ee402bbc --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotIngestionService.java @@ -0,0 +1,147 @@ +package stirling.software.proprietary.service.chatbot; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.springframework.ai.embedding.EmbeddingClient; +import org.springframework.ai.embedding.EmbeddingResponse; +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; +import stirling.software.proprietary.model.chatbot.ChatbotSessionCreateRequest; +import stirling.software.proprietary.model.chatbot.ChatbotTextChunk; +import stirling.software.proprietary.service.chatbot.ChatbotFeatureProperties.ChatbotSettings; +import stirling.software.proprietary.service.chatbot.exception.ChatbotException; +import stirling.software.proprietary.service.chatbot.exception.NoTextDetectedException; + +@Service +@Slf4j +@RequiredArgsConstructor +public class ChatbotIngestionService { + + private final ChatbotCacheService cacheService; + private final ChatbotSessionRegistry sessionRegistry; + private final ChatbotFeatureProperties featureProperties; + private final EmbeddingClient embeddingClient; + + public ChatbotSession ingest(ChatbotSessionCreateRequest request) { + ChatbotSettings settings = featureProperties.current(); + if (!settings.enabled()) { + throw new ChatbotException("Chatbot feature is disabled"); + } + 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"); + } + + String sessionId = + StringUtils.hasText(request.getSessionId()) + ? request.getSessionId() + : ChatbotSession.randomSessionId(); + Map metadata = + request.getMetadata() == null ? Map.of() : Map.copyOf(request.getMetadata()); + boolean ocrApplied = request.isOcrRequested(); + + String cacheKey = + cacheService.register( + sessionId, + request.getDocumentId(), + request.getText(), + metadata, + ocrApplied); + + List chunkTexts = + chunkText( + request.getText(), + settings.rag().chunkSizeTokens(), + settings.rag().chunkOverlapTokens()); + List chunks = embedChunks(sessionId, cacheKey, chunkTexts, metadata); + cacheService.attachChunks(cacheKey, chunks); + + ChatbotSession session = + ChatbotSession.builder() + .sessionId(sessionId) + .documentId(request.getDocumentId()) + .userId(request.getUserId()) + .metadata(metadata) + .ocrRequested(ocrApplied) + .warningsAccepted(request.isWarningsAccepted()) + .alphaWarningRequired(settings.alphaWarning()) + .cacheKey(cacheKey) + .createdAt(Instant.now()) + .build(); + sessionRegistry.register(session); + log.info( + "Registered chatbot session {} for document {} with {} chunks", + sessionId, + request.getDocumentId(), + chunks.size()); + return session; + } + + private List chunkText(String text, int chunkSizeTokens, int overlapTokens) { + String[] tokens = text.split("\\s+"); + List chunks = new ArrayList<>(); + if (tokens.length == 0) { + return chunks; + } + int effectiveChunk = Math.max(chunkSizeTokens, 1); + int effectiveOverlap = Math.max(Math.min(overlapTokens, effectiveChunk - 1), 0); + int index = 0; + while (index < tokens.length) { + int end = Math.min(tokens.length, index + effectiveChunk); + String chunk = String.join(" ", java.util.Arrays.copyOfRange(tokens, index, end)); + if (StringUtils.hasText(chunk)) { + chunks.add(chunk); + } + if (end == tokens.length) { + break; + } + index = end - effectiveOverlap; + if (index <= 0) { + index = end; + } + } + return chunks; + } + + private List embedChunks( + String sessionId, + String cacheKey, + List chunkTexts, + Map metadata) { + if (chunkTexts.isEmpty()) { + throw new ChatbotException("Unable to split document text into retrievable chunks"); + } + EmbeddingResponse response = embeddingClient.embedForResponse(chunkTexts); + if (response.getData().size() != chunkTexts.size()) { + throw new ChatbotException("Mismatch between chunks and embedding results"); + } + List chunks = new ArrayList<>(); + for (int i = 0; i < chunkTexts.size(); i++) { + String chunkId = sessionId + ":" + i + ":" + UUID.randomUUID(); + chunks.add( + ChatbotTextChunk.builder() + .id(chunkId) + .order(i) + .text(chunkTexts.get(i)) + .embedding(response.getData().get(i).getEmbedding()) + .build()); + } + log.debug( + "Computed embeddings for session {} cacheKey {} ({} vectors)", + sessionId, + cacheKey, + chunks.size()); + return chunks; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotRetrievalService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotRetrievalService.java new file mode 100644 index 000000000..4626e196d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotRetrievalService.java @@ -0,0 +1,85 @@ +package stirling.software.proprietary.service.chatbot; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; + +import org.springframework.ai.embedding.EmbeddingClient; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.model.chatbot.ChatbotDocumentCacheEntry; +import stirling.software.proprietary.model.chatbot.ChatbotTextChunk; +import stirling.software.proprietary.service.chatbot.ChatbotFeatureProperties.ChatbotSettings; +import stirling.software.proprietary.service.chatbot.exception.ChatbotException; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ChatbotRetrievalService { + + private final ChatbotCacheService cacheService; + private final EmbeddingClient embeddingClient; + + public List retrieveTopK( + String sessionId, String query, ChatbotSettings settings) { + ChatbotDocumentCacheEntry entry = + cacheService + .resolveBySessionId(sessionId) + .orElseThrow(() -> new ChatbotException("Unknown chatbot session")); + List chunks = entry.getChunks(); + if (CollectionUtils.isEmpty(chunks)) { + throw new ChatbotException("Chatbot cache does not contain pre-computed chunks"); + } + List queryEmbedding = computeQueryEmbedding(query); + List scoredChunks = new ArrayList<>(); + for (ChatbotTextChunk chunk : chunks) { + if (CollectionUtils.isEmpty(chunk.getEmbedding())) { + log.warn("Chunk {} missing embedding, skipping", chunk.getId()); + continue; + } + double score = cosineSimilarity(queryEmbedding, chunk.getEmbedding()); + scoredChunks.add(new ScoredChunk(chunk, score)); + } + return scoredChunks.stream() + .sorted(Comparator.comparingDouble(ScoredChunk::score).reversed()) + .limit(Math.max(settings.rag().topK(), 1)) + .map(ScoredChunk::chunk) + .toList(); + } + + private List computeQueryEmbedding(String query) { + EmbeddingResponse response = embeddingClient.embedForResponse(List.of(query)); + return Optional.ofNullable(response.getData().stream().findFirst().orElse(null)) + .map(org.springframework.ai.embedding.Embedding::getEmbedding) + .orElseThrow(() -> new ChatbotException("Failed to compute query embedding")); + } + + private double cosineSimilarity(List v1, List v2) { + int size = Math.min(v1.size(), v2.size()); + if (size == 0) { + return -1.0; + } + double dot = 0.0; + double mag1 = 0.0; + double mag2 = 0.0; + for (int i = 0; i < size; i++) { + double a = v1.get(i); + double b = v2.get(i); + dot += a * b; + mag1 += a * a; + mag2 += b * b; + } + if (mag1 == 0.0 || mag2 == 0.0) { + return -1.0; + } + return dot / (Math.sqrt(mag1) * Math.sqrt(mag2)); + } + + private record ScoredChunk(ChatbotTextChunk chunk, double score) {} +} 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 new file mode 100644 index 000000000..edbd90e6e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotService.java @@ -0,0 +1,72 @@ +package stirling.software.proprietary.service.chatbot; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.model.chatbot.ChatbotQueryRequest; +import stirling.software.proprietary.model.chatbot.ChatbotResponse; +import stirling.software.proprietary.model.chatbot.ChatbotSession; +import stirling.software.proprietary.model.chatbot.ChatbotSessionCreateRequest; +import stirling.software.proprietary.service.AuditService; +import stirling.software.proprietary.service.chatbot.exception.ChatbotException; + +@Service +@Slf4j +@RequiredArgsConstructor +public class ChatbotService { + + private final ChatbotIngestionService ingestionService; + private final ChatbotConversationService conversationService; + private final ChatbotSessionRegistry sessionRegistry; + private final ChatbotCacheService cacheService; + private final ChatbotFeatureProperties featureProperties; + private final AuditService auditService; + + public ChatbotSession createSession(ChatbotSessionCreateRequest request) { + ChatbotSession session = ingestionService.ingest(request); + log.debug("Chatbot session {} initialised", session.getSessionId()); + audit( + "CHATBOT_SESSION_CREATED", + session.getSessionId(), + Map.of( + "documentId", session.getDocumentId(), + "ocrRequested", session.isOcrRequested())); + return session; + } + + public ChatbotResponse ask(ChatbotQueryRequest request) { + ChatbotResponse response = conversationService.handleQuery(request); + audit( + "CHATBOT_QUERY", + request.getSessionId(), + Map.of( + "modelUsed", response.getModelUsed(), + "escalated", response.isEscalated(), + "confidence", response.getConfidence())); + return response; + } + + public void close(String sessionId) { + sessionRegistry + .findById(sessionId) + .orElseThrow(() -> new ChatbotException("Session not found for closure")); + sessionRegistry.remove(sessionId); + cacheService.invalidateSession(sessionId); + audit("CHATBOT_SESSION_CLOSED", sessionId, Map.of()); + log.debug("Chatbot session {} closed", sessionId); + } + + private void audit(String action, String sessionId, Map data) { + if (!featureProperties.current().audit().enabled()) { + return; + } + Map payload = new HashMap<>(data == null ? Map.of() : data); + payload.put("sessionId", sessionId); + auditService.audit(stirling.software.proprietary.audit.AuditEventType.PDF_PROCESS, payload); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotSessionRegistry.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotSessionRegistry.java new file mode 100644 index 000000000..246383db6 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotSessionRegistry.java @@ -0,0 +1,27 @@ +package stirling.software.proprietary.service.chatbot; + +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.stereotype.Component; + +import stirling.software.proprietary.model.chatbot.ChatbotSession; + +@Component +public class ChatbotSessionRegistry { + + private final Map sessionStore = new ConcurrentHashMap<>(); + + public void register(ChatbotSession session) { + sessionStore.put(session.getSessionId(), session); + } + + public Optional findById(String sessionId) { + return Optional.ofNullable(sessionStore.get(sessionId)); + } + + public void remove(String sessionId) { + sessionStore.remove(sessionId); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/exception/ChatbotException.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/exception/ChatbotException.java new file mode 100644 index 000000000..dbbde0578 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/exception/ChatbotException.java @@ -0,0 +1,12 @@ +package stirling.software.proprietary.service.chatbot.exception; + +public class ChatbotException extends RuntimeException { + + public ChatbotException(String message) { + super(message); + } + + public ChatbotException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/exception/NoTextDetectedException.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/exception/NoTextDetectedException.java new file mode 100644 index 000000000..1e0f853a0 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/exception/NoTextDetectedException.java @@ -0,0 +1,8 @@ +package stirling.software.proprietary.service.chatbot.exception; + +public class NoTextDetectedException extends ChatbotException { + + public NoTextDetectedException(String message) { + super(message); + } +} 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 new file mode 100644 index 000000000..bd1f8a7fc --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/chatbot/ChatbotCacheServiceTest.java @@ -0,0 +1,57 @@ +package stirling.software.proprietary.service.chatbot; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.model.chatbot.ChatbotDocumentCacheEntry; +import stirling.software.proprietary.service.chatbot.exception.ChatbotException; + +class ChatbotCacheServiceTest { + + private ApplicationProperties properties; + + @BeforeEach + void setup() { + properties = new ApplicationProperties(); + ApplicationProperties.Premium premium = new ApplicationProperties.Premium(); + ApplicationProperties.Premium.ProFeatures pro = + new ApplicationProperties.Premium.ProFeatures(); + ApplicationProperties.Premium.ProFeatures.Chatbot chatbot = + new ApplicationProperties.Premium.ProFeatures.Chatbot(); + chatbot.setEnabled(true); + chatbot.getCache().setMaxDocumentCharacters(50); + chatbot.getCache().setMaxEntries(10); + chatbot.getCache().setTtlMinutes(60); + pro.setChatbot(chatbot); + premium.setProFeatures(pro); + properties.setPremium(premium); + } + + @Test + void registerRejectsOversizedText() { + ChatbotCacheService cacheService = new ChatbotCacheService(properties); + String longText = "a".repeat(51); + assertThrows( + ChatbotException.class, + () -> cacheService.register("session", "doc", longText, Map.of(), false)); + } + + @Test + void registerAndResolveSession() { + ChatbotCacheService cacheService = new ChatbotCacheService(properties); + String cacheKey = + cacheService.register( + "session1", "doc1", "hello world", Map.of("title", "Sample"), false); + assertTrue(cacheService.resolveBySessionId("session1").isPresent()); + ChatbotDocumentCacheEntry entry = cacheService.resolveByCacheKey(cacheKey).orElseThrow(); + assertEquals("doc1", entry.getDocumentId()); + assertEquals("Sample", entry.getMetadata().get("title")); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/chatbot/ChatbotServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/chatbot/ChatbotServiceTest.java new file mode 100644 index 000000000..805933c85 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/chatbot/ChatbotServiceTest.java @@ -0,0 +1,130 @@ +package stirling.software.proprietary.service.chatbot; + +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.proprietary.model.chatbot.ChatbotQueryRequest; +import stirling.software.proprietary.model.chatbot.ChatbotResponse; +import stirling.software.proprietary.model.chatbot.ChatbotSession; +import stirling.software.proprietary.model.chatbot.ChatbotSessionCreateRequest; +import stirling.software.proprietary.service.AuditService; +import stirling.software.proprietary.service.chatbot.ChatbotFeatureProperties.ChatbotSettings; + +@ExtendWith(MockitoExtension.class) +class ChatbotServiceTest { + + @Mock private ChatbotIngestionService ingestionService; + @Mock private ChatbotConversationService conversationService; + @Mock private ChatbotSessionRegistry sessionRegistry; + @Mock private ChatbotCacheService cacheService; + @Mock private ChatbotFeatureProperties featureProperties; + @Mock private AuditService auditService; + + @InjectMocks private ChatbotService chatbotService; + + private ChatbotSettings auditEnabledSettings; + private ChatbotSettings auditDisabledSettings; + + @BeforeEach + void init() { + auditEnabledSettings = + new ChatbotSettings( + true, + true, + 4000, + 0.5D, + new ChatbotSettings.ModelSettings("gpt-5-nano", "gpt-5-mini", "embed"), + new ChatbotSettings.RagSettings(512, 128, 4), + new ChatbotSettings.CacheSettings(60, 10, 1000), + new ChatbotSettings.OcrSettings(false), + new ChatbotSettings.AuditSettings(true)); + + auditDisabledSettings = + new ChatbotSettings( + true, + true, + 4000, + 0.5D, + new ChatbotSettings.ModelSettings("gpt-5-nano", "gpt-5-mini", "embed"), + new ChatbotSettings.RagSettings(512, 128, 4), + new ChatbotSettings.CacheSettings(60, 10, 1000), + new ChatbotSettings.OcrSettings(false), + new ChatbotSettings.AuditSettings(false)); + } + + @Test + void createSessionEmitsAuditWhenEnabled() { + ChatbotSession session = + ChatbotSession.builder() + .sessionId("session-1") + .documentId("doc-1") + .ocrRequested(true) + .createdAt(Instant.now()) + .build(); + when(ingestionService.ingest(any())).thenReturn(session); + when(featureProperties.current()).thenReturn(auditEnabledSettings); + + chatbotService.createSession( + ChatbotSessionCreateRequest.builder().text("abc").warningsAccepted(true).build()); + + ArgumentCaptor> payloadCaptor = ArgumentCaptor.forClass(Map.class); + verify(auditService) + .audit( + eq(stirling.software.proprietary.audit.AuditEventType.PDF_PROCESS), + payloadCaptor.capture()); + Map payload = payloadCaptor.getValue(); + verify(cacheService, times(0)).invalidateSession(any()); + org.junit.jupiter.api.Assertions.assertEquals("session-1", payload.get("sessionId")); + } + + @Test + void querySkipsAuditWhenDisabled() { + ChatbotQueryRequest request = + ChatbotQueryRequest.builder() + .sessionId("session-2") + .prompt("Hello?") + .allowEscalation(true) + .build(); + ChatbotResponse response = + ChatbotResponse.builder() + .sessionId("session-2") + .modelUsed("gpt-5-nano") + .confidence(0.8D) + .build(); + when(conversationService.handleQuery(request)).thenReturn(response); + when(featureProperties.current()).thenReturn(auditDisabledSettings); + + chatbotService.ask(request); + + verify(auditService, times(0)) + .audit(eq(stirling.software.proprietary.audit.AuditEventType.PDF_PROCESS), any()); + } + + @Test + void closeSessionInvalidatesCache() { + ChatbotSession session = + ChatbotSession.builder().sessionId("session-3").documentId("doc").build(); + when(sessionRegistry.findById("session-3")).thenReturn(Optional.of(session)); + when(featureProperties.current()).thenReturn(auditEnabledSettings); + + chatbotService.close("session-3"); + + verify(sessionRegistry).remove("session-3"); + verify(cacheService).invalidateSession("session-3"); + } +} diff --git a/build.gradle b/build.gradle index 9acb8c9f0..d9220c9f5 100644 --- a/build.gradle +++ b/build.gradle @@ -15,6 +15,7 @@ import com.github.jk1.license.render.* ext { springBootVersion = "3.5.6" + springAiVersion = "1.0.1" pdfboxVersion = "3.0.5" imageioVersion = "3.12.0" lombokVersion = "1.18.42" @@ -53,6 +54,7 @@ springBoot { repositories { mavenCentral() maven { url = 'https://build.shibboleth.net/maven/releases' } + maven { url = 'https://repo.spring.io/release' } } allprojects { @@ -93,6 +95,7 @@ subprojects { repositories { mavenCentral() + maven { url = 'https://repo.spring.io/release' } } configurations.configureEach { @@ -107,6 +110,7 @@ subprojects { dependencyManagement { imports { mavenBom "org.springframework.boot:spring-boot-dependencies:$springBootVersion" + mavenBom "org.springframework.ai:spring-ai-bom:$springAiVersion" } }