From 6852e07607a3a5f9c1d50ccfdb1898612bf15829 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Fri, 13 Mar 2026 11:57:58 +0900 Subject: [PATCH] Improve translated search flow and modal layout --- backend/handlers/api.go | 10 ++-- backend/services/cse.go | 120 ++++++++++++++++++++++--------------- backend/services/gemini.go | 114 ++++++++++++++++++++++++++++++----- frontend/index.html | 12 ++-- 4 files changed, 186 insertions(+), 70 deletions(-) diff --git a/backend/handlers/api.go b/backend/handlers/api.go index ac280f5..0489b95 100644 --- a/backend/handlers/api.go +++ b/backend/handlers/api.go @@ -348,7 +348,9 @@ func rankSearchResults(query string, results []services.SearchResult) []services } negativeTerms := []string{ "shocking", "amazing", "crazy", "must watch", "reaction", "gossip", "celebrity", - "thumbnail", "meme", "prank", "drama", "breaking", "viral", + "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 services.SearchResult @@ -379,11 +381,11 @@ func rankSearchResults(query string, results []services.SearchResult) []services } switch result.Source { case "Google Video": - score += 3 + score += 2 case "Envato": - score += 4 + score += 5 case "Artgrid": - score += 4 + score += 5 } scored = append(scored, scoredResult{item: result, score: score}) } diff --git a/backend/services/cse.go b/backend/services/cse.go index 50be200..bbde08b 100644 --- a/backend/services/cse.go +++ b/backend/services/cse.go @@ -52,30 +52,30 @@ func (s *SearchService) SearchMedia(queries []string) ([]SearchResult, error) { categories string engine string queryBuilder func(string) string - match func(string) bool + match func(SearchResult) bool }{ { name: "Google Video", categories: "videos", engine: s.GoogleVideoEngine, queryBuilder: func(query string) string { - return query + return buildGoogleVideoQuery(query) }, - match: func(string) bool { return true }, + match: isUsefulGoogleVideoResult, }, { name: "Envato", categories: "general", engine: s.WebEngine, queryBuilder: buildEnvatoQuery, - match: isEnvatoURL, + match: isRenderableEnvatoResult, }, { name: "Artgrid", categories: "general", engine: s.WebEngine, queryBuilder: buildArtgridQuery, - match: func(link string) bool { return strings.Contains(strings.ToLower(link), "artgrid.io") }, + match: isRenderableArtgridResult, }, } @@ -88,10 +88,7 @@ func (s *SearchService) SearchMedia(queries []string) ([]SearchResult, error) { continue } for _, source := range sources { - searchQuery := query - if source.queryBuilder != nil { - searchQuery = source.queryBuilder(query) - } + searchQuery := source.queryBuilder(query) items, err := s.search(searchQuery, source.categories, source.engine, source.name) if err != nil { @@ -112,10 +109,7 @@ func (s *SearchService) SearchMedia(queries []string) ([]SearchResult, error) { if item.Link == "" || seen[item.Link] { continue } - if source.match != nil && !source.match(item.Link) { - continue - } - if !isRenderableLink(item.Link, item.Source) { + if !source.match(item) { continue } seen[item.Link] = true @@ -138,7 +132,7 @@ func (s *SearchService) search(query, categories, engine, source string) ([]Sear values.Set("q", query) values.Set("format", "json") values.Set("safesearch", "0") - values.Set("language", "ko-KR") + values.Set("language", "en-US") if categories != "" { values.Set("categories", categories) } @@ -189,6 +183,71 @@ func (s *SearchService) search(query, categories, engine, source string) ([]Sear return results, nil } +func buildGoogleVideoQuery(query string) string { + return fmt.Sprintf(`"%s" ("stock footage" OR "b-roll" OR cinematic OR "establishing shot" OR editorial) -tutorial -"how to" -review -reaction -course -podcast -vlog -interview -breakdown -edit -editing`, query) +} + +func buildEnvatoQuery(query string) string { + return fmt.Sprintf(`"%s" ("stock footage" OR "stock video" OR "motion graphics" OR cinematic OR "b-roll") (site:elements.envato.com OR site:videohive.net/item) -site:elements.envato.com/stock-video -site:elements.envato.com/video-templates -site:elements.envato.com/stock-video/stock-footage`, query) +} + +func buildArtgridQuery(query string) string { + return fmt.Sprintf(`"%s" ("stock footage" OR "b-roll" OR cinematic OR editorial) site:artgrid.io/clip/`, query) +} + +func isUsefulGoogleVideoResult(result SearchResult) bool { + text := strings.ToLower(result.Title + " " + result.Snippet) + for _, banned := range []string{ + "tutorial", "how to", "review", "reaction", "podcast", "interview", "walkthrough", + "course", "lesson", "edit tutorial", "editing tutorial", "premiere pro", "after effects", + "breakdown", "explained", "vlog", + } { + if strings.Contains(text, banned) { + return false + } + } + for _, desired := range []string{ + "b-roll", "stock footage", "cinematic", "footage", "establishing shot", "4k", + } { + if strings.Contains(text, desired) { + return true + } + } + lowerLink := strings.ToLower(result.Link) + return strings.Contains(lowerLink, "youtube.com/watch") || strings.Contains(lowerLink, "youtu.be/") +} + +func isRenderableEnvatoResult(result SearchResult) bool { + parsed, err := url.Parse(result.Link) + if err != nil { + return false + } + host := strings.ToLower(parsed.Host) + path := strings.Trim(parsed.Path, "/") + if strings.Contains(host, "videohive.net") { + return strings.HasPrefix(path, "item/") + } + if strings.Contains(host, "elements.envato.com") { + if path == "" || strings.Contains(path, "/") { + return false + } + return regexp.MustCompile(`-[A-Z0-9]{6,}$`).MatchString(path) + } + return false +} + +func isRenderableArtgridResult(result SearchResult) bool { + parsed, err := url.Parse(result.Link) + if err != nil { + return false + } + if !strings.Contains(strings.ToLower(parsed.Host), "artgrid.io") { + return false + } + path := strings.Trim(parsed.Path, "/") + return regexp.MustCompile(`^clip/[0-9]+/`).MatchString(path) +} + func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { @@ -225,39 +284,6 @@ func inferDisplayLink(link string, parsed []any) string { return "" } -func isEnvatoURL(link string) bool { - lower := strings.ToLower(link) - return strings.Contains(lower, "envato") || strings.Contains(lower, "videohive.net") -} - -func isRenderableLink(link, source string) bool { - parsed, err := url.Parse(link) - if err != nil { - return false - } - path := strings.Trim(parsed.Path, "/") - if path == "" { - return false - } - lower := strings.ToLower(link) - switch source { - case "Envato": - return strings.Contains(lower, "/item/") || strings.Contains(lower, "/stock-video/") || strings.Contains(lower, "/video-templates/") - case "Artgrid": - return strings.Contains(lower, "artgrid.io") && len(strings.Split(path, "/")) >= 2 - default: - return true - } -} - -func buildEnvatoQuery(query string) string { - return fmt.Sprintf(`%s ("stock video" OR footage OR "video template" OR cinematic) (site:elements.envato.com/stock-video OR site:elements.envato.com/video-templates OR site:videohive.net/item)`, query) -} - -func buildArtgridQuery(query string) string { - return fmt.Sprintf(`%s ("stock footage" OR "b-roll" OR cinematic OR editorial) (site:artgrid.io)`, query) -} - func deriveThumbnail(link string) string { if videoID := extractYouTubeID(link); videoID != "" { return "https://i.ytimg.com/vi/" + videoID + "/hqdefault.jpg" diff --git a/backend/services/gemini.go b/backend/services/gemini.go index e940fc8..41b5e9f 100644 --- a/backend/services/gemini.go +++ b/backend/services/gemini.go @@ -39,9 +39,11 @@ func NewGeminiService(apiKey string) *GeminiService { func (g *GeminiService) ExpandQuery(query string) ([]string, error) { if g.APIKey == "" { - return fallbackQueryExpansion(query), nil + return fallbackQueryExpansion(query, query), nil } + englishBase := g.TranslateQuery(query) + body := map[string]any{ "systemInstruction": map[string]any{ "parts": []map[string]string{ @@ -55,12 +57,13 @@ func (g *GeminiService) ExpandQuery(query string) ([]string, error) { "parts": []map[string]string{ { "text": `Return JSON only in this shape: {"querywords":["..."]}. -Generate at most 10 concise search variations for media discovery across Google Video, Envato, and Artgrid. -If the user query is in Korean, include strong English search variants that a stock footage editor would use. +Generate at most 10 concise English search variations for media discovery across Google Video, Envato, and Artgrid. +The queries must be usable directly in English search engines for stock footage discovery. Prioritize media, video footage, stock footage, cinematic b-roll, editorial footage, and scene-based search terms. Avoid celebrity gossip, reaction-style phrasing, clickbait phrasing, and generic web search wording. -Mix Korean and English when useful, but make sure several queries are clean English production keywords. -User query: ` + query, +Do not output Korean unless it is part of a proper noun. +Original user query: ` + query + ` +English base translation: ` + englishBase, }, }, }, @@ -86,7 +89,7 @@ User query: ` + query, rawText, err := g.generateText(body) if err != nil { - return fallbackQueryExpansion(query), nil + return fallbackQueryExpansion(query, englishBase), nil } jsonText, err := extractJSONObject(rawText) @@ -108,8 +111,9 @@ Output must start with { and end with }. Do not add prose, explanations, markdown, code fences, or labels. Return exactly this shape: {"querywords":["..."]}. Generate up to 10 search queries for media discovery across Google Video, Envato, and Artgrid. -If the original query is Korean, include strong English stock-footage search phrases. -User query: ` + query, +Every query must be in natural English and suitable for stock-footage search. +Original user query: ` + query + ` +English base translation: ` + englishBase, }, }, }, @@ -134,20 +138,20 @@ User query: ` + query, } rawText, retryErr := g.generateText(strictBody) if retryErr != nil { - return fallbackQueryExpansion(query), nil + return fallbackQueryExpansion(query, englishBase), nil } jsonText, err = extractJSONObject(rawText) if err != nil { - return fallbackQueryExpansion(query), nil + return fallbackQueryExpansion(query, englishBase), nil } } var parsed QueryExpansion if err := json.Unmarshal([]byte(jsonText), &parsed); err != nil { - return fallbackQueryExpansion(query), nil + return fallbackQueryExpansion(query, englishBase), nil } - queries := fallbackQueryExpansion(query) + queries := fallbackQueryExpansion(query, englishBase) seen := map[string]bool{} for _, existing := range queries { seen[strings.ToLower(strings.TrimSpace(existing))] = true @@ -167,6 +171,47 @@ User query: ` + query, return queries, nil } +func (g *GeminiService) TranslateQuery(query string) string { + if strings.TrimSpace(query) == "" || looksMostlyASCII(query) || g.APIKey == "" { + return strings.TrimSpace(query) + } + + body := map[string]any{ + "systemInstruction": map[string]any{ + "parts": []map[string]string{ + { + "text": "You translate media search intents into natural English. Output one plain English search phrase only. No labels, no quotes, no explanations.", + }, + }, + }, + "contents": []map[string]any{ + { + "parts": []map[string]string{ + { + "text": "Translate this user query into concise English suitable for stock-footage search: " + query, + }, + }, + }, + }, + "generationConfig": map[string]any{ + "responseMimeType": "text/plain", + "temperature": 0.1, + "maxOutputTokens": 40, + }, + } + + rawText, err := g.generateText(body) + if err != nil { + return strings.TrimSpace(query) + } + + translated := sanitizePlainEnglishLine(rawText) + if translated == "" { + return strings.TrimSpace(query) + } + return translated +} + func (g *GeminiService) generateText(body map[string]any) (string, error) { rawBody, _ := json.Marshal(body) endpoint := "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=" + g.APIKey @@ -402,8 +447,11 @@ func truncateForError(text string, limit int) string { return trimmed[:limit] + "..." } -func fallbackQueryExpansion(query string) []string { - base := strings.TrimSpace(query) +func fallbackQueryExpansion(originalQuery, englishQuery string) []string { + base := strings.TrimSpace(englishQuery) + if base == "" { + base = strings.TrimSpace(originalQuery) + } candidates := []string{ base, base + " b-roll", @@ -416,6 +464,9 @@ func fallbackQueryExpansion(query string) []string { base + " 4k footage", base + " cinematic b-roll", } + if strings.TrimSpace(originalQuery) != "" && !strings.EqualFold(strings.TrimSpace(originalQuery), strings.TrimSpace(englishQuery)) { + candidates = append(candidates, strings.TrimSpace(originalQuery)) + } seen := map[string]bool{} queries := make([]string, 0, len(candidates)) @@ -433,3 +484,38 @@ func fallbackQueryExpansion(query string) []string { } return queries } + +func sanitizePlainEnglishLine(text string) string { + lines := strings.Split(text, "\n") + for _, line := range lines { + line = strings.TrimSpace(strings.Trim(line, "\"'`")) + if line == "" { + continue + } + lower := strings.ToLower(line) + for _, prefix := range []string{"translation:", "english:", "translated query:"} { + if strings.HasPrefix(lower, prefix) { + line = strings.TrimSpace(line[len(prefix):]) + lower = strings.ToLower(line) + } + } + if strings.HasPrefix(lower, "here is") || strings.HasPrefix(lower, "the translation") { + continue + } + if line != "" { + return line + } + } + return "" +} + +func looksMostlyASCII(text string) bool { + ascii := 0 + runes := []rune(text) + for _, r := range runes { + if r <= 127 { + ascii++ + } + } + return ascii >= len(runes)*8/10 +} diff --git a/frontend/index.html b/frontend/index.html index 97bcb0e..ac58ca7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -70,7 +70,7 @@