From 93b9f571ab9cd943c10d81b2025aeae751687786 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Mon, 16 Mar 2026 16:15:16 +0900 Subject: [PATCH] Cache repeated query translation and expansion --- TODO.md | 3 +- backend/services/gemini.go | 91 ++++++++++++++++++++++++++++++++- backend/services/gemini_test.go | 26 ++++++++++ 3 files changed, 117 insertions(+), 3 deletions(-) diff --git a/TODO.md b/TODO.md index 746e156..c082178 100644 --- a/TODO.md +++ b/TODO.md @@ -257,11 +257,12 @@ ## Recent Change Log - Date: `2026-03-16` - What changed: + - Added in-process query translation / expansion cache inside `GeminiService` so repeated identical searches can reuse the same English query and variant list without re-calling Gemini or Google Translate. - Added in-process response caching for repeated SearXNG requests and for source fetches used during Envato / Artgrid enrichment. - Added in-process Gemini visual cache for fetched thumbnails and extracted preview frames so repeated candidate evaluation no longer re-downloads the same asset or reruns `ffmpeg` every time. - Tightened backend tests to cover the new cache helpers. - Why it changed: - - Even after reducing query fan-out, repeated search passes and Gemini reevaluation were still paying duplicate network and media-processing cost inside the same running backend. + - Even after reducing query fan-out, repeated search passes and Gemini reevaluation were still paying duplicate translation, network, and media-processing cost inside the same running backend. - How it was verified: - `go test ./...` - `bash scripts/selftest.sh` diff --git a/backend/services/gemini.go b/backend/services/gemini.go index 1df1d49..dd43149 100644 --- a/backend/services/gemini.go +++ b/backend/services/gemini.go @@ -26,6 +26,8 @@ type GeminiService struct { Debug func(message string, data any) cacheMu sync.Mutex visualCache map[string]cachedVisualData + translationCache map[string]cachedStringValue + expansionCache map[string]cachedExpansionValue } type cachedVisualData struct { @@ -34,6 +36,16 @@ type cachedVisualData struct { expiresAt time.Time } +type cachedStringValue struct { + value string + expiresAt time.Time +} + +type cachedExpansionValue struct { + value []string + expiresAt time.Time +} + type AIRecommendation struct { Title string `json:"title"` Link string `json:"link"` @@ -56,12 +68,21 @@ func NewGeminiService(apiKey string) *GeminiService { GenerateEndpoint: "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent", TranslateEndpoint: "https://translate.googleapis.com/translate_a/single", visualCache: map[string]cachedVisualData{}, + translationCache: map[string]cachedStringValue{}, + expansionCache: map[string]cachedExpansionValue{}, } } func (g *GeminiService) ExpandQuery(query string) ([]string, error) { + cacheKey := strings.TrimSpace(query) + if cached, ok := g.getCachedExpansion(cacheKey); ok { + g.debug("gemini:expand_query_cache_hit", map[string]any{"query": query, "expanded": cached}) + return cached, nil + } + englishBase := g.TranslateQuery(query) expanded := buildSearchQueries(query, englishBase) + g.setCachedExpansion(cacheKey, expanded, 15*time.Minute) g.debug("gemini:expand_query", map[string]any{ "original": query, "english": englishBase, @@ -75,11 +96,18 @@ func (g *GeminiService) TranslateQuery(query string) string { if trimmed == "" { return "" } + if cached, ok := g.getCachedTranslation(trimmed); ok { + g.debug("gemini:translate_cache_hit", map[string]any{"query": trimmed, "translated": cached}) + return cached + } normalizedIntent := normalizeKnownMediaPhrases(trimmed) if looksMostlyASCII(normalizedIntent) { - return strings.TrimSpace(normalizedIntent) + result := strings.TrimSpace(normalizedIntent) + g.setCachedTranslation(trimmed, result, 15*time.Minute) + return result } if looksMostlyASCII(trimmed) { + g.setCachedTranslation(trimmed, trimmed, 15*time.Minute) return trimmed } @@ -114,6 +142,7 @@ func (g *GeminiService) TranslateQuery(query string) string { translated := sanitizePlainEnglishLine(rawText) if translated != "" && !strings.EqualFold(translated, trimmed) && !isOvercompressedTranslation(trimmed, translated) { g.debug("gemini:translate_success", map[string]any{"mode": "gemini", "query": trimmed, "translated": translated}) + g.setCachedTranslation(trimmed, translated, 15*time.Minute) return translated } } @@ -125,14 +154,18 @@ func (g *GeminiService) TranslateQuery(query string) string { g.debug("gemini:translate_attempt", map[string]any{"mode": "google", "query": trimmed}) if translated, err := g.translateViaGoogle(trimmed); err == nil && translated != "" && isLikelyEnglishQuery(translated) && !isOvercompressedTranslation(trimmed, translated) { g.debug("gemini:translate_success", map[string]any{"mode": "google", "query": trimmed, "translated": translated}) + g.setCachedTranslation(trimmed, translated, 15*time.Minute) return translated } if translated := translateKoreanMediaTerms(normalizedIntent); translated != "" && !strings.EqualFold(translated, trimmed) { g.debug("gemini:translate_success", map[string]any{"mode": "dictionary", "query": trimmed, "translated": translated}) + g.setCachedTranslation(trimmed, translated, 15*time.Minute) return translated } g.debug("gemini:translate_fallback_original", map[string]any{"query": trimmed, "normalized": normalizedIntent}) - return strings.TrimSpace(normalizedIntent) + result := strings.TrimSpace(normalizedIntent) + g.setCachedTranslation(trimmed, result, 15*time.Minute) + return result } func (g *GeminiService) generateText(body map[string]any) (string, error) { @@ -366,6 +399,60 @@ func (g *GeminiService) setCachedVisual(key, data, mimeType string, ttl time.Dur } } +func (g *GeminiService) getCachedTranslation(key string) (string, bool) { + g.cacheMu.Lock() + defer g.cacheMu.Unlock() + + entry, ok := g.translationCache[key] + if !ok { + return "", false + } + if time.Now().After(entry.expiresAt) { + delete(g.translationCache, key) + return "", false + } + return entry.value, true +} + +func (g *GeminiService) setCachedTranslation(key, value string, ttl time.Duration) { + g.cacheMu.Lock() + defer g.cacheMu.Unlock() + + g.translationCache[key] = cachedStringValue{ + value: value, + expiresAt: time.Now().Add(ttl), + } +} + +func (g *GeminiService) getCachedExpansion(key string) ([]string, bool) { + g.cacheMu.Lock() + defer g.cacheMu.Unlock() + + entry, ok := g.expansionCache[key] + if !ok { + return nil, false + } + if time.Now().After(entry.expiresAt) { + delete(g.expansionCache, key) + return nil, false + } + cloned := make([]string, len(entry.value)) + copy(cloned, entry.value) + return cloned, true +} + +func (g *GeminiService) setCachedExpansion(key string, value []string, ttl time.Duration) { + g.cacheMu.Lock() + defer g.cacheMu.Unlock() + + cloned := make([]string, len(value)) + copy(cloned, value) + g.expansionCache[key] = cachedExpansionValue{ + value: cloned, + expiresAt: time.Now().Add(ttl), + } +} + func newBrowserStyleImageRequest(imageURL, referer string) (*http.Request, error) { req, err := http.NewRequest(http.MethodGet, imageURL, nil) if err != nil { diff --git a/backend/services/gemini_test.go b/backend/services/gemini_test.go index d35dfc9..a3c59d8 100644 --- a/backend/services/gemini_test.go +++ b/backend/services/gemini_test.go @@ -88,3 +88,29 @@ func TestGeminiVisualCacheRoundTrip(t *testing.T) { t.Fatalf("unexpected cached visual data: %q %q", data, mimeType) } } + +func TestGeminiTranslationCacheRoundTrip(t *testing.T) { + service := NewGeminiService("") + service.setCachedTranslation("비 오는 도시", "rainy city", time.Minute) + + value, ok := service.getCachedTranslation("비 오는 도시") + if !ok { + t.Fatal("expected translation cache hit") + } + if value != "rainy city" { + t.Fatalf("unexpected translation cache value: %q", value) + } +} + +func TestGeminiExpansionCacheRoundTrip(t *testing.T) { + service := NewGeminiService("") + service.setCachedExpansion("city rain", []string{"city rain", "city rain stock footage"}, time.Minute) + + value, ok := service.getCachedExpansion("city rain") + if !ok { + t.Fatal("expected expansion cache hit") + } + if len(value) != 2 || value[1] != "city rain stock footage" { + t.Fatalf("unexpected expansion cache value: %#v", value) + } +}