package services import ( "fmt" "math/rand" "sort" "strings" "sync" "time" ) const GeminiFallbackReason = "Gemini Vision 응답이 부족해 키워드 기준으로 보강된 결과입니다." type GeminiBatchStats struct { CandidateCap int `json:"candidateCap"` Requested int `json:"requested"` Batches int `json:"batches"` Succeeded int `json:"succeeded"` Failed int `json:"failed"` SequentialRetried int `json:"sequentialRetried"` RecommendedCount int `json:"recommendedCount"` Errors []string `json:"errors,omitempty"` } func RankSearchResults(query string, results []SearchResult) []SearchResult { queryTerms := strings.Fields(strings.ToLower(query)) positiveTerms := []string{ "b-roll", "b roll", "stock", "stock footage", "footage", "cinematic", "editorial", "establishing", "4k", "hd", "drone", "ambient", "scene", "urban", "cityscape", } negativeTerms := []string{ "shocking", "amazing", "crazy", "must watch", "reaction", "gossip", "celebrity", "thumbnail", "meme", "prank", "drama", "breaking", "viral", "tutorial", "how to", "review", "walkthrough", "course", "lesson", "podcast", "interview", "premiere pro", "after effects", "explained", "breakdown", "vlog", } type scoredResult struct { item SearchResult score int } scored := make([]scoredResult, 0, len(results)) for _, result := range results { score := 0 text := strings.ToLower(result.Title + " " + result.Snippet + " " + result.Source) for _, term := range queryTerms { if strings.Contains(text, term) { score += 3 } } for _, term := range positiveTerms { if strings.Contains(text, term) { score += 2 } } for _, term := range negativeTerms { if strings.Contains(text, term) { score -= 4 } } if result.ThumbnailURL != "" { score += 2 } if result.PreviewVideoURL != "" { score += 3 } switch result.Source { case "Google Video": score -= 1 case "Envato": score += 7 case "Artgrid": score += 7 } scored = append(scored, scoredResult{item: result, score: score}) } sort.SliceStable(scored, func(i, j int) bool { return scored[i].score > scored[j].score }) ranked := make([]SearchResult, 0, len(scored)) for _, item := range scored { ranked = append(ranked, item.item) } return ranked } func GeminiCandidateLimit(total int) int { return min(total, 12) } 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") } limit := GeminiCandidateLimit(len(ranked)) stats := GeminiBatchStats{ 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 { 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 } stats.Failed++ if len(stats.Errors) < 5 { stats.Errors = append(stats.Errors, batch.err.Error()) } continue } stats.Succeeded++ for _, item := range batch.recommendations { if item.Link == "" || seen[item.Link] { continue } seen[item.Link] = true merged = append(merged, item) } } stats.RecommendedCount = len(merged) switch { 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) 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 batches") default: return nil, stats, fmt.Errorf("gemini vision returned no candidate evaluations") } } func BuildFallbackRecommendations(ranked []SearchResult, limit int, reason string) []AIRecommendation { if strings.TrimSpace(reason) == "" { reason = GeminiFallbackReason } fallback := make([]AIRecommendation, 0, min(limit, len(ranked))) for _, item := range ranked[:min(limit, len(ranked))] { fallback = append(fallback, AIRecommendation{ Title: item.Title, Link: item.Link, Snippet: item.Snippet, ThumbnailURL: item.ThumbnailURL, PreviewVideoURL: item.PreviewVideoURL, Source: item.Source, Reason: reason, Recommended: false, }) } return fallback } func RandomizeTopRecommendations(items []AIRecommendation, window int) []AIRecommendation { if len(items) < 2 || window < 2 { return items } limit := min(window, len(items)) shuffled := make([]AIRecommendation, len(items)) copy(shuffled, items) rng := rand.New(rand.NewSource(time.Now().UnixNano())) rng.Shuffle(limit, func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] }) 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]}) if err != nil { if len(errs) < 4 { errs = append(errs, err.Error()) } time.Sleep(350 * time.Millisecond) continue } recovered = append(recovered, recs...) time.Sleep(350 * time.Millisecond) } return recovered, errs } func NeedsSupplementalExploration(items []AIRecommendation) bool { if len(items) == 0 { return true } recommendedCount := 0 negativeCount := 0 for _, item := range items { if item.Recommended { recommendedCount++ } if looksNegativeReason(item.Reason) { negativeCount++ } } if recommendedCount >= 3 { return false } return negativeCount >= max(2, len(items)/2) } func looksNegativeReason(reason string) bool { lower := strings.ToLower(strings.TrimSpace(reason)) if lower == "" { return false } for _, token := range []string{ "부적합", "관련이 없", "맞지 않", "의도와 맞지", "무관", "연관성 낮", "적절하지 않", "불일치", "not relevant", "irrelevant", "mismatch", "does not match", "unsuitable", } { if strings.Contains(lower, token) { return true } } return false } func MergeRecommendations(recommended []AIRecommendation, ranked []SearchResult, limit int) []AIRecommendation { merged := make([]AIRecommendation, 0, min(limit, len(ranked))) seen := map[string]bool{} for _, item := range recommended { if !item.Recommended { continue } if item.Link == "" || seen[item.Link] { continue } seen[item.Link] = true merged = append(merged, item) } return merged } func max(a, b int) int { if a > b { return a } return b }