From 4db2b1f9639837b7bd76e31f03800251e4d7888a Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Mon, 16 Mar 2026 17:22:12 +0900 Subject: [PATCH] Stabilize Gemini visual fallback handling --- TODO.md | 14 ++++++++++++ backend/handlers/api.go | 3 +++ backend/services/cse_test.go | 10 +++++++++ backend/services/gemini.go | 31 +++++++++++++++++++++------ backend/services/search_collectors.go | 4 ++-- 5 files changed, 53 insertions(+), 9 deletions(-) diff --git a/TODO.md b/TODO.md index f0a788f..6c2ca48 100644 --- a/TODO.md +++ b/TODO.md @@ -255,6 +255,20 @@ - backend debug broadcasts ## Recent Change Log +- Date: `2026-03-16` +- What changed: + - Stabilized the Gemini visual-review path after widened search budgets caused full-batch “no candidate thumbnails or preview frames” failures. + - Google Video enrichment now always prefers the canonical YouTube `ytimg` thumbnail instead of keeping a potentially broken search-engine thumbnail. + - Gemini visual fetch now preserves the last concrete fetch error, retries with derived YouTube thumbnails when possible, and only falls back to the generic no-visual message after all image/frame paths fail. + - User-facing fallback warning text for the all-batches-no-visual case is now softened so ranked results can still be shown without surfacing the raw internal Gemini error string in the UI. +- Why it changed: + - The latest deployed build widened search enough that top-ranked candidates sometimes carried metadata without any fetchable image bytes, causing Gemini review to fail for every batch and surfacing an alarming warning even though ranked fallback results still existed. +- How it was verified: + - `go test ./...` + - `bash scripts/selftest.sh` +- What is still risky or incomplete: + - This improves YouTube-backed candidates immediately, but Envato/Artgrid thumbnails can still be inaccessible in some provider-side cases, so Gemini can still fall back to ranked results when source media is locked down. + - Date: `2026-03-16` - What changed: - Hid the expanded query-variant chip list from the search UI while leaving backend/debug query visibility intact. diff --git a/backend/handlers/api.go b/backend/handlers/api.go index 810887b..4f0731a 100644 --- a/backend/handlers/api.go +++ b/backend/handlers/api.go @@ -471,6 +471,9 @@ func (a *App) searchMedia(c *gin.Context) { } if geminiErr != nil && len(recommended) == 0 { warning := geminiErr.Error() + if strings.Contains(warning, "no candidate thumbnails or preview frames could be fetched for gemini vision") { + warning = "AI visual review was unavailable for this search, so ranked results are being shown instead." + } fallback := services.BuildFallbackRecommendations(scored, 20, "") a.debug("search fallback summary", summarizeRecommendationResults(fallback, time.Since(started), warning)) a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "Gemini Vision fallback to ranked results", "progress": 90, "message": warning}) diff --git a/backend/services/cse_test.go b/backend/services/cse_test.go index 6087f05..1b0927f 100644 --- a/backend/services/cse_test.go +++ b/backend/services/cse_test.go @@ -134,6 +134,16 @@ func TestLowValueThumbnailDetection(t *testing.T) { } } +func TestGoogleVideoCollectorPrefersYouTubeDerivedThumbnail(t *testing.T) { + result := googleVideoCollector{}.Enrich(nil, SearchResult{ + Link: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + ThumbnailURL: "https://example.com/some-search-thumb.jpg", + }) + if result.ThumbnailURL != "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg" { + t.Fatalf("expected derived youtube thumbnail, got %q", result.ThumbnailURL) + } +} + func TestGeminiCandidateLimitNeverExceedsCandidates(t *testing.T) { if got := GeminiCandidateLimit(9); got != 9 { t.Fatalf("expected Gemini limit to stay within candidate count, got %d", got) diff --git a/backend/services/gemini.go b/backend/services/gemini.go index 609de87..bb37349 100644 --- a/backend/services/gemini.go +++ b/backend/services/gemini.go @@ -474,6 +474,7 @@ func newBrowserStyleImageRequest(imageURL, referer string) (*http.Request, error } func (g *GeminiService) fetchCandidateVisualInlineData(candidate SearchResult) (string, string, error) { + lastErr := fmt.Errorf("candidate has no thumbnail or preview video") if candidate.PreviewVideoURL != "" && (candidate.Source == "Envato" || candidate.Source == "Artgrid") { cacheKey := "frame\n" + candidate.PreviewVideoURL if data, mimeType, ok := g.getCachedVisual(cacheKey); ok { @@ -484,6 +485,7 @@ func (g *GeminiService) fetchCandidateVisualInlineData(candidate SearchResult) ( g.setCachedVisual(cacheKey, data, mimeType, 10*time.Minute) return data, mimeType, nil } + lastErr = err } if candidate.ThumbnailURL != "" { if isLowValueThumbnail(candidate.ThumbnailURL) { @@ -492,17 +494,31 @@ func (g *GeminiService) fetchCandidateVisualInlineData(candidate SearchResult) ( "source": candidate.Source, "thumbnailUrl": candidate.ThumbnailURL, }) - return "", "", fmt.Errorf("candidate thumbnail is low value") + lastErr = fmt.Errorf("candidate thumbnail is low value") + } else { + cacheKey := "image\n" + candidate.ThumbnailURL + if data, mimeType, ok := g.getCachedVisual(cacheKey); ok { + return data, mimeType, nil + } + data, mimeType, err := fetchImageAsInlineData(g.Client, candidate.ThumbnailURL, candidate.Link) + if err == nil { + g.setCachedVisual(cacheKey, data, mimeType, 10*time.Minute) + return data, mimeType, nil + } + lastErr = err } - cacheKey := "image\n" + candidate.ThumbnailURL + } + if fallbackThumbnail := deriveThumbnail(candidate.Link); fallbackThumbnail != "" && fallbackThumbnail != candidate.ThumbnailURL { + cacheKey := "image\n" + fallbackThumbnail if data, mimeType, ok := g.getCachedVisual(cacheKey); ok { return data, mimeType, nil } - data, mimeType, err := fetchImageAsInlineData(g.Client, candidate.ThumbnailURL, candidate.Link) + data, mimeType, err := fetchImageAsInlineData(g.Client, fallbackThumbnail, candidate.Link) if err == nil { g.setCachedVisual(cacheKey, data, mimeType, 10*time.Minute) return data, mimeType, nil } + lastErr = err } if candidate.PreviewVideoURL != "" { cacheKey := "frame\n" + candidate.PreviewVideoURL @@ -511,12 +527,13 @@ func (g *GeminiService) fetchCandidateVisualInlineData(candidate SearchResult) ( } data, mimeType, err := extractFrameFromVideo(candidate.PreviewVideoURL) if err != nil { - return "", "", err + lastErr = err + } else { + g.setCachedVisual(cacheKey, data, mimeType, 10*time.Minute) + return data, mimeType, nil } - g.setCachedVisual(cacheKey, data, mimeType, 10*time.Minute) - return data, mimeType, nil } - return "", "", fmt.Errorf("candidate has no thumbnail or preview video") + return "", "", lastErr } func extractFrameFromVideo(videoURL string) (string, string, error) { diff --git a/backend/services/search_collectors.go b/backend/services/search_collectors.go index dc6ae78..973f10b 100644 --- a/backend/services/search_collectors.go +++ b/backend/services/search_collectors.go @@ -59,8 +59,8 @@ func (googleVideoCollector) Accept(result SearchResult) bool { return isUsefulGoogleVideoResult(result) } func (googleVideoCollector) Enrich(searcher *SearchService, result SearchResult) SearchResult { - if result.ThumbnailURL == "" { - result.ThumbnailURL = deriveThumbnail(result.Link) + if derived := deriveThumbnail(result.Link); derived != "" { + result.ThumbnailURL = derived } result.Source = strings.TrimSpace(result.Source) if result.Source == "" {