diff --git a/src/main/java/de/assecutor/votianlt/ai/config/LlmConfig.java b/src/main/java/de/assecutor/votianlt/ai/config/LlmConfig.java index 43b423c..fa85818 100644 --- a/src/main/java/de/assecutor/votianlt/ai/config/LlmConfig.java +++ b/src/main/java/de/assecutor/votianlt/ai/config/LlmConfig.java @@ -17,7 +17,7 @@ import java.net.URL; @Slf4j public class LlmConfig { - @Value("${spring.ai.openai.base-url:http://192.168.180.3:1234}") + @Value("${spring.ai.openai.base-url:https://lmstudio.appcreation.de}") private String baseUrl; @Value("${spring.ai.openai.chat.options.model:local-model}") diff --git a/src/main/java/de/assecutor/votianlt/ai/service/LlmRestClient.java b/src/main/java/de/assecutor/votianlt/ai/service/LlmRestClient.java index aea0803..1744e25 100644 --- a/src/main/java/de/assecutor/votianlt/ai/service/LlmRestClient.java +++ b/src/main/java/de/assecutor/votianlt/ai/service/LlmRestClient.java @@ -24,7 +24,7 @@ public class LlmRestClient { private final ObjectMapper objectMapper; private final String model; - public LlmRestClient(@Value("${spring.ai.openai.base-url:http://192.168.180.3:1234}") String baseUrl, + public LlmRestClient(@Value("${spring.ai.openai.base-url:https://lmstudio.appcreation.de}") String baseUrl, @Value("${spring.ai.openai.chat.options.model:local-model}") String model, ObjectMapper objectMapper) { this.webClient = WebClient.builder().baseUrl(baseUrl + "/v1/chat/completions").build(); diff --git a/src/main/java/de/assecutor/votianlt/messaging/MessagingPublisher.java b/src/main/java/de/assecutor/votianlt/messaging/MessagingPublisher.java index cb429ef..5b8cac3 100644 --- a/src/main/java/de/assecutor/votianlt/messaging/MessagingPublisher.java +++ b/src/main/java/de/assecutor/votianlt/messaging/MessagingPublisher.java @@ -12,7 +12,9 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.List; +import java.util.Map; // Force recompile @@ -69,21 +71,32 @@ class MessagingPublisherImpl implements MessagingPublisher { } /** - * Processes the payload and adds translations for job remarks, task - * descriptions/texts, and cargo item descriptions. + * Collects all translatable texts from the payload, fetches all translations in + * one batch (at most one LLM call), then applies them to the JSON tree. */ private Object processPayloadWithTranslations(Object payload) { try { - // Handle single JobWithRelatedDataDTO if (payload instanceof JobWithRelatedDataDTO dto) { - return convertToTranslatedJson(dto); + List texts = collectTexts(dto); + Map> translations = translationService + .translateBatch(texts); + return convertToTranslatedJson(dto, translations); } - // Handle list of JobWithRelatedDataDTO - if (payload instanceof List list && !list.isEmpty() && list.get(0) instanceof JobWithRelatedDataDTO) { + if (payload instanceof List list && !list.isEmpty() + && list.get(0) instanceof JobWithRelatedDataDTO) { @SuppressWarnings("unchecked") List dtoList = (List) list; - return dtoList.stream().map(this::convertToTranslatedJson).toList(); + + // Collect all texts from all DTOs and translate in one batch + List allTexts = dtoList.stream() + .flatMap(d -> collectTexts(d).stream()) + .distinct() + .toList(); + Map> translations = translationService + .translateBatch(allTexts); + + return dtoList.stream().map(d -> convertToTranslatedJson(d, translations)).toList(); } return payload; @@ -94,53 +107,86 @@ class MessagingPublisherImpl implements MessagingPublisher { } /** - * Converts JobWithRelatedDataDTO to JSON with translated fields. + * Collects all non-blank translatable strings from a DTO. */ - private ObjectNode convertToTranslatedJson(JobWithRelatedDataDTO dto) { - // Convert to JSON tree - ObjectNode root = objectMapper.valueToTree(dto); + private List collectTexts(JobWithRelatedDataDTO dto) { + List texts = new ArrayList<>(); - // Translate job remark - if (dto.getJob() != null && dto.getJob().getRemark() != null && !dto.getJob().getRemark().isBlank()) { - List translations = translationService - .translateToAllLanguages(dto.getJob().getRemark()); - root.withObject("job").set("remark", createTranslationArray(translations)); + if (dto.getJob() != null && isNonBlank(dto.getJob().getRemark())) { + texts.add(dto.getJob().getRemark()); } - // Translate task descriptions, displayNames and button texts + if (dto.getTasks() != null) { + for (BaseTask task : dto.getTasks()) { + if (isNonBlank(task.getDescription())) { + texts.add(task.getDescription()); + } + if (isNonBlank(task.getDisplayName())) { + texts.add(task.getDisplayName()); + } + if (task instanceof ConfirmationTask ct && isNonBlank(ct.getButtonText())) { + texts.add(ct.getButtonText()); + } + } + } + + if (dto.getCargoItems() != null) { + for (CargoItem item : dto.getCargoItems()) { + if (isNonBlank(item.getDescription())) { + texts.add(item.getDescription()); + } + } + } + + return texts; + } + + /** + * Converts a DTO to a JSON tree and replaces translatable string fields with + * translation arrays, using the pre-fetched translation map. + */ + private ObjectNode convertToTranslatedJson(JobWithRelatedDataDTO dto, + Map> translations) { + + ObjectNode root = objectMapper.valueToTree(dto); + + // Job remark + if (dto.getJob() != null && isNonBlank(dto.getJob().getRemark())) { + List t = translations.get(dto.getJob().getRemark()); + if (t != null) { + root.withObject("job").set("remark", createTranslationArray(t)); + } + } + + // Tasks if (dto.getTasks() != null && !dto.getTasks().isEmpty()) { ArrayNode tasksNode = root.withArray("tasks"); for (int i = 0; i < dto.getTasks().size(); i++) { BaseTask task = dto.getTasks().get(i); ObjectNode taskNode = (ObjectNode) tasksNode.get(i); - // Translate description - if (task.getDescription() != null && !task.getDescription().isBlank()) { - List translations = translationService - .translateToAllLanguages(task.getDescription()); - taskNode.set("description", createTranslationArray(translations)); + if (isNonBlank(task.getDescription())) { + List t = translations.get(task.getDescription()); + if (t != null) { + taskNode.set("description", createTranslationArray(t)); + } } - // Translate displayName - if (task.getDisplayName() != null && !task.getDisplayName().isBlank()) { - List translations = translationService - .translateToAllLanguages(task.getDisplayName()); - taskNode.set("displayName", createTranslationArray(translations)); + if (isNonBlank(task.getDisplayName())) { + List t = translations.get(task.getDisplayName()); + if (t != null) { + taskNode.set("displayName", createTranslationArray(t)); + } } - // Translate buttonText in taskSpecificData for ConfirmationTask - if (task instanceof ConfirmationTask confirmationTask) { - if (confirmationTask.getButtonText() != null && !confirmationTask.getButtonText().isBlank()) { - // Translate buttonText at task level - List translations = translationService - .translateToAllLanguages(confirmationTask.getButtonText()); - taskNode.set("buttonText", createTranslationArray(translations)); - - // Also translate buttonText in taskSpecificData if present + if (task instanceof ConfirmationTask ct && isNonBlank(ct.getButtonText())) { + List t = translations.get(ct.getButtonText()); + if (t != null) { + taskNode.set("buttonText", createTranslationArray(t)); if (taskNode.has("taskSpecificData")) { - ObjectNode taskSpecificData = (ObjectNode) taskNode.get("taskSpecificData"); - if (taskSpecificData.has("buttonText")) { - taskSpecificData.set("buttonText", createTranslationArray(translations)); + ObjectNode tsd = (ObjectNode) taskNode.get("taskSpecificData"); + if (tsd.has("buttonText")) { + tsd.set("buttonText", createTranslationArray(t)); } } } @@ -148,17 +194,18 @@ class MessagingPublisherImpl implements MessagingPublisher { } } - // Translate cargo item descriptions + // Cargo items if (dto.getCargoItems() != null && !dto.getCargoItems().isEmpty()) { ArrayNode cargoItemsNode = root.withArray("cargoItems"); for (int i = 0; i < dto.getCargoItems().size(); i++) { CargoItem item = dto.getCargoItems().get(i); ObjectNode itemNode = (ObjectNode) cargoItemsNode.get(i); - if (item.getDescription() != null && !item.getDescription().isBlank()) { - List translations = translationService - .translateToAllLanguages(item.getDescription()); - itemNode.set("description", createTranslationArray(translations)); + if (isNonBlank(item.getDescription())) { + List t = translations.get(item.getDescription()); + if (t != null) { + itemNode.set("description", createTranslationArray(t)); + } } } } @@ -179,4 +226,8 @@ class MessagingPublisherImpl implements MessagingPublisher { } return array; } -} \ No newline at end of file + + private static boolean isNonBlank(String s) { + return s != null && !s.isBlank(); + } +} diff --git a/src/main/java/de/assecutor/votianlt/service/TranslationService.java b/src/main/java/de/assecutor/votianlt/service/TranslationService.java index b362a6f..6ba7bb9 100644 --- a/src/main/java/de/assecutor/votianlt/service/TranslationService.java +++ b/src/main/java/de/assecutor/votianlt/service/TranslationService.java @@ -1,5 +1,7 @@ package de.assecutor.votianlt.service; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import de.assecutor.votianlt.ai.service.LlmRestClient; import de.assecutor.votianlt.model.Language; import de.assecutor.votianlt.model.TranslationCacheEntry; @@ -20,6 +22,11 @@ import java.util.Optional; /** * Service for translating text into all available languages. Uses LLM API for * translations with MongoDB-backed caching. + * + *

+ * The central method is {@link #translateBatch(List)}: it checks the cache for + * every requested text and, for all cache misses, issues exactly ONE LLM call + * that returns translations for all missing texts and all languages at once. */ @Service @Slf4j @@ -30,10 +37,13 @@ public class TranslationService { private final LlmRestClient llmRestClient; private final TranslationCacheRepository cacheRepository; + private final ObjectMapper objectMapper; - public TranslationService(LlmRestClient llmRestClient, TranslationCacheRepository cacheRepository) { + public TranslationService(LlmRestClient llmRestClient, TranslationCacheRepository cacheRepository, + ObjectMapper objectMapper) { this.llmRestClient = llmRestClient; this.cacheRepository = cacheRepository; + this.objectMapper = objectMapper; } /** @@ -51,8 +61,71 @@ public class TranslationService { } /** - * Translates the given text into all available languages. Uses MongoDB cache to - * avoid redundant LLM calls. + * Translates a list of texts into all available languages using at most one LLM + * call. Texts already present in the MongoDB cache are served from there; + * remaining cache misses are translated together in a single LLM request. + * + * @param texts + * the texts to translate (duplicates and blanks are ignored) + * @return map from each source text to its list of translations + */ + public Map> translateBatch(List texts) { + if (texts == null || texts.isEmpty()) { + return Map.of(); + } + + Map> result = new LinkedHashMap<>(); + List cacheMisses = new ArrayList<>(); + + // Phase 1: serve from cache where possible + for (String text : texts) { + if (text == null || text.isBlank()) { + continue; + } + Optional cached = cacheRepository.findBySourceText(text); + if (cached.isPresent()) { + log.debug("[TranslationCache] Cache hit for '{}'", text); + result.put(text, toTranslationList(cached.get().getTranslations())); + } else { + cacheMisses.add(text); + } + } + + if (cacheMisses.isEmpty()) { + log.debug("[TranslationCache] All {} texts served from cache", texts.size()); + return result; + } + + log.debug("[TranslationCache] {} cached, {} cache misses – calling LLM once", result.size(), + cacheMisses.size()); + + // Phase 2: one LLM call for all cache misses + Map> batchResult = callLlmForBatch(cacheMisses); + + for (int i = 0; i < cacheMisses.size(); i++) { + String text = cacheMisses.get(i); + Map translationMap = batchResult.getOrDefault(i, Map.of()); + + List translations = buildTranslationList(text, translationMap); + result.put(text, translations); + + // Persist in MongoDB cache + Map mapToStore = translationMap.isEmpty() ? toTranslationMap(translations) : translationMap; + try { + cacheRepository.save(new TranslationCacheEntry(text, mapToStore)); + } catch (Exception e) { + log.debug("[TranslationCache] Could not persist cache entry (may be duplicate): {}", e.getMessage()); + } + } + + log.debug("[TranslationCache] Batch complete – {} texts translated, cache size: {}", cacheMisses.size(), + cacheRepository.count()); + return result; + } + + /** + * Convenience wrapper for a single text. Delegates to + * {@link #translateBatch(List)}. * * @param text * the text to translate @@ -62,37 +135,14 @@ public class TranslationService { if (text == null || text.isBlank()) { return List.of(); } - - // Check MongoDB cache first - Optional cached = cacheRepository.findBySourceText(text); - if (cached.isPresent()) { - log.debug("[TranslationCache] Cache hit for '{}'", text); - return toTranslationList(cached.get().getTranslations()); - } - - // Cache miss - translate via LLM - log.debug("[TranslationCache] Cache miss for '{}', calling LLM", text); - List translations = translateViaLlm(text); - - // Persist in MongoDB cache - Map translationMap = toTranslationMap(translations); - try { - cacheRepository.save(new TranslationCacheEntry(text, translationMap)); - log.debug("[TranslationCache] Stored translations for '{}', cache size: {}", text, - cacheRepository.count()); - } catch (Exception e) { - // Duplicate key on concurrent insert is harmless - log and continue - log.debug("[TranslationCache] Could not persist cache entry (may be duplicate): {}", e.getMessage()); - } - - return translations; + return translateBatch(List.of(text)).getOrDefault(text, List.of()); } /** * Scheduled cleanup: if the cache exceeds 100,000 entries, deletes the oldest * entries until only 50,000 remain. Runs every hour. */ - @Scheduled(fixedDelay = 3_600_000) // every hour + @Scheduled(fixedDelay = 3_600_000) public void cleanupCacheIfNeeded() { long count = cacheRepository.count(); log.debug("[TranslationCache] Scheduled cleanup check – current size: {}", count); @@ -106,69 +156,96 @@ public class TranslationService { PageRequest.of(0, (int) toDelete, Sort.by(Sort.Direction.ASC, "inserted_at"))); cacheRepository.deleteAll(oldest); - log.info("[TranslationCache] Deleted {} entries, new size: {}", oldest.size(), - cacheRepository.count()); + log.info("[TranslationCache] Deleted {} entries, new size: {}", oldest.size(), cacheRepository.count()); } } /** - * Returns cache statistics for monitoring. + * Returns the current number of entries in the MongoDB translation cache. */ public long getCacheSize() { return cacheRepository.count(); } /** - * Clears the translation cache. + * Clears the entire translation cache. */ public void clearCache() { cacheRepository.deleteAll(); log.info("[TranslationCache] Cache cleared"); } - // --- private helpers --- + // --------------------------------------------------------------------------- + // private helpers + // --------------------------------------------------------------------------- - private List translateViaLlm(String text) { - List translations = new ArrayList<>(); - for (Language lang : getAvailableLanguages()) { - String translatedText = translateTextToLanguage(text, lang); - translations.add(new Translation(lang.name().toLowerCase(), translatedText)); + /** + * Sends a single LLM request for a list of texts and returns translations + * indexed by the position in the input list. + */ + private Map> callLlmForBatch(List texts) { + StringBuilder textsListing = new StringBuilder(); + for (int i = 0; i < texts.size(); i++) { + textsListing.append(i).append(": ").append(texts.get(i)).append("\n"); } - return translations; - } - private String translateTextToLanguage(String text, Language targetLanguage) { - String languageName = getLanguageName(targetLanguage); - String systemPrompt = "You are a professional translator. Translate the given text to " + languageName - + ". Return ONLY the translated text, nothing else. Do not add quotes or explanations."; + String systemPrompt = """ + You are a professional translator. + Translate ALL of the following numbered texts into ALL of these languages and return \ + the result as a single valid JSON object. + Languages: de (German), en (English), fr (French), es (Spanish), tr (Turkish), \ + pl (Polish), ru (Russian), ee (Estonian), lv (Latvian), lt (Lithuanian). + The JSON must use the text index as the key (as a string: "0", "1", ...) and an \ + object with language codes as the value. + Return ONLY the JSON object, no markdown, no explanations, no extra text. + Example for 2 texts: + {"0":{"de":"...","en":"...","fr":"...","es":"...","tr":"...","pl":"...","ru":"...","ee":"...","lv":"...","lt":"..."},\ + "1":{"de":"...","en":"...","fr":"...","es":"...","tr":"...","pl":"...","ru":"...","ee":"...","lv":"...","lt":"..."}} + """; + + // Estimate output tokens: ~400 tokens per text × languages, capped at 8 000 + int maxTokens = Math.min(500 + 400 * texts.size(), 8_000); + try { - String result = llmRestClient.chat(systemPrompt, text, 0.3, 500); - if (result != null && !result.isBlank()) { - result = result.trim(); - if (result.startsWith("\"") && result.endsWith("\"")) { - result = result.substring(1, result.length() - 1); - } - return result.trim(); + String raw = llmRestClient.chat(systemPrompt, textsListing.toString(), 0.3, maxTokens); + if (raw != null && !raw.isBlank()) { + raw = extractJsonObject(raw); + Map> parsed = objectMapper.readValue(raw, new TypeReference<>() { + }); + Map> indexed = new LinkedHashMap<>(); + parsed.forEach((key, value) -> { + try { + indexed.put(Integer.parseInt(key), value); + } catch (NumberFormatException e) { + log.warn("[TranslationCache] Unexpected key in batch LLM response: {}", key); + } + }); + return indexed; } } catch (Exception e) { - log.warn("Translation failed for {}: {}", languageName, e.getMessage()); + log.warn("[TranslationCache] Batch LLM call failed: {}", e.getMessage()); } - return text; + + return Map.of(); } - private String getLanguageName(Language language) { - return switch (language) { - case DE -> "German"; - case EN -> "English"; - case FR -> "French"; - case ES -> "Spanish"; - case TR -> "Turkish"; - case PL -> "Polish"; - case RU -> "Russian"; - case EE -> "Estonian"; - case LV -> "Latvian"; - case LT -> "Lithuanian"; - }; + private List buildTranslationList(String fallback, Map translationMap) { + List list = new ArrayList<>(); + for (Language lang : getAvailableLanguages()) { + String code = lang.name().toLowerCase(); + String translated = translationMap.getOrDefault(code, fallback); + list.add(new Translation(code, (translated == null || translated.isBlank()) ? fallback : translated)); + } + return list; + } + + private static String extractJsonObject(String raw) { + int start = raw.indexOf('{'); + int end = raw.lastIndexOf('}'); + if (start >= 0 && end > start) { + return raw.substring(start, end + 1); + } + return raw; } private static Map toTranslationMap(List translations) { diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 82fcbcf..3b9d1d9 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -74,7 +74,7 @@ app.google.maps.api-key=AIzaSyDnbitL06iLp3elmj-WtPudCykX9xvXcVE # =========================================== # LLM Configuration (LM Studio) # =========================================== -spring.ai.openai.base-url=http://192.168.180.3:1234 +spring.ai.openai.base-url=https://lmstudio.appcreation.de spring.ai.openai.api-key=not-used spring.ai.openai.chat.options.model=local-model spring.ai.openai.chat.options.temperature=0.7