summaryrefslogtreecommitdiff
path: root/internal/phonetic
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-02 16:36:25 +0300
committerPaul Buetow <paul@buetow.org>2026-04-02 16:36:25 +0300
commit6bd23a588bacee2e8c75f477150b7e2d345002ff (patch)
tree67bfa78afe206e7560350bdd52c60e0e80ec3243 /internal/phonetic
parent6ce9123de04ffff961cbf1da73648679216ff637 (diff)
Release v0.9.0v0.9.0
Diffstat (limited to 'internal/phonetic')
-rw-r--r--internal/phonetic/fetcher.go80
-rw-r--r--internal/phonetic/fetcher_test.go75
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)
+ }
+}