diff --git a/internal/cli/run.go b/internal/cli/run.go index 07b5195..f15c198 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -5,6 +5,9 @@ import ( "flag" "fmt" "io" + "strings" + + "gitea.maximumdirect.net/eric/audita/internal/core/config" ) const processNotImplementedMessage = "process command is not implemented yet" @@ -36,18 +39,83 @@ func runProcess(args []string, stdout, stderr io.Writer) int { return 0 } + cfg, err := config.LoadFromEnv() + if err != nil { + fmt.Fprintf(stderr, "audita process: invalid environment configuration: %v\n", err) + return 2 + } + fs := flag.NewFlagSet("process", flag.ContinueOnError) fs.SetOutput(stderr) glossaryPath := fs.String("glossary", "", "Path to glossary YAML file") outputPath := fs.String("output", "", "Path to corrected transcript JSON output file") reportJSONPath := fs.String("report-json", "", "Path to machine-readable report JSON output file") - modules := fs.String("modules", "", "Comma-separated module sequence override") + modules := fs.String("modules", strings.Join(cfg.Modules, ","), "Comma-separated module sequence override") + llmAPIKey := fs.String("llm-api-key", cfg.PrimaryLLM.APIKey, "Primary LLM API key") + validationLLMAPIKey := fs.String("validation-llm-api-key", cfg.ValidationLLM.APIKey, "Validation LLM API key") + model := fs.String("model", cfg.PrimaryLLM.Model, "Primary LLM model name") + validationModel := fs.String("validation-model", cfg.ValidationLLM.Model, "Validation LLM model name") + baseURL := fs.String("base-url", cfg.PrimaryLLM.BaseURL, "Primary OpenAI-compatible base URL") + validationBaseURL := fs.String("validation-base-url", cfg.ValidationLLM.BaseURL, "Validation OpenAI-compatible base URL") + llmTimeoutSeconds := fs.Int("llm-timeout-seconds", cfg.PrimaryLLM.TimeoutSeconds, "Primary LLM timeout in seconds") + validationTimeoutSecondsDefault := cfg.PrimaryLLM.TimeoutSeconds + if cfg.ValidationLLM.TimeoutSeconds != nil { + validationTimeoutSecondsDefault = *cfg.ValidationLLM.TimeoutSeconds + } + validationLLMTimeoutSeconds := fs.Int("validation-llm-timeout-seconds", validationTimeoutSecondsDefault, "Validation LLM timeout in seconds") + maxRetries := fs.Int("max-retries", cfg.PrimaryLLM.MaxRetries, "Maximum structured-output retries") + validationMaxRetriesDefault := cfg.PrimaryLLM.MaxRetries + if cfg.ValidationLLM.MaxRetries != nil { + validationMaxRetriesDefault = *cfg.ValidationLLM.MaxRetries + } + validationMaxRetries := fs.Int("validation-max-retries", validationMaxRetriesDefault, "Validation structured-output retries") + validationMaxPromptTokens := fs.Int("validation-max-prompt-tokens", cfg.ValidationMaxPromptTokens, "Validation max prompt tokens") + maxSectionTokens := fs.Int("max-section-tokens", cfg.MaxSectionTokens, "Max section tokens") + minSectionTokens := fs.Int("min-section-tokens", cfg.MinSectionTokens, "Min section tokens") + targetSectionsDefault := 0 + if cfg.TargetSections != nil { + targetSectionsDefault = *cfg.TargetSections + } + targetSections := fs.Int("target-sections", targetSectionsDefault, "Target number of transcript sections") + glossaryConfidenceThreshold := fs.Float64("glossary-confidence-threshold", cfg.Thresholds.Glossary, "Glossary confidence threshold") + grammarConfidenceThreshold := fs.Float64("grammar-confidence-threshold", cfg.Thresholds.Grammar, "Grammar confidence threshold") + homophonesConfidenceThreshold := fs.Float64("homophones-confidence-threshold", cfg.Thresholds.Homophones, "Homophones confidence threshold") + spokenWordConfidenceThreshold := fs.Float64("spoken-word-confidence-threshold", cfg.Thresholds.SpokenWord, "Spoken-word confidence threshold") + normalizeMaxSegmentGap := fs.Float64("normalize-max-segment-gap", cfg.Normalization.MaxSegmentGap, "Maximum same-speaker merge gap") + normalizeEllipsisGap := fs.Float64("normalize-ellipsis-gap", cfg.Normalization.EllipsisGap, "Gap threshold for ellipsis insertion") + normalizeMaxSegmentDuration := fs.Float64("normalize-max-segment-duration", cfg.Normalization.MaxSegmentDuration, "Maximum merged segment duration") + normalizeMaxSegmentTokens := fs.Int("normalize-max-segment-tokens", cfg.Normalization.MaxSegmentTokens, "Maximum merged segment token estimate") + workDir := fs.String("work-dir", cfg.WorkDir, "Per-run work directory") + workDirRetention := fs.String("work-dir-retention", string(cfg.WorkDirRetention), "Work-dir retention policy: auto|always|never") _ = glossaryPath _ = outputPath _ = reportJSONPath - _ = modules + _ = llmAPIKey + _ = validationLLMAPIKey + _ = model + _ = validationModel + _ = baseURL + _ = validationBaseURL + _ = llmTimeoutSeconds + _ = validationLLMTimeoutSeconds + _ = maxRetries + _ = validationMaxRetries + _ = validationMaxPromptTokens + _ = maxSectionTokens + _ = minSectionTokens + _ = targetSections + _ = glossaryConfidenceThreshold + _ = grammarConfidenceThreshold + _ = homophonesConfidenceThreshold + _ = spokenWordConfidenceThreshold + _ = normalizeMaxSegmentGap + _ = normalizeEllipsisGap + _ = normalizeMaxSegmentDuration + _ = normalizeMaxSegmentTokens + _ = workDir + _ = workDirRetention if err := fs.Parse(args); err != nil { if errors.Is(err, flag.ErrHelp) { @@ -57,6 +125,67 @@ func runProcess(args []string, stdout, stderr io.Writer) int { return 2 } + overrides := config.CLIOverrides{} + fs.Visit(func(f *flag.Flag) { + switch f.Name { + case "modules": + overrides.ModulesCSV = modules + case "llm-api-key": + overrides.PrimaryLLMAPIKey = llmAPIKey + case "validation-llm-api-key": + overrides.ValidationLLMAPIKey = validationLLMAPIKey + case "model": + overrides.PrimaryModel = model + case "validation-model": + overrides.ValidationModel = validationModel + case "base-url": + overrides.PrimaryBaseURL = baseURL + case "validation-base-url": + overrides.ValidationBaseURL = validationBaseURL + case "llm-timeout-seconds": + overrides.PrimaryLLMTimeoutSeconds = llmTimeoutSeconds + case "validation-llm-timeout-seconds": + overrides.ValidationLLMTimeoutSeconds = validationLLMTimeoutSeconds + case "max-retries": + overrides.MaxRetries = maxRetries + case "validation-max-retries": + overrides.ValidationMaxRetries = validationMaxRetries + case "validation-max-prompt-tokens": + overrides.ValidationMaxPromptTokens = validationMaxPromptTokens + case "max-section-tokens": + overrides.MaxSectionTokens = maxSectionTokens + case "min-section-tokens": + overrides.MinSectionTokens = minSectionTokens + case "target-sections": + overrides.TargetSections = targetSections + case "glossary-confidence-threshold": + overrides.GlossaryConfidenceThreshold = glossaryConfidenceThreshold + case "grammar-confidence-threshold": + overrides.GrammarConfidenceThreshold = grammarConfidenceThreshold + case "homophones-confidence-threshold": + overrides.HomophonesConfidenceThreshold = homophonesConfidenceThreshold + case "spoken-word-confidence-threshold": + overrides.SpokenWordConfidenceThreshold = spokenWordConfidenceThreshold + case "normalize-max-segment-gap": + overrides.NormalizeMaxSegmentGap = normalizeMaxSegmentGap + case "normalize-ellipsis-gap": + overrides.NormalizeEllipsisGap = normalizeEllipsisGap + case "normalize-max-segment-duration": + overrides.NormalizeMaxSegmentDuration = normalizeMaxSegmentDuration + case "normalize-max-segment-tokens": + overrides.NormalizeMaxSegmentTokens = normalizeMaxSegmentTokens + case "work-dir": + overrides.WorkDir = workDir + case "work-dir-retention": + overrides.WorkDirRetention = workDirRetention + } + }) + + if err := cfg.ApplyCLIOverrides(overrides); err != nil { + fmt.Fprintf(stderr, "audita process: invalid CLI configuration: %v\n", err) + return 2 + } + remaining := fs.Args() if len(remaining) != 1 { fmt.Fprintln(stderr, "audita process: expected exactly 1 transcript JSON path argument") @@ -121,6 +250,11 @@ func writeProcessUsage(w io.Writer) { fmt.Fprintln(w, " --output Path to corrected transcript JSON output file") fmt.Fprintln(w, " --report-json Path to machine-readable report JSON output file") fmt.Fprintln(w, " --modules Comma-separated module sequence override") + fmt.Fprintln(w, " --llm-api-key Primary LLM API key") + fmt.Fprintln(w, " --model Primary LLM model") + fmt.Fprintln(w, " --base-url Primary OpenAI-compatible API base URL") + fmt.Fprintln(w, " --work-dir Per-run work directory") + fmt.Fprintln(w, " --work-dir-retention auto|always|never") fmt.Fprintln(w) fmt.Fprintln(w, "Example:") fmt.Fprintln(w, " audita process transcript.json --glossary glossary.yaml --output corrected.json") diff --git a/internal/core/config/config.go b/internal/core/config/config.go new file mode 100644 index 0000000..7de0483 --- /dev/null +++ b/internal/core/config/config.go @@ -0,0 +1,147 @@ +package config + +import ( + "fmt" + "strings" +) + +type WorkDirRetention string + +const ( + WorkDirRetentionAuto WorkDirRetention = "auto" + WorkDirRetentionAlways WorkDirRetention = "always" + WorkDirRetentionNever WorkDirRetention = "never" +) + +const ( + DefaultModulesCSV = "glossary,homophones,glossary,spoken_word,grammar" + DefaultPrimaryModel = "openrouter/google/gemma-4-31b-it" + DefaultPrimaryBaseURL = "https://openrouter.ai/api/v1" + DefaultPrimaryLLMTimeoutSeconds = 600 + DefaultMaxRetries = 3 + DefaultValidationMaxPromptTokens = 2048 + DefaultMaxSectionTokens = 8192 + DefaultMinSectionTokens = 2048 + DefaultConfidenceThreshold = 0.8 + DefaultNormalizeMaxSegmentGap = 4.0 + DefaultNormalizeEllipsisGap = 3.5 + DefaultNormalizeMaxSegmentDuration = 60.0 + DefaultNormalizeMaxSegmentTokens = 2048 + DefaultWorkDir = "/tmp/audita" + DefaultWorkDirRetention WorkDirRetention = WorkDirRetentionAuto +) + +type Config struct { + Modules []string + PrimaryLLM LLMConfig + ValidationLLM ValidationLLMConfig + ValidationMaxPromptTokens int + MaxSectionTokens int + MinSectionTokens int + TargetSections *int + Thresholds ConfidenceThresholds + Normalization NormalizationConfig + WorkDir string + WorkDirRetention WorkDirRetention +} + +type LLMConfig struct { + APIKey string + Model string + BaseURL string + TimeoutSeconds int + MaxRetries int +} + +type ValidationLLMConfig struct { + APIKey string + Model string + BaseURL string + TimeoutSeconds *int + MaxRetries *int +} + +type ConfidenceThresholds struct { + Glossary float64 + Grammar float64 + Homophones float64 + SpokenWord float64 +} + +type NormalizationConfig struct { + MaxSegmentGap float64 + EllipsisGap float64 + MaxSegmentDuration float64 + MaxSegmentTokens int +} + +func Default() Config { + modules, _ := ParseModulesCSV(DefaultModulesCSV) + + return Config{ + Modules: modules, + PrimaryLLM: LLMConfig{ + Model: DefaultPrimaryModel, + BaseURL: DefaultPrimaryBaseURL, + TimeoutSeconds: DefaultPrimaryLLMTimeoutSeconds, + MaxRetries: DefaultMaxRetries, + }, + ValidationLLM: ValidationLLMConfig{}, + ValidationMaxPromptTokens: DefaultValidationMaxPromptTokens, + MaxSectionTokens: DefaultMaxSectionTokens, + MinSectionTokens: DefaultMinSectionTokens, + TargetSections: nil, + Thresholds: ConfidenceThresholds{ + Glossary: DefaultConfidenceThreshold, + Grammar: DefaultConfidenceThreshold, + Homophones: DefaultConfidenceThreshold, + SpokenWord: DefaultConfidenceThreshold, + }, + Normalization: NormalizationConfig{ + MaxSegmentGap: DefaultNormalizeMaxSegmentGap, + EllipsisGap: DefaultNormalizeEllipsisGap, + MaxSegmentDuration: DefaultNormalizeMaxSegmentDuration, + MaxSegmentTokens: DefaultNormalizeMaxSegmentTokens, + }, + WorkDir: DefaultWorkDir, + WorkDirRetention: DefaultWorkDirRetention, + } +} + +func ParseModulesCSV(raw string) ([]string, error) { + parts := strings.Split(raw, ",") + modules := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed == "" { + return nil, fmt.Errorf("modules list contains an empty value") + } + modules = append(modules, trimmed) + } + if len(modules) == 0 { + return nil, fmt.Errorf("modules list must not be empty") + } + return modules, nil +} + +func (c Config) EffectiveValidationLLMConfig() LLMConfig { + effective := c.PrimaryLLM + + if c.ValidationLLM.APIKey != "" { + effective.APIKey = c.ValidationLLM.APIKey + } + if c.ValidationLLM.Model != "" { + effective.Model = c.ValidationLLM.Model + } + if c.ValidationLLM.BaseURL != "" { + effective.BaseURL = c.ValidationLLM.BaseURL + } + if c.ValidationLLM.TimeoutSeconds != nil { + effective.TimeoutSeconds = *c.ValidationLLM.TimeoutSeconds + } + if c.ValidationLLM.MaxRetries != nil { + effective.MaxRetries = *c.ValidationLLM.MaxRetries + } + + return effective +} diff --git a/internal/core/config/config_test.go b/internal/core/config/config_test.go new file mode 100644 index 0000000..f9a5157 --- /dev/null +++ b/internal/core/config/config_test.go @@ -0,0 +1,215 @@ +package config + +import ( + "reflect" + "strings" + "testing" +) + +func TestDefaultConfigValues(t *testing.T) { + cfg := Default() + + if got, want := strings.Join(cfg.Modules, ","), DefaultModulesCSV; got != want { + t.Fatalf("modules mismatch: got %q want %q", got, want) + } + if cfg.PrimaryLLM.Model != DefaultPrimaryModel { + t.Fatalf("unexpected default primary model: %q", cfg.PrimaryLLM.Model) + } + if cfg.PrimaryLLM.BaseURL != DefaultPrimaryBaseURL { + t.Fatalf("unexpected default primary base url: %q", cfg.PrimaryLLM.BaseURL) + } + if cfg.PrimaryLLM.TimeoutSeconds != DefaultPrimaryLLMTimeoutSeconds { + t.Fatalf("unexpected default timeout seconds: %d", cfg.PrimaryLLM.TimeoutSeconds) + } + if cfg.PrimaryLLM.MaxRetries != DefaultMaxRetries { + t.Fatalf("unexpected default max retries: %d", cfg.PrimaryLLM.MaxRetries) + } + if cfg.ValidationLLM.TimeoutSeconds != nil { + t.Fatalf("expected validation timeout to be unset by default") + } + if cfg.ValidationLLM.MaxRetries != nil { + t.Fatalf("expected validation max retries to be unset by default") + } + if cfg.TargetSections != nil { + t.Fatalf("expected target sections to be unset by default") + } + if cfg.WorkDir != DefaultWorkDir { + t.Fatalf("unexpected default work dir: %q", cfg.WorkDir) + } + if cfg.WorkDirRetention != DefaultWorkDirRetention { + t.Fatalf("unexpected default work dir retention: %q", cfg.WorkDirRetention) + } + if err := cfg.Validate(); err != nil { + t.Fatalf("default config should validate: %v", err) + } +} + +func TestLoadFromEnvOverridesAndFallback(t *testing.T) { + env := map[string]string{ + "AUDITA_MODEL": "openai/gpt-4.1-mini", + "AUDITA_BASE_URL": "https://api.openai.com/v1", + "AUDITA_LLM_TIMEOUT_SECONDS": "120", + "AUDITA_MAX_RETRIES": "7", + "AUDITA_VALIDATION_MAX_PROMPT_TOKENS": "4096", + "AUDITA_MAX_SECTION_TOKENS": "9000", + "AUDITA_MIN_SECTION_TOKENS": "3000", + "AUDITA_TARGET_SECTIONS": "5", + "AUDITA_GLOSSARY_CONFIDENCE_THRESHOLD": "0.9", + "AUDITA_GRAMMAR_CONFIDENCE_THRESHOLD": "0.7", + "AUDITA_HOMOPHONES_CONFIDENCE_THRESHOLD": "0.6", + "AUDITA_SPOKEN_WORD_CONFIDENCE_THRESHOLD": "0.5", + "AUDITA_NORMALIZE_MAX_SEGMENT_GAP": "2.5", + "AUDITA_NORMALIZE_ELLIPSIS_GAP": "2.0", + "AUDITA_NORMALIZE_MAX_SEGMENT_DURATION": "30.0", + "AUDITA_NORMALIZE_MAX_SEGMENT_TOKENS": "1024", + "AUDITA_WORK_DIR": "/var/tmp/audita", + "AUDITA_WORK_DIR_RETENTION": "always", + "OPENROUTER_API_KEY": "fallback-key", + } + + cfg, err := loadFromLookup(mapLookup(env)) + if err != nil { + t.Fatalf("loadFromLookup returned error: %v", err) + } + + if cfg.PrimaryLLM.APIKey != "fallback-key" { + t.Fatalf("expected OPENROUTER_API_KEY fallback, got %q", cfg.PrimaryLLM.APIKey) + } + if cfg.PrimaryLLM.Model != env["AUDITA_MODEL"] { + t.Fatalf("unexpected model: %q", cfg.PrimaryLLM.Model) + } + if cfg.PrimaryLLM.BaseURL != env["AUDITA_BASE_URL"] { + t.Fatalf("unexpected base url: %q", cfg.PrimaryLLM.BaseURL) + } + if cfg.TargetSections == nil || *cfg.TargetSections != 5 { + t.Fatalf("unexpected target sections: %#v", cfg.TargetSections) + } + if cfg.WorkDirRetention != WorkDirRetentionAlways { + t.Fatalf("unexpected work dir retention: %q", cfg.WorkDirRetention) + } +} + +func TestLoadFromEnvUsesAuditaLLMAPIKeyOverFallback(t *testing.T) { + env := map[string]string{ + "AUDITA_LLM_API_KEY": "primary-key", + "OPENROUTER_API_KEY": "fallback-key", + } + + cfg, err := loadFromLookup(mapLookup(env)) + if err != nil { + t.Fatalf("loadFromLookup returned error: %v", err) + } + + if cfg.PrimaryLLM.APIKey != "primary-key" { + t.Fatalf("expected AUDITA_LLM_API_KEY to win, got %q", cfg.PrimaryLLM.APIKey) + } +} + +func TestApplyCLIOverridesPrecedence(t *testing.T) { + cfg := Default() + cfg.PrimaryLLM.Model = "env-model" + cfg.WorkDir = "/env/work" + + model := "cli-model" + workDir := "/cli/work" + modules := "grammar" + overrides := CLIOverrides{ + PrimaryModel: &model, + WorkDir: &workDir, + ModulesCSV: &modules, + } + + if err := cfg.ApplyCLIOverrides(overrides); err != nil { + t.Fatalf("ApplyCLIOverrides failed: %v", err) + } + + if cfg.PrimaryLLM.Model != "cli-model" { + t.Fatalf("expected CLI model override, got %q", cfg.PrimaryLLM.Model) + } + if cfg.WorkDir != "/cli/work" { + t.Fatalf("expected CLI work dir override, got %q", cfg.WorkDir) + } + if !reflect.DeepEqual(cfg.Modules, []string{"grammar"}) { + t.Fatalf("unexpected modules: %#v", cfg.Modules) + } +} + +func TestValidationFailures(t *testing.T) { + cfg := Default() + cfg.PrimaryLLM.TimeoutSeconds = -1 + cfg.ValidationMaxPromptTokens = 0 + cfg.MaxSectionTokens = 100 + cfg.MinSectionTokens = 200 + cfg.Thresholds.Grammar = 1.5 + cfg.WorkDirRetention = WorkDirRetention("sometimes") + + err := cfg.Validate() + if err == nil { + t.Fatalf("expected validation error") + } + + message := err.Error() + for _, expected := range []string{ + "primary llm timeout seconds", + "validation max prompt tokens", + "min section tokens", + "grammar confidence threshold", + "work dir retention", + } { + if !strings.Contains(message, expected) { + t.Fatalf("expected error to contain %q, got %q", expected, message) + } + } +} + +func TestEffectiveValidationLLMInheritance(t *testing.T) { + cfg := Default() + cfg.PrimaryLLM.APIKey = "primary-key" + cfg.PrimaryLLM.Model = "primary-model" + cfg.PrimaryLLM.BaseURL = "https://primary.example/v1" + cfg.PrimaryLLM.TimeoutSeconds = 111 + cfg.PrimaryLLM.MaxRetries = 2 + + effective := cfg.EffectiveValidationLLMConfig() + if effective.APIKey != "primary-key" || effective.Model != "primary-model" || effective.BaseURL != "https://primary.example/v1" || effective.TimeoutSeconds != 111 || effective.MaxRetries != 2 { + t.Fatalf("unexpected inherited config: %#v", effective) + } + + validationTimeout := 222 + validationRetries := 9 + cfg.ValidationLLM.APIKey = "validation-key" + cfg.ValidationLLM.Model = "validation-model" + cfg.ValidationLLM.BaseURL = "https://validation.example/v1" + cfg.ValidationLLM.TimeoutSeconds = &validationTimeout + cfg.ValidationLLM.MaxRetries = &validationRetries + + effective = cfg.EffectiveValidationLLMConfig() + if effective.APIKey != "validation-key" || effective.Model != "validation-model" || effective.BaseURL != "https://validation.example/v1" || effective.TimeoutSeconds != 222 || effective.MaxRetries != 9 { + t.Fatalf("unexpected overridden validation config: %#v", effective) + } +} + +func TestRedactedConfig(t *testing.T) { + cfg := Default() + cfg.PrimaryLLM.APIKey = "secret-primary" + cfg.ValidationLLM.APIKey = "secret-validation" + + redacted := cfg.Redacted() + + if redacted.PrimaryLLM.APIKey != redactedSecret { + t.Fatalf("expected primary api key to be redacted, got %q", redacted.PrimaryLLM.APIKey) + } + if redacted.ValidationLLM.APIKey != redactedSecret { + t.Fatalf("expected validation api key to be redacted, got %q", redacted.ValidationLLM.APIKey) + } + if cfg.PrimaryLLM.APIKey != "secret-primary" { + t.Fatalf("redaction should not mutate original config") + } +} + +func mapLookup(values map[string]string) func(string) (string, bool) { + return func(key string) (string, bool) { + value, ok := values[key] + return value, ok + } +} diff --git a/internal/core/config/env.go b/internal/core/config/env.go new file mode 100644 index 0000000..9a30ad3 --- /dev/null +++ b/internal/core/config/env.go @@ -0,0 +1,198 @@ +package config + +import ( + "fmt" + "os" + "strconv" +) + +func LoadFromEnv() (Config, error) { + return loadFromLookup(os.LookupEnv) +} + +func loadFromLookup(lookup func(string) (string, bool)) (Config, error) { + cfg := Default() + + if raw, ok := lookup("AUDITA_MODULES"); ok { + modules, err := ParseModulesCSV(raw) + if err != nil { + return Config{}, fmt.Errorf("AUDITA_MODULES: %w", err) + } + cfg.Modules = modules + } + + if raw, ok := lookup("AUDITA_LLM_API_KEY"); ok { + cfg.PrimaryLLM.APIKey = raw + } else if raw, ok := lookup("OPENROUTER_API_KEY"); ok { + cfg.PrimaryLLM.APIKey = raw + } + + if raw, ok := lookup("AUDITA_VALIDATION_LLM_API_KEY"); ok { + cfg.ValidationLLM.APIKey = raw + } + + if raw, ok := lookup("AUDITA_MODEL"); ok { + cfg.PrimaryLLM.Model = raw + } + if raw, ok := lookup("AUDITA_VALIDATION_MODEL"); ok { + cfg.ValidationLLM.Model = raw + } + + if raw, ok := lookup("AUDITA_BASE_URL"); ok { + cfg.PrimaryLLM.BaseURL = raw + } + if raw, ok := lookup("AUDITA_VALIDATION_BASE_URL"); ok { + cfg.ValidationLLM.BaseURL = raw + } + + if raw, ok := lookup("AUDITA_LLM_TIMEOUT_SECONDS"); ok { + value, err := parseInt(raw) + if err != nil { + return Config{}, fmt.Errorf("AUDITA_LLM_TIMEOUT_SECONDS: %w", err) + } + cfg.PrimaryLLM.TimeoutSeconds = value + } + + if raw, ok := lookup("AUDITA_VALIDATION_LLM_TIMEOUT_SECONDS"); ok { + value, err := parseInt(raw) + if err != nil { + return Config{}, fmt.Errorf("AUDITA_VALIDATION_LLM_TIMEOUT_SECONDS: %w", err) + } + cfg.ValidationLLM.TimeoutSeconds = &value + } + + if raw, ok := lookup("AUDITA_MAX_RETRIES"); ok { + value, err := parseInt(raw) + if err != nil { + return Config{}, fmt.Errorf("AUDITA_MAX_RETRIES: %w", err) + } + cfg.PrimaryLLM.MaxRetries = value + } + + if raw, ok := lookup("AUDITA_VALIDATION_MAX_RETRIES"); ok { + value, err := parseInt(raw) + if err != nil { + return Config{}, fmt.Errorf("AUDITA_VALIDATION_MAX_RETRIES: %w", err) + } + cfg.ValidationLLM.MaxRetries = &value + } + + if raw, ok := lookup("AUDITA_VALIDATION_MAX_PROMPT_TOKENS"); ok { + value, err := parseInt(raw) + if err != nil { + return Config{}, fmt.Errorf("AUDITA_VALIDATION_MAX_PROMPT_TOKENS: %w", err) + } + cfg.ValidationMaxPromptTokens = value + } + + if raw, ok := lookup("AUDITA_MAX_SECTION_TOKENS"); ok { + value, err := parseInt(raw) + if err != nil { + return Config{}, fmt.Errorf("AUDITA_MAX_SECTION_TOKENS: %w", err) + } + cfg.MaxSectionTokens = value + } + + if raw, ok := lookup("AUDITA_MIN_SECTION_TOKENS"); ok { + value, err := parseInt(raw) + if err != nil { + return Config{}, fmt.Errorf("AUDITA_MIN_SECTION_TOKENS: %w", err) + } + cfg.MinSectionTokens = value + } + + if raw, ok := lookup("AUDITA_TARGET_SECTIONS"); ok { + value, err := parseInt(raw) + if err != nil { + return Config{}, fmt.Errorf("AUDITA_TARGET_SECTIONS: %w", err) + } + cfg.TargetSections = &value + } + + if raw, ok := lookup("AUDITA_GLOSSARY_CONFIDENCE_THRESHOLD"); ok { + value, err := parseFloat(raw) + if err != nil { + return Config{}, fmt.Errorf("AUDITA_GLOSSARY_CONFIDENCE_THRESHOLD: %w", err) + } + cfg.Thresholds.Glossary = value + } + if raw, ok := lookup("AUDITA_GRAMMAR_CONFIDENCE_THRESHOLD"); ok { + value, err := parseFloat(raw) + if err != nil { + return Config{}, fmt.Errorf("AUDITA_GRAMMAR_CONFIDENCE_THRESHOLD: %w", err) + } + cfg.Thresholds.Grammar = value + } + if raw, ok := lookup("AUDITA_HOMOPHONES_CONFIDENCE_THRESHOLD"); ok { + value, err := parseFloat(raw) + if err != nil { + return Config{}, fmt.Errorf("AUDITA_HOMOPHONES_CONFIDENCE_THRESHOLD: %w", err) + } + cfg.Thresholds.Homophones = value + } + if raw, ok := lookup("AUDITA_SPOKEN_WORD_CONFIDENCE_THRESHOLD"); ok { + value, err := parseFloat(raw) + if err != nil { + return Config{}, fmt.Errorf("AUDITA_SPOKEN_WORD_CONFIDENCE_THRESHOLD: %w", err) + } + cfg.Thresholds.SpokenWord = value + } + + if raw, ok := lookup("AUDITA_NORMALIZE_MAX_SEGMENT_GAP"); ok { + value, err := parseFloat(raw) + if err != nil { + return Config{}, fmt.Errorf("AUDITA_NORMALIZE_MAX_SEGMENT_GAP: %w", err) + } + cfg.Normalization.MaxSegmentGap = value + } + if raw, ok := lookup("AUDITA_NORMALIZE_ELLIPSIS_GAP"); ok { + value, err := parseFloat(raw) + if err != nil { + return Config{}, fmt.Errorf("AUDITA_NORMALIZE_ELLIPSIS_GAP: %w", err) + } + cfg.Normalization.EllipsisGap = value + } + if raw, ok := lookup("AUDITA_NORMALIZE_MAX_SEGMENT_DURATION"); ok { + value, err := parseFloat(raw) + if err != nil { + return Config{}, fmt.Errorf("AUDITA_NORMALIZE_MAX_SEGMENT_DURATION: %w", err) + } + cfg.Normalization.MaxSegmentDuration = value + } + if raw, ok := lookup("AUDITA_NORMALIZE_MAX_SEGMENT_TOKENS"); ok { + value, err := parseInt(raw) + if err != nil { + return Config{}, fmt.Errorf("AUDITA_NORMALIZE_MAX_SEGMENT_TOKENS: %w", err) + } + cfg.Normalization.MaxSegmentTokens = value + } + + if raw, ok := lookup("AUDITA_WORK_DIR"); ok { + cfg.WorkDir = raw + } + if raw, ok := lookup("AUDITA_WORK_DIR_RETENTION"); ok { + cfg.WorkDirRetention = WorkDirRetention(raw) + } + + if err := cfg.Validate(); err != nil { + return Config{}, err + } + + return cfg, nil +} + +func parseInt(raw string) (int, error) { + value, err := strconv.Atoi(raw) + if err != nil { + return 0, fmt.Errorf("must be an integer") + } + return value, nil +} + +func parseFloat(raw string) (float64, error) { + value, err := strconv.ParseFloat(raw, 64) + if err != nil { + return 0, fmt.Errorf("must be a number") + } + return value, nil +} diff --git a/internal/core/config/flags.go b/internal/core/config/flags.go new file mode 100644 index 0000000..6fccdef --- /dev/null +++ b/internal/core/config/flags.go @@ -0,0 +1,119 @@ +package config + +import "fmt" + +type CLIOverrides struct { + ModulesCSV *string + PrimaryLLMAPIKey *string + ValidationLLMAPIKey *string + PrimaryModel *string + ValidationModel *string + PrimaryBaseURL *string + ValidationBaseURL *string + PrimaryLLMTimeoutSeconds *int + ValidationLLMTimeoutSeconds *int + MaxRetries *int + ValidationMaxRetries *int + ValidationMaxPromptTokens *int + MaxSectionTokens *int + MinSectionTokens *int + TargetSections *int + GlossaryConfidenceThreshold *float64 + GrammarConfidenceThreshold *float64 + HomophonesConfidenceThreshold *float64 + SpokenWordConfidenceThreshold *float64 + NormalizeMaxSegmentGap *float64 + NormalizeEllipsisGap *float64 + NormalizeMaxSegmentDuration *float64 + NormalizeMaxSegmentTokens *int + WorkDir *string + WorkDirRetention *string +} + +func (c *Config) ApplyCLIOverrides(overrides CLIOverrides) error { + if overrides.ModulesCSV != nil { + modules, err := ParseModulesCSV(*overrides.ModulesCSV) + if err != nil { + return fmt.Errorf("--modules: %w", err) + } + c.Modules = modules + } + + if overrides.PrimaryLLMAPIKey != nil { + c.PrimaryLLM.APIKey = *overrides.PrimaryLLMAPIKey + } + if overrides.ValidationLLMAPIKey != nil { + c.ValidationLLM.APIKey = *overrides.ValidationLLMAPIKey + } + if overrides.PrimaryModel != nil { + c.PrimaryLLM.Model = *overrides.PrimaryModel + } + if overrides.ValidationModel != nil { + c.ValidationLLM.Model = *overrides.ValidationModel + } + if overrides.PrimaryBaseURL != nil { + c.PrimaryLLM.BaseURL = *overrides.PrimaryBaseURL + } + if overrides.ValidationBaseURL != nil { + c.ValidationLLM.BaseURL = *overrides.ValidationBaseURL + } + if overrides.PrimaryLLMTimeoutSeconds != nil { + c.PrimaryLLM.TimeoutSeconds = *overrides.PrimaryLLMTimeoutSeconds + } + if overrides.ValidationLLMTimeoutSeconds != nil { + value := *overrides.ValidationLLMTimeoutSeconds + c.ValidationLLM.TimeoutSeconds = &value + } + if overrides.MaxRetries != nil { + c.PrimaryLLM.MaxRetries = *overrides.MaxRetries + } + if overrides.ValidationMaxRetries != nil { + value := *overrides.ValidationMaxRetries + c.ValidationLLM.MaxRetries = &value + } + if overrides.ValidationMaxPromptTokens != nil { + c.ValidationMaxPromptTokens = *overrides.ValidationMaxPromptTokens + } + if overrides.MaxSectionTokens != nil { + c.MaxSectionTokens = *overrides.MaxSectionTokens + } + if overrides.MinSectionTokens != nil { + c.MinSectionTokens = *overrides.MinSectionTokens + } + if overrides.TargetSections != nil { + value := *overrides.TargetSections + c.TargetSections = &value + } + if overrides.GlossaryConfidenceThreshold != nil { + c.Thresholds.Glossary = *overrides.GlossaryConfidenceThreshold + } + if overrides.GrammarConfidenceThreshold != nil { + c.Thresholds.Grammar = *overrides.GrammarConfidenceThreshold + } + if overrides.HomophonesConfidenceThreshold != nil { + c.Thresholds.Homophones = *overrides.HomophonesConfidenceThreshold + } + if overrides.SpokenWordConfidenceThreshold != nil { + c.Thresholds.SpokenWord = *overrides.SpokenWordConfidenceThreshold + } + if overrides.NormalizeMaxSegmentGap != nil { + c.Normalization.MaxSegmentGap = *overrides.NormalizeMaxSegmentGap + } + if overrides.NormalizeEllipsisGap != nil { + c.Normalization.EllipsisGap = *overrides.NormalizeEllipsisGap + } + if overrides.NormalizeMaxSegmentDuration != nil { + c.Normalization.MaxSegmentDuration = *overrides.NormalizeMaxSegmentDuration + } + if overrides.NormalizeMaxSegmentTokens != nil { + c.Normalization.MaxSegmentTokens = *overrides.NormalizeMaxSegmentTokens + } + if overrides.WorkDir != nil { + c.WorkDir = *overrides.WorkDir + } + if overrides.WorkDirRetention != nil { + c.WorkDirRetention = WorkDirRetention(*overrides.WorkDirRetention) + } + + return c.Validate() +} diff --git a/internal/core/config/redaction.go b/internal/core/config/redaction.go new file mode 100644 index 0000000..e28a694 --- /dev/null +++ b/internal/core/config/redaction.go @@ -0,0 +1,17 @@ +package config + +const redactedSecret = "[REDACTED]" + +func (c Config) Redacted() Config { + redacted := c + redacted.PrimaryLLM.APIKey = redactSecret(redacted.PrimaryLLM.APIKey) + redacted.ValidationLLM.APIKey = redactSecret(redacted.ValidationLLM.APIKey) + return redacted +} + +func redactSecret(value string) string { + if value == "" { + return "" + } + return redactedSecret +} diff --git a/internal/core/config/validation.go b/internal/core/config/validation.go new file mode 100644 index 0000000..da7bdd2 --- /dev/null +++ b/internal/core/config/validation.go @@ -0,0 +1,106 @@ +package config + +import ( + "fmt" + "strings" +) + +func (c Config) Validate() error { + var issues []string + + if len(c.Modules) == 0 { + issues = append(issues, "modules must not be empty") + } + for _, module := range c.Modules { + if strings.TrimSpace(module) == "" { + issues = append(issues, "modules must not contain empty values") + break + } + } + + if c.PrimaryLLM.TimeoutSeconds <= 0 { + issues = append(issues, "primary llm timeout seconds must be greater than zero") + } + if c.PrimaryLLM.MaxRetries < 0 { + issues = append(issues, "max retries must be zero or greater") + } + + if c.ValidationLLM.TimeoutSeconds != nil && *c.ValidationLLM.TimeoutSeconds <= 0 { + issues = append(issues, "validation llm timeout seconds must be greater than zero") + } + if c.ValidationLLM.MaxRetries != nil && *c.ValidationLLM.MaxRetries < 0 { + issues = append(issues, "validation max retries must be zero or greater") + } + + if c.ValidationMaxPromptTokens <= 0 { + issues = append(issues, "validation max prompt tokens must be greater than zero") + } + if c.MaxSectionTokens <= 0 { + issues = append(issues, "max section tokens must be greater than zero") + } + if c.MinSectionTokens <= 0 { + issues = append(issues, "min section tokens must be greater than zero") + } + if c.MinSectionTokens > c.MaxSectionTokens { + issues = append(issues, "min section tokens must be less than or equal to max section tokens") + } + if c.TargetSections != nil && *c.TargetSections <= 0 { + issues = append(issues, "target sections must be greater than zero when set") + } + + if err := validateConfidence("glossary", c.Thresholds.Glossary); err != nil { + issues = append(issues, err.Error()) + } + if err := validateConfidence("grammar", c.Thresholds.Grammar); err != nil { + issues = append(issues, err.Error()) + } + if err := validateConfidence("homophones", c.Thresholds.Homophones); err != nil { + issues = append(issues, err.Error()) + } + if err := validateConfidence("spoken-word", c.Thresholds.SpokenWord); err != nil { + issues = append(issues, err.Error()) + } + + if c.Normalization.MaxSegmentGap < 0 { + issues = append(issues, "normalize max segment gap must be zero or greater") + } + if c.Normalization.EllipsisGap < 0 { + issues = append(issues, "normalize ellipsis gap must be zero or greater") + } + if c.Normalization.MaxSegmentDuration <= 0 { + issues = append(issues, "normalize max segment duration must be greater than zero") + } + if c.Normalization.MaxSegmentTokens <= 0 { + issues = append(issues, "normalize max segment tokens must be greater than zero") + } + + if strings.TrimSpace(c.WorkDir) == "" { + issues = append(issues, "work dir must not be empty") + } + + if err := validateRetention(c.WorkDirRetention); err != nil { + issues = append(issues, err.Error()) + } + + if len(issues) > 0 { + return fmt.Errorf("invalid config: %s", strings.Join(issues, "; ")) + } + + return nil +} + +func validateConfidence(name string, threshold float64) error { + if threshold < 0.0 || threshold > 1.0 { + return fmt.Errorf("%s confidence threshold must be between 0.0 and 1.0", name) + } + return nil +} + +func validateRetention(retention WorkDirRetention) error { + switch retention { + case WorkDirRetentionAuto, WorkDirRetentionAlways, WorkDirRetentionNever: + return nil + default: + return fmt.Errorf("work dir retention must be one of: auto, always, never") + } +}