319 lines
10 KiB
Go
319 lines
10 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Validate checks resolved configuration for required fields and parseable durations.
|
|
func Validate(cfg *Config) error {
|
|
if cfg == nil {
|
|
return fmt.Errorf("config is nil")
|
|
}
|
|
if cfg.Pipeline == nil {
|
|
return fmt.Errorf("pipeline config is required")
|
|
}
|
|
if cfg.Session == nil {
|
|
return fmt.Errorf("session config is required")
|
|
}
|
|
|
|
if err := validatePipeline(cfg.Pipeline); err != nil {
|
|
return fmt.Errorf("pipeline config %q invalid: %w", shortName(cfg.PipelinePath, "pipeline.yml"), err)
|
|
}
|
|
if err := validateSession(cfg.Session); err != nil {
|
|
return fmt.Errorf("session config %q invalid: %w", shortName(cfg.SessionPath, "session.yml"), err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validatePipeline(cfg *PipelineConfig) error {
|
|
if strings.TrimSpace(cfg.Workspace.Root) == "" {
|
|
return fmt.Errorf("pipeline.workspace.root is required")
|
|
}
|
|
if err := validateWhisperX(cfg.WhisperX); err != nil {
|
|
return err
|
|
}
|
|
if err := validateSeriatim(cfg.Seriatim); err != nil {
|
|
return err
|
|
}
|
|
if err := validateAudita(cfg.Audita); err != nil {
|
|
return err
|
|
}
|
|
if err := validateNormalize(cfg.Normalize); err != nil {
|
|
return err
|
|
}
|
|
if err := validateTrim(cfg.Trim); err != nil {
|
|
return err
|
|
}
|
|
if err := validateScriptorium(cfg.Scriptorium); err != nil {
|
|
return err
|
|
}
|
|
if err := validateDuration("pipeline.analyzer.timeout", cfg.Analyzer.Timeout); err != nil {
|
|
return err
|
|
}
|
|
if err := validateDuration("pipeline.notification.timeout", cfg.Notification.Timeout); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateNormalize(cfg *NormalizeConfig) error {
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
if strings.TrimSpace(cfg.OutputPath) == "" {
|
|
return fmt.Errorf("pipeline.normalize.output_path must be non-empty")
|
|
}
|
|
if err := validateSeriatimOutputSchema("pipeline.normalize.output_schema", cfg.OutputSchema); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateTrim(cfg *TrimConfig) error {
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
if !cfg.Enabled {
|
|
return nil
|
|
}
|
|
|
|
if strings.TrimSpace(cfg.OutputPath) == "" {
|
|
return fmt.Errorf("pipeline.trim.output_path is required when pipeline.trim.enabled is true")
|
|
}
|
|
if strings.TrimSpace(cfg.Bounds.PromptID) == "" {
|
|
return fmt.Errorf("pipeline.trim.bounds.prompt_id is required when pipeline.trim.enabled is true")
|
|
}
|
|
if strings.TrimSpace(cfg.Bounds.TranscriptInputName) == "" {
|
|
return fmt.Errorf("pipeline.trim.bounds.transcript_input_name is required when pipeline.trim.enabled is true")
|
|
}
|
|
if strings.TrimSpace(cfg.Bounds.OutputPath) == "" {
|
|
return fmt.Errorf("pipeline.trim.bounds.output_path is required when pipeline.trim.enabled is true")
|
|
}
|
|
if err := validateDuration("pipeline.trim.bounds.timeout", cfg.Bounds.Timeout); err != nil {
|
|
return err
|
|
}
|
|
if cfg.Bounds.RenderDebug && strings.TrimSpace(cfg.Bounds.RenderOutputPath) == "" {
|
|
return fmt.Errorf("pipeline.trim.bounds.render_output_path is required when pipeline.trim.bounds.render_debug is true")
|
|
}
|
|
|
|
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 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
|
|
}
|
|
if err := validateSeriatimOutputSchema("pipeline.seriatim.output_schema", cfg.OutputSchema); err != nil {
|
|
return err
|
|
}
|
|
|
|
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 validateSeriatimOutputSchema(field, value string) error {
|
|
switch strings.TrimSpace(value) {
|
|
case "seriatim-minimal", "seriatim-intermediate", "seriatim-full":
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("%s must be one of: seriatim-minimal, seriatim-intermediate, seriatim-full", field)
|
|
}
|
|
}
|
|
|
|
func validateAudita(cfg AuditaConfig) error {
|
|
if strings.TrimSpace(cfg.Binary) == "" {
|
|
return fmt.Errorf("pipeline.audita.binary is required")
|
|
}
|
|
if err := validateDuration("pipeline.audita.timeout", cfg.Timeout); err != nil {
|
|
return err
|
|
}
|
|
if len(cfg.Modules) == 0 {
|
|
return fmt.Errorf("pipeline.audita.modules must include at least one module")
|
|
}
|
|
for i, mod := range cfg.Modules {
|
|
m := strings.TrimSpace(mod)
|
|
if m == "" {
|
|
return fmt.Errorf("pipeline.audita.modules[%d] must be non-empty", i)
|
|
}
|
|
switch m {
|
|
case "glossary", "homophones", "spoken_word", "grammar":
|
|
default:
|
|
return fmt.Errorf("pipeline.audita.modules[%d] must be one of: glossary, homophones, spoken_word, grammar", i)
|
|
}
|
|
}
|
|
if strings.TrimSpace(cfg.BaseURL) != "" {
|
|
u, err := url.Parse(cfg.BaseURL)
|
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
|
if err != nil {
|
|
return fmt.Errorf("pipeline.audita.base_url must be a valid URL: %w", err)
|
|
}
|
|
return fmt.Errorf("pipeline.audita.base_url must be a valid URL")
|
|
}
|
|
}
|
|
if strings.TrimSpace(cfg.Model) == "" {
|
|
return fmt.Errorf("pipeline.audita.model is required")
|
|
}
|
|
if cfg.LLMConcurrency == nil {
|
|
return fmt.Errorf("pipeline.audita.llm_concurrency must be set (defaults should populate this)")
|
|
}
|
|
if *cfg.LLMConcurrency <= 0 {
|
|
return fmt.Errorf("pipeline.audita.llm_concurrency must be > 0")
|
|
}
|
|
if cfg.ValidationLLMConcurrency == nil {
|
|
return fmt.Errorf("pipeline.audita.validation_llm_concurrency must be set (defaults should populate this)")
|
|
}
|
|
if *cfg.ValidationLLMConcurrency <= 0 {
|
|
return fmt.Errorf("pipeline.audita.validation_llm_concurrency must be > 0")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateScriptorium(cfg *ScriptoriumConfig) error {
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
if strings.TrimSpace(cfg.Binary) == "" {
|
|
return fmt.Errorf("pipeline.scriptorium.binary is required when pipeline.scriptorium is configured")
|
|
}
|
|
if cfg.ConfigPath != "" && strings.TrimSpace(cfg.ConfigPath) == "" {
|
|
return fmt.Errorf("pipeline.scriptorium.config_path must be non-empty when provided")
|
|
}
|
|
if err := validateDuration("pipeline.scriptorium.timeout", cfg.Timeout); err != nil {
|
|
return err
|
|
}
|
|
|
|
for artifactName, artifactCfg := range cfg.Artifacts {
|
|
trimmedArtifactName := strings.TrimSpace(artifactName)
|
|
if trimmedArtifactName == "" {
|
|
return fmt.Errorf("pipeline.scriptorium.artifacts keys must be non-empty")
|
|
}
|
|
if artifactCfg.Enabled && strings.TrimSpace(artifactCfg.PromptID) == "" {
|
|
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.prompt_id is required when enabled", artifactName)
|
|
}
|
|
if artifactCfg.Enabled && strings.TrimSpace(artifactCfg.OutputPath) == "" {
|
|
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.output_path is required when enabled", artifactName)
|
|
}
|
|
if err := validateDuration("pipeline.scriptorium.artifacts."+artifactName+".timeout", artifactCfg.Timeout); err != nil {
|
|
return err
|
|
}
|
|
for inputName, inputCfg := range artifactCfg.Inputs {
|
|
trimmedInputName := strings.TrimSpace(inputName)
|
|
if trimmedInputName == "" {
|
|
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.inputs keys must be non-empty", artifactName)
|
|
}
|
|
if strings.TrimSpace(inputCfg.Source) == "" {
|
|
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.inputs.%s.source is required", artifactName, inputName)
|
|
}
|
|
}
|
|
for varName, varValue := range artifactCfg.Vars {
|
|
if strings.TrimSpace(varName) == "" {
|
|
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.vars keys must be non-empty", artifactName)
|
|
}
|
|
switch varValue.(type) {
|
|
case bool, string:
|
|
default:
|
|
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.vars.%s must be a string or boolean", artifactName, varName)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateSession(cfg *SessionConfig) error {
|
|
if strings.TrimSpace(cfg.SessionID) == "" {
|
|
return fmt.Errorf("session.session_id is required")
|
|
}
|
|
|
|
if strings.TrimSpace(cfg.Inputs.SpeakersFile) == "" {
|
|
return fmt.Errorf("session.inputs.speakers_file is required")
|
|
}
|
|
if strings.TrimSpace(cfg.Inputs.AutocorrectFile) == "" {
|
|
return fmt.Errorf("session.inputs.autocorrect_file is required")
|
|
}
|
|
if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" {
|
|
return fmt.Errorf("session.inputs.glossary_file is required")
|
|
}
|
|
|
|
hasAudioDir := strings.TrimSpace(cfg.Inputs.AudioDir) != ""
|
|
hasAudioFiles := len(cfg.Inputs.AudioFiles) > 0
|
|
if !hasAudioDir && !hasAudioFiles {
|
|
return fmt.Errorf("session.inputs requires audio_dir or at least one audio_files entry")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateDuration(fieldName, value string) error {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
return nil
|
|
}
|
|
|
|
if _, err := time.ParseDuration(trimmed); err != nil {
|
|
return fmt.Errorf("%s must be a valid duration: %w", fieldName, err)
|
|
}
|
|
|
|
return nil
|
|
}
|