This commit is contained in:
+125
-1
@@ -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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user