improving context

This commit is contained in:
Dario Ghunney Ware
2025-11-14 17:58:12 +00:00
parent f82020a3b7
commit f4b39101ac
6 changed files with 199 additions and 81 deletions

View File

@@ -28,10 +28,12 @@ public class ChatbotVectorStoreConfig {
public VectorStore chatbotVectorStore(
ObjectProvider<JedisPooled> jedisProvider, EmbeddingModel embeddingModel) {
JedisPooled jedis = jedisProvider.getIfAvailable();
if (jedis != null) {
try {
jedis.ping();
log.info("Initialising Redis vector store for chatbot usage");
return RedisVectorStore.builder(jedis, embeddingModel)
.indexName(DEFAULT_INDEX)
.prefix(DEFAULT_PREFIX)
@@ -45,6 +47,7 @@ public class ChatbotVectorStoreConfig {
} else {
log.info("No Redis connection detected; using SimpleVectorStore for chatbot.");
}
return SimpleVectorStore.builder(embeddingModel).build();
}

View File

@@ -107,7 +107,7 @@ public class ChatbotController {
warnings.add("Images detected - Images are not currently supported.");
}
warnings.add("Only extracted text is sent for analysis.");
warnings.add("Images are not yet supported. Only extracted text is sent for analysis.");
if (session != null && session.isOcrRequested()) {
warnings.add("OCR requested uses credits .");
}

View File

@@ -0,0 +1,49 @@
package stirling.software.proprietary.service.chatbot;
import java.util.List;
import org.springframework.ai.document.Document;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
@Component
public class ChatbotContextCompressor {
private static final int DEFAULT_SUMMARY_LIMIT = 3000;
private static final int MIN_CHUNK_SNIPPET = 160;
public String summarize(List<Document> documents, int requestedLimit) {
if (CollectionUtils.isEmpty(documents)) {
return "No contextual snippets available for this session.";
}
int maxChars =
requestedLimit > 0
? Math.min(requestedLimit, DEFAULT_SUMMARY_LIMIT)
: DEFAULT_SUMMARY_LIMIT;
StringBuilder builder = new StringBuilder();
int perChunkLimit = Math.max(MIN_CHUNK_SNIPPET, maxChars / Math.max(documents.size(), 1));
for (Document doc : documents) {
if (builder.length() >= maxChars) {
break;
}
String chunkOrder = doc.getMetadata().getOrDefault("chunkOrder", "?").toString();
String text = trimContent(doc.getText(), perChunkLimit);
builder.append("Chunk ").append(chunkOrder).append(": ").append(text).append('\n');
}
if (builder.length() == 0) {
return "Unable to summarise context; original content unavailable.";
}
return builder.substring(0, Math.min(builder.length(), maxChars)).trim();
}
private String trimContent(String content, int perChunkLimit) {
if (content == null || content.isBlank()) {
return "(empty chunk)";
}
String normalized = content.replaceAll("\\s+", " ").trim();
if (normalized.length() <= perChunkLimit) {
return normalized;
}
return normalized.substring(0, Math.max(0, perChunkLimit - 3)) + "...";
}
}

View File

@@ -47,6 +47,8 @@ public class ChatbotConversationService {
private final ChatbotCacheService cacheService;
private final ChatbotFeatureProperties featureProperties;
private final ChatbotRetrievalService retrievalService;
private final ChatbotContextCompressor contextCompressor;
private final ChatbotMemoryService memoryService;
private final ChatbotUsageService usageService;
private final ObjectMapper objectMapper;
private final AtomicBoolean modelSwitchVerified = new AtomicBoolean(false);
@@ -79,6 +81,9 @@ public class ChatbotConversationService {
List<Document> context =
retrievalService.retrieveTopK(
request.getSessionId(), request.getPrompt(), settings);
String contextSummary =
contextCompressor.summarize(
context, (int) Math.max(settings.maxPromptCharacters() / 2, 1000));
ModelReply nanoReply =
invokeModel(
@@ -87,6 +92,7 @@ public class ChatbotConversationService {
request.getPrompt(),
session,
context,
contextSummary,
cacheEntry.getMetadata());
boolean shouldEscalate =
@@ -106,6 +112,7 @@ public class ChatbotConversationService {
request.getPrompt(),
session,
context,
contextSummary,
cacheEntry.getMetadata());
}
@@ -116,6 +123,8 @@ public class ChatbotConversationService {
finalReply.completionTokens());
session.setUsageSummary(usageSummary);
memoryService.recordTurn(session, request.getPrompt(), finalReply.answer());
return ChatbotResponse.builder()
.sessionId(request.getSessionId())
.modelUsed(
@@ -200,8 +209,10 @@ public class ChatbotConversationService {
String prompt,
ChatbotSession session,
List<Document> context,
String contextSummary,
Map<String, String> metadata) {
Prompt requestPrompt = buildPrompt(settings, model, prompt, session, context, metadata);
Prompt requestPrompt =
buildPrompt(settings, model, prompt, session, context, contextSummary, metadata);
ChatResponse response;
try {
response = chatModel.call(requestPrompt);
@@ -244,16 +255,9 @@ public class ChatbotConversationService {
String question,
ChatbotSession session,
List<Document> context,
String contextSummary,
Map<String, String> metadata) {
StringBuilder contextBuilder = new StringBuilder();
for (Document chunk : context) {
contextBuilder
.append("[Chunk ")
.append(chunk.getMetadata().getOrDefault("chunkOrder", "?"))
.append("]\n")
.append(chunk.getText())
.append("\n\n");
}
String chunkOutline = buildChunkOutline(context);
String metadataSummary =
metadata.entrySet().stream()
.map(entry -> entry.getKey() + ": " + entry.getValue())
@@ -277,8 +281,10 @@ public class ChatbotConversationService {
+ session.isOcrRequested()
+ "\n"
+ imageDirective
+ "\nContext:\n"
+ contextBuilder
+ "\nContext summary:\n"
+ contextSummary
+ "\nContext outline:\n"
+ chunkOutline
+ "Question: "
+ question;
@@ -298,6 +304,27 @@ public class ChatbotConversationService {
return builder.build();
}
private String buildChunkOutline(List<Document> context) {
if (context == null || context.isEmpty()) {
return "No chunks retrieved for this question.";
}
StringBuilder outline = new StringBuilder();
for (Document chunk : context) {
String order = chunk.getMetadata().getOrDefault("chunkOrder", "?").toString();
String snippet = chunk.getText();
if (snippet != null) {
snippet = snippet.replaceAll("\\s+", " ").trim();
if (snippet.length() > 240) {
snippet = snippet.substring(0, 237) + "...";
}
} else {
snippet = "(empty)";
}
outline.append("- Chunk ").append(order).append(": ").append(snippet).append("\n");
}
return outline.toString();
}
private ModelReply parseModelResponse(
String raw, long promptTokens, long completionTokens, long totalTokens) {
if (!StringUtils.hasText(raw)) {

View File

@@ -0,0 +1,52 @@
package stirling.software.proprietary.service.chatbot;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.VectorStore;
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;
@Slf4j
@Service
@RequiredArgsConstructor
public class ChatbotMemoryService {
private final VectorStore vectorStore;
public void recordTurn(ChatbotSession session, String prompt, String answer) {
if (session == null) {
return;
}
if (!StringUtils.hasText(prompt) && !StringUtils.hasText(answer)) {
return;
}
Map<String, Object> metadata = new HashMap<>();
metadata.put("sessionId", session.getSessionId());
metadata.put("documentId", session.getDocumentId());
metadata.put("turnType", "conversation");
metadata.put("turnTimestamp", Instant.now().toString());
metadata.put("userId", session.getUserId());
StringBuilder contentBuilder = new StringBuilder();
if (StringUtils.hasText(prompt)) {
contentBuilder.append("User: ").append(prompt.trim()).append("\n");
}
if (StringUtils.hasText(answer)) {
contentBuilder.append("Assistant: ").append(answer.trim());
}
try {
vectorStore.add(List.of(new Document(contentBuilder.toString(), metadata)));
} catch (RuntimeException ex) {
log.warn("Failed to persist chatbot conversation turn: {}", ex.getMessage());
}
}
}