Erweiterungen
This commit is contained in:
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user