From 7aedca2ca1af6f2e405d47004a3966b2d0ce4308 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 3 May 2026 19:13:50 -0500 Subject: [PATCH] Add Seriatim configuration contract --- README.md | 12 ++ architecture.md | 14 +++ examples/pipeline.minimal.yml | 11 +- internal/app/commands_test.go | 6 +- internal/config/config.go | 17 ++- internal/config/load.go | 29 +++++ internal/config/load_validate_test.go | 169 +++++++++++++++++++++++++- internal/config/validate.go | 41 ++++++- 8 files changed, 291 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 07024e3..6e2d483 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,18 @@ 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`) +Seriatim config contract in `pipeline.yml`: + +- required: `seriatim.binary` (name or path; existence is checked at execution time, not config validation time) +- defaulted when omitted: `seriatim.timeout` (`10m`), `seriatim.output_schema` (`seriatim-intermediate`), `seriatim.coalesce_gap` (`3.0`), `seriatim.report` (`true`) +- allowed `seriatim.output_schema` values: `seriatim-minimal`, `seriatim-intermediate`, `seriatim-full` +- optional tuning: `seriatim.env.*` (`overlap_word_run_gap`, `overlap_word_run_reorder_window`, `backchannel_max_duration`, `filler_max_duration`) must be `> 0` when provided + +`speakers.yml` note: + +- use Seriatim’s documented `match:` format (not the legacy direct mapping style used by older scripts/scaffolds) +- TODO: add a concrete `speakers.yml` example once the Seriatim README/spec is vendored or linked in-repo. + 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 7f4b76f..25068b3 100644 --- a/architecture.md +++ b/architecture.md @@ -115,6 +115,15 @@ WhisperX config keys: - `pipeline.whisperx.retry_delay` (default: `2s`) - `pipeline.whisperx.concurrency` (default: `2`) +Seriatim config keys: + +- `pipeline.seriatim.binary` (required) +- `pipeline.seriatim.timeout` (default: `10m`) +- `pipeline.seriatim.output_schema` (default: `seriatim-intermediate`; allowed: `seriatim-minimal|seriatim-intermediate|seriatim-full`) +- `pipeline.seriatim.coalesce_gap` (default: `3.0`, must be `>= 0`) +- `pipeline.seriatim.report` (default: `true`) +- optional tuning under `pipeline.seriatim.env.*` (must be `> 0` when provided) + Validation currently enforces: - `pipeline.workspace.root` is required. @@ -122,6 +131,11 @@ Validation currently enforces: - `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`. +- `pipeline.seriatim.binary` is required. +- `pipeline.seriatim.timeout` must parse as Go duration. +- `pipeline.seriatim.output_schema` must be one of `seriatim-minimal|seriatim-intermediate|seriatim-full`. +- `pipeline.seriatim.coalesce_gap` must be `>= 0`. +- optional `pipeline.seriatim.env.*` values must be `> 0` when provided. - `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 8d1386f..6a10e88 100644 --- a/examples/pipeline.minimal.yml +++ b/examples/pipeline.minimal.yml @@ -13,7 +13,16 @@ whisperx: concurrency: 2 seriatim: - timeout: 30s + binary: "seriatim" + timeout: "10m" + output_schema: "seriatim-intermediate" + coalesce_gap: 3.0 + report: true + env: + overlap_word_run_gap: 1.0 + overlap_word_run_reorder_window: 1.0 + backchannel_max_duration: 2.0 + filler_max_duration: 1.25 audita: timeout: 1h diff --git a/internal/app/commands_test.go b/internal/app/commands_test.go index 846c9ea..8d85dce 100644 --- a/internal/app/commands_test.go +++ b/internal/app/commands_test.go @@ -207,7 +207,11 @@ whisperx: retry_delay: 1ms concurrency: 1 seriatim: - timeout: 30s + binary: seriatim + timeout: 10m + output_schema: seriatim-intermediate + coalesce_gap: 3.0 + report: true audita: timeout: 1h analyzer: diff --git a/internal/config/config.go b/internal/config/config.go index 8ce83d4..8901e50 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -52,9 +52,20 @@ type WhisperXConfig struct { // SeriatimConfig configures seriatim adapter settings. type SeriatimConfig struct { - BinaryPath string `yaml:"binary_path"` - Timeout string `yaml:"timeout"` - Args []string `yaml:"args"` + Binary string `yaml:"binary"` + Timeout string `yaml:"timeout"` + OutputSchema string `yaml:"output_schema"` + CoalesceGap *float64 `yaml:"coalesce_gap"` + Report *bool `yaml:"report"` + Env SeriatimEnvConfig `yaml:"env"` +} + +// SeriatimEnvConfig configures optional advanced seriatim environment tuning. +type SeriatimEnvConfig struct { + OverlapWordRunGap *float64 `yaml:"overlap_word_run_gap"` + OverlapWordRunReorderWindow *float64 `yaml:"overlap_word_run_reorder_window"` + BackchannelMaxDuration *float64 `yaml:"backchannel_max_duration"` + FillerMaxDuration *float64 `yaml:"filler_max_duration"` } // AuditaConfig configures audita adapter settings. diff --git a/internal/config/load.go b/internal/config/load.go index 9379364..a132dad 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -82,6 +82,7 @@ func applyPipelineDefaults(cfg *PipelineConfig) { return } applyWhisperXDefaults(&cfg.WhisperX) + applySeriatimDefaults(&cfg.Seriatim) } func applyWhisperXDefaults(cfg *WhisperXConfig) { @@ -109,3 +110,31 @@ func intPtr(v int) *int { p := v return &p } + +func applySeriatimDefaults(cfg *SeriatimConfig) { + if cfg == nil { + return + } + if cfg.Timeout == "" { + cfg.Timeout = "10m" + } + if cfg.OutputSchema == "" { + cfg.OutputSchema = "seriatim-intermediate" + } + if cfg.CoalesceGap == nil { + cfg.CoalesceGap = float64Ptr(3.0) + } + if cfg.Report == nil { + cfg.Report = boolPtr(true) + } +} + +func float64Ptr(v float64) *float64 { + p := v + return &p +} + +func boolPtr(v bool) *bool { + p := v + return &p +} diff --git a/internal/config/load_validate_test.go b/internal/config/load_validate_test.go index bf59c33..46bd4f7 100644 --- a/internal/config/load_validate_test.go +++ b/internal/config/load_validate_test.go @@ -23,7 +23,7 @@ func TestLoadAndValidate(t *testing.T) { whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe seriatim: - timeout: 30s + binary: seriatim audita: timeout: 1h analyzer: @@ -93,6 +93,8 @@ unknown_field: true root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe +seriatim: + binary: seriatim `, sessionYAML: `session_id: "" inputs: @@ -108,6 +110,8 @@ inputs: pipelineYAML: `workspace: root: /tmp/narratio whisperx: +seriatim: + binary: seriatim `, sessionYAML: `session_id: 2026-05-03 inputs: @@ -124,6 +128,8 @@ inputs: root: /tmp/narratio whisperx: transcribe_url: :// +seriatim: + binary: seriatim `, sessionYAML: `session_id: 2026-05-03 inputs: @@ -141,6 +147,8 @@ inputs: whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe timeout: definitely-not-a-duration +seriatim: + binary: seriatim `, sessionYAML: `session_id: 2026-05-03 inputs: @@ -158,6 +166,8 @@ inputs: whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe retry_delay: not-a-duration +seriatim: + binary: seriatim `, sessionYAML: `session_id: 2026-05-03 inputs: @@ -175,6 +185,8 @@ inputs: whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe retries: -1 +seriatim: + binary: seriatim `, sessionYAML: `session_id: 2026-05-03 inputs: @@ -192,6 +204,8 @@ inputs: whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe concurrency: 0 +seriatim: + binary: seriatim `, sessionYAML: `session_id: 2026-05-03 inputs: @@ -202,6 +216,140 @@ inputs: `, wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.concurrency must be > 0", }, + { + name: "unknown seriatim field fails", + pipelineYAML: `workspace: + root: /tmp/narratio +whisperx: + transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe +seriatim: + binary: seriatim + 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 seriatim env field fails", + pipelineYAML: `workspace: + root: /tmp/narratio +whisperx: + transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe +seriatim: + binary: seriatim + env: + unknown_knob: 1.0 +`, + 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: "missing seriatim binary fails", + pipelineYAML: `workspace: + root: /tmp/narratio +whisperx: + transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe +seriatim: + timeout: 10m +`, + 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.seriatim.binary is required", + }, + { + name: "invalid seriatim timeout fails", + pipelineYAML: `workspace: + root: /tmp/narratio +whisperx: + transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe +seriatim: + binary: seriatim + timeout: 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.seriatim.timeout must be a valid duration", + }, + { + name: "invalid seriatim output schema fails", + pipelineYAML: `workspace: + root: /tmp/narratio +whisperx: + transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe +seriatim: + binary: seriatim + output_schema: invalid-schema +`, + 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.seriatim.output_schema must be one of: seriatim-minimal, seriatim-intermediate, seriatim-full", + }, + { + name: "negative seriatim coalesce gap fails", + pipelineYAML: `workspace: + root: /tmp/narratio +whisperx: + transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe +seriatim: + binary: seriatim + coalesce_gap: -0.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.seriatim.coalesce_gap must be >= 0", + }, + { + name: "invalid seriatim env float fails", + pipelineYAML: `workspace: + root: /tmp/narratio +whisperx: + transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe +seriatim: + binary: seriatim + env: + overlap_word_run_gap: 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.seriatim.env.overlap_word_run_gap must be > 0 when provided", + }, } for _, tt := range tests { @@ -247,6 +395,18 @@ inputs: if cfg.Pipeline.WhisperX.Concurrency == nil || *cfg.Pipeline.WhisperX.Concurrency != 2 { t.Fatalf("whisperx.concurrency = %v, want 2", cfg.Pipeline.WhisperX.Concurrency) } + if cfg.Pipeline.Seriatim.Timeout != "10m" { + t.Fatalf("seriatim.timeout = %q, want %q", cfg.Pipeline.Seriatim.Timeout, "10m") + } + if cfg.Pipeline.Seriatim.OutputSchema != "seriatim-intermediate" { + t.Fatalf("seriatim.output_schema = %q, want %q", cfg.Pipeline.Seriatim.OutputSchema, "seriatim-intermediate") + } + if cfg.Pipeline.Seriatim.CoalesceGap == nil || *cfg.Pipeline.Seriatim.CoalesceGap != 3.0 { + t.Fatalf("seriatim.coalesce_gap = %v, want 3.0", cfg.Pipeline.Seriatim.CoalesceGap) + } + if cfg.Pipeline.Seriatim.Report == nil || *cfg.Pipeline.Seriatim.Report != true { + t.Fatalf("seriatim.report = %v, want true", cfg.Pipeline.Seriatim.Report) + } } err = Validate(cfg) @@ -280,6 +440,13 @@ func TestValidateMissingAudioSource(t *testing.T) { RetryDelay: "2s", Concurrency: &concurrency, }, + Seriatim: SeriatimConfig{ + Binary: "seriatim", + Timeout: "10m", + OutputSchema: "seriatim-intermediate", + CoalesceGap: float64Ptr(3.0), + Report: boolPtr(true), + }, }, Session: &SessionConfig{ SessionID: "2026-05-03", diff --git a/internal/config/validate.go b/internal/config/validate.go index c55a77e..6e0df67 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -36,8 +36,7 @@ func validatePipeline(cfg *PipelineConfig) error { if err := validateWhisperX(cfg.WhisperX); err != nil { return err } - - if err := validateDuration("pipeline.seriatim.timeout", cfg.Seriatim.Timeout); err != nil { + if err := validateSeriatim(cfg.Seriatim); err != nil { return err } if err := validateDuration("pipeline.audita.timeout", cfg.Audita.Timeout); err != nil { @@ -85,6 +84,44 @@ func validateWhisperX(cfg WhisperXConfig) error { return nil } +func validateSeriatim(cfg SeriatimConfig) error { + if strings.TrimSpace(cfg.Binary) == "" { + return fmt.Errorf("pipeline.seriatim.binary is required") + } + if err := validateDuration("pipeline.seriatim.timeout", cfg.Timeout); err != nil { + return err + } + + switch cfg.OutputSchema { + case "seriatim-minimal", "seriatim-intermediate", "seriatim-full": + default: + return fmt.Errorf("pipeline.seriatim.output_schema must be one of: seriatim-minimal, seriatim-intermediate, seriatim-full") + } + + if cfg.CoalesceGap == nil { + return fmt.Errorf("pipeline.seriatim.coalesce_gap must be set (defaults should populate this)") + } + if *cfg.CoalesceGap < 0 { + return fmt.Errorf("pipeline.seriatim.coalesce_gap must be >= 0") + } + + for _, item := range []struct { + name string + value *float64 + }{ + {name: "pipeline.seriatim.env.overlap_word_run_gap", value: cfg.Env.OverlapWordRunGap}, + {name: "pipeline.seriatim.env.overlap_word_run_reorder_window", value: cfg.Env.OverlapWordRunReorderWindow}, + {name: "pipeline.seriatim.env.backchannel_max_duration", value: cfg.Env.BackchannelMaxDuration}, + {name: "pipeline.seriatim.env.filler_max_duration", value: cfg.Env.FillerMaxDuration}, + } { + if item.value != nil && *item.value <= 0 { + return fmt.Errorf("%s must be > 0 when provided", item.name) + } + } + + return nil +} + func validateSession(cfg *SessionConfig) error { if strings.TrimSpace(cfg.SessionID) == "" { return fmt.Errorf("session.session_id is required")