summaryrefslogtreecommitdiff
path: root/internal/image/prompt.go
blob: 3d5f889a79901dce6f358f4c8d9e7192061c9969 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
package image

import (
	"fmt"
	"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 + "."
	}
}

// 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)

	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."
}