summaryrefslogtreecommitdiff
path: root/internal/image
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-02 21:47:12 +0300
committerPaul Buetow <paul@buetow.org>2026-04-02 21:47:12 +0300
commit993b2efe63e221cee550756770894c9c58474d25 (patch)
tree126b0dad9e1225c575f2521f29c6ad68556f4056 /internal/image
parentdac35c77721c97f093a44d98164b38534452de9f (diff)
task 00g/00k/00h/008: gofmt, remove ProviderWithFallback, stdlib helpers, shared prompt
- task 00g: fix gofmt violations (trailing whitespace, missing newlines, indentation) in 8 files; all pass gofmt -l now - task 00k: remove unused ProviderWithFallback and its tests (YAGNI — no production caller existed; voice-level fallback via RunWithVoiceFallbacks already covers the real use case) - task 00h: replace private splitLines/trimSpace/isSpace helpers in internal/batch/processor.go with strings.Split+ReplaceAll and strings.TrimSpace from the stdlib; remove the now-redundant tests - task 008: extract buildEducationalPrompt into internal/image/prompt.go so the prompt-assembly policy (scene truncation cascade, char limit) lives in one place; both OpenAIClient and NanoBananaClient delegate to it Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/image')
-rw-r--r--internal/image/doc.go2
-rw-r--r--internal/image/nanobanana.go62
-rw-r--r--internal/image/openai.go74
-rw-r--r--internal/image/prompt.go73
-rw-r--r--internal/image/search.go1
-rw-r--r--internal/image/search_test.go36
6 files changed, 107 insertions, 141 deletions
diff --git a/internal/image/doc.go b/internal/image/doc.go
index 2fb3723..c1b7ca3 100644
--- a/internal/image/doc.go
+++ b/internal/image/doc.go
@@ -1,3 +1,3 @@
// Package image provides image search functionality to find
// representative images for Bulgarian words from various APIs.
-package image \ No newline at end of file
+package image
diff --git a/internal/image/nanobanana.go b/internal/image/nanobanana.go
index a35e4cf..1f3ac57 100644
--- a/internal/image/nanobanana.go
+++ b/internal/image/nanobanana.go
@@ -301,8 +301,13 @@ func (c *NanoBananaClient) buildPrompt(ctx context.Context, opts *SearchOptions)
return prompt, translatedWord, nil
}
+// createEducationalPrompt generates a prompt optimized for language learning.
+// Scene generation and style selection are handled here; the shared
+// buildEducationalPrompt helper assembles the actual prompt text so that the
+// same policy is used by both NanoBananaClient and OpenAIClient.
func (c *NanoBananaClient) createEducationalPrompt(ctx context.Context, bulgarianWord, englishTranslation string) string {
subject := promptSubject(englishTranslation, bulgarianWord)
+
scene, err := c.generateSceneDescription(ctx, bulgarianWord, englishTranslation)
if err != nil {
fmt.Printf(" Failed to generate scene: %v, using basic prompt\n", err)
@@ -316,66 +321,15 @@ func (c *NanoBananaClient) createEducationalPrompt(ctx context.Context, bulgaria
}
}
+ // Select a random style from the shared pool. Fall back to a generic style
+ // if the pool has been exhausted by tests or other callers.
selectedStyle := chooseArtisticStyle()
if selectedStyle == defaultArtisticStyle {
fmt.Printf(" No artistic styles available, using generic prompt\n")
}
fmt.Printf(" Using image style: %s\n", selectedStyle)
- var prompt string
-
- if scene != "" {
- fullPrompt := fmt.Sprintf(
- "Generate a %s educational flashcard image illustrating \"%s\". Scene: %s "+
- "The image should be educational and suitable for language learning flashcards. "+
- "Requirements: The main subject or concept must be clearly visible, easily recognizable, and prominent in the image. It should occupy the central area with sharp focus and proper lighting. Ensure the scene makes \"%s\" immediately identifiable. "+
- "IMPORTANT: No text whatsoever. Do not include any words, letters, typography, labels, captions, or writing of any kind. Image only, without any text elements.",
- selectedStyle, subject, withTerminalPunctuation(scene), subject,
- )
-
- if len(fullPrompt) > maxImagePromptChars {
- prompt = fmt.Sprintf(
- "Generate a %s flashcard image illustrating \"%s\". Scene: %s "+
- "The image should be educational and suitable for language learning flashcards. "+
- "Requirements: The main subject or concept must be clearly visible, centered, well lit, and easy to identify.",
- selectedStyle, subject, withTerminalPunctuation(scene),
- )
-
- if len(prompt) > maxImagePromptChars {
- maxSceneLen := maxImagePromptChars - len(fmt.Sprintf(
- "Generate a %s flashcard image illustrating \"%s\". Scene: "+
- "The image should be educational and suitable for language learning flashcards. "+
- "Requirements: The main subject or concept must be clearly visible, centered, well lit, and easy to identify.",
- selectedStyle, subject,
- ))
- if maxSceneLen > 3 && len(scene) > maxSceneLen {
- scene = scene[:maxSceneLen] + "..."
- }
- prompt = fmt.Sprintf(
- "Generate a %s flashcard image illustrating \"%s\". Scene: %s "+
- "The image should be educational and suitable for language learning flashcards. "+
- "Requirements: The main subject or concept must be clearly visible, centered, well lit, and easy to identify.",
- selectedStyle, subject, withTerminalPunctuation(scene),
- )
- }
- } else {
- prompt = fullPrompt
- }
- } else {
- prompt = fmt.Sprintf(
- "Generate a %s educational flashcard image illustrating \"%s\". %s "+
- "The image should be educational and suitable for language learning flashcards. "+
- "Requirements: The main subject or concept must be clearly visible, easily recognizable, and prominent in the image. Show it prominently centered with excellent lighting and sharp focus. "+
- "IMPORTANT: No text whatsoever. Do not include any words, letters, typography, labels, captions, or writing of any kind. Image only, without any text elements.",
- selectedStyle, subject, fallbackVisualDirection(subject),
- )
- }
-
- if len(prompt) > maxImagePromptChars {
- prompt = prompt[:997] + "..."
- }
-
- return prompt
+ return buildEducationalPrompt(selectedStyle, scene, subject)
}
func (c *NanoBananaClient) translateBulgarianToEnglish(ctx context.Context, word string) (string, error) {
diff --git a/internal/image/openai.go b/internal/image/openai.go
index 8edb182..fdd88e4 100644
--- a/internal/image/openai.go
+++ b/internal/image/openai.go
@@ -243,10 +243,13 @@ func (c *OpenAIClient) SetPromptCallback(callback func(prompt string)) {
c.PromptCallback = callback
}
-// createEducationalPrompt generates a prompt optimized for language learning
+// createEducationalPrompt generates a prompt optimized for language learning.
+// Scene generation and style selection are handled here; the shared
+// buildEducationalPrompt helper assembles the actual prompt text so that the
+// same policy is used by both OpenAIClient and NanoBananaClient.
func (c *OpenAIClient) createEducationalPrompt(ctx context.Context, bulgarianWord, englishTranslation string) string {
subject := promptSubject(englishTranslation, bulgarianWord)
- // Generate a scene description for the word
+
scene, err := c.generateSceneDescription(ctx, bulgarianWord, englishTranslation)
if err != nil {
fmt.Printf(" Failed to generate scene: %v, using basic prompt\n", err)
@@ -260,76 +263,15 @@ func (c *OpenAIClient) createEducationalPrompt(ctx context.Context, bulgarianWor
}
}
- // Select a random style from the shared pool. Fall back to a generic style if
- // the pool has been emptied by tests or future callers.
+ // Select a random style from the shared pool. Fall back to a generic style
+ // if the pool has been exhausted by tests or other callers.
selectedStyle := chooseArtisticStyle()
if selectedStyle == defaultArtisticStyle {
fmt.Printf(" No artistic styles available, using generic prompt\n")
}
fmt.Printf(" Using image style: %s\n", selectedStyle)
- // Define prompt components in order of importance
- var prompt string
-
- if scene != "" {
- // Full prompt with scene
- fullPrompt := fmt.Sprintf(
- "Generate a %s educational flashcard image illustrating \"%s\". Scene: %s "+
- "The image should be educational and suitable for language learning flashcards. "+
- "Requirements: The main subject or concept must be clearly visible, easily recognizable, and prominent in the image. It should occupy the central area with sharp focus and proper lighting. Ensure the scene makes \"%s\" immediately identifiable. "+
- "IMPORTANT: No text whatsoever. Do not include any words, letters, typography, labels, captions, or writing of any kind. Image only, without any text elements.",
- selectedStyle, subject, withTerminalPunctuation(scene), subject,
- )
-
- // Check if full prompt exceeds 1000 characters
- if len(fullPrompt) > maxImagePromptChars {
- // Try without the IMPORTANT notice
- prompt = fmt.Sprintf(
- "Generate a %s flashcard image illustrating \"%s\". Scene: %s "+
- "The image should be educational and suitable for language learning flashcards. "+
- "Requirements: The main subject or concept must be clearly visible, centered, well lit, and easy to identify.",
- selectedStyle, subject, withTerminalPunctuation(scene),
- )
-
- // If still too long, truncate the scene
- if len(prompt) > maxImagePromptChars {
- // Truncate scene to fit within limit
- maxSceneLen := maxImagePromptChars - len(fmt.Sprintf(
- "Generate a %s flashcard image illustrating \"%s\". Scene: "+
- "The image should be educational and suitable for language learning flashcards. "+
- "Requirements: The main subject or concept must be clearly visible, centered, well lit, and easy to identify.",
- selectedStyle, subject,
- ))
- if maxSceneLen > 3 && len(scene) > maxSceneLen {
- scene = scene[:maxSceneLen] + "..."
- }
- prompt = fmt.Sprintf(
- "Generate a %s flashcard image illustrating \"%s\". Scene: %s "+
- "The image should be educational and suitable for language learning flashcards. "+
- "Requirements: The main subject or concept must be clearly visible, centered, well lit, and easy to identify.",
- selectedStyle, subject, withTerminalPunctuation(scene),
- )
- }
- } else {
- prompt = fullPrompt
- }
- } else {
- // Basic prompt without scene
- prompt = fmt.Sprintf(
- "Generate a %s educational flashcard image illustrating \"%s\". %s "+
- "The image should be educational and suitable for language learning flashcards. "+
- "Requirements: The main subject or concept must be clearly visible, easily recognizable, and prominent in the image. Show it prominently centered with excellent lighting and sharp focus. "+
- "IMPORTANT: No text whatsoever. Do not include any words, letters, typography, labels, captions, or writing of any kind. Image only, without any text elements.",
- selectedStyle, subject, fallbackVisualDirection(subject),
- )
- }
-
- // Final check to ensure prompt is within 1000 characters
- if len(prompt) > maxImagePromptChars {
- prompt = prompt[:997] + "..."
- }
-
- return prompt
+ return buildEducationalPrompt(selectedStyle, scene, subject)
}
// translateBulgarianToEnglish translates a Bulgarian word to English using OpenAI
diff --git a/internal/image/prompt.go b/internal/image/prompt.go
index 7e69a8c..3d5f889 100644
--- a/internal/image/prompt.go
+++ b/internal/image/prompt.go
@@ -1,6 +1,9 @@
package image
-import "strings"
+import (
+ "fmt"
+ "strings"
+)
const maxImagePromptChars = 1000
@@ -92,6 +95,74 @@ func withTerminalPunctuation(text string) string {
}
}
+// buildEducationalPrompt assembles the final image-generation prompt from a
+// pre-chosen artistic style, an optional scene description, and the word subject.
+// Both OpenAIClient and NanoBananaClient share this logic so the prompt policy
+// has a single authoritative home. The scene parameter may be empty, in which
+// case a simpler fallback prompt is used. The result is always capped at
+// maxImagePromptChars characters.
+func buildEducationalPrompt(style, scene, subject string) string {
+ var prompt string
+
+ if scene != "" {
+ fullPrompt := fmt.Sprintf(
+ "Generate a %s educational flashcard image illustrating \"%s\". Scene: %s "+
+ "The image should be educational and suitable for language learning flashcards. "+
+ "Requirements: The main subject or concept must be clearly visible, easily recognizable, and prominent in the image. It should occupy the central area with sharp focus and proper lighting. Ensure the scene makes \"%s\" immediately identifiable. "+
+ "IMPORTANT: No text whatsoever. Do not include any words, letters, typography, labels, captions, or writing of any kind. Image only, without any text elements.",
+ style, subject, withTerminalPunctuation(scene), subject,
+ )
+
+ if len(fullPrompt) <= maxImagePromptChars {
+ prompt = fullPrompt
+ } else {
+ // Try a shorter version without the IMPORTANT notice.
+ prompt = fmt.Sprintf(
+ "Generate a %s flashcard image illustrating \"%s\". Scene: %s "+
+ "The image should be educational and suitable for language learning flashcards. "+
+ "Requirements: The main subject or concept must be clearly visible, centered, well lit, and easy to identify.",
+ style, subject, withTerminalPunctuation(scene),
+ )
+
+ // If still too long, truncate the scene to fit.
+ if len(prompt) > maxImagePromptChars {
+ template := fmt.Sprintf(
+ "Generate a %s flashcard image illustrating \"%s\". Scene: "+
+ "The image should be educational and suitable for language learning flashcards. "+
+ "Requirements: The main subject or concept must be clearly visible, centered, well lit, and easy to identify.",
+ style, subject,
+ )
+ maxSceneLen := maxImagePromptChars - len(template)
+ if maxSceneLen > 3 && len(scene) > maxSceneLen {
+ scene = scene[:maxSceneLen] + "..."
+ }
+ prompt = fmt.Sprintf(
+ "Generate a %s flashcard image illustrating \"%s\". Scene: %s "+
+ "The image should be educational and suitable for language learning flashcards. "+
+ "Requirements: The main subject or concept must be clearly visible, centered, well lit, and easy to identify.",
+ style, subject, withTerminalPunctuation(scene),
+ )
+ }
+ }
+ } else {
+ // No scene available — use a simpler fallback prompt.
+ prompt = fmt.Sprintf(
+ "Generate a %s educational flashcard image illustrating \"%s\". %s "+
+ "The image should be educational and suitable for language learning flashcards. "+
+ "Requirements: The main subject or concept must be clearly visible, easily recognizable, and prominent in the image. Show it prominently centered with excellent lighting and sharp focus. "+
+ "IMPORTANT: No text whatsoever. Do not include any words, letters, typography, labels, captions, or writing of any kind. Image only, without any text elements.",
+ style, subject, fallbackVisualDirection(subject),
+ )
+ }
+
+ // Hard cap at maxImagePromptChars.
+ if len(prompt) > maxImagePromptChars {
+ prompt = prompt[:997] + "..."
+ }
+
+ return prompt
+}
+
func fallbackVisualDirection(subject string) string {
subject = normalizePromptText(subject)
lower := strings.ToLower(subject)
diff --git a/internal/image/search.go b/internal/image/search.go
index 80274eb..9be72ed 100644
--- a/internal/image/search.go
+++ b/internal/image/search.go
@@ -80,4 +80,3 @@ type RateLimitError struct {
func (e *RateLimitError) Error() string {
return e.Provider + ": rate limit exceeded"
}
-
diff --git a/internal/image/search_test.go b/internal/image/search_test.go
index b7018d9..fa3d770 100644
--- a/internal/image/search_test.go
+++ b/internal/image/search_test.go
@@ -39,27 +39,27 @@ func (m *mockSearcher) Name() string {
func TestDefaultSearchOptions(t *testing.T) {
opts := DefaultSearchOptions("ябълка")
-
+
if opts.Query != "ябълка" {
t.Errorf("Expected query 'ябълка', got '%s'", opts.Query)
}
-
+
if opts.Language != "bg" {
t.Errorf("Expected language 'bg', got '%s'", opts.Language)
}
-
+
if !opts.SafeSearch {
t.Error("Expected SafeSearch to be true")
}
-
+
if opts.PerPage != 10 {
t.Errorf("Expected PerPage 10, got %d", opts.PerPage)
}
-
+
if opts.Page != 1 {
t.Errorf("Expected Page 1, got %d", opts.Page)
}
-
+
if opts.ImageType != "photo" {
t.Errorf("Expected ImageType 'photo', got '%s'", opts.ImageType)
}
@@ -71,7 +71,7 @@ func TestSearchError(t *testing.T) {
Code: "404",
Message: "Not found",
}
-
+
expected := "test: Not found"
if err.Error() != expected {
t.Errorf("Expected error '%s', got '%s'", expected, err.Error())
@@ -84,7 +84,7 @@ func TestRateLimitError(t *testing.T) {
RetryAfter: 60,
LimitPerHour: 100,
}
-
+
expected := "test: rate limit exceeded"
if err.Error() != expected {
t.Errorf("Expected error '%s', got '%s'", expected, err.Error())
@@ -102,24 +102,24 @@ func TestMockSearcher(t *testing.T) {
Source: "mock",
},
}
-
+
searcher := &mockSearcher{
name: "mock",
searchResults: mockResults,
}
-
+
ctx := context.Background()
opts := DefaultSearchOptions("test")
-
+
results, err := searcher.Search(ctx, opts)
if err != nil {
t.Fatalf("Search() failed: %v", err)
}
-
+
if len(results) != 1 {
t.Fatalf("Expected 1 result, got %d", len(results))
}
-
+
if results[0].ID != "1" {
t.Errorf("Expected ID '1', got '%s'", results[0].ID)
}
@@ -127,20 +127,20 @@ func TestMockSearcher(t *testing.T) {
func TestDownloadOptions(t *testing.T) {
opts := DefaultDownloadOptions()
-
+
if opts.OutputDir != "./images" {
t.Errorf("Expected output dir './images', got '%s'", opts.OutputDir)
}
-
+
if opts.OverwriteExisting {
t.Error("Expected OverwriteExisting to be false")
}
-
+
if !opts.CreateDir {
t.Error("Expected CreateDir to be true")
}
-
+
if opts.MaxSizeBytes != 10*1024*1024 {
t.Errorf("Expected MaxSizeBytes 10MB, got %d", opts.MaxSizeBytes)
}
-} \ No newline at end of file
+}