From c2a715d3f6b53d7dddd91ca17e20b6e607ff6c82 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 3 May 2026 15:23:47 -0500 Subject: [PATCH] Add WhisperX configuration contract --- README.md | 5 + architecture.md | 5 + examples/pipeline.minimal.yml | 8 +- internal/app/commands_test.go | 2 +- internal/config/config.go | 9 +- internal/config/load.go | 34 +++++++ internal/config/load_validate_test.go | 137 +++++++++++++++++++++++++- internal/config/validate.go | 37 ++++++- 8 files changed, 227 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index ed3808a..66a0f83 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,11 @@ This repository currently contains a **working scaffold** with strict config loa - `pipeline.yml`: pipeline/workspace settings (`workspace`, `storage`, `whisperx`, `seriatim`, `audita`, `analyzer`, `notification`) - `session.yml`: per-session settings (`session_id`, `inputs`, optional metadata) +WhisperX config contract in `pipeline.yml`: + +- required: `whisperx.transcribe_url` +- defaulted when omitted: `whisperx.language` (`en`), `whisperx.timeout` (`30m`), `whisperx.retries` (`3`), `whisperx.retry_delay` (`2s`), `whisperx.concurrency` (`2`) + Decoding is strict (`KnownFields(true)`), so unknown YAML fields fail fast. Example minimal files are available under `examples/`: diff --git a/architecture.md b/architecture.md index bee11f1..84c1964 100644 --- a/architecture.md +++ b/architecture.md @@ -102,10 +102,15 @@ Key behavior: - Strict YAML decoding via `yaml.Decoder.KnownFields(true)`. - Unknown fields are rejected. - Combined resolved config type keeps source paths (`PipelinePath`, `SessionPath`) for provenance/errors. +- WhisperX optional fields are defaulted during load for deterministic resolved config values. Validation currently enforces: - `pipeline.workspace.root` is required. +- `pipeline.whisperx.transcribe_url` is required and must be a valid URL. +- `pipeline.whisperx.timeout` and `pipeline.whisperx.retry_delay` must parse as Go durations. +- `pipeline.whisperx.retries` must be `>= 0`. +- `pipeline.whisperx.concurrency` must be `> 0`. - `session.session_id` is required. - `session.inputs.speakers_file`, `autocorrect_file`, `glossary_file` are required. - At least one audio source: `session.inputs.audio_dir` or non-empty `session.inputs.audio_files`. diff --git a/examples/pipeline.minimal.yml b/examples/pipeline.minimal.yml index 8e05f38..a992a44 100644 --- a/examples/pipeline.minimal.yml +++ b/examples/pipeline.minimal.yml @@ -5,7 +5,12 @@ storage: backend: local whisperx: - timeout: 15m + transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe + language: en + timeout: 30m + retries: 3 + retry_delay: 2s + concurrency: 2 seriatim: timeout: 30s @@ -20,4 +25,3 @@ analyzer: notification: timeout: 10s - diff --git a/internal/app/commands_test.go b/internal/app/commands_test.go index 6f16c26..b36c8d4 100644 --- a/internal/app/commands_test.go +++ b/internal/app/commands_test.go @@ -145,7 +145,7 @@ func writeValidConfigFiles(t *testing.T, workspaceRoot string) (string, string) storage: backend: s3 whisperx: - timeout: 15m + transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe seriatim: timeout: 30s audita: diff --git a/internal/config/config.go b/internal/config/config.go index f6cca38..8ce83d4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -42,9 +42,12 @@ type StorageConfig struct { // WhisperXConfig configures WhisperX adapter settings. type WhisperXConfig struct { - BaseURL string `yaml:"base_url"` - Timeout string `yaml:"timeout"` - Concurrency int `yaml:"concurrency"` + TranscribeURL string `yaml:"transcribe_url"` + Language string `yaml:"language"` + Timeout string `yaml:"timeout"` + Retries *int `yaml:"retries"` + RetryDelay string `yaml:"retry_delay"` + Concurrency *int `yaml:"concurrency"` } // SeriatimConfig configures seriatim adapter settings. diff --git a/internal/config/load.go b/internal/config/load.go index 4dd28cd..9379364 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -15,6 +15,7 @@ func LoadPipeline(path string) (*PipelineConfig, error) { if err := decodeStrictYAML("pipeline", path, &cfg); err != nil { return nil, fmt.Errorf("load pipeline config: %w", err) } + applyPipelineDefaults(&cfg) return &cfg, nil } @@ -75,3 +76,36 @@ func shortName(path, fallback string) string { } return base } + +func applyPipelineDefaults(cfg *PipelineConfig) { + if cfg == nil { + return + } + applyWhisperXDefaults(&cfg.WhisperX) +} + +func applyWhisperXDefaults(cfg *WhisperXConfig) { + if cfg == nil { + return + } + if cfg.Language == "" { + cfg.Language = "en" + } + if cfg.Timeout == "" { + cfg.Timeout = "30m" + } + if cfg.RetryDelay == "" { + cfg.RetryDelay = "2s" + } + if cfg.Concurrency == nil { + cfg.Concurrency = intPtr(2) + } + if cfg.Retries == nil { + cfg.Retries = intPtr(3) + } +} + +func intPtr(v int) *int { + p := v + return &p +} diff --git a/internal/config/load_validate_test.go b/internal/config/load_validate_test.go index 953a085..bf59c33 100644 --- a/internal/config/load_validate_test.go +++ b/internal/config/load_validate_test.go @@ -14,13 +14,14 @@ func TestLoadAndValidate(t *testing.T) { sessionYAML string wantLoadErr string wantValidate string + checkDefault bool }{ { name: "valid minimal config", pipelineYAML: `workspace: root: /tmp/narratio whisperx: - timeout: 10m + transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe seriatim: timeout: 30s audita: @@ -37,6 +38,7 @@ inputs: autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, + checkDefault: true, }, { name: "unknown pipeline field fails", @@ -53,6 +55,23 @@ inputs: `, wantLoadErr: "pipeline file", }, + { + name: "unknown whisperx field fails", + pipelineYAML: `workspace: + root: /tmp/narratio +whisperx: + transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe + bogus: true +`, + sessionYAML: `session_id: 2026-05-03 +inputs: + audio_dir: ./audio + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml +`, + wantLoadErr: "strict decode failed", + }, { name: "unknown session field fails", pipelineYAML: `workspace: @@ -72,6 +91,8 @@ unknown_field: true name: "missing required field fails", pipelineYAML: `workspace: root: /tmp/narratio +whisperx: + transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe `, sessionYAML: `session_id: "" inputs: @@ -82,11 +103,43 @@ inputs: `, wantValidate: "session config \"session.yml\" invalid: session.session_id is required", }, + { + name: "missing transcribe_url fails", + pipelineYAML: `workspace: + root: /tmp/narratio +whisperx: +`, + sessionYAML: `session_id: 2026-05-03 +inputs: + audio_dir: ./audio + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml +`, + wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.transcribe_url is required", + }, + { + name: "invalid transcribe_url fails", + pipelineYAML: `workspace: + root: /tmp/narratio +whisperx: + transcribe_url: :// +`, + sessionYAML: `session_id: 2026-05-03 +inputs: + audio_dir: ./audio + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml +`, + wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.transcribe_url must be a valid URL", + }, { name: "invalid timeout fails", pipelineYAML: `workspace: root: /tmp/narratio whisperx: + transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe timeout: definitely-not-a-duration `, sessionYAML: `session_id: 2026-05-03 @@ -98,6 +151,57 @@ inputs: `, wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.timeout must be a valid duration", }, + { + name: "invalid retry_delay fails", + pipelineYAML: `workspace: + root: /tmp/narratio +whisperx: + transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe + retry_delay: not-a-duration +`, + sessionYAML: `session_id: 2026-05-03 +inputs: + audio_dir: ./audio + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml +`, + wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.retry_delay must be a valid duration", + }, + { + name: "negative retries fails", + pipelineYAML: `workspace: + root: /tmp/narratio +whisperx: + transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe + retries: -1 +`, + sessionYAML: `session_id: 2026-05-03 +inputs: + audio_dir: ./audio + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml +`, + wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.retries must be >= 0", + }, + { + name: "invalid concurrency fails", + pipelineYAML: `workspace: + root: /tmp/narratio +whisperx: + transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe + concurrency: 0 +`, + sessionYAML: `session_id: 2026-05-03 +inputs: + audio_dir: ./audio + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml +`, + wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.concurrency must be > 0", + }, } for _, tt := range tests { @@ -127,6 +231,23 @@ inputs: if cfg.SessionPath != sessionPath { t.Fatalf("SessionPath = %q, want %q", cfg.SessionPath, sessionPath) } + if tt.checkDefault { + if cfg.Pipeline.WhisperX.Language != "en" { + t.Fatalf("whisperx.language = %q, want %q", cfg.Pipeline.WhisperX.Language, "en") + } + if cfg.Pipeline.WhisperX.Timeout != "30m" { + t.Fatalf("whisperx.timeout = %q, want %q", cfg.Pipeline.WhisperX.Timeout, "30m") + } + if cfg.Pipeline.WhisperX.RetryDelay != "2s" { + t.Fatalf("whisperx.retry_delay = %q, want %q", cfg.Pipeline.WhisperX.RetryDelay, "2s") + } + if cfg.Pipeline.WhisperX.Retries == nil || *cfg.Pipeline.WhisperX.Retries != 3 { + t.Fatalf("whisperx.retries = %v, want 3", cfg.Pipeline.WhisperX.Retries) + } + if cfg.Pipeline.WhisperX.Concurrency == nil || *cfg.Pipeline.WhisperX.Concurrency != 2 { + t.Fatalf("whisperx.concurrency = %v, want 2", cfg.Pipeline.WhisperX.Concurrency) + } + } err = Validate(cfg) if tt.wantValidate != "" { @@ -146,8 +267,20 @@ inputs: } func TestValidateMissingAudioSource(t *testing.T) { + retries := 3 + concurrency := 2 cfg := &Config{ - Pipeline: &PipelineConfig{Workspace: WorkspaceConfig{Root: "/tmp/narratio"}}, + Pipeline: &PipelineConfig{ + Workspace: WorkspaceConfig{Root: "/tmp/narratio"}, + WhisperX: WhisperXConfig{ + TranscribeURL: "https://transcription.ai.rakestrawhome.com/transcribe", + Language: "en", + Timeout: "30m", + Retries: &retries, + RetryDelay: "2s", + Concurrency: &concurrency, + }, + }, Session: &SessionConfig{ SessionID: "2026-05-03", Inputs: SessionInputsConfig{ diff --git a/internal/config/validate.go b/internal/config/validate.go index b6f42c5..c55a77e 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "net/url" "strings" "time" ) @@ -32,10 +33,10 @@ func validatePipeline(cfg *PipelineConfig) error { if strings.TrimSpace(cfg.Workspace.Root) == "" { return fmt.Errorf("pipeline.workspace.root is required") } - - if err := validateDuration("pipeline.whisperx.timeout", cfg.WhisperX.Timeout); err != nil { + if err := validateWhisperX(cfg.WhisperX); err != nil { return err } + if err := validateDuration("pipeline.seriatim.timeout", cfg.Seriatim.Timeout); err != nil { return err } @@ -52,6 +53,38 @@ func validatePipeline(cfg *PipelineConfig) error { return nil } +func validateWhisperX(cfg WhisperXConfig) error { + if strings.TrimSpace(cfg.TranscribeURL) == "" { + return fmt.Errorf("pipeline.whisperx.transcribe_url is required") + } + u, err := url.Parse(cfg.TranscribeURL) + if err != nil || u.Scheme == "" || u.Host == "" { + if err != nil { + return fmt.Errorf("pipeline.whisperx.transcribe_url must be a valid URL: %w", err) + } + return fmt.Errorf("pipeline.whisperx.transcribe_url must be a valid URL") + } + if err := validateDuration("pipeline.whisperx.timeout", cfg.Timeout); err != nil { + return err + } + if err := validateDuration("pipeline.whisperx.retry_delay", cfg.RetryDelay); err != nil { + return err + } + if cfg.Retries == nil { + return fmt.Errorf("pipeline.whisperx.retries must be set (defaults should populate this)") + } + if *cfg.Retries < 0 { + return fmt.Errorf("pipeline.whisperx.retries must be >= 0") + } + if cfg.Concurrency == nil { + return fmt.Errorf("pipeline.whisperx.concurrency must be set (defaults should populate this)") + } + if *cfg.Concurrency <= 0 { + return fmt.Errorf("pipeline.whisperx.concurrency must be > 0") + } + return nil +} + func validateSession(cfg *SessionConfig) error { if strings.TrimSpace(cfg.SessionID) == "" { return fmt.Errorf("session.session_id is required")