Stabilize modal rendering and sequential Gemini flow
build-push / docker (push) Successful in 4m13s

This commit is contained in:
AI Assistant
2026-03-16 12:45:12 +09:00
parent 82cead950e
commit c0830b5fde
7 changed files with 145 additions and 125 deletions
+15 -4
View File
@@ -337,14 +337,25 @@ func (a *App) searchMedia(c *gin.Context) {
}
if geminiErr != nil && len(recommended) == 0 {
warning := geminiErr.Error()
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})
c.JSON(http.StatusOK, gin.H{"results": fallback, "warning": warning, "queries": queryVariants})
if strings.Contains(warning, "gemini api key is not configured") {
fallback := services.BuildFallbackRecommendations(scored, 20, "")
a.debug("search fallback summary", summarizeRecommendationResults(fallback, time.Since(started), warning))
c.JSON(http.StatusOK, gin.H{"results": fallback, "warning": warning, "queries": queryVariants})
return
}
a.debug("search fallback summary", summarizeRecommendationResults([]services.AIRecommendation{}, time.Since(started), warning))
a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "Gemini Vision returned no usable results", "progress": 90, "message": warning})
c.JSON(http.StatusOK, gin.H{"results": []services.AIRecommendation{}, "warning": warning, "queries": queryVariants})
return
}
merged := services.MergeRecommendations(recommended, scored, 20)
if len(merged) == 0 && len(recommended) > 0 {
warning := "Gemini가 대부분의 후보를 부정적으로 평가해 표시할 결과가 없습니다."
a.debug("search fallback summary", summarizeRecommendationResults([]services.AIRecommendation{}, time.Since(started), warning))
c.JSON(http.StatusOK, gin.H{"results": []services.AIRecommendation{}, "warning": warning, "queries": queryVariants})
return
}
merged = services.RandomizeTopRecommendations(merged, 8)
warning := ""
if geminiErr != nil {
+3 -15
View File
@@ -12,7 +12,6 @@ import (
"regexp"
"sort"
"strings"
"sync"
"time"
)
@@ -127,26 +126,15 @@ func (s *SearchService) SearchMedia(queries []string, enabledPlatforms map[strin
}
func (s *SearchService) EnrichResults(results []SearchResult) []SearchResult {
limit := minInt(len(results), 18)
if limit == 0 {
if len(results) == 0 {
return results
}
enriched := make([]SearchResult, len(results))
copy(enriched, results)
var wg sync.WaitGroup
sem := make(chan struct{}, 4)
for idx := 0; idx < limit; idx++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
enriched[i] = s.enrichResult(enriched[i])
}(idx)
for idx := range enriched {
enriched[idx] = s.enrichResult(enriched[idx])
}
wg.Wait()
return enriched
}
-4
View File
@@ -252,10 +252,6 @@ User query: ` + query,
})
}
if len(recommendations) == 0 {
recommendations = BuildFallbackRecommendations(candidates, 8, "Gemini Vision 평가를 받지 못해 키워드 기준으로 보강된 결과입니다.")
}
return recommendations, nil
}
+35 -98
View File
@@ -5,7 +5,6 @@ import (
"math/rand"
"sort"
"strings"
"sync"
"time"
)
@@ -91,8 +90,6 @@ func GeminiCandidateLimit(total int) int {
}
func EvaluateAllCandidatesWithGemini(service *GeminiService, query string, ranked []SearchResult) ([]AIRecommendation, GeminiBatchStats, error) {
const chunkSize = 8
const maxConcurrentBatches = 2
if service == nil {
return nil, GeminiBatchStats{}, fmt.Errorf("gemini service is not configured")
}
@@ -102,76 +99,28 @@ func EvaluateAllCandidatesWithGemini(service *GeminiService, query string, ranke
CandidateCap: limit,
Requested: min(limit, len(ranked)),
}
type batchResult struct {
index int
recommendations []AIRecommendation
err error
}
batches := make([][]SearchResult, 0, (limit+chunkSize-1)/chunkSize)
for start := 0; start < limit; start += chunkSize {
end := start + chunkSize
if end > limit {
end = limit
}
batches = append(batches, ranked[start:end])
}
stats.Batches = len(batches)
if len(batches) == 0 {
stats.Batches = limit
if limit == 0 {
return []AIRecommendation{}, stats, nil
}
results := make([]batchResult, len(batches))
var wg sync.WaitGroup
sem := make(chan struct{}, maxConcurrentBatches)
for idx, batch := range batches {
wg.Add(1)
go func(batchIndex int, candidates []SearchResult) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
recommended, err := service.Recommend(query, candidates)
results[batchIndex] = batchResult{
index: batchIndex,
recommendations: recommended,
err: err,
}
}(idx, batch)
}
wg.Wait()
merged := make([]AIRecommendation, 0, len(ranked))
seen := map[string]bool{}
for _, batch := range results {
if batch.err != nil {
recovered, recoveredErrs := recoverGeminiBatchSequentially(service, query, ranked, batch.index*chunkSize)
if len(recovered) > 0 {
stats.SequentialRetried++
stats.Succeeded++
for _, item := range recovered {
if item.Link == "" || seen[item.Link] {
continue
}
seen[item.Link] = true
merged = append(merged, item)
}
if len(recoveredErrs) > 0 {
stats.Failed++
for _, recoveredErr := range recoveredErrs {
if len(stats.Errors) < 5 {
stats.Errors = append(stats.Errors, recoveredErr)
}
}
}
continue
}
for idx := 0; idx < limit; idx++ {
recommendations, err := recoverGeminiCandidateSequentially(service, query, ranked[idx])
if err != nil {
stats.Failed++
if len(stats.Errors) < 5 {
stats.Errors = append(stats.Errors, batch.err.Error())
stats.Errors = append(stats.Errors, err.Error())
}
continue
}
stats.Succeeded++
for _, item := range batch.recommendations {
if len(recommendations) == 0 {
continue
}
stats.SequentialRetried++
for _, item := range recommendations {
if item.Link == "" || seen[item.Link] {
continue
}
@@ -185,12 +134,12 @@ func EvaluateAllCandidatesWithGemini(service *GeminiService, query string, ranke
case len(merged) > 0 && stats.Failed == 0:
return merged, stats, nil
case len(merged) > 0 && stats.Failed > 0:
return merged, stats, fmt.Errorf("gemini vision partially failed on %d of %d batches", stats.Failed, stats.Batches)
return merged, stats, fmt.Errorf("gemini vision partially failed on %d of %d candidates", stats.Failed, stats.Batches)
case stats.Failed == stats.Batches:
if len(stats.Errors) > 0 {
return nil, stats, fmt.Errorf("gemini vision failed for all batches: %s", strings.Join(stats.Errors, "; "))
return nil, stats, fmt.Errorf("gemini vision failed for all candidates: %s", strings.Join(stats.Errors, "; "))
}
return nil, stats, fmt.Errorf("gemini vision failed for all batches")
return nil, stats, fmt.Errorf("gemini vision failed for all candidates")
default:
return nil, stats, fmt.Errorf("gemini vision returned no candidate evaluations")
}
@@ -232,23 +181,21 @@ func RandomizeTopRecommendations(items []AIRecommendation, window int) []AIRecom
return shuffled
}
func recoverGeminiBatchSequentially(service *GeminiService, query string, ranked []SearchResult, startIndex int) ([]AIRecommendation, []string) {
recovered := make([]AIRecommendation, 0, 8)
errs := make([]string, 0, 4)
endIndex := min(startIndex+8, len(ranked))
for idx := startIndex; idx < endIndex; idx++ {
recs, err := service.Recommend(query, []SearchResult{ranked[idx]})
func recoverGeminiCandidateSequentially(service *GeminiService, query string, candidate SearchResult) ([]AIRecommendation, error) {
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
recs, err := service.Recommend(query, []SearchResult{candidate})
if err != nil {
if len(errs) < 4 {
errs = append(errs, err.Error())
}
time.Sleep(350 * time.Millisecond)
lastErr = err
time.Sleep(450 * time.Millisecond)
continue
}
recovered = append(recovered, recs...)
time.Sleep(350 * time.Millisecond)
return recs, nil
}
return recovered, errs
if lastErr == nil {
lastErr = fmt.Errorf("gemini vision sequential retry returned no result")
}
return nil, lastErr
}
func NeedsSupplementalExploration(items []AIRecommendation) bool {
@@ -266,7 +213,7 @@ func NeedsSupplementalExploration(items []AIRecommendation) bool {
negativeCount++
}
}
if recommendedCount >= 3 {
if recommendedCount >= 5 {
return false
}
return negativeCount >= max(2, len(items)/2)
@@ -293,7 +240,7 @@ func MergeRecommendations(recommended []AIRecommendation, ranked []SearchResult,
seen := map[string]bool{}
for _, item := range recommended {
if !item.Recommended {
if !item.Recommended || shouldExcludeRecommendation(item) {
continue
}
if item.Link == "" || seen[item.Link] {
@@ -304,29 +251,12 @@ func MergeRecommendations(recommended []AIRecommendation, ranked []SearchResult,
}
for _, item := range recommended {
if item.Recommended || item.Link == "" || seen[item.Link] || len(merged) >= limit {
if item.Recommended || item.Link == "" || seen[item.Link] || len(merged) >= limit || shouldExcludeRecommendation(item) {
continue
}
seen[item.Link] = true
merged = append(merged, item)
}
for _, item := range ranked {
if len(merged) >= limit || item.Link == "" || seen[item.Link] {
continue
}
seen[item.Link] = true
merged = append(merged, AIRecommendation{
Title: item.Title,
Link: item.Link,
Snippet: item.Snippet,
ThumbnailURL: item.ThumbnailURL,
PreviewVideoURL: item.PreviewVideoURL,
Source: item.Source,
Reason: GeminiFallbackReason,
Recommended: false,
})
}
return merged
}
@@ -336,3 +266,10 @@ func max(a, b int) int {
}
return b
}
func shouldExcludeRecommendation(item AIRecommendation) bool {
if strings.Contains(item.Reason, GeminiFallbackReason) {
return true
}
return looksNegativeReason(item.Reason)
}