Add local self-test flow and fix fallback regressions
build-push / docker (push) Successful in 4m15s
build-push / docker (push) Successful in 4m15s
This commit is contained in:
+81
-60
@@ -12,13 +12,16 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type GeminiService struct {
|
||||
APIKey string
|
||||
Client *http.Client
|
||||
APIKey string
|
||||
Client *http.Client
|
||||
GenerateEndpoint string
|
||||
TranslateEndpoint string
|
||||
}
|
||||
|
||||
type AIRecommendation struct {
|
||||
@@ -37,8 +40,10 @@ type QueryExpansion struct {
|
||||
|
||||
func NewGeminiService(apiKey string) *GeminiService {
|
||||
return &GeminiService{
|
||||
APIKey: apiKey,
|
||||
Client: &http.Client{Timeout: 40 * time.Second},
|
||||
APIKey: apiKey,
|
||||
Client: &http.Client{Timeout: 40 * time.Second},
|
||||
GenerateEndpoint: "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent",
|
||||
TranslateEndpoint: "https://translate.googleapis.com/translate_a/single",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,54 +53,60 @@ func (g *GeminiService) ExpandQuery(query string) ([]string, error) {
|
||||
}
|
||||
|
||||
func (g *GeminiService) TranslateQuery(query string) string {
|
||||
if strings.TrimSpace(query) == "" || looksMostlyASCII(query) || g.APIKey == "" {
|
||||
return strings.TrimSpace(query)
|
||||
trimmed := strings.TrimSpace(query)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if looksMostlyASCII(trimmed) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
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{
|
||||
{
|
||||
if g.APIKey != "" {
|
||||
body := map[string]any{
|
||||
"systemInstruction": map[string]any{
|
||||
"parts": []map[string]string{
|
||||
{
|
||||
"text": "Translate this user query into concise English suitable for stock-footage search: " + query,
|
||||
"text": "You translate media search intents into natural English. Output one plain English search phrase only. No labels, no quotes, no explanations.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"generationConfig": map[string]any{
|
||||
"responseMimeType": "text/plain",
|
||||
"temperature": 0.1,
|
||||
"maxOutputTokens": 40,
|
||||
},
|
||||
}
|
||||
"contents": []map[string]any{
|
||||
{
|
||||
"parts": []map[string]string{
|
||||
{
|
||||
"text": "Translate this user query into concise English suitable for stock-footage search: " + trimmed,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"generationConfig": map[string]any{
|
||||
"responseMimeType": "text/plain",
|
||||
"temperature": 0.1,
|
||||
"maxOutputTokens": 40,
|
||||
},
|
||||
}
|
||||
|
||||
rawText, err := g.generateText(body)
|
||||
if err == nil {
|
||||
translated := sanitizePlainEnglishLine(rawText)
|
||||
if translated != "" && !strings.EqualFold(translated, strings.TrimSpace(query)) {
|
||||
return translated
|
||||
rawText, err := g.generateText(body)
|
||||
if err == nil {
|
||||
translated := sanitizePlainEnglishLine(rawText)
|
||||
if translated != "" && !strings.EqualFold(translated, trimmed) {
|
||||
return translated
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if translated, err := g.translateViaGoogle(query); err == nil && translated != "" {
|
||||
if translated, err := g.translateViaGoogle(trimmed); err == nil && translated != "" && isLikelyEnglishQuery(translated) {
|
||||
return translated
|
||||
}
|
||||
if translated := translateKoreanMediaTerms(query); translated != "" && !strings.EqualFold(translated, strings.TrimSpace(query)) {
|
||||
if translated := translateKoreanMediaTerms(trimmed); translated != "" && !strings.EqualFold(translated, trimmed) {
|
||||
return translated
|
||||
}
|
||||
return strings.TrimSpace(query)
|
||||
return trimmed
|
||||
}
|
||||
|
||||
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
|
||||
endpoint := strings.TrimRight(g.GenerateEndpoint, "?") + "?key=" + g.APIKey
|
||||
resp, err := g.Client.Post(endpoint, "application/json", bytes.NewReader(rawBody))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("gemini request failed: %w", err)
|
||||
@@ -168,7 +179,7 @@ User query: ` + query,
|
||||
}
|
||||
|
||||
rawBody, _ := json.Marshal(body)
|
||||
endpoint := "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=" + g.APIKey
|
||||
endpoint := strings.TrimRight(g.GenerateEndpoint, "?") + "?key=" + g.APIKey
|
||||
resp, err := g.Client.Post(endpoint, "application/json", bytes.NewReader(rawBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -448,41 +459,51 @@ func isLikelyEnglishQuery(text string) bool {
|
||||
}
|
||||
|
||||
func translateKoreanMediaTerms(query string) string {
|
||||
replacements := map[string]string{
|
||||
"숲속": "forest",
|
||||
"숲": "forest",
|
||||
"다정한": "affectionate",
|
||||
"커플": "couple",
|
||||
"도시": "city",
|
||||
"야경": "night city",
|
||||
"거리": "street",
|
||||
"골목": "alley",
|
||||
"바다": "ocean",
|
||||
"해변": "beach",
|
||||
"노을": "sunset",
|
||||
"자연": "nature",
|
||||
"비": "rain",
|
||||
"눈": "snow",
|
||||
"드론": "drone",
|
||||
"항공샷": "aerial shot",
|
||||
"사람들": "people",
|
||||
"인파": "crowd",
|
||||
"행복한": "happy",
|
||||
"연인": "lovers",
|
||||
"공원": "park",
|
||||
"산": "mountain",
|
||||
replacements := []struct {
|
||||
korean string
|
||||
english string
|
||||
}{
|
||||
{korean: "숲속", english: "forest"},
|
||||
{korean: "다정한", english: "affectionate"},
|
||||
{korean: "항공샷", english: "aerial shot"},
|
||||
{korean: "사람들", english: "people"},
|
||||
{korean: "행복한", english: "happy"},
|
||||
{korean: "커플", english: "couple"},
|
||||
{korean: "연인", english: "lovers"},
|
||||
{korean: "도시", english: "city"},
|
||||
{korean: "야경", english: "night city"},
|
||||
{korean: "거리", english: "street"},
|
||||
{korean: "골목", english: "alley"},
|
||||
{korean: "바다", english: "ocean"},
|
||||
{korean: "해변", english: "beach"},
|
||||
{korean: "노을", english: "sunset"},
|
||||
{korean: "자연", english: "nature"},
|
||||
{korean: "드론", english: "drone"},
|
||||
{korean: "인파", english: "crowd"},
|
||||
{korean: "공원", english: "park"},
|
||||
{korean: "숲", english: "forest"},
|
||||
{korean: "비", english: "rain"},
|
||||
{korean: "눈", english: "snow"},
|
||||
{korean: "산", english: "mountain"},
|
||||
}
|
||||
sort.SliceStable(replacements, func(i, j int) bool {
|
||||
return len([]rune(replacements[i].korean)) > len([]rune(replacements[j].korean))
|
||||
})
|
||||
|
||||
translated := strings.TrimSpace(query)
|
||||
for korean, english := range replacements {
|
||||
translated = strings.ReplaceAll(translated, korean, english)
|
||||
for _, replacement := range replacements {
|
||||
translated = strings.ReplaceAll(translated, replacement.korean, replacement.english)
|
||||
}
|
||||
translated = strings.Join(strings.Fields(translated), " ")
|
||||
return strings.TrimSpace(translated)
|
||||
}
|
||||
|
||||
func (g *GeminiService) translateViaGoogle(query string) (string, error) {
|
||||
endpoint := "https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=en&dt=t&q=" + neturl.QueryEscape(query)
|
||||
baseURL := g.TranslateEndpoint
|
||||
if strings.TrimSpace(baseURL) == "" {
|
||||
baseURL = "https://translate.googleapis.com/translate_a/single"
|
||||
}
|
||||
endpoint := baseURL + "?client=gtx&sl=auto&tl=en&dt=t&q=" + neturl.QueryEscape(query)
|
||||
resp, err := g.Client.Get(endpoint)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTranslateQueryFallsBackToGoogleWithoutGeminiKey(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(`[[["rainy city street","비 오는 도시 거리",null,null,1]],null,"ko"]`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := NewGeminiService("")
|
||||
service.Client = &http.Client{Timeout: 2 * time.Second}
|
||||
service.TranslateEndpoint = server.URL
|
||||
|
||||
translated := service.TranslateQuery("비 오는 도시 거리")
|
||||
if translated != "rainy city street" {
|
||||
t.Fatalf("expected google fallback translation, got %q", translated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateQueryFallsBackToDictionaryWhenTranslateFails(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "boom", http.StatusBadGateway)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := NewGeminiService("")
|
||||
service.Client = &http.Client{Timeout: 2 * time.Second}
|
||||
service.TranslateEndpoint = server.URL
|
||||
|
||||
translated := service.TranslateQuery("숲속 커플")
|
||||
if translated != "forest couple" {
|
||||
t.Fatalf("expected dictionary fallback translation, got %q", translated)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user