Add WhisperX configuration contract
This commit is contained in:
@@ -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`)
|
- `pipeline.yml`: pipeline/workspace settings (`workspace`, `storage`, `whisperx`, `seriatim`, `audita`, `analyzer`, `notification`)
|
||||||
- `session.yml`: per-session settings (`session_id`, `inputs`, optional metadata)
|
- `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.
|
Decoding is strict (`KnownFields(true)`), so unknown YAML fields fail fast.
|
||||||
|
|
||||||
Example minimal files are available under `examples/`:
|
Example minimal files are available under `examples/`:
|
||||||
|
|||||||
@@ -102,10 +102,15 @@ Key behavior:
|
|||||||
- Strict YAML decoding via `yaml.Decoder.KnownFields(true)`.
|
- Strict YAML decoding via `yaml.Decoder.KnownFields(true)`.
|
||||||
- Unknown fields are rejected.
|
- Unknown fields are rejected.
|
||||||
- Combined resolved config type keeps source paths (`PipelinePath`, `SessionPath`) for provenance/errors.
|
- 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:
|
Validation currently enforces:
|
||||||
|
|
||||||
- `pipeline.workspace.root` is required.
|
- `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.session_id` is required.
|
||||||
- `session.inputs.speakers_file`, `autocorrect_file`, `glossary_file` are 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`.
|
- At least one audio source: `session.inputs.audio_dir` or non-empty `session.inputs.audio_files`.
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ storage:
|
|||||||
backend: local
|
backend: local
|
||||||
|
|
||||||
whisperx:
|
whisperx:
|
||||||
timeout: 15m
|
transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe
|
||||||
|
language: en
|
||||||
|
timeout: 30m
|
||||||
|
retries: 3
|
||||||
|
retry_delay: 2s
|
||||||
|
concurrency: 2
|
||||||
|
|
||||||
seriatim:
|
seriatim:
|
||||||
timeout: 30s
|
timeout: 30s
|
||||||
@@ -20,4 +25,3 @@ analyzer:
|
|||||||
|
|
||||||
notification:
|
notification:
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
|
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ func writeValidConfigFiles(t *testing.T, workspaceRoot string) (string, string)
|
|||||||
storage:
|
storage:
|
||||||
backend: s3
|
backend: s3
|
||||||
whisperx:
|
whisperx:
|
||||||
timeout: 15m
|
transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe
|
||||||
seriatim:
|
seriatim:
|
||||||
timeout: 30s
|
timeout: 30s
|
||||||
audita:
|
audita:
|
||||||
|
|||||||
@@ -42,9 +42,12 @@ type StorageConfig struct {
|
|||||||
|
|
||||||
// WhisperXConfig configures WhisperX adapter settings.
|
// WhisperXConfig configures WhisperX adapter settings.
|
||||||
type WhisperXConfig struct {
|
type WhisperXConfig struct {
|
||||||
BaseURL string `yaml:"base_url"`
|
TranscribeURL string `yaml:"transcribe_url"`
|
||||||
|
Language string `yaml:"language"`
|
||||||
Timeout string `yaml:"timeout"`
|
Timeout string `yaml:"timeout"`
|
||||||
Concurrency int `yaml:"concurrency"`
|
Retries *int `yaml:"retries"`
|
||||||
|
RetryDelay string `yaml:"retry_delay"`
|
||||||
|
Concurrency *int `yaml:"concurrency"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SeriatimConfig configures seriatim adapter settings.
|
// SeriatimConfig configures seriatim adapter settings.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ func LoadPipeline(path string) (*PipelineConfig, error) {
|
|||||||
if err := decodeStrictYAML("pipeline", path, &cfg); err != nil {
|
if err := decodeStrictYAML("pipeline", path, &cfg); err != nil {
|
||||||
return nil, fmt.Errorf("load pipeline config: %w", err)
|
return nil, fmt.Errorf("load pipeline config: %w", err)
|
||||||
}
|
}
|
||||||
|
applyPipelineDefaults(&cfg)
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,3 +76,36 @@ func shortName(path, fallback string) string {
|
|||||||
}
|
}
|
||||||
return base
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,13 +14,14 @@ func TestLoadAndValidate(t *testing.T) {
|
|||||||
sessionYAML string
|
sessionYAML string
|
||||||
wantLoadErr string
|
wantLoadErr string
|
||||||
wantValidate string
|
wantValidate string
|
||||||
|
checkDefault bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "valid minimal config",
|
name: "valid minimal config",
|
||||||
pipelineYAML: `workspace:
|
pipelineYAML: `workspace:
|
||||||
root: /tmp/narratio
|
root: /tmp/narratio
|
||||||
whisperx:
|
whisperx:
|
||||||
timeout: 10m
|
transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe
|
||||||
seriatim:
|
seriatim:
|
||||||
timeout: 30s
|
timeout: 30s
|
||||||
audita:
|
audita:
|
||||||
@@ -37,6 +38,7 @@ inputs:
|
|||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
`,
|
`,
|
||||||
|
checkDefault: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "unknown pipeline field fails",
|
name: "unknown pipeline field fails",
|
||||||
@@ -53,6 +55,23 @@ inputs:
|
|||||||
`,
|
`,
|
||||||
wantLoadErr: "pipeline file",
|
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",
|
name: "unknown session field fails",
|
||||||
pipelineYAML: `workspace:
|
pipelineYAML: `workspace:
|
||||||
@@ -72,6 +91,8 @@ unknown_field: true
|
|||||||
name: "missing required field fails",
|
name: "missing required field fails",
|
||||||
pipelineYAML: `workspace:
|
pipelineYAML: `workspace:
|
||||||
root: /tmp/narratio
|
root: /tmp/narratio
|
||||||
|
whisperx:
|
||||||
|
transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe
|
||||||
`,
|
`,
|
||||||
sessionYAML: `session_id: ""
|
sessionYAML: `session_id: ""
|
||||||
inputs:
|
inputs:
|
||||||
@@ -82,11 +103,43 @@ inputs:
|
|||||||
`,
|
`,
|
||||||
wantValidate: "session config \"session.yml\" invalid: session.session_id is required",
|
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",
|
name: "invalid timeout fails",
|
||||||
pipelineYAML: `workspace:
|
pipelineYAML: `workspace:
|
||||||
root: /tmp/narratio
|
root: /tmp/narratio
|
||||||
whisperx:
|
whisperx:
|
||||||
|
transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe
|
||||||
timeout: definitely-not-a-duration
|
timeout: definitely-not-a-duration
|
||||||
`,
|
`,
|
||||||
sessionYAML: `session_id: 2026-05-03
|
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",
|
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 {
|
for _, tt := range tests {
|
||||||
@@ -127,6 +231,23 @@ inputs:
|
|||||||
if cfg.SessionPath != sessionPath {
|
if cfg.SessionPath != sessionPath {
|
||||||
t.Fatalf("SessionPath = %q, want %q", 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)
|
err = Validate(cfg)
|
||||||
if tt.wantValidate != "" {
|
if tt.wantValidate != "" {
|
||||||
@@ -146,8 +267,20 @@ inputs:
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateMissingAudioSource(t *testing.T) {
|
func TestValidateMissingAudioSource(t *testing.T) {
|
||||||
|
retries := 3
|
||||||
|
concurrency := 2
|
||||||
cfg := &Config{
|
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{
|
Session: &SessionConfig{
|
||||||
SessionID: "2026-05-03",
|
SessionID: "2026-05-03",
|
||||||
Inputs: SessionInputsConfig{
|
Inputs: SessionInputsConfig{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -32,10 +33,10 @@ func validatePipeline(cfg *PipelineConfig) error {
|
|||||||
if strings.TrimSpace(cfg.Workspace.Root) == "" {
|
if strings.TrimSpace(cfg.Workspace.Root) == "" {
|
||||||
return fmt.Errorf("pipeline.workspace.root is required")
|
return fmt.Errorf("pipeline.workspace.root is required")
|
||||||
}
|
}
|
||||||
|
if err := validateWhisperX(cfg.WhisperX); err != nil {
|
||||||
if err := validateDuration("pipeline.whisperx.timeout", cfg.WhisperX.Timeout); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateDuration("pipeline.seriatim.timeout", cfg.Seriatim.Timeout); err != nil {
|
if err := validateDuration("pipeline.seriatim.timeout", cfg.Seriatim.Timeout); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -52,6 +53,38 @@ func validatePipeline(cfg *PipelineConfig) error {
|
|||||||
return nil
|
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 {
|
func validateSession(cfg *SessionConfig) error {
|
||||||
if strings.TrimSpace(cfg.SessionID) == "" {
|
if strings.TrimSpace(cfg.SessionID) == "" {
|
||||||
return fmt.Errorf("session.session_id is required")
|
return fmt.Errorf("session.session_id is required")
|
||||||
|
|||||||
Reference in New Issue
Block a user