diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-19 21:58:20 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-19 21:58:20 +0300 |
| commit | baaa2a95b323296992bcce9c8cdc789c1c52d917 (patch) | |
| tree | dbb2f8a231c1827cb571a1733abd5f7c66d31f4f /internal | |
| parent | a87e799634280e2b52a5fcacafc44cb28a0d288e (diff) | |
u4: add core infrastructure scaffolding
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/apicircuit/apicircuit.go | 92 | ||||
| -rw-r--r-- | internal/apicircuit/apicircuit_test.go | 40 | ||||
| -rw-r--r-- | internal/config/config.go | 327 | ||||
| -rw-r--r-- | internal/config/config_test.go | 148 | ||||
| -rw-r--r-- | internal/config/home.go | 22 | ||||
| -rw-r--r-- | internal/httpctx/httpctx.go | 79 | ||||
| -rw-r--r-- | internal/httpctx/httpctx_test.go | 81 | ||||
| -rw-r--r-- | internal/provider/provider.go | 72 | ||||
| -rw-r--r-- | internal/provider/provider_test.go | 22 | ||||
| -rw-r--r-- | internal/vocab/reader.go | 111 | ||||
| -rw-r--r-- | internal/vocab/reader_test.go | 124 |
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, ¬Found) { + 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 |
