Erweiterungen

This commit is contained in:
2026-02-22 10:21:58 +01:00
parent ac569e1540
commit d16f30c64d
3 changed files with 162 additions and 72 deletions

View File

@@ -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<String, String> translations;
/**
* When this entry was inserted into the cache.
*/
@Indexed
@Field("inserted_at")
private LocalDateTime insertedAt;
public TranslationCacheEntry(String sourceText, Map<String, String> translations) {
this.sourceText = sourceText;
this.translations = translations;
this.insertedAt = LocalDateTime.now();
}
}

View File

@@ -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<TranslationCacheEntry, ObjectId> {
Optional<TranslationCacheEntry> 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<TranslationCacheEntry> findOldestEntries(Pageable pageable);
}

View File

@@ -2,7 +2,12 @@ package de.assecutor.votianlt.service;
import de.assecutor.votianlt.ai.service.LlmRestClient; import de.assecutor.votianlt.ai.service.LlmRestClient;
import de.assecutor.votianlt.model.Language; import de.assecutor.votianlt.model.Language;
import de.assecutor.votianlt.model.TranslationCacheEntry;
import de.assecutor.votianlt.repository.TranslationCacheRepository;
import lombok.extern.slf4j.Slf4j; 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 org.springframework.stereotype.Service;
import java.util.ArrayList; import java.util.ArrayList;
@@ -10,35 +15,25 @@ import java.util.Arrays;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
/** /**
* Service for translating text into all available languages. Uses LLM API for * Service for translating text into all available languages. Uses LLM API for
* translations with caching. * translations with MongoDB-backed caching.
*/ */
@Service @Service
@Slf4j @Slf4j
public class TranslationService { 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 LlmRestClient llmRestClient;
private final TranslationCacheRepository cacheRepository;
// Cache: Original Text -> List of all translations (language -> text) public TranslationService(LlmRestClient llmRestClient, TranslationCacheRepository cacheRepository) {
private final Map<String, List<Translation>> translationCache;
public TranslationService(LlmRestClient llmRestClient) {
this.llmRestClient = llmRestClient; this.llmRestClient = llmRestClient;
// LinkedHashMap with accessOrder=true for LRU behavior this.cacheRepository = cacheRepository;
this.translationCache = new LinkedHashMap<String, List<Translation>>(MAX_CACHE_SIZE, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, List<Translation>> eldest) {
if (size() > MAX_CACHE_SIZE) {
log.debug("[TranslationCache] Removing oldest entry, cache size: {}", size());
return true;
}
return false;
}
};
} }
/** /**
@@ -56,8 +51,8 @@ public class TranslationService {
} }
/** /**
* Translates the given text into all available languages. Uses cache to avoid * Translates the given text into all available languages. Uses MongoDB cache to
* redundant LLM calls. * avoid redundant LLM calls.
* *
* @param text * @param text
* the text to translate * the text to translate
@@ -68,63 +63,87 @@ public class TranslationService {
return List.of(); return List.of();
} }
// Check cache first // Check MongoDB cache first
List<Translation> cachedTranslations = translationCache.get(text); Optional<TranslationCacheEntry> cached = cacheRepository.findBySourceText(text);
if (cachedTranslations != null) { if (cached.isPresent()) {
log.debug("[TranslationCache] Cache hit for '{}'", text); log.debug("[TranslationCache] Cache hit for '{}'", text);
return cachedTranslations; return toTranslationList(cached.get().getTranslations());
} }
// Cache miss - translate via LLM // Cache miss - translate via LLM
log.debug("[TranslationCache] Cache miss for '{}', calling LLM", text); log.debug("[TranslationCache] Cache miss for '{}', calling LLM", text);
List<Translation> translations = translateViaLlm(text); List<Translation> translations = translateViaLlm(text);
// Store in cache // Persist in MongoDB cache
translationCache.put(text, translations); Map<String, String> translationMap = toTranslationMap(translations);
log.debug("[TranslationCache] Stored translations for '{}', cache size: {}", text, translationCache.size()); try {
cacheRepository.save(new TranslationCacheEntry(text, translationMap));
return translations; log.debug("[TranslationCache] Stored translations for '{}', cache size: {}", text,
} cacheRepository.count());
} catch (Exception e) {
/** // Duplicate key on concurrent insert is harmless - log and continue
* Translates text to all languages via LLM. log.debug("[TranslationCache] Could not persist cache entry (may be duplicate): {}", e.getMessage());
*
* @param text
* the text to translate
* @return list of translations for all languages
*/
private List<Translation> translateViaLlm(String text) {
List<Translation> translations = new ArrayList<>();
// Translate to each language
for (Language lang : getAvailableLanguages()) {
String translatedText = translateTextToLanguage(text, lang);
translations.add(new Translation(lang.name().toLowerCase(), translatedText));
} }
return translations; return translations;
} }
/** /**
* Translates a single text to the target language using LLM. * Scheduled cleanup: if the cache exceeds 100,000 entries, deletes the oldest
* * entries until only 50,000 remain. Runs every hour.
* @param text
* the text to translate
* @param targetLanguage
* the target language
* @return the translated text
*/ */
@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<TranslationCacheEntry> 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<Translation> translateViaLlm(String text) {
List<Translation> 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) { private String translateTextToLanguage(String text, Language targetLanguage) {
String languageName = getLanguageName(targetLanguage); String languageName = getLanguageName(targetLanguage);
String systemPrompt = "You are a professional translator. Translate the given text to " + languageName 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."; + ". Return ONLY the translated text, nothing else. Do not add quotes or explanations.";
try { try {
String result = llmRestClient.chat(systemPrompt, text, 0.3, 500); String result = llmRestClient.chat(systemPrompt, text, 0.3, 500);
if (result != null && !result.isBlank()) { if (result != null && !result.isBlank()) {
// Clean up the response (remove quotes if present)
result = result.trim(); result = result.trim();
if (result.startsWith("\"") && result.endsWith("\"")) { if (result.startsWith("\"") && result.endsWith("\"")) {
result = result.substring(1, result.length() - 1); result = result.substring(1, result.length() - 1);
@@ -134,14 +153,9 @@ public class TranslationService {
} catch (Exception e) { } catch (Exception e) {
log.warn("Translation failed for {}: {}", languageName, e.getMessage()); log.warn("Translation failed for {}: {}", languageName, e.getMessage());
} }
// Fallback: return original text
return text; return text;
} }
/**
* Returns the human-readable language name.
*/
private String getLanguageName(Language language) { private String getLanguageName(Language language) {
return switch (language) { return switch (language) {
case DE -> "German"; case DE -> "German";
@@ -157,18 +171,19 @@ public class TranslationService {
}; };
} }
/** private static Map<String, String> toTranslationMap(List<Translation> translations) {
* Returns cache statistics for monitoring. Map<String, String> map = new LinkedHashMap<>();
*/ for (Translation t : translations) {
public int getCacheSize() { map.put(t.language(), t.text());
return translationCache.size(); }
return map;
} }
/** private static List<Translation> toTranslationList(Map<String, String> map) {
* Clears the translation cache. List<Translation> list = new ArrayList<>();
*/ if (map != null) {
public void clearCache() { map.forEach((lang, text) -> list.add(new Translation(lang, text)));
translationCache.clear(); }
log.info("[TranslationCache] Cache cleared"); return list;
} }
} }