From c0830b5fde5d67f30597045586a7ac261daf4e7e Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Mon, 16 Mar 2026 12:45:12 +0900 Subject: [PATCH] Stabilize modal rendering and sequential Gemini flow --- TODO.md | 16 +++++ backend/handlers/api.go | 19 ++++-- backend/services/cse.go | 18 +---- backend/services/gemini.go | 4 -- backend/services/ranker.go | 133 ++++++++++--------------------------- frontend/app.js | 72 +++++++++++++++++++- frontend/index.html | 8 ++- 7 files changed, 145 insertions(+), 125 deletions(-) diff --git a/TODO.md b/TODO.md index bb1d0f8..c5cf455 100644 --- a/TODO.md +++ b/TODO.md @@ -255,6 +255,22 @@ - backend debug broadcasts ## Recent Change Log +- Date: `2026-03-16` +- What changed: + - Search enrichment now runs across the full result set sequentially instead of only enriching a capped top subset in parallel. + - Gemini Vision evaluation now runs candidate-by-candidate with retries and delay, and fallback-only / clearly negative items are excluded from final output. + - Result modal now uses YouTube embed only for Google Video and falls back to source preview video or thumbnail for Envato / Artgrid, avoiding iframe refusal for blocked providers. + - When `GEMINI_API_KEY` is absent, the API still returns ranked fallback results so local smoke tests and non-Gemini environments stay usable. +- Why it changed: + - The user preferred reliability over speed and wanted all candidates processed thoroughly. + - Negative or irrelevant Gemini outcomes were still leaking into the final result set. + - Envato iframe embeds were being blocked, and Google Video modal opening was broken by missing frontend YouTube ID extraction. +- How it was verified: + - code inspection of sequential search/enrichment/evaluation flow +- What is still risky or incomplete: + - Fully sequential Gemini and enrichment processing increases search latency noticeably. + - Some Envato / Artgrid items may still only have thumbnails if public preview URLs are not exposed in source metadata. + - Date: `2026-03-16` - What changed: - Result modal layout was rebuilt to match a top `16:9` embedded viewer with bottom-left full AI note and bottom-right action panel. diff --git a/backend/handlers/api.go b/backend/handlers/api.go index ae97522..53e86c7 100644 --- a/backend/handlers/api.go +++ b/backend/handlers/api.go @@ -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 { diff --git a/backend/services/cse.go b/backend/services/cse.go index 18bc764..c3bab7e 100644 --- a/backend/services/cse.go +++ b/backend/services/cse.go @@ -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 } diff --git a/backend/services/gemini.go b/backend/services/gemini.go index 9235648..6f08326 100644 --- a/backend/services/gemini.go +++ b/backend/services/gemini.go @@ -252,10 +252,6 @@ User query: ` + query, }) } - if len(recommendations) == 0 { - recommendations = BuildFallbackRecommendations(candidates, 8, "Gemini Vision 평가를 받지 못해 키워드 기준으로 보강된 결과입니다.") - } - return recommendations, nil } diff --git a/backend/services/ranker.go b/backend/services/ranker.go index 87a64b9..00f32f6 100644 --- a/backend/services/ranker.go +++ b/backend/services/ranker.go @@ -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) +} diff --git a/frontend/app.js b/frontend/app.js index 92ff8a6..b2503c7 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -43,6 +43,9 @@ const resultModalSource = document.getElementById("resultModalSource"); const resultModalSnippet = document.getElementById("resultModalSnippet"); const resultModalReason = document.getElementById("resultModalReason"); const resultModalFrame = document.getElementById("resultModalFrame"); +const resultModalMediaFrame = document.getElementById("resultModalMediaFrame"); +const resultModalVideo = document.getElementById("resultModalVideo"); +const resultModalThumbnail = document.getElementById("resultModalThumbnail"); const resultModalOpenExternal = document.getElementById("resultModalOpenExternal"); const resultModalDownload = document.getElementById("resultModalDownload"); const closeResultModal = document.getElementById("closeResultModal"); @@ -53,6 +56,9 @@ const resultModalReady = Boolean( resultModalSnippet && resultModalReason && resultModalFrame && + resultModalMediaFrame && + resultModalVideo && + resultModalThumbnail && resultModalOpenExternal && resultModalDownload && closeResultModal, @@ -157,6 +163,23 @@ function toClock(totalSeconds) { return `${hours}:${minutes}:${secs}`; } +function extractYouTubeID(link) { + if (!link) { + return ""; + } + const patterns = [ + /(?:v=|\/shorts\/|\/embed\/)([A-Za-z0-9_-]{11})/, + /youtu\.be\/([A-Za-z0-9_-]{11})/, + ]; + for (const pattern of patterns) { + const match = link.match(pattern); + if (match?.[1]) { + return match[1]; + } + } + return ""; +} + function syncRanges() { let start = cropStart; let end = cropEnd; @@ -373,7 +396,36 @@ function resetResultModalMedia() { if (!resultModalReady) { return; } + resultModalVideo.pause(); + detachVideoSource(resultModalVideo); resultModalFrame.src = "about:blank"; + resultModalThumbnail.removeAttribute("src"); + setHidden(resultModalFrame, true, ""); + setHidden(resultModalVideo, true, ""); + setHidden(resultModalThumbnail, true, ""); + resultModalMediaFrame.style.aspectRatio = ""; +} + +function showResultModalFrame(src) { + if (!src) { + return; + } + resultModalFrame.src = src; + setHidden(resultModalFrame, false, ""); +} + +function showResultModalVideo(src) { + if (!src) { + return; + } + attachVideoSource(resultModalVideo, src); + setHidden(resultModalVideo, false, ""); +} + +function showResultModalThumbnail(src, alt) { + resultModalThumbnail.src = src || PREVIEW_PLACEHOLDER; + resultModalThumbnail.alt = alt || ""; + setHidden(resultModalThumbnail, false, ""); } function renderResults(results) { @@ -452,7 +504,13 @@ function openResultModal(item) { const canDirectDownload = item.source === "Google Video" && item.link; resultModalDownload.classList.toggle("hidden", !canDirectDownload); resetResultModalMedia(); - resultModalFrame.src = buildResultModalEmbedURL(item); + if (item.source === "Google Video") { + showResultModalFrame(buildResultModalEmbedURL(item)); + } else if (item.previewVideoUrl) { + showResultModalVideo(item.previewVideoUrl); + } else { + showResultModalThumbnail(item.thumbnailUrl, item.title || ""); + } showModal(resultModal); logEvent("result:modal:open", { title: item.title, source: item.source, link: item.link }); } @@ -683,6 +741,18 @@ previewThumbnail.addEventListener("load", () => { previewMediaFrame.style.aspectRatio = `${previewThumbnail.naturalWidth} / ${previewThumbnail.naturalHeight}`; } }); +if (resultModalReady) { + resultModalVideo.addEventListener("loadedmetadata", () => { + if (resultModalVideo.videoWidth > 0 && resultModalVideo.videoHeight > 0) { + resultModalMediaFrame.style.aspectRatio = `${resultModalVideo.videoWidth} / ${resultModalVideo.videoHeight}`; + } + }); + resultModalThumbnail.addEventListener("load", () => { + if (resultModalThumbnail.naturalWidth > 0 && resultModalThumbnail.naturalHeight > 0) { + resultModalMediaFrame.style.aspectRatio = `${resultModalThumbnail.naturalWidth} / ${resultModalThumbnail.naturalHeight}`; + } + }); +} for (const button of platformToggles) { button.addEventListener("click", () => { const platform = button.dataset.platformToggle; diff --git a/frontend/index.html b/frontend/index.html index 872832f..171de20 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -162,8 +162,10 @@
-
- +
+ + +
@@ -200,6 +202,6 @@ - +