summaryrefslogtreecommitdiff
path: root/internal/comic
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-20 00:02:07 +0300
committerPaul Buetow <paul@buetow.org>2026-04-20 00:02:07 +0300
commita35ca6f40de73e93372ade6f2d74c231389799e2 (patch)
treefaf7a53d476cbd6d01b694443e8e601cec9257db /internal/comic
parent6130cae3fa396cff94653225e47326b642e270f3 (diff)
Fix task 25: keep comic generation single-language and single-style
Diffstat (limited to 'internal/comic')
-rw-r--r--internal/comic/artist.go12
-rw-r--r--internal/comic/comic_test.go103
-rw-r--r--internal/comic/generator.go44
-rw-r--r--internal/comic/localization.go11
-rw-r--r--internal/comic/localization_test.go42
-rw-r--r--internal/comic/narrator.go7
-rw-r--r--internal/comic/text_validation.go72
-rw-r--r--internal/comic/types.go10
8 files changed, 252 insertions, 49 deletions
diff --git a/internal/comic/artist.go b/internal/comic/artist.go
index 279aa1d..54f28cf 100644
--- a/internal/comic/artist.go
+++ b/internal/comic/artist.go
@@ -199,6 +199,8 @@ func (a *Artist) resolveHelperTexts(ctx context.Context, storyText, prebuiltBibl
}
systemPrompt, err := a.prompts.RenderPrompt(blurbSystemTemplate, map[string]any{
"StoryText": storyText,
+ "Language": a.language,
+ "Script": a.script,
})
if err != nil {
return "", "", fmt.Errorf("render blurb prompt: %w", err)
@@ -212,6 +214,12 @@ func (a *Artist) resolveHelperTexts(ctx context.Context, storyText, prebuiltBibl
return bible, blurb, nil
}
blurb = strings.TrimSpace(text)
+ if err := validateTextScript("back-cover blurb", blurb, a.script); err != nil {
+ return "", "", fmt.Errorf("generate back-cover blurb: %w", err)
+ }
+ if err := validateNoPromptLeakage("back-cover blurb", blurb); err != nil {
+ return "", "", fmt.Errorf("generate back-cover blurb: %w", err)
+ }
if blurb != "" {
fmt.Printf(" Back-cover blurb ready (%d chars)\n", len(blurb))
}
@@ -224,7 +232,7 @@ func (a *Artist) coverPromptData(storyText, style, bible string) map[string]any
"Script": a.script,
"Style": style,
"Bible": bible,
- "Subtitle": "ComicForge Adventures",
+ "Subtitle": localizedBrandName(a.language, a.script),
"StoryText": storyText,
"RenderingRequirement": a.renderingRequirement(),
"RenderingRequirementEnd": a.renderingRequirementEnd(),
@@ -265,7 +273,7 @@ func (a *Artist) backPromptData(storyText, style, bible, blurb string) map[strin
"Style": style,
"Bible": bible,
"BlurbBox": blurbBoxInstruction(blurb),
- "SeriesTitle": "ComicForge Adventures",
+ "SeriesTitle": localizedBrandName(a.language, a.script),
"StoryText": storyText,
"RenderingRequirement": a.renderingRequirement(),
"RenderingRequirementEnd": a.renderingRequirementEnd(),
diff --git a/internal/comic/comic_test.go b/internal/comic/comic_test.go
index ed263d8..3506303 100644
--- a/internal/comic/comic_test.go
+++ b/internal/comic/comic_test.go
@@ -79,32 +79,32 @@ func TestGeneratorGenerateFull(t *testing.T) {
t.Parallel()
text := strings.Join([]string{
- "story",
+ "история",
storyBibleSeparator,
- "bible",
+ "библия",
storyTitleSeparator,
- "Title",
+ "Заглавие",
storyPanelSeparator,
- "P1-A: a",
- "P1-B: b",
- "P1-C: c",
- "P1-D: d",
- "P2-A: e",
- "P2-B: f",
- "P2-C: g",
- "P2-D: h",
- "P3-A: i",
- "P3-B: j",
- "P3-C: k",
- "P3-D: l",
- "P4-A: m",
- "P4-B: n",
- "P4-C: o",
- "P4-D: p",
- "P5-A: q",
- "P5-B: r",
- "P5-C: s",
- "P5-D: t",
+ "P1-A: а",
+ "P1-B: б",
+ "P1-C: в",
+ "P1-D: г",
+ "P2-A: д",
+ "P2-B: е",
+ "P2-C: ж",
+ "P2-D: з",
+ "P3-A: и",
+ "P3-B: й",
+ "P3-C: к",
+ "P3-D: л",
+ "P4-A: м",
+ "P4-B: н",
+ "P4-C: о",
+ "P4-D: п",
+ "P5-A: р",
+ "P5-B: с",
+ "P5-C: т",
+ "P5-D: у",
}, "\n")
generator := NewGenerator(&GeneratorConfig{
TextProvider: fakeTextProvider{text: text},
@@ -114,7 +114,7 @@ func TestGeneratorGenerateFull(t *testing.T) {
if err != nil {
t.Fatalf("GenerateFull() error = %v", err)
}
- if got.Title != "Title" || got.StoryText != "story" || got.Bible != "bible" {
+ if got.Title != "Заглавие" || got.StoryText != "история" || got.Bible != "библия" {
t.Fatalf("GenerateFull() = %#v", got)
}
}
@@ -144,19 +144,24 @@ func TestArtistAndRunnerEndToEndWithFakes(t *testing.T) {
tmpDir := t.TempDir()
img := fakeImageProvider{t: t}
- genText := fakeTextProvider{text: strings.Join([]string{
- "story",
- storyBibleSeparator,
- "bible",
- storyTitleSeparator,
- "Title",
- storyPanelSeparator,
- "P1-A: a", "P1-B: b", "P1-C: c", "P1-D: d",
- "P2-A: e", "P2-B: f", "P2-C: g", "P2-D: h",
- "P3-A: i", "P3-B: j", "P3-C: k", "P3-D: l",
- "P4-A: m", "P4-B: n", "P4-C: o", "P4-D: p",
- "P5-A: q", "P5-B: r", "P5-C: s", "P5-D: t",
- }, "\n")}
+ genText := &scriptedTextProvider{responses: []string{
+ strings.Join([]string{
+ "история",
+ storyBibleSeparator,
+ "библия",
+ storyTitleSeparator,
+ "Заглавие",
+ storyPanelSeparator,
+ "P1-A: а", "P1-B: б", "P1-C: в", "P1-D: г",
+ "P2-A: д", "P2-B: е", "P2-C: ж", "P2-D: з",
+ "P3-A: и", "P3-B: й", "P3-C: к", "P3-D: л",
+ "P4-A: м", "P4-B: н", "P4-C: о", "P4-D: п",
+ "P5-A: р", "P5-B: с", "P5-C: т", "P5-D: у",
+ }, "\n"),
+ "Кратко рекламно описание.",
+ "Пъстра уводна реплика за разказа.",
+ "Топъл заключителен епилог за историята.",
+ }}
narr := fakeTTSProvider{}
runner := NewRunner(&RunnerConfig{
TextProvider: genText,
@@ -194,7 +199,10 @@ func TestRunnerPropagatesRenderFailures(t *testing.T) {
})
runner := NewRunner(&RunnerConfig{
- TextProvider: fakeTextProvider{text: strings.Join([]string{"story", storyBibleSeparator, "bible", storyTitleSeparator, "Title", storyPanelSeparator, "P1-A: a", "P1-B: b", "P1-C: c", "P1-D: d", "P2-A: e", "P2-B: f", "P2-C: g", "P2-D: h", "P3-A: i", "P3-B: j", "P3-C: k", "P3-D: l", "P4-A: m", "P4-B: n", "P4-C: o", "P4-D: p", "P5-A: q", "P5-B: r", "P5-C: s", "P5-D: t"}, "\n")},
+ TextProvider: &scriptedTextProvider{responses: []string{
+ strings.Join([]string{"история", storyBibleSeparator, "библия", storyTitleSeparator, "Заглавие", storyPanelSeparator, "P1-A: а", "P1-B: б", "P1-C: в", "P1-D: г", "P2-A: д", "P2-B: е", "P2-C: ж", "P2-D: з", "P3-A: и", "P3-B: й", "P3-C: к", "P3-D: л", "P4-A: м", "P4-B: н", "P4-C: о", "P4-D: п", "P5-A: р", "P5-B: с", "P5-C: т", "P5-D: у"}, "\n"),
+ "Кратко рекламно описание.",
+ }},
ImageProvider: failingImageProvider{},
MainTTSProvider: fakeTTSProvider{},
ConclusionTTSProvider: fakeTTSProvider{},
@@ -274,6 +282,25 @@ func (f fakeTextProvider) GenerateText(_ context.Context, _ string) (string, err
return f.text, nil
}
+type scriptedTextProvider struct {
+ responses []string
+ index int
+}
+
+func (p *scriptedTextProvider) Name() string { return "scripted-text" }
+func (p *scriptedTextProvider) IsAvailable() error { return nil }
+func (p *scriptedTextProvider) GenerateText(_ context.Context, _ string) (string, error) {
+ if len(p.responses) == 0 {
+ return "", nil
+ }
+ if p.index >= len(p.responses) {
+ return p.responses[len(p.responses)-1], nil
+ }
+ out := p.responses[p.index]
+ p.index++
+ return out, nil
+}
+
type fakeImageProvider struct{ t *testing.T }
func (f fakeImageProvider) Name() string { return "fake-image" }
diff --git a/internal/comic/generator.go b/internal/comic/generator.go
index 34c60c5..b3be2ef 100644
--- a/internal/comic/generator.go
+++ b/internal/comic/generator.go
@@ -79,6 +79,12 @@ func (g *Generator) Generate(ctx context.Context, entries []WordEntry) (string,
if text == "" {
return "", fmt.Errorf("no story content returned")
}
+ if err := validateTextScript("story text", text, g.script); err != nil {
+ return "", err
+ }
+ if err := validateNoPromptLeakage("story text", text); err != nil {
+ return "", err
+ }
return text, nil
}
@@ -101,7 +107,11 @@ func (g *Generator) GenerateFull(ctx context.Context, entries []WordEntry) (Gene
if text == "" {
return GenerateResult{}, fmt.Errorf("no content returned")
}
- return parseGenerateResult(text), nil
+ result := parseGenerateResult(text)
+ if err := validateGeneratedResult(result, g.script); err != nil {
+ return GenerateResult{}, err
+ }
+ return result, nil
}
func (g *Generator) ready() error {
@@ -139,3 +149,35 @@ func (g *Generator) renderStoryPrompt(templateName string, entries []WordEntry)
}
return systemPrompt + "\n\n" + userPrompt, nil
}
+
+func validateGeneratedResult(result GenerateResult, script string) error {
+ if err := validateTextScript("story text", result.StoryText, script); err != nil {
+ return err
+ }
+ if err := validateNoPromptLeakage("story text", result.StoryText); err != nil {
+ return err
+ }
+ if err := validateTextScript("character bible", result.Bible, script); err != nil {
+ return err
+ }
+ if err := validateNoPromptLeakage("character bible", result.Bible); err != nil {
+ return err
+ }
+ if err := validateTextScript("comic title", result.Title, script); err != nil {
+ return err
+ }
+ if err := validateNoPromptLeakage("comic title", result.Title); err != nil {
+ return err
+ }
+ for pageIdx, page := range result.PanelScript {
+ for panelIdx, panel := range page {
+ if err := validateTextScript(fmt.Sprintf("panel script P%d-%c", pageIdx+1, 'A'+panelIdx), panel, script); err != nil {
+ return err
+ }
+ if err := validateNoPromptLeakage(fmt.Sprintf("panel script P%d-%c", pageIdx+1, 'A'+panelIdx), panel); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
diff --git a/internal/comic/localization.go b/internal/comic/localization.go
new file mode 100644
index 0000000..2578feb
--- /dev/null
+++ b/internal/comic/localization.go
@@ -0,0 +1,11 @@
+package comic
+
+import "strings"
+
+// localizedBrandName returns the comic's subtitle/series name in the selected script.
+func localizedBrandName(language, script string) string {
+ if strings.EqualFold(script, "Cyrillic") || strings.EqualFold(language, "Bulgarian") {
+ return "КомиксФордж Приключения"
+ }
+ return "ComicForge Adventures"
+}
diff --git a/internal/comic/localization_test.go b/internal/comic/localization_test.go
new file mode 100644
index 0000000..a1609f3
--- /dev/null
+++ b/internal/comic/localization_test.go
@@ -0,0 +1,42 @@
+package comic
+
+import "testing"
+
+func TestLocalizedBrandName(t *testing.T) {
+ t.Parallel()
+
+ if got, want := localizedBrandName("Bulgarian", "Cyrillic"), "КомиксФордж Приключения"; got != want {
+ t.Fatalf("localizedBrandName() = %q, want %q", got, want)
+ }
+ if got, want := localizedBrandName("English", "Latin"), "ComicForge Adventures"; got != want {
+ t.Fatalf("localizedBrandName() = %q, want %q", got, want)
+ }
+}
+
+func TestValidateTextScript(t *testing.T) {
+ t.Parallel()
+
+ if err := validateTextScript("title", "Заглавие", "Cyrillic"); err != nil {
+ t.Fatalf("validateTextScript() error = %v", err)
+ }
+ if err := validateTextScript("title", "Title", "Cyrillic"); err == nil {
+ t.Fatal("validateTextScript() error = nil, want Latin rejection")
+ }
+ if err := validateTextScript("title", "Title", "Latin"); err != nil {
+ t.Fatalf("validateTextScript() error = %v", err)
+ }
+ if err := validateTextScript("title", "Заглавие", "Latin"); err == nil {
+ t.Fatal("validateTextScript() error = nil, want Cyrillic rejection")
+ }
+}
+
+func TestValidateNoPromptLeakage(t *testing.T) {
+ t.Parallel()
+
+ if err := validateNoPromptLeakage("story", "A calm story without artifacts."); err != nil {
+ t.Fatalf("validateNoPromptLeakage() error = %v", err)
+ }
+ if err := validateNoPromptLeakage("story", "Words to include:\n- ябълка"); err == nil {
+ t.Fatal("validateNoPromptLeakage() error = nil, want prompt leakage rejection")
+ }
+}
diff --git a/internal/comic/narrator.go b/internal/comic/narrator.go
index 1b4365f..a7c76c3 100644
--- a/internal/comic/narrator.go
+++ b/internal/comic/narrator.go
@@ -206,7 +206,12 @@ func (n *Narrator) buildTeaser(ctx context.Context, templateName, storyText stri
fmt.Printf(" Warning: teaser generation failed: %v\n", err)
return ""
}
- return strings.TrimSpace(text)
+ teaser := strings.TrimSpace(text)
+ if err := validateTextScript("narration teaser", teaser, n.script); err != nil {
+ fmt.Printf(" Warning: teaser validation failed: %v\n", err)
+ return ""
+ }
+ return teaser
}
func (n *Narrator) narrateChunkWith(ctx context.Context, provider provider.TTSProvider, text, outputFile string) error {
diff --git a/internal/comic/text_validation.go b/internal/comic/text_validation.go
new file mode 100644
index 0000000..31f1bba
--- /dev/null
+++ b/internal/comic/text_validation.go
@@ -0,0 +1,72 @@
+package comic
+
+import (
+ "fmt"
+ "strings"
+ "unicode"
+)
+
+var promptLeakMarkers = []string{
+ "words to include",
+ "required words",
+ "character guide",
+ "comic title",
+ "panel script",
+ "mandatory language rule",
+ "mandatory panel layout",
+ "mandatory speech bubbles",
+ "strict consistency rules",
+ "story excerpt",
+ "story teaser",
+ "story ending hint",
+ "---character guide---",
+ "---comic title---",
+ "---panel script---",
+}
+
+func validateTextScript(label, text, script string) error {
+ text = strings.TrimSpace(text)
+ if text == "" {
+ return fmt.Errorf("%s is empty", label)
+ }
+
+ switch {
+ case strings.EqualFold(script, "Cyrillic"):
+ if containsLatinLetters(text) {
+ return fmt.Errorf("%s contains Latin letters despite %s script", label, script)
+ }
+ case strings.EqualFold(script, "Latin"):
+ if containsCyrillicLetters(text) {
+ return fmt.Errorf("%s contains Cyrillic letters despite %s script", label, script)
+ }
+ }
+ return nil
+}
+
+func containsLatinLetters(text string) bool {
+ for _, r := range text {
+ if unicode.Is(unicode.Latin, r) {
+ return true
+ }
+ }
+ return false
+}
+
+func containsCyrillicLetters(text string) bool {
+ for _, r := range text {
+ if unicode.Is(unicode.Cyrillic, r) {
+ return true
+ }
+ }
+ return false
+}
+
+func validateNoPromptLeakage(label, text string) error {
+ lower := strings.ToLower(text)
+ for _, marker := range promptLeakMarkers {
+ if strings.Contains(lower, marker) {
+ return fmt.Errorf("%s contains prompt leakage marker %q", label, marker)
+ }
+ }
+ return nil
+}
diff --git a/internal/comic/types.go b/internal/comic/types.go
index 59b410c..6689095 100644
--- a/internal/comic/types.go
+++ b/internal/comic/types.go
@@ -234,18 +234,14 @@ func buildWordList(entries []WordEntry, header string) string {
var sb strings.Builder
sb.WriteString(header)
if header != "" {
- sb.WriteString("Words to include:\n")
+ sb.WriteString("Required words:\n")
}
- for i, entry := range entries {
+ for _, entry := range entries {
word := strings.TrimSpace(entry.Word)
if word == "" {
word = strings.TrimSpace(entry.Translation)
}
- if entry.Translation != "" && entry.Word != "" {
- sb.WriteString(fmt.Sprintf("%d. %s (%s)\n", i+1, entry.Word, entry.Translation))
- continue
- }
- sb.WriteString(fmt.Sprintf("%d. %s\n", i+1, word))
+ sb.WriteString(fmt.Sprintf("- %s\n", word))
}
return sb.String()
}