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