From a35ca6f40de73e93372ade6f2d74c231389799e2 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Mon, 20 Apr 2026 00:02:07 +0300 Subject: Fix task 25: keep comic generation single-language and single-style --- internal/comic/artist.go | 12 ++++- internal/comic/comic_test.go | 103 +++++++++++++++++++++++------------- internal/comic/generator.go | 44 ++++++++++++++- internal/comic/localization.go | 11 ++++ internal/comic/localization_test.go | 42 +++++++++++++++ internal/comic/narrator.go | 7 ++- internal/comic/text_validation.go | 72 +++++++++++++++++++++++++ internal/comic/types.go | 10 ++-- 8 files changed, 252 insertions(+), 49 deletions(-) create mode 100644 internal/comic/localization.go create mode 100644 internal/comic/localization_test.go create mode 100644 internal/comic/text_validation.go (limited to 'internal/comic') 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() } -- cgit v1.2.3