wip - building skeleton

# Conflicts:
#	app/proprietary/build.gradle
#	build.gradle
This commit is contained in:
Dario Ghunney Ware 2025-11-03 17:34:52 +00:00
parent 0594cb490f
commit 57d35324e3
24 changed files with 1441 additions and 1 deletions

View File

@ -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

View File

@ -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

View File

@ -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'

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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) {}
}

View File

@ -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) {}
}
}

View File

@ -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;
}
}

View File

@ -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) {}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,8 @@
package stirling.software.proprietary.service.chatbot.exception;
public class NoTextDetectedException extends ChatbotException {
public NoTextDetectedException(String message) {
super(message);
}
}

View File

@ -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"));
}
}

View File

@ -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");
}
}

View File

@ -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"
}
}