diff --git a/TODO.md b/TODO.md index 8d3300e..bb1d0f8 100644 --- a/TODO.md +++ b/TODO.md @@ -255,6 +255,22 @@ - backend debug broadcasts ## Recent Change Log +- 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. + - Google Video results now load YouTube embed URLs in the modal viewer and keep the white `Direct Download` action in the lower-right panel. + - When Gemini evaluation comes back mostly negative or too weak, the backend now runs one supplemental search pass with broader intent variants and reevaluates the merged pool. + - Failed Gemini batch evaluations now retry sequentially candidate-by-candidate with a short delay so more candidates can still be processed when batch/token evaluation is unstable. +- Why it changed: + - The requested modal information hierarchy was different from the previous implementation. + - The user wanted negative Gemini feedback to trigger more exploration instead of stopping at the first pool. + - Batch-level Gemini failures were causing too many results to skip evaluation entirely. +- How it was verified: + - code-path inspection against the updated modal wiring and search flow +- What is still risky or incomplete: + - Non-YouTube third-party pages can still refuse iframe embedding via CSP or `X-Frame-Options`. + - Sequential Gemini retries improve coverage but also increase latency when the model is degraded. + - Date: `2026-03-16` - What changed: - Bumped frontend asset version and added result-modal initialization guards to avoid click failures when browser cache serves mismatched HTML/JS. diff --git a/backend/handlers/api.go b/backend/handlers/api.go index 77c949b..ae97522 100644 --- a/backend/handlers/api.go +++ b/backend/handlers/api.go @@ -323,6 +323,18 @@ func (a *App) searchMedia(c *gin.Context) { a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "analyzing all candidate visuals with Gemini Vision", "progress": 75}) recommended, geminiStats, geminiErr := services.EvaluateAllCandidatesWithGemini(a.GeminiService, req.Query, scored) a.debug("search gemini evaluation", geminiStats) + if services.NeedsSupplementalExploration(recommended) { + a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "Gemini 평가가 약해 추가 후보를 탐색하는 중", "progress": 82}) + explorationQueries := buildSupplementalQueries(req.Query, queryVariants) + extraResults, extraErr := a.SearchService.SearchMedia(explorationQueries, enabledPlatforms) + if extraErr == nil && len(extraResults) > 0 { + results = mergeSearchResults(results, extraResults) + scored = services.RankSearchResults(strings.Join(explorationQueries[:min(len(explorationQueries), 3)], " "), results) + recommended, geminiStats, geminiErr = services.EvaluateAllCandidatesWithGemini(a.GeminiService, req.Query, scored) + a.debug("search supplemental query variants", gin.H{"variants": explorationQueries, "variantCount": len(explorationQueries)}) + a.debug("search gemini evaluation after supplemental search", geminiStats) + } + } if geminiErr != nil && len(recommended) == 0 { warning := geminiErr.Error() fallback := services.BuildFallbackRecommendations(scored, 20, "") @@ -419,6 +431,45 @@ func selectedPlatformLabel(platforms map[string]bool) string { return strings.Join(labels, ", ") } +func buildSupplementalQueries(query string, existing []string) []string { + candidates := append([]string{}, existing...) + candidates = append(candidates, + query+" cinematic stock footage", + query+" editorial b-roll", + query+" establishing shot", + query+" drone footage", + ) + + seen := map[string]bool{} + result := make([]string, 0, len(candidates)) + for _, item := range candidates { + trimmed := strings.Join(strings.Fields(strings.TrimSpace(item)), " ") + if trimmed == "" { + continue + } + key := strings.ToLower(trimmed) + if seen[key] { + continue + } + seen[key] = true + result = append(result, trimmed) + } + return result +} + +func mergeSearchResults(base, extra []services.SearchResult) []services.SearchResult { + merged := make([]services.SearchResult, 0, len(base)+len(extra)) + seen := map[string]bool{} + for _, item := range append(base, extra...) { + if item.Link == "" || seen[item.Link] { + continue + } + seen[item.Link] = true + merged = append(merged, item) + } + return merged +} + func summarizeSearchResults(results []services.SearchResult, duration time.Duration, geminiCap int, warning string) searchDebugSummary { bySource := map[string]int{} withPreview := 0 diff --git a/backend/services/ranker.go b/backend/services/ranker.go index d9b3d97..87a64b9 100644 --- a/backend/services/ranker.go +++ b/backend/services/ranker.go @@ -12,13 +12,14 @@ import ( 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"` - RecommendedCount int `json:"recommendedCount"` - Errors []string `json:"errors,omitempty"` + 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 { @@ -142,6 +143,27 @@ func EvaluateAllCandidatesWithGemini(service *GeminiService, query string, ranke 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()) @@ -210,6 +232,62 @@ 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]}) + 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{} @@ -251,3 +329,10 @@ func MergeRecommendations(recommended []AIRecommendation, ranked []SearchResult, } return merged } + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/frontend/app.js b/frontend/app.js index 7d2217e..92ff8a6 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -42,26 +42,20 @@ const resultModalTitle = document.getElementById("resultModalTitle"); const resultModalSource = document.getElementById("resultModalSource"); const resultModalSnippet = document.getElementById("resultModalSnippet"); const resultModalReason = document.getElementById("resultModalReason"); +const resultModalFrame = document.getElementById("resultModalFrame"); const resultModalOpenExternal = document.getElementById("resultModalOpenExternal"); const resultModalDownload = document.getElementById("resultModalDownload"); const closeResultModal = document.getElementById("closeResultModal"); -const resultModalMediaFrame = document.getElementById("resultModalMediaFrame"); -const resultModalVideo = document.getElementById("resultModalVideo"); -const resultModalThumbnail = document.getElementById("resultModalThumbnail"); -const resultModalEmbedNotice = document.getElementById("resultModalEmbedNotice"); const resultModalReady = Boolean( resultModal && resultModalTitle && resultModalSource && resultModalSnippet && resultModalReason && + resultModalFrame && resultModalOpenExternal && resultModalDownload && - closeResultModal && - resultModalMediaFrame && - resultModalVideo && - resultModalThumbnail && - resultModalEmbedNotice, + closeResultModal, ); let pendingDownload = null; @@ -362,16 +356,24 @@ function hideModal(element) { setHidden(element, true); } +function buildResultModalEmbedURL(item) { + if (!item?.link) { + return "about:blank"; + } + if (item.source === "Google Video") { + const videoId = extractYouTubeID(item.link); + if (videoId) { + return `https://www.youtube.com/embed/${videoId}?autoplay=1&rel=0`; + } + } + return item.link; +} + function resetResultModalMedia() { if (!resultModalReady) { return; } - resultModalVideo.pause(); - detachVideoSource(resultModalVideo); - resultModalMediaFrame.style.aspectRatio = ""; - setHidden(resultModalVideo, true, ""); - setHidden(resultModalThumbnail, true, ""); - setHidden(resultModalEmbedNotice, false, ""); + resultModalFrame.src = "about:blank"; } function renderResults(results) { @@ -450,16 +452,7 @@ function openResultModal(item) { const canDirectDownload = item.source === "Google Video" && item.link; resultModalDownload.classList.toggle("hidden", !canDirectDownload); resetResultModalMedia(); - if (item.previewVideoUrl) { - attachVideoSource(resultModalVideo, item.previewVideoUrl); - setHidden(resultModalVideo, false, ""); - setHidden(resultModalEmbedNotice, true, ""); - } else if (item.thumbnailUrl) { - resultModalThumbnail.src = item.thumbnailUrl; - resultModalThumbnail.alt = item.title || ""; - setHidden(resultModalThumbnail, false, ""); - setHidden(resultModalEmbedNotice, true, ""); - } + resultModalFrame.src = buildResultModalEmbedURL(item); showModal(resultModal); logEvent("result:modal:open", { title: item.title, source: item.source, link: item.link }); } @@ -690,18 +683,6 @@ 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 (!resultModalVideo.src && 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 f420118..872832f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -150,7 +150,7 @@