diff --git a/src/main/java/de/assecutor/votianlt/model/TranslationCacheEntry.java b/src/main/java/de/assecutor/votianlt/model/TranslationCacheEntry.java new file mode 100644 index 0000000..8ccb09b --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/model/TranslationCacheEntry.java @@ -0,0 +1,51 @@ +package de.assecutor.votianlt.model; + +import lombok.Data; +import lombok.NoArgsConstructor; +import org.bson.types.ObjectId; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * MongoDB document for caching LLM translations. Stores the original text, + * all translations keyed by language code, and the insertion timestamp. + */ +@Data +@NoArgsConstructor +@Document(collection = "translation_cache") +public class TranslationCacheEntry { + + @Id + private ObjectId id; + + /** + * The original text that was translated. + */ + @Indexed(unique = true) + @Field("source_text") + private String sourceText; + + /** + * Translations keyed by language code (e.g. "de", "en", "fr"). + */ + @Field("translations") + private Map translations; + + /** + * When this entry was inserted into the cache. + */ + @Indexed + @Field("inserted_at") + private LocalDateTime insertedAt; + + public TranslationCacheEntry(String sourceText, Map translations) { + this.sourceText = sourceText; + this.translations = translations; + this.insertedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/de/assecutor/votianlt/repository/TranslationCacheRepository.java b/src/main/java/de/assecutor/votianlt/repository/TranslationCacheRepository.java new file mode 100644 index 0000000..1af2459 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/repository/TranslationCacheRepository.java @@ -0,0 +1,24 @@ +package de.assecutor.votianlt.repository; + +import de.assecutor.votianlt.model.TranslationCacheEntry; +import org.bson.types.ObjectId; +import org.springframework.data.domain.Pageable; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.data.mongodb.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface TranslationCacheRepository extends MongoRepository { + + Optional findBySourceText(String sourceText); + + /** + * Returns the oldest entries (by insertedAt ASC), only projecting the id field, + * for efficient bulk deletion. + */ + @Query(value = "{}", fields = "{'_id': 1}") + List findOldestEntries(Pageable pageable); +} diff --git a/src/main/java/de/assecutor/votianlt/service/TranslationService.java b/src/main/java/de/assecutor/votianlt/service/TranslationService.java index d97fbf9..b362a6f 100644 --- a/src/main/java/de/assecutor/votianlt/service/TranslationService.java +++ b/src/main/java/de/assecutor/votianlt/service/TranslationService.java @@ -2,7 +2,12 @@ package de.assecutor.votianlt.service; import de.assecutor.votianlt.ai.service.LlmRestClient; import de.assecutor.votianlt.model.Language; +import de.assecutor.votianlt.model.TranslationCacheEntry; +import de.assecutor.votianlt.repository.TranslationCacheRepository; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import java.util.ArrayList; @@ -10,35 +15,25 @@ import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; /** * Service for translating text into all available languages. Uses LLM API for - * translations with caching. + * translations with MongoDB-backed caching. */ @Service @Slf4j public class TranslationService { - private static final int MAX_CACHE_SIZE = 1000; + private static final long CACHE_CLEANUP_THRESHOLD = 100_000L; + private static final long CACHE_TARGET_SIZE = 50_000L; private final LlmRestClient llmRestClient; + private final TranslationCacheRepository cacheRepository; - // Cache: Original Text -> List of all translations (language -> text) - private final Map> translationCache; - - public TranslationService(LlmRestClient llmRestClient) { + public TranslationService(LlmRestClient llmRestClient, TranslationCacheRepository cacheRepository) { this.llmRestClient = llmRestClient; - // LinkedHashMap with accessOrder=true for LRU behavior - this.translationCache = new LinkedHashMap>(MAX_CACHE_SIZE, 0.75f, true) { - @Override - protected boolean removeEldestEntry(Map.Entry> eldest) { - if (size() > MAX_CACHE_SIZE) { - log.debug("[TranslationCache] Removing oldest entry, cache size: {}", size()); - return true; - } - return false; - } - }; + this.cacheRepository = cacheRepository; } /** @@ -56,8 +51,8 @@ public class TranslationService { } /** - * Translates the given text into all available languages. Uses cache to avoid - * redundant LLM calls. + * Translates the given text into all available languages. Uses MongoDB cache to + * avoid redundant LLM calls. * * @param text * the text to translate @@ -68,63 +63,87 @@ public class TranslationService { return List.of(); } - // Check cache first - List cachedTranslations = translationCache.get(text); - if (cachedTranslations != null) { + // Check MongoDB cache first + Optional cached = cacheRepository.findBySourceText(text); + if (cached.isPresent()) { log.debug("[TranslationCache] Cache hit for '{}'", text); - return cachedTranslations; + return toTranslationList(cached.get().getTranslations()); } // Cache miss - translate via LLM log.debug("[TranslationCache] Cache miss for '{}', calling LLM", text); List translations = translateViaLlm(text); - // Store in cache - translationCache.put(text, translations); - log.debug("[TranslationCache] Stored translations for '{}', cache size: {}", text, translationCache.size()); - - return translations; - } - - /** - * Translates text to all languages via LLM. - * - * @param text - * the text to translate - * @return list of translations for all languages - */ - private List translateViaLlm(String text) { - List translations = new ArrayList<>(); - - // Translate to each language - for (Language lang : getAvailableLanguages()) { - String translatedText = translateTextToLanguage(text, lang); - translations.add(new Translation(lang.name().toLowerCase(), translatedText)); + // 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; } /** - * Translates a single text to the target language using LLM. - * - * @param text - * the text to translate - * @param targetLanguage - * the target language - * @return the translated text + * 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 + public void cleanupCacheIfNeeded() { + long count = cacheRepository.count(); + log.debug("[TranslationCache] Scheduled cleanup check – current size: {}", count); + + if (count > CACHE_CLEANUP_THRESHOLD) { + long toDelete = count - CACHE_TARGET_SIZE; + log.info("[TranslationCache] Cache size {} exceeds threshold {}, deleting {} oldest entries", count, + CACHE_CLEANUP_THRESHOLD, toDelete); + + List oldest = cacheRepository.findOldestEntries( + 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()); + } + } + + /** + * Returns cache statistics for monitoring. + */ + public long getCacheSize() { + return cacheRepository.count(); + } + + /** + * Clears the translation cache. + */ + public void clearCache() { + cacheRepository.deleteAll(); + log.info("[TranslationCache] Cache cleared"); + } + + // --- 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)); + } + 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."; - try { String result = llmRestClient.chat(systemPrompt, text, 0.3, 500); - if (result != null && !result.isBlank()) { - // Clean up the response (remove quotes if present) result = result.trim(); if (result.startsWith("\"") && result.endsWith("\"")) { result = result.substring(1, result.length() - 1); @@ -134,14 +153,9 @@ public class TranslationService { } catch (Exception e) { log.warn("Translation failed for {}: {}", languageName, e.getMessage()); } - - // Fallback: return original text return text; } - /** - * Returns the human-readable language name. - */ private String getLanguageName(Language language) { return switch (language) { case DE -> "German"; @@ -157,18 +171,19 @@ public class TranslationService { }; } - /** - * Returns cache statistics for monitoring. - */ - public int getCacheSize() { - return translationCache.size(); + private static Map toTranslationMap(List translations) { + Map map = new LinkedHashMap<>(); + for (Translation t : translations) { + map.put(t.language(), t.text()); + } + return map; } - /** - * Clears the translation cache. - */ - public void clearCache() { - translationCache.clear(); - log.info("[TranslationCache] Cache cleared"); + private static List toTranslationList(Map map) { + List list = new ArrayList<>(); + if (map != null) { + map.forEach((lang, text) -> list.add(new Translation(lang, text))); + } + return list; } -} \ No newline at end of file +}