Cache repeated query translation and expansion
build-push / docker (push) Successful in 5m20s

This commit is contained in:
AI Assistant
2026-03-16 16:15:16 +09:00
parent 60fdd7842c
commit 93b9f571ab
3 changed files with 117 additions and 3 deletions
+2 -1
View File
@@ -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`
+89 -2
View File
@@ -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 {
+26
View File
@@ -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)
}
}