package services import ( "fmt" "math/rand" "sort" "strings" "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 total } func EvaluateAllCandidatesWithGemini(service *GeminiService, query string, ranked []SearchResult) ([]AIRecommendation, GeminiBatchStats, error) { 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)), } stats.Batches = limit if limit == 0 { return []AIRecommendation{}, stats, nil } merged := make([]AIRecommendation, 0, len(ranked)) seen := map[string]bool{} 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, err.Error()) } continue } stats.Succeeded++ if len(recommendations) == 0 { continue } stats.SequentialRetried++ for _, item := range 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 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 candidates: %s", strings.Join(stats.Errors, "; ")) } return nil, stats, fmt.Errorf("gemini vision failed for all candidates") 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 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 { lastErr = err time.Sleep(450 * time.Millisecond) continue } return recs, nil } if lastErr == nil { lastErr = fmt.Errorf("gemini vision sequential retry returned no result") } return nil, lastErr } 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 >= 5 { 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 || shouldExcludeRecommendation(item) { continue } if item.Link == "" || seen[item.Link] { continue } seen[item.Link] = true merged = append(merged, item) } for _, item := range recommended { if item.Recommended || item.Link == "" || seen[item.Link] || len(merged) >= limit || shouldExcludeRecommendation(item) { continue } seen[item.Link] = true merged = append(merged, item) } return merged } func max(a, b int) int { if a > b { return a } return b } func shouldExcludeRecommendation(item AIRecommendation) bool { if strings.Contains(item.Reason, GeminiFallbackReason) { return true } return looksNegativeReason(item.Reason) }