Add in-app result viewer and expand Gemini review
build-push / docker (push) Successful in 4m52s

This commit is contained in:
AI Assistant
2026-03-16 10:12:12 +09:00
parent 9637b761bd
commit b43886e950
6 changed files with 414 additions and 306 deletions
+3 -3
View File
@@ -320,7 +320,7 @@ func (a *App) searchMedia(c *gin.Context) {
}
scored := services.RankSearchResults(rankQuery, results)
a.debug("search ranked summary", summarizeSearchResults(scored, time.Since(started), services.GeminiCandidateLimit(len(scored)), ""))
a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "analyzing top candidate visuals with Gemini Vision", "progress": 75})
a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "analyzing all candidate visuals with Gemini Vision", "progress": 75})
recommended, geminiStats := services.EvaluateAllCandidatesWithGemini(a.GeminiService, req.Query, scored)
a.debug("search gemini evaluation", geminiStats)
err = nil
@@ -337,8 +337,8 @@ func (a *App) searchMedia(c *gin.Context) {
ThumbnailURL: result.ThumbnailURL,
PreviewVideoURL: result.PreviewVideoURL,
Source: result.Source,
Reason: "Keyword-ranked result added without extra Gemini vision tokens.",
Recommended: true,
Reason: "Gemini Vision 응답이 부족해 키워드 기준으로 보강된 결과입니다.",
Recommended: false,
})
}
warning := err.Error()
+16 -7
View File
@@ -154,7 +154,8 @@ func (g *GeminiService) Recommend(query string, candidates []SearchResult) ([]AI
{
"text": `Analyze the provided images for the user's search intent. Return JSON only in this shape:
{"recommendations":[{"index":0,"reason":"short reason","recommended":true}]}
Mark only the best matches as recommended=true. Keep reasons concise. Recommend up to 8 items.
Return one entry for every analyzed candidate. Use Korean for every reason. Keep reasons concise but specific enough to explain usefulness.
Mark the strongest matches as recommended=true and weaker matches as recommended=false.
Prefer cinematic b-roll, stock footage, editorial footage, clean composition, usable establishing shots, and professional media thumbnails.
Avoid clickbait faces, exaggerated expressions, meme aesthetics, low-information thumbnails, sensational text overlays, or gossip-style imagery.
Favor thumbnails that look directly useful for media editing and footage sourcing.
@@ -230,7 +231,7 @@ User query: ` + query,
recommendations := make([]AIRecommendation, 0, len(parsed.Recommendations))
for _, rec := range parsed.Recommendations {
if rec.Index < 0 || rec.Index >= len(candidates) || !rec.Recommended {
if rec.Index < 0 || rec.Index >= len(candidates) {
continue
}
src := candidates[rec.Index]
@@ -241,13 +242,13 @@ User query: ` + query,
ThumbnailURL: src.ThumbnailURL,
PreviewVideoURL: src.PreviewVideoURL,
Source: src.Source,
Reason: rec.Reason,
Recommended: true,
Reason: normalizeKoreanReason(rec.Reason),
Recommended: rec.Recommended,
})
}
if len(recommendations) == 0 {
for _, candidate := range candidates[:min(4, len(candidates))] {
for _, candidate := range candidates[:min(8, len(candidates))] {
recommendations = append(recommendations, AIRecommendation{
Title: candidate.Title,
Link: candidate.Link,
@@ -255,8 +256,8 @@ User query: ` + query,
ThumbnailURL: candidate.ThumbnailURL,
PreviewVideoURL: candidate.PreviewVideoURL,
Source: candidate.Source,
Reason: "Fallback result because Gemini returned no recommended items.",
Recommended: true,
Reason: "Gemini Vision 평가를 받지 못해 키워드 기준으로 보강된 결과입니다.",
Recommended: false,
})
}
}
@@ -412,6 +413,14 @@ func truncateForError(text string, limit int) string {
return trimmed[:limit] + "..."
}
func normalizeKoreanReason(reason string) string {
trimmed := strings.TrimSpace(reason)
if trimmed == "" {
return "시각 정보가 제한적이지만 검색 의도와의 관련성을 기준으로 평가했습니다."
}
return trimmed
}
func buildSearchQueries(originalQuery, englishQuery string) []string {
base := strings.TrimSpace(englishQuery)
if base == "" {
+51 -18
View File
@@ -3,6 +3,7 @@ package services
import (
"sort"
"strings"
"sync"
)
type GeminiBatchStats struct {
@@ -80,42 +81,63 @@ func RankSearchResults(query string, results []SearchResult) []SearchResult {
}
func GeminiCandidateLimit(total int) int {
switch {
case total <= 12:
return total
case total <= 16:
return 12
default:
return 16
}
return total
}
func EvaluateAllCandidatesWithGemini(service *GeminiService, query string, ranked []SearchResult) ([]AIRecommendation, GeminiBatchStats) {
const chunkSize = 8
const maxConcurrentBatches = 2
limit := GeminiCandidateLimit(len(ranked))
stats := GeminiBatchStats{
CandidateCap: limit,
Requested: min(limit, len(ranked)),
}
merged := make([]AIRecommendation, 0, len(ranked))
seen := map[string]bool{}
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
}
batch := ranked[start:end]
stats.Batches++
recommended, err := service.Recommend(query, batch)
if err != nil {
batches = append(batches, ranked[start:end])
}
stats.Batches = len(batches)
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 {
stats.Failed++
if len(stats.Errors) < 5 {
stats.Errors = append(stats.Errors, err.Error())
stats.Errors = append(stats.Errors, batch.err.Error())
}
continue
}
stats.Succeeded++
for _, item := range recommended {
for _, item := range batch.recommendations {
if item.Link == "" || seen[item.Link] {
continue
}
@@ -132,6 +154,9 @@ func MergeRecommendations(recommended []AIRecommendation, ranked []SearchResult,
seen := map[string]bool{}
for _, item := range recommended {
if !item.Recommended {
continue
}
if item.Link == "" || seen[item.Link] {
continue
}
@@ -139,6 +164,14 @@ func MergeRecommendations(recommended []AIRecommendation, ranked []SearchResult,
merged = append(merged, item)
}
for _, item := range recommended {
if item.Recommended || item.Link == "" || seen[item.Link] || len(merged) >= limit {
continue
}
seen[item.Link] = true
merged = append(merged, item)
}
for _, item := range ranked {
if len(merged) >= limit || item.Link == "" || seen[item.Link] {
continue
@@ -151,8 +184,8 @@ func MergeRecommendations(recommended []AIRecommendation, ranked []SearchResult,
ThumbnailURL: item.ThumbnailURL,
PreviewVideoURL: item.PreviewVideoURL,
Source: item.Source,
Reason: "Keyword-ranked result added without extra Gemini vision tokens.",
Recommended: true,
Reason: "Gemini Vision 응답이 부족해 키워드 기준으로 보강된 결과입니다.",
Recommended: false,
})
}
return merged