diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-02 16:36:25 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-02 16:36:25 +0300 |
| commit | 6bd23a588bacee2e8c75f477150b7e2d345002ff (patch) | |
| tree | 67bfa78afe206e7560350bdd52c60e0e80ec3243 /internal/phonetic | |
| parent | 6ce9123de04ffff961cbf1da73648679216ff637 (diff) | |
Release v0.9.0v0.9.0
Diffstat (limited to 'internal/phonetic')
| -rw-r--r-- | internal/phonetic/fetcher.go | 80 | ||||
| -rw-r--r-- | internal/phonetic/fetcher_test.go | 75 |
2 files changed, 139 insertions, 16 deletions
diff --git a/internal/phonetic/fetcher.go b/internal/phonetic/fetcher.go index 5d39fc3..0346213 100644 --- a/internal/phonetic/fetcher.go +++ b/internal/phonetic/fetcher.go @@ -2,9 +2,11 @@ package phonetic import ( "context" + "errors" "fmt" "os" "path/filepath" + "regexp" "strings" "time" @@ -21,11 +23,15 @@ const ( defaultGeminiModel = "gemini-2.5-flash" defaultOpenAIModel = openai.GPT4o phoneticTimeout = 30 * time.Second + phoneticRetryCount = 3 phoneticTemperature = 0.3 phoneticMaxTokens = 50 phoneticSystemPrompt = "You are a Bulgarian language expert. Provide only the IPA (International Phonetic Alphabet) transcription for Bulgarian words. Return ONLY the IPA transcription in square brackets, nothing else. No explanations, no word labels, just the IPA." ) +var geminiIPAPattern = regexp.MustCompile(`\[[^\[\]\n]+\]`) +var errNoGeminiPhoneticResponse = errors.New("no response from Gemini") + // Provider selects the phonetic backend. type Provider string @@ -81,22 +87,19 @@ var fetchOpenAIPhonetic = func(ctx context.Context, client *openai.Client, word var fetchGeminiPhonetic = func(ctx context.Context, client *genai.Client, word string) (string, error) { temp := float32(phoneticTemperature) resp, err := client.Models.GenerateContent(ctx, defaultGeminiModel, []*genai.Content{ - genai.NewContentFromText(word, genai.RoleUser), + genai.NewContentFromText(buildGeminiPhoneticPrompt(word), genai.RoleUser), }, &genai.GenerateContentConfig{ - SystemInstruction: genai.NewContentFromText(phoneticSystemPrompt, genai.RoleUser), - Temperature: &temp, - MaxOutputTokens: phoneticMaxTokens, + SystemInstruction: &genai.Content{ + Parts: []*genai.Part{{Text: phoneticSystemPrompt}}, + }, + Temperature: &temp, + MaxOutputTokens: phoneticMaxTokens, }) if err != nil { return "", fmt.Errorf("gemini API error: %w", err) } - phoneticInfo := strings.TrimSpace(resp.Text()) - if phoneticInfo == "" { - return "", fmt.Errorf("no response from Gemini") - } - - return phoneticInfo, nil + return normalizeGeminiPhoneticResponse(resp.Text()) } // NewFetcher creates a new phonetic information fetcher. @@ -190,12 +193,63 @@ func (f *Fetcher) fetchWithGemini(ctx context.Context, word string) (string, err return "", fmt.Errorf("gemini client not initialized") } - return fetchGeminiPhonetic(ctx, f.geminiClient, word) + var lastErr error + for attempt := 0; attempt < phoneticRetryCount; attempt++ { + phoneticInfo, err := fetchGeminiPhonetic(ctx, f.geminiClient, word) + if err == nil { + return phoneticInfo, nil + } + if !errors.Is(err, errNoGeminiPhoneticResponse) { + return "", err + } + + lastErr = err + } + + if lastErr != nil { + return "", lastErr + } + + return "", errNoGeminiPhoneticResponse +} + +func buildGeminiPhoneticPrompt(word string) string { + return fmt.Sprintf("Bulgarian text or phrase:\n%s\n\nReturn only its IPA transcription in square brackets.", strings.TrimSpace(word)) +} + +func normalizeGeminiPhoneticResponse(raw string) (string, error) { + trimmed := stripMarkdownCodeFence(strings.TrimSpace(raw)) + if trimmed == "" { + return "", errNoGeminiPhoneticResponse + } + + if match := geminiIPAPattern.FindString(trimmed); match != "" { + return match, nil + } + + return trimmed, nil +} + +func stripMarkdownCodeFence(raw string) string { + trimmed := strings.TrimSpace(raw) + if !strings.HasPrefix(trimmed, "```") { + return trimmed + } + + trimmed = strings.TrimPrefix(trimmed, "```") + if newline := strings.Index(trimmed, "\n"); newline >= 0 { + trimmed = trimmed[newline+1:] + } + if closing := strings.LastIndex(trimmed, "```"); closing >= 0 { + trimmed = trimmed[:closing] + } + + return strings.TrimSpace(trimmed) } func normalizeConfig(config *Config) Config { normalized := Config{ - Provider: ProviderOpenAI, + Provider: ProviderGemini, OpenAIKey: "", GoogleAPIKey: "", } @@ -214,7 +268,7 @@ func normalizeConfig(config *Config) Config { func normalizeProvider(provider Provider) Provider { normalized := Provider(strings.ToLower(strings.TrimSpace(string(provider)))) if normalized == "" { - return ProviderOpenAI + return ProviderGemini } return normalized diff --git a/internal/phonetic/fetcher_test.go b/internal/phonetic/fetcher_test.go index 7fa74e8..0b92657 100644 --- a/internal/phonetic/fetcher_test.go +++ b/internal/phonetic/fetcher_test.go @@ -2,6 +2,7 @@ package phonetic import ( "context" + "errors" "os" "path/filepath" "testing" @@ -10,15 +11,15 @@ import ( "google.golang.org/genai" ) -func TestNewFetcher_DefaultsToOpenAI(t *testing.T) { +func TestNewFetcher_DefaultsToGemini(t *testing.T) { fetcher := NewFetcher(nil) if fetcher == nil { t.Fatal("NewFetcher returned nil") } - if got := fetcher.Provider(); got != ProviderOpenAI { - t.Fatalf("expected default provider %q, got %q", ProviderOpenAI, got) + if got := fetcher.Provider(); got != ProviderGemini { + t.Fatalf("expected default provider %q, got %q", ProviderGemini, got) } } @@ -241,3 +242,71 @@ func TestFetchAndSave_GeminiAPIFailure(t *testing.T) { t.Fatalf("unexpected Gemini API error: %v", err) } } + +func TestNormalizeGeminiPhoneticResponse(t *testing.T) { + t.Run("extracts bracketed ipa from prose", func(t *testing.T) { + got, err := normalizeGeminiPhoneticResponse("IPA: [ˈkotka]") + if err != nil { + t.Fatalf("normalizeGeminiPhoneticResponse() unexpected error: %v", err) + } + if got != "[ˈkotka]" { + t.Fatalf("normalizeGeminiPhoneticResponse() = %q, want %q", got, "[ˈkotka]") + } + }) + + t.Run("strips markdown fences", func(t *testing.T) { + got, err := normalizeGeminiPhoneticResponse("```text\n[ˈjabəɫkɐ]\n```") + if err != nil { + t.Fatalf("normalizeGeminiPhoneticResponse() unexpected error: %v", err) + } + if got != "[ˈjabəɫkɐ]" { + t.Fatalf("normalizeGeminiPhoneticResponse() = %q, want %q", got, "[ˈjabəɫkɐ]") + } + }) + + t.Run("empty response is retryable", func(t *testing.T) { + _, err := normalizeGeminiPhoneticResponse(" ") + if !errors.Is(err, errNoGeminiPhoneticResponse) { + t.Fatalf("normalizeGeminiPhoneticResponse() error = %v, want %v", err, errNoGeminiPhoneticResponse) + } + }) +} + +func TestFetch_GeminiProviderRetriesEmptyResponse(t *testing.T) { + originalFetch := fetchGeminiPhonetic + attempts := 0 + fetchGeminiPhonetic = func(context.Context, *genai.Client, string) (string, error) { + attempts++ + if attempts < 3 { + return "", errNoGeminiPhoneticResponse + } + return "[ˈkotka]", nil + } + t.Cleanup(func() { + fetchGeminiPhonetic = originalFetch + }) + + originalNewGeminiClient := newGeminiClient + newGeminiClient = func(context.Context, *genai.ClientConfig) (*genai.Client, error) { + return &genai.Client{}, nil + } + t.Cleanup(func() { + newGeminiClient = originalNewGeminiClient + }) + + fetcher := NewFetcher(&Config{ + Provider: ProviderGemini, + GoogleAPIKey: "test-google-key", + }) + + got, err := fetcher.Fetch("котка") + if err != nil { + t.Fatalf("Fetch failed: %v", err) + } + if got != "[ˈkotka]" { + t.Fatalf("unexpected phonetic content %q", got) + } + if attempts != phoneticRetryCount { + t.Fatalf("attempt count = %d, want %d", attempts, phoneticRetryCount) + } +} |
