summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-19 21:58:20 +0300
committerPaul Buetow <paul@buetow.org>2026-04-19 21:58:20 +0300
commitbaaa2a95b323296992bcce9c8cdc789c1c52d917 (patch)
treedbb2f8a231c1827cb571a1733abd5f7c66d31f4f /internal
parenta87e799634280e2b52a5fcacafc44cb28a0d288e (diff)
u4: add core infrastructure scaffolding
Diffstat (limited to 'internal')
-rw-r--r--internal/apicircuit/apicircuit.go92
-rw-r--r--internal/apicircuit/apicircuit_test.go40
-rw-r--r--internal/config/config.go327
-rw-r--r--internal/config/config_test.go148
-rw-r--r--internal/config/home.go22
-rw-r--r--internal/httpctx/httpctx.go79
-rw-r--r--internal/httpctx/httpctx_test.go81
-rw-r--r--internal/provider/provider.go72
-rw-r--r--internal/provider/provider_test.go22
-rw-r--r--internal/vocab/reader.go111
-rw-r--r--internal/vocab/reader_test.go124
11 files changed, 1118 insertions, 0 deletions
diff --git a/internal/apicircuit/apicircuit.go b/internal/apicircuit/apicircuit.go
new file mode 100644
index 0000000..05081e5
--- /dev/null
+++ b/internal/apicircuit/apicircuit.go
@@ -0,0 +1,92 @@
+// Package apicircuit wraps outbound Gemini API calls with sony/gobreaker
+// circuit breakers so repeated failures do not pile up unbounded work.
+package apicircuit
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "time"
+
+ "github.com/sony/gobreaker"
+)
+
+const (
+ // breakerInterval clears rolling failure counts in the closed state so stale
+ // errors do not keep the breaker sensitive forever.
+ breakerInterval = 2 * time.Minute
+ // breakerOpenTimeout is how long the breaker stays open before trying half-open.
+ breakerOpenTimeout = 45 * time.Second
+ // breakerMaxHalfOpenRequests limits trial traffic while recovering.
+ breakerMaxHalfOpenRequests = 3
+ // breakerTripAfterConsecutiveFailures opens the circuit after this many
+ // consecutive failed requests in the closed state.
+ breakerTripAfterConsecutiveFailures uint32 = 5
+)
+
+var (
+ geminiTTSOnce sync.Once
+ geminiTTSBreaker *gobreaker.CircuitBreaker
+ geminiImageOnce sync.Once
+ geminiImageBreaker *gobreaker.CircuitBreaker
+)
+
+// isSuccessful counts only real API outcomes: nil is success; context.Canceled is
+// treated as success so user abort does not trip the breaker. Timeouts and
+// remote errors still count as failures.
+func isSuccessful(err error) bool {
+ if err == nil {
+ return true
+ }
+
+ return errors.Is(err, context.Canceled)
+}
+
+func readyToTrip(counts gobreaker.Counts) bool {
+ return counts.ConsecutiveFailures >= breakerTripAfterConsecutiveFailures
+}
+
+func newBreaker(name string) *gobreaker.CircuitBreaker {
+ return gobreaker.NewCircuitBreaker(gobreaker.Settings{
+ Name: name,
+ MaxRequests: breakerMaxHalfOpenRequests,
+ Interval: breakerInterval,
+ Timeout: breakerOpenTimeout,
+ ReadyToTrip: readyToTrip,
+ IsSuccessful: isSuccessful,
+ })
+}
+
+func geminiBreaker(name string, slot **gobreaker.CircuitBreaker, once *sync.Once) *gobreaker.CircuitBreaker {
+ once.Do(func() {
+ *slot = newBreaker(name)
+ })
+
+ return *slot
+}
+
+func runValue[T any](cb *gobreaker.CircuitBreaker, fn func() (T, error)) (T, error) {
+ var zero T
+
+ v, err := cb.Execute(func() (interface{}, error) {
+ return fn()
+ })
+ if err != nil {
+ return zero, err
+ }
+ if v == nil {
+ return zero, nil
+ }
+
+ return v.(T), nil
+}
+
+// GeminiTTS runs one Gemini TTS GenerateContent call through its circuit breaker.
+func GeminiTTS[T any](fn func() (T, error)) (T, error) {
+ return runValue(geminiBreaker("gemini-tts", &geminiTTSBreaker, &geminiTTSOnce), fn)
+}
+
+// GeminiImage runs one Gemini image-generation call through its circuit breaker.
+func GeminiImage[T any](fn func() (T, error)) (T, error) {
+ return runValue(geminiBreaker("gemini-image", &geminiImageBreaker, &geminiImageOnce), fn)
+}
diff --git a/internal/apicircuit/apicircuit_test.go b/internal/apicircuit/apicircuit_test.go
new file mode 100644
index 0000000..842a0f2
--- /dev/null
+++ b/internal/apicircuit/apicircuit_test.go
@@ -0,0 +1,40 @@
+package apicircuit
+
+import (
+ "context"
+ "errors"
+ "testing"
+)
+
+func TestGeminiTTS_Success(t *testing.T) {
+ t.Parallel()
+
+ v, err := GeminiTTS(func() (string, error) {
+ return "ok", nil
+ })
+ if err != nil || v != "ok" {
+ t.Fatalf("GeminiTTS() = %q, %v; want ok, nil", v, err)
+ }
+}
+
+func TestGeminiImage_Success(t *testing.T) {
+ t.Parallel()
+
+ v, err := GeminiImage(func() (int, error) {
+ return 42, nil
+ })
+ if err != nil || v != 42 {
+ t.Fatalf("GeminiImage() = %d, %v; want 42, nil", v, err)
+ }
+}
+
+func TestIsSuccessful_ContextCanceled(t *testing.T) {
+ t.Parallel()
+
+ if !isSuccessful(context.Canceled) {
+ t.Fatal("context.Canceled should not count as breaker failure")
+ }
+ if isSuccessful(errors.New("api error")) {
+ t.Fatal("arbitrary errors must count as failure")
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..9267ff0
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,327 @@
+// Package config provides application configuration loading and prompt template
+// helpers for ComicForge.
+package config
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "text/template"
+
+ "github.com/spf13/viper"
+
+ "codeberg.org/snonux/comicforge/internal/provider"
+)
+
+const (
+ // DefaultPromptsDir is the fallback directory for Go template prompt files.
+ DefaultPromptsDir = "./prompts"
+)
+
+// Config holds ComicForge settings loaded from YAML, environment variables, and defaults.
+type Config struct {
+ Provider ProviderConfig `mapstructure:"provider" yaml:"provider"`
+ API APIConfig `mapstructure:"api" yaml:"api"`
+ Models ModelConfig `mapstructure:"models" yaml:"models"`
+ Comic ComicConfig `mapstructure:"comic" yaml:"comic"`
+ Language LanguageConfig `mapstructure:"language" yaml:"language"`
+ Story StoryConfig `mapstructure:"story" yaml:"story"`
+ Styles StyleConfig `mapstructure:"styles" yaml:"styles"`
+ Narration NarrationConfig `mapstructure:"narration" yaml:"narration"`
+
+ PromptsDir string `mapstructure:"prompts_dir" yaml:"prompts_dir"`
+}
+
+var (
+ _ provider.TextConfig = (*Config)(nil)
+ _ provider.ImageConfig = (*Config)(nil)
+ _ provider.TTSConfig = (*Config)(nil)
+)
+
+// ProviderConfig stores the selected provider name for each capability.
+type ProviderConfig struct {
+ Text string `mapstructure:"text" yaml:"text"`
+ Image string `mapstructure:"image" yaml:"image"`
+ TTS string `mapstructure:"tts" yaml:"tts"`
+}
+
+// APIConfig stores API keys and related secrets.
+type APIConfig struct {
+ GoogleAPIKey string `mapstructure:"google_api_key" yaml:"google_api_key"`
+}
+
+// ModelConfig stores the model IDs used by each capability.
+type ModelConfig struct {
+ Text string `mapstructure:"text" yaml:"text"`
+ Image string `mapstructure:"image" yaml:"image"`
+ ImageText string `mapstructure:"image_text" yaml:"image_text"`
+ TTS string `mapstructure:"tts" yaml:"tts"`
+}
+
+// ComicConfig stores comic generation knobs.
+type ComicConfig struct {
+ StoryPages int `mapstructure:"story_pages" yaml:"story_pages"`
+ GalleryPages int `mapstructure:"gallery_pages" yaml:"gallery_pages"`
+ PanelsPerPage int `mapstructure:"panels_per_page" yaml:"panels_per_page"`
+ AspectRatio string `mapstructure:"aspect_ratio" yaml:"aspect_ratio"`
+ PromptMaxChars int `mapstructure:"prompt_max_chars" yaml:"prompt_max_chars"`
+ PageMaxRetries int `mapstructure:"page_max_retries" yaml:"page_max_retries"`
+ PageRetryBaseSeconds int `mapstructure:"page_retry_base_seconds" yaml:"page_retry_base_seconds"`
+}
+
+// LanguageConfig stores language and script labels used by prompts.
+type LanguageConfig struct {
+ Input string `mapstructure:"input" yaml:"input"`
+ Output string `mapstructure:"output" yaml:"output"`
+ Story string `mapstructure:"story_language" yaml:"story_language"`
+ Script string `mapstructure:"script" yaml:"script"`
+}
+
+// StoryConfig stores story prompt knobs.
+type StoryConfig struct {
+ Genres []string `mapstructure:"genres" yaml:"genres"`
+ RealisticWeight float64 `mapstructure:"realistic_weight" yaml:"realistic_weight"`
+}
+
+// StyleConfig stores prompt style pools.
+type StyleConfig struct {
+ Comic []string `mapstructure:"comic" yaml:"comic"`
+ Realistic []string `mapstructure:"realistic" yaml:"realistic"`
+}
+
+// NarrationConfig stores narration prompt knobs.
+type NarrationConfig struct {
+ Voices []string `mapstructure:"voices" yaml:"voices"`
+ ChunkWords int `mapstructure:"chunk_words" yaml:"chunk_words"`
+}
+
+// DefaultConfig returns a configuration populated with the initial Gemini-first defaults.
+func DefaultConfig() *Config {
+ return &Config{
+ Provider: ProviderConfig{
+ Text: provider.Gemini,
+ Image: provider.Gemini,
+ TTS: provider.Gemini,
+ },
+ API: APIConfig{},
+ Models: ModelConfig{
+ Text: "gemini-2.5-flash",
+ Image: "gemini-3.1-flash-image-preview",
+ ImageText: "gemini-2.5-flash",
+ TTS: "gemini-2.5-flash-preview-tts",
+ },
+ Comic: ComicConfig{
+ StoryPages: 5,
+ GalleryPages: 5,
+ PanelsPerPage: 4,
+ AspectRatio: "16:9",
+ PromptMaxChars: 900,
+ PageMaxRetries: 5,
+ PageRetryBaseSeconds: 15,
+ },
+ Language: LanguageConfig{
+ Input: "Vocabulary",
+ Output: "Story",
+ Story: "Story",
+ Script: "Latin",
+ },
+ Story: StoryConfig{
+ Genres: []string{
+ "a warm slice-of-life story",
+ "a heartfelt family drama",
+ "an exciting science-fiction adventure",
+ },
+ RealisticWeight: 0.4,
+ },
+ Styles: StyleConfig{
+ Comic: []string{
+ "classic comic book with bold ink outlines",
+ "graphic novel with dramatic shadows",
+ },
+ Realistic: []string{
+ "ultra-realistic DSLR photography, cinematic 35mm lens",
+ "cinematic realism with natural light",
+ },
+ },
+ Narration: NarrationConfig{
+ Voices: []string{
+ "Charon",
+ "Fenrir",
+ },
+ ChunkWords: 100,
+ },
+ PromptsDir: DefaultPromptsDir,
+ }
+}
+
+// Load reads configuration from YAML, environment variables, and defaults.
+func Load(configPath string) (*Config, error) {
+ cfg := DefaultConfig()
+
+ v := viper.New()
+ v.SetEnvPrefix("COMICFORGE")
+ v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
+ v.AutomaticEnv()
+ v.SetConfigType("yaml")
+ setDefaults(v, cfg)
+
+ if configPath != "" {
+ v.SetConfigFile(configPath)
+ } else {
+ if homeDir, err := HomeDir(); err == nil {
+ v.AddConfigPath(filepath.Join(homeDir, ".config", "comicforge"))
+ v.AddConfigPath(homeDir)
+ }
+ v.AddConfigPath(".")
+ v.SetConfigName("config")
+ }
+
+ if err := v.ReadInConfig(); err != nil {
+ var notFound viper.ConfigFileNotFoundError
+ if configPath != "" || !errors.As(err, &notFound) {
+ return nil, fmt.Errorf("read config: %w", err)
+ }
+ }
+
+ if err := v.Unmarshal(cfg); err != nil {
+ return nil, fmt.Errorf("decode config: %w", err)
+ }
+
+ cfg.normalize()
+ if err := cfg.validate(); err != nil {
+ return nil, err
+ }
+ return cfg, nil
+}
+
+// TextProviderName returns the configured text provider name.
+func (c *Config) TextProviderName() string {
+ return provider.NormalizeName(c.Provider.Text)
+}
+
+// ImageProviderName returns the configured image provider name.
+func (c *Config) ImageProviderName() string {
+ return provider.NormalizeName(c.Provider.Image)
+}
+
+// TTSProviderName returns the configured TTS provider name.
+func (c *Config) TTSProviderName() string {
+ return provider.NormalizeName(c.Provider.TTS)
+}
+
+// PromptDir returns the configured prompts directory or the default fallback.
+func (c *Config) PromptDir() string {
+ if c == nil || strings.TrimSpace(c.PromptsDir) == "" {
+ return DefaultPromptsDir
+ }
+ return strings.TrimSpace(c.PromptsDir)
+}
+
+// PromptPath joins the configured prompts directory with the requested template file.
+func (c *Config) PromptPath(name string) string {
+ return filepath.Join(c.PromptDir(), name)
+}
+
+// LoadPromptTemplate parses a Go text/template prompt file from the configured prompts directory.
+func (c *Config) LoadPromptTemplate(name string) (*template.Template, error) {
+ path := c.PromptPath(name)
+ content, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("read prompt %q: %w", path, err)
+ }
+
+ tmpl, err := template.New(filepath.Base(path)).Option("missingkey=error").Parse(string(content))
+ if err != nil {
+ return nil, fmt.Errorf("parse prompt %q: %w", path, err)
+ }
+
+ return tmpl, nil
+}
+
+// RenderPrompt executes a prompt template with the provided data.
+func (c *Config) RenderPrompt(name string, data any) (string, error) {
+ tmpl, err := c.LoadPromptTemplate(name)
+ if err != nil {
+ return "", err
+ }
+
+ var builder strings.Builder
+ if err := tmpl.Execute(&builder, data); err != nil {
+ return "", fmt.Errorf("render prompt %q: %w", name, err)
+ }
+
+ return builder.String(), nil
+}
+
+func (c *Config) normalize() {
+ c.Provider.Text = provider.NormalizeName(c.Provider.Text)
+ c.Provider.Image = provider.NormalizeName(c.Provider.Image)
+ c.Provider.TTS = provider.NormalizeName(c.Provider.TTS)
+
+ if c.Provider.Text == "" {
+ c.Provider.Text = provider.Gemini
+ }
+ if c.Provider.Image == "" {
+ c.Provider.Image = provider.Gemini
+ }
+ if c.Provider.TTS == "" {
+ c.Provider.TTS = provider.Gemini
+ }
+
+ if c.PromptsDir == "" {
+ c.PromptsDir = DefaultPromptsDir
+ }
+}
+
+func (c *Config) validate() error {
+ if !provider.IsKnownName(c.Provider.Text) {
+ return fmt.Errorf("unknown text provider: %s", c.Provider.Text)
+ }
+ if !provider.IsKnownName(c.Provider.Image) {
+ return fmt.Errorf("unknown image provider: %s", c.Provider.Image)
+ }
+ if !provider.IsKnownName(c.Provider.TTS) {
+ return fmt.Errorf("unknown TTS provider: %s", c.Provider.TTS)
+ }
+
+ return nil
+}
+
+func setDefaults(v *viper.Viper, cfg *Config) {
+ v.SetDefault("provider.text", cfg.Provider.Text)
+ v.SetDefault("provider.image", cfg.Provider.Image)
+ v.SetDefault("provider.tts", cfg.Provider.TTS)
+
+ v.SetDefault("api.google_api_key", cfg.API.GoogleAPIKey)
+
+ v.SetDefault("models.text", cfg.Models.Text)
+ v.SetDefault("models.image", cfg.Models.Image)
+ v.SetDefault("models.image_text", cfg.Models.ImageText)
+ v.SetDefault("models.tts", cfg.Models.TTS)
+
+ v.SetDefault("comic.story_pages", cfg.Comic.StoryPages)
+ v.SetDefault("comic.gallery_pages", cfg.Comic.GalleryPages)
+ v.SetDefault("comic.panels_per_page", cfg.Comic.PanelsPerPage)
+ v.SetDefault("comic.aspect_ratio", cfg.Comic.AspectRatio)
+ v.SetDefault("comic.prompt_max_chars", cfg.Comic.PromptMaxChars)
+ v.SetDefault("comic.page_max_retries", cfg.Comic.PageMaxRetries)
+ v.SetDefault("comic.page_retry_base_seconds", cfg.Comic.PageRetryBaseSeconds)
+
+ v.SetDefault("language.input", cfg.Language.Input)
+ v.SetDefault("language.output", cfg.Language.Output)
+ v.SetDefault("language.story_language", cfg.Language.Story)
+ v.SetDefault("language.script", cfg.Language.Script)
+
+ v.SetDefault("story.genres", cfg.Story.Genres)
+ v.SetDefault("story.realistic_weight", cfg.Story.RealisticWeight)
+
+ v.SetDefault("styles.comic", cfg.Styles.Comic)
+ v.SetDefault("styles.realistic", cfg.Styles.Realistic)
+
+ v.SetDefault("narration.voices", cfg.Narration.Voices)
+ v.SetDefault("narration.chunk_words", cfg.Narration.ChunkWords)
+
+ v.SetDefault("prompts_dir", cfg.PromptsDir)
+}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
new file mode 100644
index 0000000..d0a4efe
--- /dev/null
+++ b/internal/config/config_test.go
@@ -0,0 +1,148 @@
+package config
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "codeberg.org/snonux/comicforge/internal/provider"
+)
+
+func TestLoadReturnsDefaultsWhenConfigMissing(t *testing.T) {
+ t.Parallel()
+
+ cfg, err := Load("")
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+
+ if got, want := cfg.Provider.Text, provider.Gemini; got != want {
+ t.Fatalf("Provider.Text = %q, want %q", got, want)
+ }
+ if got, want := cfg.PromptsDir, DefaultPromptsDir; got != want {
+ t.Fatalf("PromptsDir = %q, want %q", got, want)
+ }
+}
+
+func TestLoadReadsFileAndEnvOverrides(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.yaml")
+ if err := os.WriteFile(configPath, []byte(strings.TrimSpace(`
+provider:
+ text: openai
+ image: openai
+ tts: openai
+prompts_dir: ./custom-prompts
+comic:
+ story_pages: 7
+`)), 0o644); err != nil {
+ t.Fatalf("write config: %v", err)
+ }
+
+ t.Setenv("COMICFORGE_PROVIDER_TEXT", "gemini")
+
+ cfg, err := Load(configPath)
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+
+ if got, want := cfg.Provider.Text, provider.Gemini; got != want {
+ t.Fatalf("Provider.Text = %q, want %q", got, want)
+ }
+ if got, want := cfg.Provider.Image, provider.OpenAI; got != want {
+ t.Fatalf("Provider.Image = %q, want %q", got, want)
+ }
+ if got, want := cfg.Comic.StoryPages, 7; got != want {
+ t.Fatalf("Comic.StoryPages = %d, want %d", got, want)
+ }
+ if got, want := cfg.PromptsDir, "./custom-prompts"; got != want {
+ t.Fatalf("PromptsDir = %q, want %q", got, want)
+ }
+}
+
+func TestRenderPrompt(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ cfg := DefaultConfig()
+ cfg.PromptsDir = tmpDir
+
+ if err := os.WriteFile(filepath.Join(tmpDir, "story.md"), []byte("{{.Word}} -> {{.Translation}}"), 0o644); err != nil {
+ t.Fatalf("write prompt: %v", err)
+ }
+
+ got, err := cfg.RenderPrompt("story.md", map[string]string{
+ "Word": "ябълка",
+ "Translation": "apple",
+ })
+ if err != nil {
+ t.Fatalf("RenderPrompt() error = %v", err)
+ }
+ if got != "ябълка -> apple" {
+ t.Fatalf("RenderPrompt() = %q, want %q", got, "ябълка -> apple")
+ }
+}
+
+func TestRenderPromptMissingKeyReturnsError(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ cfg := DefaultConfig()
+ cfg.PromptsDir = tmpDir
+
+ if err := os.WriteFile(filepath.Join(tmpDir, "story.md"), []byte("{{.Word}} -> {{.Translation}}"), 0o644); err != nil {
+ t.Fatalf("write prompt: %v", err)
+ }
+
+ _, err := cfg.RenderPrompt("story.md", map[string]string{"Word": "ябълка"})
+ if err == nil {
+ t.Fatal("RenderPrompt() error = nil, want error")
+ }
+ if !strings.Contains(err.Error(), "render prompt") {
+ t.Fatalf("RenderPrompt() error = %v, want wrapped render error", err)
+ }
+}
+
+func TestLoadRejectsUnknownProvider(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.yaml")
+ if err := os.WriteFile(configPath, []byte(strings.TrimSpace(`
+provider:
+ text: mystery
+`)), 0o644); err != nil {
+ t.Fatalf("write config: %v", err)
+ }
+
+ _, err := Load(configPath)
+ if err == nil {
+ t.Fatal("Load() error = nil, want error")
+ }
+ if !strings.Contains(err.Error(), "unknown text provider") {
+ t.Fatalf("Load() error = %v, want unknown provider error", err)
+ }
+}
+
+func TestHomeDirReturnsFallbackWhenResolutionFails(t *testing.T) {
+ t.Parallel()
+
+ oldUserHomeDir := userHomeDir
+ t.Cleanup(func() {
+ userHomeDir = oldUserHomeDir
+ })
+
+ userHomeDir = func() (string, error) {
+ return "", errors.New("boom")
+ }
+
+ homeDir, err := HomeDir()
+ if err == nil {
+ t.Fatal("HomeDir() error = nil, want error")
+ }
+ if homeDir != "." {
+ t.Fatalf("HomeDir() homeDir = %q, want %q", homeDir, ".")
+ }
+}
diff --git a/internal/config/home.go b/internal/config/home.go
new file mode 100644
index 0000000..8089b6e
--- /dev/null
+++ b/internal/config/home.go
@@ -0,0 +1,22 @@
+package config
+
+import (
+ "fmt"
+ "os"
+)
+
+var userHomeDir = os.UserHomeDir
+
+// HomeDir returns the user's home directory.
+//
+// It falls back to "." when the home directory cannot be resolved so callers
+// can still build a safe relative path instead of joining against an empty
+// string.
+func HomeDir() (string, error) {
+ homeDir, err := userHomeDir()
+ if err != nil {
+ return ".", fmt.Errorf("resolve home directory: %w", err)
+ }
+
+ return homeDir, nil
+}
diff --git a/internal/httpctx/httpctx.go b/internal/httpctx/httpctx.go
new file mode 100644
index 0000000..3f717f4
--- /dev/null
+++ b/internal/httpctx/httpctx.go
@@ -0,0 +1,79 @@
+// Package httpctx provides HTTP client defaults and context helpers for outbound
+// Gemini API calls and remote asset downloads.
+package httpctx
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "time"
+
+ "google.golang.org/genai"
+)
+
+const (
+ // GenAIHTTPTimeout bounds each Google GenAI SDK HTTP request.
+ GenAIHTTPTimeout = 30 * time.Minute
+
+ // ImageDownloadTimeout limits fetches of remote image URLs.
+ ImageDownloadTimeout = 60 * time.Second
+
+ // OperationTimeoutDefault caps a full high-level operation when the caller did not set a deadline.
+ OperationTimeoutDefault = 15 * time.Minute
+
+ // ListModelsTimeout bounds model-listing CLI calls.
+ ListModelsTimeout = 3 * time.Minute
+
+ // StoryPageImageTimeout bounds a single comic page image pipeline when no parent deadline exists.
+ StoryPageImageTimeout = 25 * time.Minute
+
+ // VeoCLIPerVideoTimeout bounds one gallery-to-video run when the CLI passes Background.
+ VeoCLIPerVideoTimeout = 25 * time.Minute
+
+ // SingleWordProcessTimeout caps a single vocabulary processing operation when the caller uses Background.
+ SingleWordProcessTimeout = 10 * time.Minute
+)
+
+// GenAIHTTPClient returns an http.Client for google.golang.org/genai.
+func GenAIHTTPClient() *http.Client {
+ return &http.Client{Timeout: GenAIHTTPTimeout}
+}
+
+// ImageDownloadHTTPClient returns a client for generic image URL downloads.
+func ImageDownloadHTTPClient() *http.Client {
+ return &http.Client{Timeout: ImageDownloadTimeout}
+}
+
+// NewGenAIClient wraps genai.NewClient, setting HTTPClient when the config does
+// not supply one so outbound requests never rely on an unbounded default.
+func NewGenAIClient(ctx context.Context, cfg *genai.ClientConfig) (*genai.Client, error) {
+ if cfg == nil {
+ cfg = &genai.ClientConfig{}
+ }
+
+ merged := *cfg
+ if merged.HTTPClient == nil {
+ merged.HTTPClient = GenAIHTTPClient()
+ }
+
+ client, err := genai.NewClient(ctx, &merged)
+ if err != nil {
+ return nil, fmt.Errorf("create genai client: %w", err)
+ }
+
+ return client, nil
+}
+
+// WithTimeoutUnlessSet returns a child context with timeout d when ctx has no
+// deadline. If ctx already has a deadline, it returns ctx and a no-op cancel.
+func WithTimeoutUnlessSet(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ if _, ok := ctx.Deadline(); ok {
+ return ctx, func() {}
+ }
+
+ return context.WithTimeout(ctx, d)
+}
diff --git a/internal/httpctx/httpctx_test.go b/internal/httpctx/httpctx_test.go
new file mode 100644
index 0000000..ffad68c
--- /dev/null
+++ b/internal/httpctx/httpctx_test.go
@@ -0,0 +1,81 @@
+package httpctx
+
+import (
+ "context"
+ "net/http"
+ "testing"
+ "time"
+
+ "google.golang.org/genai"
+)
+
+func TestWithTimeoutUnlessSet_AlreadyHasDeadline(t *testing.T) {
+ t.Parallel()
+
+ parent, cancel := context.WithTimeout(context.Background(), time.Hour)
+ defer cancel()
+
+ ctx, childCancel := WithTimeoutUnlessSet(parent, time.Nanosecond)
+ defer childCancel()
+
+ if ctx != parent {
+ t.Fatal("expected same context when parent already has deadline")
+ }
+}
+
+func TestWithTimeoutUnlessSet_NoDeadline(t *testing.T) {
+ t.Parallel()
+
+ ctx, cancel := WithTimeoutUnlessSet(context.Background(), 50*time.Millisecond)
+ defer cancel()
+
+ deadline, ok := ctx.Deadline()
+ if !ok {
+ t.Fatal("expected deadline")
+ }
+ if time.Until(deadline) > time.Second {
+ t.Fatalf("deadline too far: %v", deadline)
+ }
+}
+
+func TestWithTimeoutUnlessSet_NilUsesBackground(t *testing.T) {
+ t.Parallel()
+
+ ctx, cancel := WithTimeoutUnlessSet(nil, 50*time.Millisecond)
+ defer cancel()
+
+ if err := ctx.Err(); err != nil {
+ t.Fatalf("context should not be done: %v", err)
+ }
+}
+
+func TestNewGenAIClientAppliesDefaultHTTPClient(t *testing.T) {
+ t.Parallel()
+
+ customClient := &http.Client{Timeout: time.Second}
+ cfg := &genai.ClientConfig{
+ APIKey: "test-key",
+ }
+
+ client, err := NewGenAIClient(context.Background(), cfg)
+ if err != nil {
+ t.Fatalf("NewGenAIClient() error = %v", err)
+ }
+ if client == nil {
+ t.Fatal("NewGenAIClient() client = nil")
+ }
+ if cfg.HTTPClient != nil {
+ t.Fatalf("NewGenAIClient() mutated input config HTTPClient = %#v, want nil", cfg.HTTPClient)
+ }
+
+ cfg2 := &genai.ClientConfig{
+ APIKey: "test-key",
+ HTTPClient: customClient,
+ }
+ if _, err := NewGenAIClient(context.Background(), cfg2); err != nil {
+ t.Fatalf("NewGenAIClient() error = %v", err)
+ }
+ if cfg2.HTTPClient != customClient {
+ t.Fatal("NewGenAIClient() should preserve caller HTTPClient")
+ }
+}
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
new file mode 100644
index 0000000..6239ecf
--- /dev/null
+++ b/internal/provider/provider.go
@@ -0,0 +1,72 @@
+// Package provider defines capability-specific AI provider interfaces and shared
+// provider naming helpers. The concrete Gemini implementations will satisfy
+// these interfaces once the comic pipeline is wired up.
+package provider
+
+import (
+ "context"
+ "errors"
+ "strings"
+)
+
+const (
+ // Gemini is the canonical provider name for Google's Gemini backend.
+ Gemini = "gemini"
+
+ // OpenAI is the canonical provider name for OpenAI backends.
+ OpenAI = "openai"
+)
+
+// TextProvider generates text from prompts.
+type TextProvider interface {
+ Name() string
+ IsAvailable() error
+ GenerateText(ctx context.Context, prompt string) (string, error)
+}
+
+// ImageProvider generates images from prompts.
+type ImageProvider interface {
+ Name() string
+ IsAvailable() error
+ GenerateImage(ctx context.Context, prompt string, outputFile string) error
+}
+
+// TTSProvider generates audio from text.
+type TTSProvider interface {
+ Name() string
+ IsAvailable() error
+ GenerateAudio(ctx context.Context, text string, outputFile string) error
+}
+
+// TextConfig exposes the configured text provider name.
+type TextConfig interface {
+ TextProviderName() string
+}
+
+// ImageConfig exposes the configured image provider name.
+type ImageConfig interface {
+ ImageProviderName() string
+}
+
+// TTSConfig exposes the configured TTS provider name.
+type TTSConfig interface {
+ TTSProviderName() string
+}
+
+// NormalizeName returns a canonical lower-case provider name.
+func NormalizeName(name string) string {
+ return strings.ToLower(strings.TrimSpace(name))
+}
+
+// IsKnownName reports whether the name matches a supported provider family.
+func IsKnownName(name string) bool {
+ switch NormalizeName(name) {
+ case Gemini, OpenAI:
+ return true
+ default:
+ return false
+ }
+}
+
+// ErrUnknownProvider indicates that a provider name does not map to a known backend.
+var ErrUnknownProvider = errors.New("unknown provider")
diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go
new file mode 100644
index 0000000..46931d1
--- /dev/null
+++ b/internal/provider/provider_test.go
@@ -0,0 +1,22 @@
+package provider
+
+import "testing"
+
+func TestNormalizeName(t *testing.T) {
+ t.Parallel()
+
+ if got, want := NormalizeName(" Gemini "), Gemini; got != want {
+ t.Fatalf("NormalizeName() = %q, want %q", got, want)
+ }
+}
+
+func TestIsKnownName(t *testing.T) {
+ t.Parallel()
+
+ if !IsKnownName("gemini") {
+ t.Fatal("expected gemini to be recognized")
+ }
+ if IsKnownName("bogus") {
+ t.Fatal("expected bogus provider to be rejected")
+ }
+}
diff --git a/internal/vocab/reader.go b/internal/vocab/reader.go
new file mode 100644
index 0000000..f1506ba
--- /dev/null
+++ b/internal/vocab/reader.go
@@ -0,0 +1,111 @@
+// Package vocab reads language-agnostic vocabulary files.
+package vocab
+
+import (
+ "fmt"
+ "os"
+ "strings"
+)
+
+// CardType identifies the semantic shape of a vocabulary entry.
+type CardType string
+
+const (
+ // CardTypeTranslation marks a standard word-to-translation entry.
+ CardTypeTranslation CardType = "translation"
+ // CardTypeDefinition marks a same-language word-to-definition entry.
+ CardTypeDefinition CardType = "definition"
+)
+
+// WordEntry represents a vocabulary entry with an optional translation.
+type WordEntry struct {
+ Word string
+ Translation string
+ NeedsTranslation bool
+ CardType CardType
+}
+
+// ReadVocabularyFile reads vocabulary entries from a file and returns the parsed slice.
+func ReadVocabularyFile(filename string) ([]WordEntry, error) {
+ content, err := os.ReadFile(filename)
+ if err != nil {
+ return nil, fmt.Errorf("read vocabulary file: %w", err)
+ }
+
+ normalized := strings.ReplaceAll(string(content), "\r\n", "\n")
+ lines := strings.Split(normalized, "\n")
+ entries := make([]WordEntry, 0, len(lines))
+ for _, line := range lines {
+ if entry := parseLine(line); entry != nil {
+ entries = append(entries, *entry)
+ }
+ }
+
+ if len(entries) == 0 {
+ return nil, nil
+ }
+
+ return entries, nil
+}
+
+func parseLine(line string) *WordEntry {
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" {
+ return nil
+ }
+
+ if strings.Contains(trimmed, "==") {
+ parts := strings.SplitN(trimmed, "==", 2)
+ if len(parts) != 2 {
+ return nil
+ }
+
+ word := strings.TrimSpace(parts[0])
+ translation := strings.TrimSpace(parts[1])
+ if word == "" || translation == "" {
+ return nil
+ }
+
+ return &WordEntry{
+ Word: word,
+ Translation: translation,
+ NeedsTranslation: false,
+ CardType: CardTypeDefinition,
+ }
+ }
+
+ if strings.Contains(trimmed, "=") {
+ parts := strings.SplitN(trimmed, "=", 2)
+ if len(parts) != 2 {
+ return nil
+ }
+
+ word := strings.TrimSpace(parts[0])
+ translation := strings.TrimSpace(parts[1])
+ if word == "" && translation != "" {
+ return &WordEntry{
+ Word: "",
+ Translation: translation,
+ NeedsTranslation: true,
+ CardType: CardTypeTranslation,
+ }
+ }
+ if word != "" && translation != "" {
+ return &WordEntry{
+ Word: word,
+ Translation: translation,
+ NeedsTranslation: false,
+ CardType: CardTypeTranslation,
+ }
+ }
+
+ return nil
+ }
+
+ return &WordEntry{
+ Word: trimmed,
+ Translation: "",
+ NeedsTranslation: false,
+ CardType: CardTypeTranslation,
+ }
+}
diff --git a/internal/vocab/reader_test.go b/internal/vocab/reader_test.go
new file mode 100644
index 0000000..ad43a07
--- /dev/null
+++ b/internal/vocab/reader_test.go
@@ -0,0 +1,124 @@
+package vocab
+
+import (
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+)
+
+func TestReadVocabularyFile(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ fileContent string
+ want []WordEntry
+ }{
+ {
+ name: "empty file",
+ fileContent: "",
+ want: nil,
+ },
+ {
+ name: "words with translations",
+ fileContent: "ябълка = apple\nкотка = cat\nкуче = dog",
+ want: []WordEntry{
+ {Word: "ябълка", Translation: "apple", NeedsTranslation: false, CardType: CardTypeTranslation},
+ {Word: "котка", Translation: "cat