summaryrefslogtreecommitdiff
path: root/internal/image
diff options
context:
space:
mode:
Diffstat (limited to 'internal/image')
-rw-r--r--internal/image/nanobanana.go53
-rw-r--r--internal/image/nanobanana_test.go53
-rw-r--r--internal/image/openai.go54
-rw-r--r--internal/image/prompt.go104
-rw-r--r--internal/image/prompt_test.go48
5 files changed, 269 insertions, 43 deletions
diff --git a/internal/image/nanobanana.go b/internal/image/nanobanana.go
index ae67513..93afe06 100644
--- a/internal/image/nanobanana.go
+++ b/internal/image/nanobanana.go
@@ -302,11 +302,19 @@ func (c *NanoBananaClient) buildPrompt(ctx context.Context, opts *SearchOptions)
}
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)
scene = ""
}
+ if scene != "" {
+ scene = sanitizeSceneDescription(scene)
+ if !usableSceneDescription(scene) {
+ fmt.Printf(" Scene response was too short or generic, using basic prompt\n")
+ scene = ""
+ }
+ }
selectedStyle := chooseArtisticStyle()
if selectedStyle == defaultArtisticStyle {
@@ -318,36 +326,36 @@ func (c *NanoBananaClient) createEducationalPrompt(ctx context.Context, bulgaria
if scene != "" {
fullPrompt := fmt.Sprintf(
- "Generate a %s depicting: %s. "+
+ "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 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 subject is shown from an angle that makes it immediately identifiable. "+
+ "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, scene,
+ selectedStyle, subject, withTerminalPunctuation(scene), subject,
)
- if len(fullPrompt) > 1000 {
+ if len(fullPrompt) > maxImagePromptChars {
prompt = fmt.Sprintf(
- "Generate a %s depicting: %s. "+
+ "Generate a %s flashcard image illustrating \"%s\". Scene: %s "+
"The image should be educational and suitable for language learning flashcards. "+
- "Requirements: The main subject must be clearly visible, easily recognizable, and prominent in the image. It should occupy the central area with sharp focus and proper lighting.",
- selectedStyle, scene,
+ "Requirements: The main subject or concept must be clearly visible, centered, well lit, and easy to identify.",
+ selectedStyle, subject, withTerminalPunctuation(scene),
)
- if len(prompt) > 1000 {
- maxSceneLen := 1000 - len(fmt.Sprintf(
- "Generate a %s depicting: . "+
+ 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 must be clearly visible, easily recognizable, and prominent in the image.",
- selectedStyle,
+ "Requirements: The main subject or concept must be clearly visible, centered, well lit, and easy to identify.",
+ selectedStyle, subject,
))
- if len(scene) > maxSceneLen {
+ if maxSceneLen > 3 && len(scene) > maxSceneLen {
scene = scene[:maxSceneLen] + "..."
}
prompt = fmt.Sprintf(
- "Generate a %s depicting: %s. "+
+ "Generate a %s flashcard image illustrating \"%s\". Scene: %s "+
"The image should be educational and suitable for language learning flashcards. "+
- "Requirements: The main subject must be clearly visible, easily recognizable, and prominent in the image.",
- selectedStyle, scene,
+ "Requirements: The main subject or concept must be clearly visible, centered, well lit, and easy to identify.",
+ selectedStyle, subject, withTerminalPunctuation(scene),
)
}
} else {
@@ -355,14 +363,15 @@ func (c *NanoBananaClient) createEducationalPrompt(ctx context.Context, bulgaria
}
} else {
prompt = fmt.Sprintf(
- "Generate a %s of %s. "+
+ "Generate a %s educational flashcard image illustrating \"%s\". %s "+
"The image should be educational and suitable for language learning flashcards. "+
- "Requirements: The %s must be clearly visible and easily recognizable. Show it prominently centered with excellent lighting and sharp focus.",
- selectedStyle, englishTranslation, englishTranslation,
+ "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) > 1000 {
+ if len(prompt) > maxImagePromptChars {
prompt = prompt[:997] + "..."
}
@@ -404,6 +413,10 @@ func (c *NanoBananaClient) generateSceneDescription(ctx context.Context, bulgari
if err != nil {
return "", fmt.Errorf("scene generation failed: %w", err)
}
+ scene = sanitizeSceneDescription(scene)
+ if !usableSceneDescription(scene) {
+ return "", fmt.Errorf("scene generation returned unusable content")
+ }
fmt.Printf("Generated scene: %s\n", scene)
return scene, nil
diff --git a/internal/image/nanobanana_test.go b/internal/image/nanobanana_test.go
index 5b4f5ef..6d34b83 100644
--- a/internal/image/nanobanana_test.go
+++ b/internal/image/nanobanana_test.go
@@ -202,8 +202,11 @@ func TestNanoBananaClient_Search_GeneratedPromptFlow(t *testing.T) {
if !strings.Contains(result.Description, "apple") {
t.Fatalf("Description = %q, want translated word", result.Description)
}
- if !strings.Contains(gotPrompt, "Generate a Photorealism depicting: A bright apple sits centered on a wooden table.") {
- t.Fatalf("Prompt = %q, want generated scene and selected style", gotPrompt)
+ if !strings.Contains(gotPrompt, "Generate a Photorealism educational flashcard image illustrating \"apple\".") {
+ t.Fatalf("Prompt = %q, want translated subject in generated prompt", gotPrompt)
+ }
+ if !strings.Contains(gotPrompt, "Scene: A bright apple sits centered on a wooden table.") {
+ t.Fatalf("Prompt = %q, want generated scene in prompt", gotPrompt)
}
reader, err := client.Download(context.Background(), result.URL)
@@ -277,6 +280,52 @@ func TestNanoBananaClient_Search_TranslationFailureFallsBackToQuery(t *testing.T
}
}
+func TestNanoBananaClient_Search_TrivialSceneFallsBackToSubjectPrompt(t *testing.T) {
+ originalText := nanoBananaGenerateText
+ originalImage := nanoBananaGenerateImage
+ originalStyles := append([]string(nil), ArtisticStyles...)
+ t.Cleanup(func() {
+ nanoBananaGenerateText = originalText
+ nanoBananaGenerateImage = originalImage
+ ArtisticStyles = originalStyles
+ })
+
+ ArtisticStyles = []string{"Slow Design"}
+
+ nanoBananaGenerateText = func(_ context.Context, _ *NanoBananaClient, _, systemPrompt, _ string, _ float32, _ int32) (string, error) {
+ switch {
+ case strings.Contains(systemPrompt, "Bulgarian language expert"):
+ return "apple", nil
+ case strings.Contains(systemPrompt, "educational flashcards for language learning"):
+ return "A", nil
+ default:
+ t.Fatalf("unexpected system prompt: %q", systemPrompt)
+ return "", nil
+ }
+ }
+
+ var gotPrompt string
+ nanoBananaGenerateImage = func(_ context.Context, _ *NanoBananaClient, prompt string) ([]byte, string, error) {
+ gotPrompt = prompt
+ return mustPNGBytes(t), "image/png", nil
+ }
+
+ client := NewNanoBananaClient(&NanoBananaConfig{APIKey: "test-key"})
+ _, err := client.Search(context.Background(), &SearchOptions{
+ Query: "ябълка",
+ Translation: "apple",
+ })
+ if err != nil {
+ t.Fatalf("Search() unexpected error: %v", err)
+ }
+ if !strings.Contains(gotPrompt, "illustrating \"apple\"") {
+ t.Fatalf("Prompt = %q, want subject preserved in fallback prompt", gotPrompt)
+ }
+ if strings.Contains(gotPrompt, "Scene: A.") {
+ t.Fatalf("Prompt = %q, did not expect trivial scene to survive", gotPrompt)
+ }
+}
+
func TestNanoBananaClient_Search_ImageGenerationError(t *testing.T) {
originalText := nanoBananaGenerateText
originalImage := nanoBananaGenerateImage
diff --git a/internal/image/openai.go b/internal/image/openai.go
index 7673b7d..e322d32 100644
--- a/internal/image/openai.go
+++ b/internal/image/openai.go
@@ -237,12 +237,20 @@ func (c *OpenAIClient) SetPromptCallback(callback func(prompt string)) {
// createEducationalPrompt generates a prompt optimized for language learning
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)
scene = ""
}
+ if scene != "" {
+ scene = sanitizeSceneDescription(scene)
+ if !usableSceneDescription(scene) {
+ fmt.Printf(" Scene response was too short or generic, using basic prompt\n")
+ scene = ""
+ }
+ }
// 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.
@@ -258,40 +266,40 @@ func (c *OpenAIClient) createEducationalPrompt(ctx context.Context, bulgarianWor
if scene != "" {
// Full prompt with scene
fullPrompt := fmt.Sprintf(
- "Generate a %s depicting: %s. "+
+ "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 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 subject is shown from an angle that makes it immediately identifiable. "+
+ "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, scene,
+ selectedStyle, subject, withTerminalPunctuation(scene), subject,
)
// Check if full prompt exceeds 1000 characters
- if len(fullPrompt) > 1000 {
+ if len(fullPrompt) > maxImagePromptChars {
// Try without the IMPORTANT notice
prompt = fmt.Sprintf(
- "Generate a %s depicting: %s. "+
+ "Generate a %s flashcard image illustrating \"%s\". Scene: %s "+
"The image should be educational and suitable for language learning flashcards. "+
- "Requirements: The main subject must be clearly visible, easily recognizable, and prominent in the image. It should occupy the central area with sharp focus and proper lighting.",
- selectedStyle, scene,
+ "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) > 1000 {
+ if len(prompt) > maxImagePromptChars {
// Truncate scene to fit within limit
- maxSceneLen := 1000 - len(fmt.Sprintf(
- "Generate a %s depicting: . "+
+ 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 must be clearly visible, easily recognizable, and prominent in the image.",
- selectedStyle,
+ "Requirements: The main subject or concept must be clearly visible, centered, well lit, and easy to identify.",
+ selectedStyle, subject,
))
- if len(scene) > maxSceneLen {
+ if maxSceneLen > 3 && len(scene) > maxSceneLen {
scene = scene[:maxSceneLen] + "..."
}
prompt = fmt.Sprintf(
- "Generate a %s depicting: %s. "+
+ "Generate a %s flashcard image illustrating \"%s\". Scene: %s "+
"The image should be educational and suitable for language learning flashcards. "+
- "Requirements: The main subject must be clearly visible, easily recognizable, and prominent in the image.",
- selectedStyle, scene,
+ "Requirements: The main subject or concept must be clearly visible, centered, well lit, and easy to identify.",
+ selectedStyle, subject, withTerminalPunctuation(scene),
)
}
} else {
@@ -300,15 +308,16 @@ func (c *OpenAIClient) createEducationalPrompt(ctx context.Context, bulgarianWor
} else {
// Basic prompt without scene
prompt = fmt.Sprintf(
- "Generate a %s of %s. "+
+ "Generate a %s educational flashcard image illustrating \"%s\". %s "+
"The image should be educational and suitable for language learning flashcards. "+
- "Requirements: The %s must be clearly visible and easily recognizable. Show it prominently centered with excellent lighting and sharp focus.",
- selectedStyle, englishTranslation, englishTranslation,
+ "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) > 1000 {
+ if len(prompt) > maxImagePromptChars {
prompt = prompt[:997] + "..."
}
@@ -377,7 +386,10 @@ func (c *OpenAIClient) generateSceneDescription(ctx context.Context, bulgarianWo
return "", fmt.Errorf("no scene description received")
}
- scene := strings.TrimSpace(resp.Choices[0].Message.Content)
+ scene := sanitizeSceneDescription(resp.Choices[0].Message.Content)
+ if !usableSceneDescription(scene) {
+ return "", fmt.Errorf("scene generation returned unusable content")
+ }
fmt.Printf("Generated scene: %s\n", scene)
return scene, nil
diff --git a/internal/image/prompt.go b/internal/image/prompt.go
new file mode 100644
index 0000000..7e69a8c
--- /dev/null
+++ b/internal/image/prompt.go
@@ -0,0 +1,104 @@
+package image
+
+import "strings"
+
+const maxImagePromptChars = 1000
+
+func promptSubject(englishTranslation, fallback string) string {
+ subject := normalizePromptText(englishTranslation)
+ if subject != "" {
+ return subject
+ }
+
+ subject = normalizePromptText(fallback)
+ if subject != "" {
+ return subject
+ }
+
+ return "the requested term"
+}
+
+func normalizePromptText(text string) string {
+ text = trimMarkdownFence(text)
+ text = strings.TrimSpace(text)
+ text = strings.Trim(text, "`\"'")
+ text = strings.Join(strings.Fields(text), " ")
+ return strings.TrimSpace(text)
+}
+
+func sanitizeSceneDescription(scene string) string {
+ scene = normalizePromptText(scene)
+ lower := strings.ToLower(scene)
+
+ for _, prefix := range []string{"scene description:", "scene:", "description:", "image prompt:", "prompt:"} {
+ if strings.HasPrefix(lower, prefix) {
+ scene = strings.TrimSpace(scene[len(prefix):])
+ break
+ }
+ }
+
+ return strings.TrimSpace(strings.Trim(scene, "."))
+}
+
+func usableSceneDescription(scene string) bool {
+ scene = sanitizeSceneDescription(scene)
+ if scene == "" {
+ return false
+ }
+ if len(scene) < 24 {
+ return false
+ }
+ if len(strings.Fields(scene)) < 3 {
+ return false
+ }
+ return true
+}
+
+func trimMarkdownFence(text string) string {
+ text = strings.TrimSpace(text)
+ if !strings.HasPrefix(text, "```") {
+ return text
+ }
+
+ lines := strings.Split(text, "\n")
+ if len(lines) < 3 {
+ return text
+ }
+ if !strings.HasPrefix(strings.TrimSpace(lines[0]), "```") {
+ return text
+ }
+ if strings.TrimSpace(lines[len(lines)-1]) != "```" {
+ return text
+ }
+
+ return strings.Join(lines[1:len(lines)-1], "\n")
+}
+
+func withTerminalPunctuation(text string) string {
+ text = strings.TrimSpace(text)
+ if text == "" {
+ return ""
+ }
+
+ switch {
+ case strings.HasSuffix(text, "."):
+ return text
+ case strings.HasSuffix(text, "!"):
+ return text
+ case strings.HasSuffix(text, "?"):
+ return text
+ default:
+ return text + "."
+ }
+}
+
+func fallbackVisualDirection(subject string) string {
+ subject = normalizePromptText(subject)
+ lower := strings.ToLower(subject)
+
+ if strings.HasPrefix(lower, "to ") || len(strings.Fields(subject)) > 1 {
+ return "Show a realistic everyday scene with people, actions, facial expressions, and surrounding objects that make the meaning of \"" + subject + "\" obvious without any text."
+ }
+
+ return "Show a single " + subject + " as the clear focal point, prominently centered and immediately recognizable."
+}
diff --git a/internal/image/prompt_test.go b/internal/image/prompt_test.go
new file mode 100644
index 0000000..1961ce2
--- /dev/null
+++ b/internal/image/prompt_test.go
@@ -0,0 +1,48 @@
+package image
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestPromptSubjectUsesTranslationFirst(t *testing.T) {
+ if got := promptSubject(" apple ", "ябълка"); got != "apple" {
+ t.Fatalf("promptSubject() = %q, want %q", got, "apple")
+ }
+}
+
+func TestPromptSubjectFallsBackToOriginalWord(t *testing.T) {
+ if got := promptSubject(" ", "ябълка"); got != "ябълка" {
+ t.Fatalf("promptSubject() = %q, want %q", got, "ябълка")
+ }
+}
+
+func TestSanitizeSceneDescriptionRemovesLabelsAndFences(t *testing.T) {
+ scene := "```text\nScene: A bright apple sits centered on a wooden table.\n```"
+ if got := sanitizeSceneDescription(scene); got != "A bright apple sits centered on a wooden table" {
+ t.Fatalf("sanitizeSceneDescription() = %q", got)
+ }
+}
+
+func TestUsableSceneDescriptionRejectsTrivialContent(t *testing.T) {
+ if usableSceneDescription("A") {
+ t.Fatal("usableSceneDescription() unexpectedly accepted trivial scene")
+ }
+ if usableSceneDescription("A single, perfectly") {
+ t.Fatal("usableSceneDescription() unexpectedly accepted incomplete scene fragment")
+ }
+}
+
+func TestFallbackVisualDirectionForPhrase(t *testing.T) {
+ got := fallbackVisualDirection("to indulge someone")
+ if got == "" || !usableSceneDescription(got) {
+ t.Fatalf("fallbackVisualDirection() returned unusable phrase direction: %q", got)
+ }
+}
+
+func TestFallbackVisualDirectionForSingleWord(t *testing.T) {
+ got := fallbackVisualDirection("apple")
+ if got == "" || !strings.Contains(got, "single apple") {
+ t.Fatalf("fallbackVisualDirection() = %q", got)
+ }
+}