mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-11-16 01:21:16 +01:00
wip - building skeleton
# Conflicts: # app/proprietary/build.gradle # build.gradle
This commit is contained in:
parent
0594cb490f
commit
57d35324e3
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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<ChatbotSessionResponse> 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<ChatbotResponse> query(@RequestBody ChatbotQueryRequest request) {
|
||||
ChatbotResponse response = chatbotService.ask(request);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@GetMapping("/session/{sessionId}")
|
||||
public ResponseEntity<ChatbotSessionResponse> 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<Void> closeSession(@PathVariable String sessionId) {
|
||||
chatbotService.close(sessionId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private List<String> defaultWarnings(ChatbotSettings settings) {
|
||||
List<String> 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;
|
||||
}
|
||||
}
|
||||
@ -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<Map<String, Object>> handleNoText(NoTextDetectedException ex) {
|
||||
return buildResponse(HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(ChatbotException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleChatbot(ChatbotException ex) {
|
||||
log.debug("Chatbot exception: {}", ex.getMessage());
|
||||
return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleIllegalArgument(IllegalArgumentException ex) {
|
||||
return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> buildResponse(HttpStatus status, String message) {
|
||||
Map<String, Object> payload =
|
||||
Map.of(
|
||||
"timestamp", Instant.now().toString(),
|
||||
"status", status.value(),
|
||||
"error", message);
|
||||
return ResponseEntity.status(status).body(payload);
|
||||
}
|
||||
}
|
||||
@ -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<String, String> metadata;
|
||||
private String text;
|
||||
private List<ChatbotTextChunk> chunks;
|
||||
private boolean ocrApplied;
|
||||
private String vectorStoreId;
|
||||
private Instant storedAt;
|
||||
|
||||
public Map<String, String> getMetadata() {
|
||||
return metadata == null ? Collections.emptyMap() : metadata;
|
||||
}
|
||||
|
||||
public List<ChatbotTextChunk> getChunks() {
|
||||
return chunks == null ? Collections.emptyList() : chunks;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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<String> warnings;
|
||||
private Map<String, Object> metadata;
|
||||
|
||||
public List<String> getWarnings() {
|
||||
return warnings == null ? Collections.emptyList() : warnings;
|
||||
}
|
||||
|
||||
public Map<String, Object> getMetadata() {
|
||||
return metadata == null ? Collections.emptyMap() : metadata;
|
||||
}
|
||||
}
|
||||
@ -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<String, String> 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<String, String> getMetadata() {
|
||||
return metadata == null ? Collections.emptyMap() : metadata;
|
||||
}
|
||||
}
|
||||
@ -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<String, String> metadata;
|
||||
private boolean ocrRequested;
|
||||
private boolean warningsAccepted;
|
||||
}
|
||||
@ -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<String> warnings;
|
||||
private Map<String, String> metadata;
|
||||
|
||||
public List<String> getWarnings() {
|
||||
return warnings == null ? Collections.emptyList() : warnings;
|
||||
}
|
||||
|
||||
public Map<String, String> getMetadata() {
|
||||
return metadata == null ? Collections.emptyMap() : metadata;
|
||||
}
|
||||
}
|
||||
@ -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<Double> embedding;
|
||||
}
|
||||
@ -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<String, ChatbotDocumentCacheEntry> documentCache;
|
||||
private final long maxDocumentCharacters;
|
||||
private final Map<String, String> 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<String, String> 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<ChatbotTextChunk> chunks) {
|
||||
documentCache
|
||||
.asMap()
|
||||
.computeIfPresent(
|
||||
cacheKey,
|
||||
(key, existing) -> {
|
||||
existing.setChunks(chunks);
|
||||
return existing;
|
||||
});
|
||||
}
|
||||
|
||||
public Optional<ChatbotDocumentCacheEntry> resolveByCacheKey(String cacheKey) {
|
||||
return Optional.ofNullable(documentCache.getIfPresent(cacheKey));
|
||||
}
|
||||
|
||||
public Optional<ChatbotDocumentCacheEntry> 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<String, ChatbotDocumentCacheEntry> 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;
|
||||
}
|
||||
}
|
||||
@ -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<String> warnings = buildWarnings(settings, session);
|
||||
|
||||
List<ChatbotTextChunk> 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<ChatbotTextChunk> 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<String> buildWarnings(ChatbotSettings settings, ChatbotSession session) {
|
||||
List<String> 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<String, Object> buildMetadata(
|
||||
ModelReply reply, int contextSize, boolean escalated) {
|
||||
Map<String, Object> 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<ChatbotTextChunk> ensureMinimumContext(
|
||||
List<ChatbotTextChunk> context, ChatbotDocumentCacheEntry entry) {
|
||||
if (context.size() >= 3 || entry.getChunks().size() <= context.size()) {
|
||||
return context;
|
||||
}
|
||||
List<ChatbotTextChunk> 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<ChatbotTextChunk> context,
|
||||
Map<String, String> 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<ChatbotTextChunk> context,
|
||||
Map<String, String> 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) {}
|
||||
}
|
||||
@ -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) {}
|
||||
}
|
||||
}
|
||||
@ -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<String, String> 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<String> chunkTexts =
|
||||
chunkText(
|
||||
request.getText(),
|
||||
settings.rag().chunkSizeTokens(),
|
||||
settings.rag().chunkOverlapTokens());
|
||||
List<ChatbotTextChunk> 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<String> chunkText(String text, int chunkSizeTokens, int overlapTokens) {
|
||||
String[] tokens = text.split("\\s+");
|
||||
List<String> 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<ChatbotTextChunk> embedChunks(
|
||||
String sessionId,
|
||||
String cacheKey,
|
||||
List<String> chunkTexts,
|
||||
Map<String, String> 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<ChatbotTextChunk> 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;
|
||||
}
|
||||
}
|
||||
@ -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<ChatbotTextChunk> retrieveTopK(
|
||||
String sessionId, String query, ChatbotSettings settings) {
|
||||
ChatbotDocumentCacheEntry entry =
|
||||
cacheService
|
||||
.resolveBySessionId(sessionId)
|
||||
.orElseThrow(() -> new ChatbotException("Unknown chatbot session"));
|
||||
List<ChatbotTextChunk> chunks = entry.getChunks();
|
||||
if (CollectionUtils.isEmpty(chunks)) {
|
||||
throw new ChatbotException("Chatbot cache does not contain pre-computed chunks");
|
||||
}
|
||||
List<Double> queryEmbedding = computeQueryEmbedding(query);
|
||||
List<ScoredChunk> 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<Double> 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<Double> v1, List<Double> 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) {}
|
||||
}
|
||||
@ -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<String, Object> data) {
|
||||
if (!featureProperties.current().audit().enabled()) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> payload = new HashMap<>(data == null ? Map.of() : data);
|
||||
payload.put("sessionId", sessionId);
|
||||
auditService.audit(stirling.software.proprietary.audit.AuditEventType.PDF_PROCESS, payload);
|
||||
}
|
||||
}
|
||||
@ -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<String, ChatbotSession> sessionStore = new ConcurrentHashMap<>();
|
||||
|
||||
public void register(ChatbotSession session) {
|
||||
sessionStore.put(session.getSessionId(), session);
|
||||
}
|
||||
|
||||
public Optional<ChatbotSession> findById(String sessionId) {
|
||||
return Optional.ofNullable(sessionStore.get(sessionId));
|
||||
}
|
||||
|
||||
public void remove(String sessionId) {
|
||||
sessionStore.remove(sessionId);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
package stirling.software.proprietary.service.chatbot.exception;
|
||||
|
||||
public class NoTextDetectedException extends ChatbotException {
|
||||
|
||||
public NoTextDetectedException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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<Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(Map.class);
|
||||
verify(auditService)
|
||||
.audit(
|
||||
eq(stirling.software.proprietary.audit.AuditEventType.PDF_PROCESS),
|
||||
payloadCaptor.capture());
|
||||
Map<String, Object> 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");
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user