347 lines
9.4 KiB
Go
347 lines
9.4 KiB
Go
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, 16)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
for _, item := range recommended {
|
|
if item.Recommended || item.Link == "" || seen[item.Link] || len(merged) >= limit {
|
|
continue
|
|
}
|
|
if looksNegativeReason(item.Reason) || strings.Contains(item.Reason, GeminiFallbackReason) {
|
|
continue
|
|
}
|
|
seen[item.Link] = true
|
|
merged = append(merged, item)
|
|
}
|
|
|
|
if len(merged) < min(12, limit) {
|
|
for _, item := range ranked {
|
|
if len(merged) >= min(12, limit) || item.Link == "" || seen[item.Link] {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(item.ThumbnailURL) == "" && strings.TrimSpace(item.PreviewVideoURL) == "" {
|
|
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: "Gemini 검토가 부족해 편집용 후보로 추가된 결과입니다.",
|
|
Recommended: false,
|
|
})
|
|
}
|
|
}
|
|
return merged
|
|
}
|
|
|
|
func max(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|