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.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<String, List<Translation>> 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<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;
}
};
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<Translation> cachedTranslations = translationCache.get(text);
if (cachedTranslations != null) {
// Check MongoDB cache first
Optional<TranslationCacheEntry> 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<Translation> 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<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));
// Persist in MongoDB cache
Map<String, String> 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<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) {
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<String, String> toTranslationMap(List<Translation> translations) {
Map<String, String> 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<Translation> toTranslationList(Map<String, String> map) {
List<Translation> list = new ArrayList<>();
if (map != null) {
map.forEach((lang, text) -> list.add(new Translation(lang, text)));
}
return list;
}
}