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 ssoAutoLogin;
|
||||||
private boolean database;
|
private boolean database;
|
||||||
private CustomMetadata customMetadata = new CustomMetadata();
|
private CustomMetadata customMetadata = new CustomMetadata();
|
||||||
|
private Chatbot chatbot = new Chatbot();
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public static class CustomMetadata {
|
public static class CustomMetadata {
|
||||||
@ -599,6 +600,50 @@ public class ApplicationProperties {
|
|||||||
: producer;
|
: 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
|
@Data
|
||||||
|
|||||||
@ -91,6 +91,27 @@ premium:
|
|||||||
author: username
|
author: username
|
||||||
creator: Stirling-PDF
|
creator: Stirling-PDF
|
||||||
producer: 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:
|
enterpriseFeatures:
|
||||||
audit:
|
audit:
|
||||||
enabled: true # Enable audit logging
|
enabled: true # Enable audit logging
|
||||||
|
|||||||
@ -41,6 +41,8 @@ dependencies {
|
|||||||
api 'org.springframework:spring-webmvc'
|
api 'org.springframework:spring-webmvc'
|
||||||
api 'org.springframework.session:spring-session-core'
|
api 'org.springframework.session:spring-session-core'
|
||||||
api "org.springframework.security:spring-security-core:$springSecuritySamlVersion"
|
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.security:spring-security-saml2-service-provider:$springSecuritySamlVersion"
|
||||||
api 'org.springframework.boot:spring-boot-starter-jetty'
|
api 'org.springframework.boot:spring-boot-starter-jetty'
|
||||||
api 'org.springframework.boot:spring-boot-starter-security'
|
api 'org.springframework.boot:spring-boot-starter-security'
|
||||||
@ -50,12 +52,14 @@ dependencies {
|
|||||||
api 'org.springframework.boot:spring-boot-starter-cache'
|
api 'org.springframework.boot:spring-boot-starter-cache'
|
||||||
api 'com.github.ben-manes.caffeine:caffeine'
|
api 'com.github.ben-manes.caffeine:caffeine'
|
||||||
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.38'
|
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'
|
implementation 'com.bucket4j:bucket4j_jdk17-core:8.15.0'
|
||||||
|
|
||||||
// https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17
|
// https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17
|
||||||
implementation 'org.bouncycastle:bcprov-jdk18on:1.82'
|
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'
|
api 'io.micrometer:micrometer-registry-prometheus'
|
||||||
implementation 'com.unboundid.product.scim2:scim2-sdk-client:4.0.0'
|
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 {
|
ext {
|
||||||
springBootVersion = "3.5.6"
|
springBootVersion = "3.5.6"
|
||||||
|
springAiVersion = "1.0.1"
|
||||||
pdfboxVersion = "3.0.5"
|
pdfboxVersion = "3.0.5"
|
||||||
imageioVersion = "3.12.0"
|
imageioVersion = "3.12.0"
|
||||||
lombokVersion = "1.18.42"
|
lombokVersion = "1.18.42"
|
||||||
@ -53,6 +54,7 @@ springBoot {
|
|||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
maven { url = 'https://build.shibboleth.net/maven/releases' }
|
maven { url = 'https://build.shibboleth.net/maven/releases' }
|
||||||
|
maven { url = 'https://repo.spring.io/release' }
|
||||||
}
|
}
|
||||||
|
|
||||||
allprojects {
|
allprojects {
|
||||||
@ -93,6 +95,7 @@ subprojects {
|
|||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
maven { url = 'https://repo.spring.io/release' }
|
||||||
}
|
}
|
||||||
|
|
||||||
configurations.configureEach {
|
configurations.configureEach {
|
||||||
@ -107,6 +110,7 @@ subprojects {
|
|||||||
dependencyManagement {
|
dependencyManagement {
|
||||||
imports {
|
imports {
|
||||||
mavenBom "org.springframework.boot:spring-boot-dependencies:$springBootVersion"
|
mavenBom "org.springframework.boot:spring-boot-dependencies:$springBootVersion"
|
||||||
|
mavenBom "org.springframework.ai:spring-ai-bom:$springAiVersion"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user