Improve modal fit and Gemini search refinement
build-push / docker (push) Successful in 4m8s

This commit is contained in:
AI Assistant
2026-03-17 12:30:50 +09:00
parent 0b68feff80
commit 556d4d6c1b
8 changed files with 353 additions and 77 deletions
+125 -1
View File
@@ -55,6 +55,8 @@ type AIRecommendation struct {
Source string `json:"source"`
Reason string `json:"reason"`
Recommended bool `json:"recommended"`
Assessment string `json:"assessment,omitempty"`
SearchHint string `json:"searchHint,omitempty"`
MediaMode string `json:"mediaMode,omitempty"`
EmbedURL string `json:"embedUrl,omitempty"`
PreviewBlockedReason string `json:"previewBlockedReason,omitempty"`
@@ -252,10 +254,17 @@ func (g *GeminiService) Recommend(query string, candidates []SearchResult) ([]AI
parts := []geminiPart{
{
"text": `You are a professional video editor. Analyze whether each provided visual is suitable as a usable scene or shot for the user's requested keyword. Return JSON only in this shape:
{"recommendations":[{"index":0,"verdict":"Yes","reason":"short reason","recommended":true}]}
{"recommendations":[{"index":0,"verdict":"Yes","reason":"short reason","recommended":true,"assessment":"positive","searchHint":"short english hint"}]}
Return one entry for every analyzed candidate. Use Korean for every reason. Keep reasons concise but specific enough to explain usefulness.
Set verdict to "Yes" or "No" for every candidate. "Yes" means the scene is usable and relevant for editing against the user's keyword. "No" means it is not suitable or not relevant enough.
Set recommended=true only when verdict is "Yes". Set recommended=false when verdict is "No".
Set assessment to one of: positive, unclear, irrelevant, inappropriate.
- positive: directly usable and relevant to the query
- unclear: visually ambiguous, weak, or not confident enough
- irrelevant: visibly unrelated to the query intent
- inappropriate: low-quality, spammy, misleading, meme-like, or otherwise unsuitable for professional editing
When assessment is not positive, provide searchHint as a short English stock-footage search phrase that could help find better candidates. Keep it under 8 words.
When assessment is positive, searchHint may be empty.
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 scenes that look directly useful for professional editing, sequencing, establishing, cutaway, or mood-building usage.
@@ -340,6 +349,8 @@ User query: ` + query,
Verdict string `json:"verdict"`
Reason string `json:"reason"`
Recommended bool `json:"recommended"`
Assessment string `json:"assessment"`
SearchHint string `json:"searchHint"`
} `json:"recommendations"`
}
if err := json.Unmarshal([]byte(jsonText), &parsed); err != nil {
@@ -353,6 +364,7 @@ User query: ` + query,
}
src := candidates[rec.Index]
recommended := rec.Recommended || strings.EqualFold(strings.TrimSpace(rec.Verdict), "yes")
assessment := normalizeAssessment(rec.Assessment, recommended)
recommendations = append(recommendations, AIRecommendation{
Title: src.Title,
Link: src.Link,
@@ -362,6 +374,8 @@ User query: ` + query,
Source: src.Source,
Reason: normalizeKoreanReason(rec.Reason),
Recommended: recommended,
Assessment: assessment,
SearchHint: normalizeSearchHint(rec.SearchHint),
})
}
g.debug("gemini:vision_complete", map[string]any{
@@ -372,6 +386,72 @@ User query: ` + query,
return recommendations, nil
}
func (g *GeminiService) BuildSupplementalQueries(query string, existing []string, reviewed []AIRecommendation) ([]string, error) {
baseExisting := make([]string, 0, len(existing))
for _, item := range existing {
trimmed := strings.TrimSpace(item)
if trimmed != "" {
baseExisting = append(baseExisting, trimmed)
}
}
if len(baseExisting) == 0 {
baseExisting = append(baseExisting, query)
}
positive := make([]string, 0, 3)
negativeHints := make([]string, 0, 4)
sourceCounts := map[string]int{}
for _, item := range reviewed {
sourceCounts[item.Source]++
if item.Assessment == "positive" && len(positive) < 3 {
positive = append(positive, truncateForError(strings.TrimSpace(item.Title), 80))
}
if (item.Assessment == "irrelevant" || item.Assessment == "inappropriate" || item.Assessment == "unclear") && item.SearchHint != "" && len(negativeHints) < 4 {
negativeHints = append(negativeHints, item.SearchHint)
}
}
if g.APIKey == "" {
return nil, fmt.Errorf("gemini api key is not configured")
}
body := map[string]any{
"systemInstruction": map[string]any{
"parts": []map[string]string{{
"text": "You generate improved stock-footage search phrases. Return 3 to 5 plain English search phrases only, one per line, no numbering, no quotes, no explanations.",
}},
},
"contents": []map[string]any{{
"parts": []map[string]string{{
"text": fmt.Sprintf("Original query: %s\nExisting search phrases: %s\nPositive candidate titles: %s\nNegative or weak search hints: %s\nSource distribution: Envato=%d, Artgrid=%d, Google Video=%d\nGenerate improved English search phrases that avoid weak or irrelevant results and increase provider diversity.",
query,
strings.Join(baseExisting, " | "),
strings.Join(positive, " | "),
strings.Join(negativeHints, " | "),
sourceCounts["Envato"],
sourceCounts["Artgrid"],
sourceCounts["Google Video"],
),
}},
}},
"generationConfig": map[string]any{
"responseMimeType": "text/plain",
"temperature": 0.3,
"maxOutputTokens": 120,
},
}
rawText, err := g.generateText(body)
if err != nil {
return nil, err
}
queries := parseSupplementalQueryLines(rawText)
if len(queries) == 0 {
return nil, fmt.Errorf("gemini returned no supplemental queries")
}
return queries, nil
}
func (g *GeminiService) debug(message string, data any) {
if g != nil && g.Debug != nil {
g.Debug(message, data)
@@ -655,6 +735,50 @@ func normalizeKoreanReason(reason string) string {
return trimmed
}
func normalizeAssessment(assessment string, recommended bool) string {
switch strings.ToLower(strings.TrimSpace(assessment)) {
case "positive", "unclear", "irrelevant", "inappropriate":
return strings.ToLower(strings.TrimSpace(assessment))
}
if recommended {
return "positive"
}
return "unclear"
}
func normalizeSearchHint(text string) string {
trimmed := strings.Join(strings.Fields(strings.TrimSpace(strings.Trim(text, "\"'`"))), " ")
if trimmed == "" {
return ""
}
if len(trimmed) > 80 {
return trimmed[:80]
}
return trimmed
}
func parseSupplementalQueryLines(text string) []string {
lines := strings.Split(text, "\n")
seen := map[string]bool{}
queries := make([]string, 0, 5)
for _, line := range lines {
trimmed := strings.TrimSpace(strings.Trim(line, "\"'`-0123456789. "))
if trimmed == "" {
continue
}
key := strings.ToLower(trimmed)
if seen[key] {
continue
}
seen[key] = true
queries = append(queries, trimmed)
if len(queries) >= 5 {
break
}
}
return queries
}
func buildSearchQueries(originalQuery, englishQuery string) []string {
base := strings.TrimSpace(englishQuery)
if base == "" {
+41
View File
@@ -77,6 +77,28 @@ func TestNormalizeKnownMediaPhrases(t *testing.T) {
}
}
func TestBuildSupplementalQueriesReturnsGeneratedLines(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"candidates":[{"content":{"parts":[{"text":"authentic couple city walk\ncandid couple park footage\nnatural lifestyle b-roll"}]}}]}`))
}))
defer server.Close()
service := NewGeminiService("dummy-key")
service.Client = &http.Client{Timeout: 2 * time.Second}
service.GenerateEndpoint = server.URL
queries, err := service.BuildSupplementalQueries("다정한 커플", []string{"friendly couple"}, []AIRecommendation{
{Assessment: "irrelevant", SearchHint: "authentic lifestyle couple"},
})
if err != nil {
t.Fatalf("expected supplemental query generation to succeed, got %v", err)
}
if len(queries) < 3 || queries[0] != "authentic couple city walk" {
t.Fatalf("unexpected supplemental queries: %#v", queries)
}
}
func TestSelectUnevaluatedCandidatesSkipsReviewedLinks(t *testing.T) {
ranked := []SearchResult{
{Link: "https://a.example"},
@@ -172,3 +194,22 @@ func TestRankSearchResultsPrefersUsableVisuals(t *testing.T) {
t.Fatalf("expected usable thumbnail result first, got %#v", ranked)
}
}
func TestMergeRecommendationsExcludesIrrelevantAndPendingFiller(t *testing.T) {
recommended := []AIRecommendation{
{Title: "keep", Link: "https://a.example", Recommended: true, Assessment: "positive", ThumbnailURL: "https://example.com/a.jpg"},
{Title: "drop", Link: "https://b.example", Recommended: false, Assessment: "irrelevant", ThumbnailURL: "https://example.com/b.jpg", Reason: "관련이 없습니다."},
}
ranked := []SearchResult{
{Title: "keep", Link: "https://a.example", ThumbnailURL: "https://example.com/a.jpg"},
{Title: "extra", Link: "https://c.example", ThumbnailURL: "https://example.com/c.jpg"},
}
merged := MergeRecommendations(recommended, ranked, 16)
if len(merged) != 1 {
t.Fatalf("expected only the positive recommendation without pending filler, got %#v", merged)
}
if merged[0].Link != "https://a.example" {
t.Fatalf("unexpected merged result: %#v", merged)
}
}
+26 -36
View File
@@ -11,7 +11,7 @@ import (
const GeminiFallbackReason = "Gemini Vision 응답이 부족해 키워드 기준으로 보강된 결과입니다."
const FallbackPreviewReason = "Fallback due to missing provider preview."
const PendingVisualReason = "Ranked candidate pending stronger visual evidence."
const SupplementalFallbackReason = "추가 탐색 후에도 충분한 확신 후보가 부족해 시각 자산이 있는 후보를 제한적으로 보강했습니다."
type GeminiBatchStats struct {
CandidateCap int `json:"candidateCap"`
@@ -258,6 +258,7 @@ func BuildFallbackRecommendations(ranked []SearchResult, limit int, reason strin
Source: item.Source,
Reason: reason,
Recommended: false,
Assessment: "unclear",
}))
}
return fallback
@@ -370,18 +371,22 @@ func NeedsSupplementalExploration(items []AIRecommendation) bool {
recommendedCount := 0
negativeCount := 0
unclearCount := 0
for _, item := range items {
if item.Recommended {
if item.Recommended && item.Assessment == "positive" {
recommendedCount++
}
if looksNegativeReason(item.Reason) {
if IsExcludedAssessment(item.Assessment) || looksNegativeReason(item.Reason) {
negativeCount++
}
if item.Assessment == "unclear" {
unclearCount++
}
}
if recommendedCount >= 3 {
if recommendedCount >= 4 {
return false
}
return negativeCount >= max(2, len(items)/2)
return negativeCount >= max(2, len(items)/3) || unclearCount >= max(2, len(items)/2)
}
func looksNegativeReason(reason string) bool {
@@ -403,11 +408,9 @@ func looksNegativeReason(reason string) bool {
func MergeRecommendations(recommended []AIRecommendation, ranked []SearchResult, limit int) []AIRecommendation {
merged := make([]AIRecommendation, 0, min(limit, len(ranked)))
seen := map[string]bool{}
fillerCount := 0
maxFiller := min(4, limit)
for _, item := range recommended {
if !item.Recommended {
if !item.Recommended || item.Assessment != "positive" {
continue
}
if item.Link == "" || seen[item.Link] {
@@ -421,7 +424,10 @@ func MergeRecommendations(recommended []AIRecommendation, ranked []SearchResult,
if item.Recommended || item.Link == "" || seen[item.Link] || len(merged) >= limit {
continue
}
if looksNegativeReason(item.Reason) || strings.Contains(item.Reason, GeminiFallbackReason) {
if IsExcludedAssessment(item.Assessment) || looksNegativeReason(item.Reason) || strings.Contains(item.Reason, GeminiFallbackReason) {
continue
}
if item.Assessment == "unclear" {
continue
}
if strings.TrimSpace(item.PreviewVideoURL) == "" && !hasUsableThumbnail(item.ThumbnailURL) {
@@ -430,32 +436,6 @@ func MergeRecommendations(recommended []AIRecommendation, ranked []SearchResult,
seen[item.Link] = true
merged = append(merged, DecorateRecommendationMedia(item))
}
if len(merged) < min(16, limit) {
for _, item := range ranked {
if len(merged) >= min(16, limit) || item.Link == "" || seen[item.Link] {
continue
}
if fillerCount >= maxFiller {
break
}
if strings.TrimSpace(item.PreviewVideoURL) == "" && !hasUsableThumbnail(item.ThumbnailURL) {
continue
}
seen[item.Link] = true
merged = append(merged, DecorateRecommendationMedia(AIRecommendation{
Title: item.Title,
Link: item.Link,
Snippet: item.Snippet,
ThumbnailURL: item.ThumbnailURL,
PreviewVideoURL: item.PreviewVideoURL,
Source: item.Source,
Reason: PendingVisualReason,
Recommended: false,
}))
fillerCount++
}
}
return merged
}
@@ -489,14 +469,24 @@ func BackfillRecommendations(existing []AIRecommendation, ranked []SearchResult,
ThumbnailURL: item.ThumbnailURL,
PreviewVideoURL: item.PreviewVideoURL,
Source: item.Source,
Reason: firstNonEmpty(strings.TrimSpace(reason), FallbackPreviewReason),
Reason: firstNonEmpty(strings.TrimSpace(reason), SupplementalFallbackReason),
Recommended: false,
Assessment: "unclear",
}))
fillerCount++
}
return merged
}
func IsExcludedAssessment(assessment string) bool {
switch strings.ToLower(strings.TrimSpace(assessment)) {
case "irrelevant", "inappropriate":
return true
default:
return false
}
}
func max(a, b int) int {
if a > b {
return a