This commit is contained in:
DarioGii 2025-11-06 11:31:46 +00:00 committed by Dario Ghunney Ware
parent 5e26decf19
commit cd01febc6b
13 changed files with 159 additions and 38 deletions

View File

@ -622,6 +622,7 @@ public class ApplicationProperties {
@Data
public static class Models {
private String provider = "openai";
private String primary = "gpt-5-nano";
private String fallback = "gpt-5-mini";
private String embedding = "text-embedding-3-small";

View File

@ -53,6 +53,7 @@ dependencies {
api 'com.github.ben-manes.caffeine:caffeine'
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.38'
implementation 'org.springframework.ai:spring-ai-openai'
implementation 'org.springframework.ai:spring-ai-ollama'
implementation 'com.bucket4j:bucket4j_jdk17-core:8.15.0'
// https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17

View File

@ -54,9 +54,11 @@ public class ChatbotController {
.documentId(session.getDocumentId())
.alphaWarning(settings.alphaWarning())
.ocrRequested(session.isOcrRequested())
.imageContentDetected(session.isImageContentDetected())
.textCharacters(session.getTextCharacters())
.maxCachedCharacters(cacheService.getMaxDocumentCharacters())
.createdAt(session.getCreatedAt())
.warnings(defaultWarnings(settings))
.warnings(sessionWarnings(settings, session))
.metadata(session.getMetadata())
.build();
return ResponseEntity.status(HttpStatus.CREATED).body(response);
@ -81,9 +83,11 @@ public class ChatbotController {
.documentId(session.getDocumentId())
.alphaWarning(settings.alphaWarning())
.ocrRequested(session.isOcrRequested())
.imageContentDetected(session.isImageContentDetected())
.textCharacters(session.getTextCharacters())
.maxCachedCharacters(cacheService.getMaxDocumentCharacters())
.createdAt(session.getCreatedAt())
.warnings(defaultWarnings(settings))
.warnings(sessionWarnings(settings, session))
.metadata(session.getMetadata())
.build();
return ResponseEntity.ok(response);
@ -95,13 +99,19 @@ public class ChatbotController {
return ResponseEntity.noContent().build();
}
private List<String> defaultWarnings(ChatbotSettings settings) {
private List<String> sessionWarnings(ChatbotSettings settings, ChatbotSession session) {
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.");
if (session != null && session.isImageContentDetected()) {
warnings.add("Detected images will be ignored until image support ships.");
}
warnings.add("Only extracted text is sent for analysis.");
if (session != null && session.isOcrRequested()) {
warnings.add("OCR was requested extra processing charges may apply.");
}
return warnings;
}
}

View File

@ -23,6 +23,8 @@ public class ChatbotDocumentCacheEntry {
private String text;
private List<ChatbotTextChunk> chunks;
private boolean ocrApplied;
private boolean imageContentDetected;
private long textCharacters;
private String vectorStoreId;
private Instant storedAt;

View File

@ -19,6 +19,8 @@ public class ChatbotSession {
private boolean ocrRequested;
private boolean warningsAccepted;
private boolean alphaWarningRequired;
private boolean imageContentDetected;
private long textCharacters;
private String cacheKey;
private String vectorStoreId;
private Instant createdAt;

View File

@ -20,4 +20,5 @@ public class ChatbotSessionCreateRequest {
private Map<String, String> metadata;
private boolean ocrRequested;
private boolean warningsAccepted;
private boolean imagesDetected;
}

View File

@ -20,7 +20,9 @@ public class ChatbotSessionResponse {
private String documentId;
private boolean alphaWarning;
private boolean ocrRequested;
private boolean imageContentDetected;
private long maxCachedCharacters;
private long textCharacters;
private Instant createdAt;
private List<String> warnings;
private Map<String, String> metadata;

View File

@ -64,7 +64,9 @@ public class ChatbotCacheService {
String documentId,
String rawText,
Map<String, String> metadata,
boolean ocrApplied) {
boolean ocrApplied,
boolean imageContentDetected,
long textCharacters) {
Objects.requireNonNull(sessionId, "sessionId must not be null");
Objects.requireNonNull(documentId, "documentId must not be null");
Objects.requireNonNull(rawText, "rawText must not be null");
@ -82,6 +84,8 @@ public class ChatbotCacheService {
.metadata(metadata)
.text(rawText)
.ocrApplied(ocrApplied)
.imageContentDetected(imageContentDetected)
.textCharacters(textCharacters)
.storedAt(Instant.now())
.build();
documentCache.put(cacheKey, entry);

View File

@ -14,6 +14,8 @@ import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.ollama.OllamaChatModel;
import org.springframework.ai.ollama.api.OllamaOptions;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
@ -33,6 +35,7 @@ import stirling.software.proprietary.model.chatbot.ChatbotResponse;
import stirling.software.proprietary.model.chatbot.ChatbotSession;
import stirling.software.proprietary.model.chatbot.ChatbotTextChunk;
import stirling.software.proprietary.service.chatbot.ChatbotFeatureProperties.ChatbotSettings;
import stirling.software.proprietary.service.chatbot.ChatbotFeatureProperties.ChatbotSettings.ModelProvider;
import stirling.software.proprietary.service.chatbot.exception.ChatbotException;
@Service
@ -66,7 +69,7 @@ public class ChatbotConversationService {
.findById(request.getSessionId())
.orElseThrow(() -> new ChatbotException("Unknown chatbot session"));
ensureModelSwitchCapability();
ensureModelSwitchCapability(settings);
ChatbotDocumentCacheEntry cacheEntry =
cacheService
@ -81,6 +84,7 @@ public class ChatbotConversationService {
ModelReply nanoReply =
invokeModel(
settings,
settings.models().primary(),
request.getPrompt(),
session,
@ -100,6 +104,7 @@ public class ChatbotConversationService {
List<ChatbotTextChunk> expandedContext = ensureMinimumContext(context, cacheEntry);
finalReply =
invokeModel(
settings,
settings.models().fallback(),
request.getPrompt(),
session,
@ -118,7 +123,7 @@ public class ChatbotConversationService {
.cacheHit(true)
.respondedAt(Instant.now())
.warnings(warnings)
.metadata(buildMetadata(finalReply, context.size(), escalated))
.metadata(buildMetadata(settings, session, finalReply, context.size(), escalated))
.build();
}
@ -126,6 +131,10 @@ public class ChatbotConversationService {
List<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.isImageContentDetected()) {
warnings.add(
"Detected document images will be ignored until image support is available.");
}
if (session.isOcrRequested()) {
warnings.add("OCR costs may apply for this session.");
}
@ -133,30 +142,44 @@ public class ChatbotConversationService {
}
private Map<String, Object> buildMetadata(
ModelReply reply, int contextSize, boolean escalated) {
ChatbotSettings settings,
ChatbotSession session,
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());
metadata.put("modelProvider", settings.models().provider().name());
metadata.put("imageContentDetected", session.isImageContentDetected());
metadata.put("charactersCached", session.getTextCharacters());
return metadata;
}
private void ensureModelSwitchCapability() {
if (!(chatModel instanceof OpenAiChatModel)) {
throw new ChatbotException(
"Chatbot requires OpenAI chat model to support runtime model switching");
private void ensureModelSwitchCapability(ChatbotSettings settings) {
ModelProvider provider = settings.models().provider();
switch (provider) {
case OPENAI -> {
if (!(chatModel instanceof OpenAiChatModel)) {
throw new ChatbotException(
"Chatbot requires an OpenAI chat model to support runtime model switching.");
}
}
case OLLAMA -> {
if (!(chatModel instanceof OllamaChatModel)) {
throw new ChatbotException(
"Chatbot is configured for Ollama but no Ollama chat model bean is available.");
}
}
}
if (modelSwitchVerified.compareAndSet(false, true)) {
ChatbotSettings settings = featureProperties.current();
OpenAiChatOptions primary =
OpenAiChatOptions.builder().model(settings.models().primary()).build();
OpenAiChatOptions fallback =
OpenAiChatOptions.builder().model(settings.models().fallback()).build();
log.info(
"Verified runtime model override support ({} -> {})",
primary.getModel(),
fallback.getModel());
"Verified runtime model override support for provider {} ({} -> {})",
provider,
settings.models().primary(),
settings.models().fallback());
}
}
@ -178,12 +201,13 @@ public class ChatbotConversationService {
}
private ModelReply invokeModel(
ChatbotSettings settings,
String model,
String prompt,
ChatbotSession session,
List<ChatbotTextChunk> context,
Map<String, String> metadata) {
Prompt requestPrompt = buildPrompt(model, prompt, session, context, metadata);
Prompt requestPrompt = buildPrompt(settings, model, prompt, session, context, metadata);
ChatResponse response = chatModel.call(requestPrompt);
String content =
Optional.ofNullable(response)
@ -195,6 +219,7 @@ public class ChatbotConversationService {
}
private Prompt buildPrompt(
ChatbotSettings settings,
String model,
String question,
ChatbotSession session,
@ -215,28 +240,46 @@ public class ChatbotConversationService {
.reduce((left, right) -> left + ", " + right)
.orElse("none");
String imageDirective =
session.isImageContentDetected()
? "Images were detected in this PDF. You must explain that image analysis is not available."
: "No images detected in this PDF.";
String systemPrompt =
"You are Stirling PDF Bot. Use provided context strictly. "
+ "Respond in compact JSON with fields answer (string), confidence (0..1), requiresEscalation (boolean), rationale (string). "
+ "Explain limitations when context insufficient.";
+ "Explain limitations when context insufficient. Always note that image analysis is not supported yet.";
String userPrompt =
"Document metadata: "
+ metadataSummary
+ "\nOCR applied: "
+ session.isOcrRequested()
+ "\n"
+ imageDirective
+ "\nContext:\n"
+ contextBuilder
+ "Question: "
+ question;
OpenAiChatOptions options =
OpenAiChatOptions.builder().model(model).temperature(0.2).build();
Object options = buildChatOptions(settings, model);
return new Prompt(
List.of(new SystemMessage(systemPrompt), new UserMessage(userPrompt)), options);
}
private Object buildChatOptions(ChatbotSettings settings, String model) {
return switch (settings.models().provider()) {
case OPENAI ->
OpenAiChatOptions.builder()
.model(model)
.temperature(0.2)
.responseFormat("json_object")
.build();
case OLLAMA -> OllamaOptions.builder().model(model).temperature(0.2).build();
};
}
private ModelReply parseModelResponse(String raw) {
if (!StringUtils.hasText(raw)) {
throw new ChatbotException("Model returned empty response");

View File

@ -3,6 +3,7 @@ package stirling.software.proprietary.service.chatbot;
import java.util.Optional;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Premium;
@ -20,15 +21,18 @@ public class ChatbotFeatureProperties {
public ChatbotSettings current() {
Chatbot chatbot = resolveChatbot();
ChatbotSettings.ModelSettings modelSettings =
new ChatbotSettings.ModelSettings(
resolveProvider(chatbot.getModels().getProvider()),
chatbot.getModels().getPrimary(),
chatbot.getModels().getFallback(),
chatbot.getModels().getEmbedding());
return new ChatbotSettings(
chatbot.isEnabled(),
chatbot.isAlphaWarning(),
chatbot.getMaxPromptCharacters(),
chatbot.getMinConfidenceNano(),
new ChatbotSettings.ModelSettings(
chatbot.getModels().getPrimary(),
chatbot.getModels().getFallback(),
chatbot.getModels().getEmbedding()),
modelSettings,
new ChatbotSettings.RagSettings(
chatbot.getRag().getChunkSizeTokens(),
chatbot.getRag().getChunkOverlapTokens(),
@ -53,6 +57,17 @@ public class ChatbotFeatureProperties {
.orElseGet(Chatbot::new);
}
private ChatbotSettings.ModelProvider resolveProvider(String configuredProvider) {
if (!StringUtils.hasText(configuredProvider)) {
return ChatbotSettings.ModelProvider.OPENAI;
}
try {
return ChatbotSettings.ModelProvider.valueOf(configuredProvider.trim().toUpperCase());
} catch (IllegalArgumentException ignored) {
return ChatbotSettings.ModelProvider.OPENAI;
}
}
public record ChatbotSettings(
boolean enabled,
boolean alphaWarning,
@ -64,7 +79,8 @@ public class ChatbotFeatureProperties {
OcrSettings ocr,
AuditSettings audit) {
public record ModelSettings(String primary, String fallback, String embedding) {}
public record ModelSettings(
ModelProvider provider, String primary, String fallback, String embedding) {}
public record RagSettings(int chunkSizeTokens, int chunkOverlapTokens, int topK) {}
@ -73,5 +89,10 @@ public class ChatbotFeatureProperties {
public record OcrSettings(boolean enabledByDefault) {}
public record AuditSettings(boolean enabled) {}
public enum ModelProvider {
OPENAI,
OLLAMA
}
}
}

View File

@ -2,6 +2,7 @@ package stirling.software.proprietary.service.chatbot;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@ -43,25 +44,38 @@ public class ChatbotIngestionService {
if (!request.isWarningsAccepted() && settings.alphaWarning()) {
throw new ChatbotException("Alpha warning must be accepted before use");
}
if (!StringUtils.hasText(request.getText())) {
throw new NoTextDetectedException("No text detected in document payload");
boolean hasText = StringUtils.hasText(request.getText());
if (!hasText) {
throw new NoTextDetectedException(
"No text detected in document payload. Images are currently unsupported enable OCR to continue.");
}
String sessionId =
StringUtils.hasText(request.getSessionId())
? request.getSessionId()
: ChatbotSession.randomSessionId();
Map<String, String> metadata =
request.getMetadata() == null ? Map.of() : Map.copyOf(request.getMetadata());
boolean imagesDetected = request.isImagesDetected();
long textCharacters = request.getText().length();
boolean ocrApplied = request.isOcrRequested();
Map<String, String> metadata = new HashMap<>();
if (request.getMetadata() != null) {
metadata.putAll(request.getMetadata());
}
metadata.put("content.imagesDetected", Boolean.toString(imagesDetected));
metadata.put("content.characterCount", String.valueOf(textCharacters));
metadata.put(
"content.extractionSource", ocrApplied ? "ocr-text-layer" : "embedded-text-layer");
Map<String, String> immutableMetadata = Map.copyOf(metadata);
String cacheKey =
cacheService.register(
sessionId,
request.getDocumentId(),
request.getText(),
metadata,
ocrApplied);
immutableMetadata,
ocrApplied,
imagesDetected,
textCharacters);
List<String> chunkTexts =
chunkText(
@ -76,8 +90,10 @@ public class ChatbotIngestionService {
.sessionId(sessionId)
.documentId(request.getDocumentId())
.userId(request.getUserId())
.metadata(metadata)
.metadata(immutableMetadata)
.ocrRequested(ocrApplied)
.imageContentDetected(imagesDetected)
.textCharacters(textCharacters)
.warningsAccepted(request.isWarningsAccepted())
.alphaWarningRequired(settings.alphaWarning())
.cacheKey(cacheKey)

View File

@ -39,7 +39,9 @@ public class ChatbotService {
session.getSessionId(),
Map.of(
"documentId", session.getDocumentId(),
"ocrRequested", session.isOcrRequested()));
"ocrRequested", session.isOcrRequested(),
"imagesDetected", session.isImageContentDetected(),
"textCharacters", session.getTextCharacters()));
return session;
}

View File

@ -40,7 +40,15 @@ class ChatbotCacheServiceTest {
String longText = "a".repeat(51);
assertThrows(
ChatbotException.class,
() -> cacheService.register("session", "doc", longText, Map.of(), false));
() ->
cacheService.register(
"session",
"doc",
longText,
Map.of(),
false,
false,
longText.length()));
}
@Test
@ -48,10 +56,18 @@ class ChatbotCacheServiceTest {
ChatbotCacheService cacheService = new ChatbotCacheService(properties);
String cacheKey =
cacheService.register(
"session1", "doc1", "hello world", Map.of("title", "Sample"), false);
"session1",
"doc1",
"hello world",
Map.of("title", "Sample"),
false,
false,
"hello world".length());
assertTrue(cacheService.resolveBySessionId("session1").isPresent());
ChatbotDocumentCacheEntry entry = cacheService.resolveByCacheKey(cacheKey).orElseThrow();
assertEquals("doc1", entry.getDocumentId());
assertEquals("Sample", entry.getMetadata().get("title"));
assertEquals("hello world".length(), entry.getTextCharacters());
assertTrue(!entry.isImageContentDetected());
}
}