diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/comic/artist.go | 12 | ||||
| -rw-r--r-- | internal/comic/comic_test.go | 103 | ||||
| -rw-r--r-- | internal/comic/generator.go | 44 | ||||
| -rw-r--r-- | internal/comic/localization.go | 11 | ||||
| -rw-r--r-- | internal/comic/localization_test.go | 42 | ||||
| -rw-r--r-- | internal/comic/narrator.go | 7 | ||||
| -rw-r--r-- | internal/comic/text_validation.go | 72 | ||||
| -rw-r--r-- | internal/comic/types.go | 10 | ||||
| -rw-r--r-- | internal/config/config.go | 4 | ||||
| -rw-r--r-- | internal/config/config_test.go | 12 | ||||
| -rw-r--r-- | internal/image/gemini.go | 2 | ||||
| -rw-r--r-- | internal/image/gemini_test.go | 28 |
12 files changed, 289 insertions, 58 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() } diff --git a/internal/config/config.go b/internal/config/config.go index 2726c46..ba8a56e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -126,8 +126,8 @@ func DefaultConfig() *Config { Language: LanguageConfig{ Input: "Vocabulary", Output: "Story", - Story: "Story", - Script: "Latin", + Story: "Bulgarian", + Script: "Cyrillic", }, Story: StoryConfig{ Genres: []string{ diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ae2127a..74fa944 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -88,7 +88,7 @@ func TestRenderPromptFallsBackToEmbeddedTemplate(t *testing.T) { got, err := cfg.RenderPrompt("story_prompt.md", map[string]any{ "Language": "Bulgarian", "Genre": "a mystery with a surprising twist", - "Words": "1. ябълка (apple)\n2. книга (book)\n", + "Words": "- ябълка\n- книга\n", }) if err != nil { t.Fatalf("RenderPrompt() error = %v", err) @@ -145,9 +145,9 @@ func TestEmbeddedPromptTemplatesRender(t *testing.T) { "Script": "Cyrillic", "Genre": "a mystery with a surprising twist", "Style": "cinematic realism", - "Words": "1. ябълка (apple)\n2. книга (book)\n", - "Bible": "Mira: young adult, brown hair, blue eyes, red coat.\n", - "Subtitle": "Sample Subtitle", + "Words": "- ябълка\n- книга\n", + "Bible": "Мира: млада жена, кафява коса, сини очи, червено палто.\n", + "Subtitle": "КомиксФордж Приключения", "StoryText": "A short story teaser.", "RenderingRequirement": "ULTRA-REALISTIC RENDERING", "RenderingRequirementEnd": "FINAL LOCK — PHOTOREALISM", @@ -157,8 +157,8 @@ func TestEmbeddedPromptTemplatesRender(t *testing.T) { "PageNum": 1, "TotalPages": 5, "PanelLayout": "MANDATORY PANEL LAYOUT — divide the image into exactly 4 panels in a 2×2 grid.\n", - "BlurbBox": "a rectangular text box with a short blurb", - "SeriesTitle": "ComicForge Adventures", + "BlurbBox": "правоъгълно текстово поле с кратък текст", + "SeriesTitle": "КомиксФордж Приключения", "Pose": "extreme close-up portrait", } diff --git a/internal/image/gemini.go b/internal/image/gemini.go index 85da3a8..15373e2 100644 --- a/internal/image/gemini.go +++ b/internal/image/gemini.go @@ -185,7 +185,7 @@ func (c *GeminiProvider) GenerateImage(ctx context.Context, prompt, outputFile s if strings.TrimSpace(outputFile) == "" { return fmt.Errorf("output file is required") } - results, err := c.Search(ctx, &SearchOptions{Query: prompt}) + results, err := c.Search(ctx, &SearchOptions{CustomPrompt: prompt}) if err != nil { return err } diff --git a/internal/image/gemini_test.go b/internal/image/gemini_test.go index bb3affe..bbc9dcd 100644 --- a/internal/image/gemini_test.go +++ b/internal/image/gemini_test.go @@ -107,6 +107,34 @@ func TestGeminiProvider_Search_CustomPromptSkipsTextGeneration(t *testing.T) { } } +func TestGeminiProvider_GenerateImage_UsesCustomPrompt(t *testing.T) { + originalText := geminiGenerateText + originalImage := geminiGenerateImage + t.Cleanup(func() { + geminiGenerateText = originalText + geminiGenerateImage = originalImage + }) + + geminiGenerateText = func(context.Context, *GeminiProvider, string, string, string, float32, int32) (string, error) { + t.Fatal("unexpected text generation for comic prompt") + return "", nil + } + + var gotPrompt string + geminiGenerateImage = func(_ context.Context, _ *GeminiProvider, prompt, _ string) ([]byte, string, error) { + gotPrompt = prompt + return mustJPEGBytes(t), "image/jpeg", nil + } + + client := NewGeminiProvider(&GeminiConfig{APIKey: "test-key"}) + if err := client.GenerateImage(context.Background(), "Art style: comic. FULL-BLEED SINGLE ILLUSTRATION.", t.TempDir()+"/out.png"); err != nil { + t.Fatalf("GenerateImage() unexpected error: %v", err) + } + if gotPrompt != "Art style: comic. FULL-BLEED SINGLE ILLUSTRATION." { + t.Fatalf("GenerateImage() prompt = %q, want custom prompt", gotPrompt) + } +} + func TestGeminiProvider_Search_GeneratedPromptFlow(t *testing.T) { originalText := geminiGenerateText originalImage := geminiGenerateImage |
