diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-01 13:25:46 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-01 13:25:46 +0300 |
| commit | 327817bae6a386f37d31d50a962c559d747a5383 (patch) | |
| tree | 23de5d328176e665fb34c8ed9a7e3721738f2c14 /internal | |
| parent | d3fe3586f50545575978f8d73a649afabc915008 (diff) | |
zs: add Gemini-backed phonetic fetching
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/phonetic/doc.go | 4 | ||||
| -rw-r--r-- | internal/phonetic/fetcher.go | 196 | ||||
| -rw-r--r-- | internal/phonetic/fetcher_test.go | 136 | ||||
| -rw-r--r-- | internal/processor/processor.go | 6 |
4 files changed, 269 insertions, 73 deletions
diff --git a/internal/phonetic/doc.go b/internal/phonetic/doc.go index 9209f41..e4f9d70 100644 --- a/internal/phonetic/doc.go +++ b/internal/phonetic/doc.go @@ -1,4 +1,4 @@ // Package phonetic provides functionality for fetching detailed phonetic -// information about Bulgarian words using OpenAI's GPT models. It generates -// IPA transcriptions with detailed explanations for language learners. +// information about Bulgarian words using OpenAI or Gemini. It generates IPA +// transcriptions for language learners. package phonetic diff --git a/internal/phonetic/fetcher.go b/internal/phonetic/fetcher.go index a42ee36..8919345 100644 --- a/internal/phonetic/fetcher.go +++ b/internal/phonetic/fetcher.go @@ -9,59 +9,136 @@ import ( "time" "github.com/sashabaranov/go-openai" + "google.golang.org/genai" ) -// Fetcher handles fetching phonetic information for Bulgarian words -type Fetcher struct { - apiKey string - client *openai.Client -} +const ( + // ProviderGemini routes phonetic requests to Gemini. + ProviderGemini Provider = "gemini" + // ProviderOpenAI routes phonetic requests to OpenAI. + ProviderOpenAI Provider = "openai" -// NewFetcher creates a new phonetic information fetcher -func NewFetcher(apiKey string) *Fetcher { - return &Fetcher{ - apiKey: apiKey, - client: openai.NewClient(apiKey), - } + defaultGeminiModel = "gemini-2.5-flash" + defaultOpenAIModel = openai.GPT4o + phoneticTimeout = 30 * time.Second + 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." +) + +// Provider selects the phonetic backend. +type Provider string + +// Config holds phonetic fetcher settings and API credentials. +type Config struct { + Provider Provider + OpenAIKey string + GoogleAPIKey string } -// FetchAndSave fetches phonetic information for a word and saves it to the word directory -func (f *Fetcher) FetchAndSave(word, wordDir string) error { - if f.apiKey == "" { - return fmt.Errorf("OpenAI API key not configured") - } +// Fetcher handles fetching phonetic information for Bulgarian words. +type Fetcher struct { + provider Provider + openAIKey string + googleAPIKey string - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() + openAIClient *openai.Client + geminiClient *genai.Client + geminiInitErr error +} + +var newGeminiClient = genai.NewClient +var fetchOpenAIPhonetic = func(ctx context.Context, client *openai.Client, word string) (string, error) { req := openai.ChatCompletionRequest{ - Model: openai.GPT4o, + Model: defaultOpenAIModel, Messages: []openai.ChatCompletionMessage{ { Role: openai.ChatMessageRoleSystem, - Content: "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.", + Content: phoneticSystemPrompt, }, { Role: openai.ChatMessageRoleUser, - Content: fmt.Sprintf(`%s`, word), + Content: word, }, }, - Temperature: 0.3, - MaxTokens: 50, + Temperature: phoneticTemperature, + MaxTokens: phoneticMaxTokens, } - resp, err := f.client.CreateChatCompletion(ctx, req) + resp, err := client.CreateChatCompletion(ctx, req) if err != nil { - return fmt.Errorf("OpenAI API error: %w", err) + return "", fmt.Errorf("OpenAI API error: %w", err) } if len(resp.Choices) == 0 || resp.Choices[0].Message.Content == "" { - return fmt.Errorf("no response from OpenAI") + return "", fmt.Errorf("no response from OpenAI") } - phoneticInfo := strings.TrimSpace(resp.Choices[0].Message.Content) + return strings.TrimSpace(resp.Choices[0].Message.Content), nil +} + +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.GenerateContentConfig{ + SystemInstruction: genai.NewContentFromText(phoneticSystemPrompt, genai.RoleUser), + 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 +} + +// NewFetcher creates a new phonetic information fetcher. +func NewFetcher(config *Config) *Fetcher { + normalized := normalizeConfig(config) + fetcher := &Fetcher{ + provider: normalized.Provider, + openAIKey: normalized.OpenAIKey, + googleAPIKey: normalized.GoogleAPIKey, + } + + switch fetcher.provider { + case ProviderOpenAI: + if fetcher.openAIKey != "" { + fetcher.openAIClient = openai.NewClient(fetcher.openAIKey) + } + case ProviderGemini: + if fetcher.googleAPIKey != "" { + client, err := newGeminiClient(context.Background(), &genai.ClientConfig{ + APIKey: fetcher.googleAPIKey, + }) + if err != nil { + fetcher.geminiInitErr = err + } else { + fetcher.geminiClient = client + } + } + } + + return fetcher +} + +// FetchAndSave fetches phonetic information for a word and saves it to the word directory. +func (f *Fetcher) FetchAndSave(word, wordDir string) error { + ctx, cancel := context.WithTimeout(context.Background(), phoneticTimeout) + defer cancel() + + phoneticInfo, err := f.fetchPhoneticInfo(ctx, word) + if err != nil { + return err + } - // Save phonetic info to file phoneticFile := filepath.Join(wordDir, "phonetic.txt") if err := os.WriteFile(phoneticFile, []byte(phoneticInfo), 0644); err != nil { return fmt.Errorf("failed to write phonetic file: %w", err) @@ -69,3 +146,66 @@ func (f *Fetcher) FetchAndSave(word, wordDir string) error { return nil } + +func (f *Fetcher) fetchPhoneticInfo(ctx context.Context, word string) (string, error) { + switch f.provider { + case ProviderOpenAI: + return f.fetchWithOpenAI(ctx, word) + case ProviderGemini: + return f.fetchWithGemini(ctx, word) + default: + return "", fmt.Errorf("unknown phonetic provider: %s", f.provider) + } +} + +func (f *Fetcher) fetchWithOpenAI(ctx context.Context, word string) (string, error) { + if f.openAIKey == "" { + return "", fmt.Errorf("OpenAI API key not configured") + } + if f.openAIClient == nil { + return "", fmt.Errorf("OpenAI client not initialized") + } + + return fetchOpenAIPhonetic(ctx, f.openAIClient, word) +} + +func (f *Fetcher) fetchWithGemini(ctx context.Context, word string) (string, error) { + if f.googleAPIKey == "" { + return "", fmt.Errorf("Google API key not configured") + } + if f.geminiInitErr != nil { + return "", fmt.Errorf("Gemini client initialization failed: %w", f.geminiInitErr) + } + if f.geminiClient == nil { + return "", fmt.Errorf("Gemini client not initialized") + } + + return fetchGeminiPhonetic(ctx, f.geminiClient, word) +} + +func normalizeConfig(config *Config) Config { + normalized := Config{ + Provider: ProviderGemini, + OpenAIKey: "", + GoogleAPIKey: "", + } + + if config == nil { + return normalized + } + + normalized.Provider = normalizeProvider(config.Provider) + normalized.OpenAIKey = strings.TrimSpace(config.OpenAIKey) + normalized.GoogleAPIKey = strings.TrimSpace(config.GoogleAPIKey) + + return normalized +} + +func normalizeProvider(provider Provider) Provider { + normalized := Provider(strings.ToLower(strings.TrimSpace(string(provider)))) + if normalized == "" { + return ProviderGemini + } + + return normalized +} diff --git a/internal/phonetic/fetcher_test.go b/internal/phonetic/fetcher_test.go index 19d32b2..3382457 100644 --- a/internal/phonetic/fetcher_test.go +++ b/internal/phonetic/fetcher_test.go @@ -1,91 +1,145 @@ package phonetic import ( + "context" "os" "path/filepath" - "strings" "testing" + + "github.com/sashabaranov/go-openai" + "google.golang.org/genai" ) -func TestNewFetcher(t *testing.T) { - fetcher := NewFetcher("test-api-key") +func TestNewFetcher_DefaultsToGemini(t *testing.T) { + fetcher := NewFetcher(nil) if fetcher == nil { t.Fatal("NewFetcher returned nil") } - if fetcher.apiKey != "test-api-key" { - t.Errorf("Expected API key 'test-api-key', got '%s'", fetcher.apiKey) + if fetcher.provider != ProviderGemini { + t.Fatalf("expected default provider %q, got %q", ProviderGemini, fetcher.provider) + } + + if fetcher.openAIClient != nil { + t.Error("expected OpenAI client to be nil without an API key") } - if fetcher.client == nil { - t.Error("OpenAI client not initialized") + if fetcher.geminiClient != nil { + t.Error("expected Gemini client to be nil without an API key") } } -func TestFetchAndSave_NoAPIKey(t *testing.T) { - fetcher := NewFetcher("") +func TestFetchAndSave_NoGoogleAPIKey(t *testing.T) { + fetcher := NewFetcher(nil) tmpDir := t.TempDir() err := fetcher.FetchAndSave("ябълка", tmpDir) if err == nil { - t.Error("Expected error for missing API key") + t.Fatal("expected error for missing Google API key") } - if err.Error() != "OpenAI API key not configured" { - t.Errorf("Expected 'OpenAI API key not configured' error, got: %v", err) + if err.Error() != "Google API key not configured" { + t.Fatalf("expected Google API key error, got %v", err) } } -func TestFetchAndSave_Integration(t *testing.T) { - // Skip if no API key - apiKey := os.Getenv("OPENAI_API_KEY") - if apiKey == "" { - t.Skip("Skipping integration test: OPENAI_API_KEY not set") +func TestFetchAndSave_NoOpenAIAPIKey(t *testing.T) { + fetcher := NewFetcher(&Config{Provider: ProviderOpenAI}) + tmpDir := t.TempDir() + + err := fetcher.FetchAndSave("ябълка", tmpDir) + if err == nil { + t.Fatal("expected error for missing OpenAI API key") + } + + if err.Error() != "OpenAI API key not configured" { + t.Fatalf("expected OpenAI API key error, got %v", err) } +} - fetcher := NewFetcher(apiKey) +func TestFetchAndSave_OpenAIProvider_WritesFile(t *testing.T) { + originalFetch := fetchOpenAIPhonetic + fetchOpenAIPhonetic = func(context.Context, *openai.Client, string) (string, error) { + return "[ˈjɤbɐlkɐ]", nil + } + t.Cleanup(func() { + fetchOpenAIPhonetic = originalFetch + }) + + fetcher := NewFetcher(&Config{ + Provider: ProviderOpenAI, + OpenAIKey: "test-openai-key", + }) tmpDir := t.TempDir() - // Test with a simple word - err := fetcher.FetchAndSave("ябълка", tmpDir) - if err != nil { - t.Errorf("FetchAndSave failed: %v", err) + if err := fetcher.FetchAndSave("ябълка", tmpDir); err != nil { + t.Fatalf("FetchAndSave failed: %v", err) } - // Check file was created - phoneticFile := filepath.Join(tmpDir, "phonetic.txt") - content, err := os.ReadFile(phoneticFile) + content, err := os.ReadFile(filepath.Join(tmpDir, "phonetic.txt")) if err != nil { - t.Errorf("Failed to read phonetic file: %v", err) + t.Fatalf("failed to read phonetic file: %v", err) + } + + if got := string(content); got != "[ˈjɤbɐlkɐ]" { + t.Fatalf("unexpected phonetic content %q", got) + } +} + +func TestFetchAndSave_GeminiProvider_WritesFile(t *testing.T) { + originalFetch := fetchGeminiPhonetic + fetchGeminiPhonetic = func(context.Context, *genai.Client, string) (string, error) { + 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", + }) + tmpDir := t.TempDir() - // Check content is reasonable (should be just IPA transcription) - if len(content) < 5 { - t.Error("Phonetic content seems too short") + if err := fetcher.FetchAndSave("котка", tmpDir); err != nil { + t.Fatalf("FetchAndSave failed: %v", err) } - // Should contain IPA symbols or phonetic information - contentStr := string(content) - if !strings.Contains(contentStr, "/") && !strings.Contains(contentStr, "[") { - t.Error("Content doesn't appear to contain IPA transcription") + content, err := os.ReadFile(filepath.Join(tmpDir, "phonetic.txt")) + if err != nil { + t.Fatalf("failed to read phonetic file: %v", err) } - t.Logf("Phonetic info for 'ябълка':\n%s", contentStr) + if got := string(content); got != "[ˈkotka]" { + t.Fatalf("unexpected phonetic content %q", got) + } } func TestFetchAndSave_InvalidDirectory(t *testing.T) { - // Skip if no API key - apiKey := os.Getenv("OPENAI_API_KEY") - if apiKey == "" { - t.Skip("Skipping test: OPENAI_API_KEY not set") + originalFetch := fetchOpenAIPhonetic + fetchOpenAIPhonetic = func(context.Context, *openai.Client, string) (string, error) { + return "[ˈjɤbɐlkɐ]", nil } + t.Cleanup(func() { + fetchOpenAIPhonetic = originalFetch + }) - fetcher := NewFetcher(apiKey) + fetcher := NewFetcher(&Config{ + Provider: ProviderOpenAI, + OpenAIKey: "test-openai-key", + }) - // Try to save to a non-existent directory err := fetcher.FetchAndSave("ябълка", "/nonexistent/path") if err == nil { - t.Error("Expected error for invalid directory") + t.Fatal("expected error for invalid directory") } } diff --git a/internal/processor/processor.go b/internal/processor/processor.go index 94b0448..970dafd 100644 --- a/internal/processor/processor.go +++ b/internal/processor/processor.go @@ -33,12 +33,14 @@ type Processor struct { // NewProcessor creates a new word processor func NewProcessor(flags *cli.Flags) *Processor { openAIKey := cli.GetOpenAIKey() + googleAPIKey := cli.GetGoogleAPIKey() translationProvider := translation.Provider(viper.GetString("translation.provider")) + phoneticProvider := phonetic.Provider(viper.GetString("phonetic.provider")) return &Processor{ flags: flags, - translator: translation.NewTranslator(&translation.Config{Provider: translationProvider, OpenAIKey: openAIKey, GoogleAPIKey: cli.GetGoogleAPIKey()}), + translator: translation.NewTranslator(&translation.Config{Provider: translationProvider, OpenAIKey: openAIKey, GoogleAPIKey: googleAPIKey}), translationCache: translation.NewTranslationCache(), - phoneticFetcher: phonetic.NewFetcher(openAIKey), + phoneticFetcher: phonetic.NewFetcher(&phonetic.Config{Provider: phoneticProvider, OpenAIKey: openAIKey, GoogleAPIKey: googleAPIKey}), } } |
