package config import ( "fmt" "strings" "time" ck "gitea.maximumdirect.net/eric/ConfigKit" "gitea.maximumdirect.net/eric/audita/internal/core/modulecatalog" "gitea.maximumdirect.net/eric/audita/internal/core/outputschema" ) const ( DefaultModulesCSV = "glossary,homophones,glossary,spoken_word,grammar" DefaultTranscriptDescriptionMaxChars = 500 DefaultValidationMaxPromptTokens = 2048 ) // Config is the version-2 application configuration. Invocation paths are not settings. type Config struct { Version int `yaml:"version"` ProfilesDir string `yaml:"profiles_dir"` Backends map[string]Backend `yaml:"backends"` LLM LLM `yaml:"llm"` Pipeline Pipeline `yaml:"pipeline"` Output Output `yaml:"output"` Validation Validation `yaml:"validation"` Chunking Chunking `yaml:"chunking"` Normalization Normalization `yaml:"normalization"` Thresholds Thresholds `yaml:"thresholds"` Context Context `yaml:"context"` Reporting Reporting `yaml:"reporting"` Diagnostics Diagnostics `yaml:"diagnostics"` } type Backend struct { Endpoint string `yaml:"endpoint"` Concurrency int `yaml:"concurrency"` QueueCapacity int `yaml:"queue_capacity"` Credential *ck.Secret `yaml:"credential"` } type LLM struct { ProposalProfile string `yaml:"proposal_profile"` ValidationProfile string `yaml:"validation_profile"` TransientRetries int `yaml:"transient_retries"` RepairAttempts int `yaml:"repair_attempts"` OperationTimeout time.Duration `yaml:"operation_timeout"` } type Pipeline struct { Modules []string `yaml:"modules"` } type Output struct { Schema string `yaml:"schema"` } type Validation struct { MaxPromptTokens int `yaml:"max_prompt_tokens"` } type Chunking struct { TargetSections *int `yaml:"target_sections"` MaxSectionTokens int `yaml:"max_section_tokens"` MinSectionTokens int `yaml:"min_section_tokens"` } type Normalization struct { MaxSegmentGap float64 `yaml:"max_segment_gap"` EllipsisGap float64 `yaml:"ellipsis_gap"` MaxSegmentDuration float64 `yaml:"max_segment_duration"` MaxSegmentTokens int `yaml:"max_segment_tokens"` } type Thresholds struct { Glossary float64 `yaml:"glossary"` Homophones float64 `yaml:"homophones"` SpokenWord float64 `yaml:"spoken_word"` Grammar float64 `yaml:"grammar"` } type Context struct { Description string `yaml:"description"` } type Diagnostics struct { Debug bool `yaml:"debug"` WorkDir string `yaml:"work_dir"` } type Reporting struct { Enabled bool `yaml:"enabled"` StdoutDir string `yaml:"stdout_dir"` } func defaults() Config { return Config{ ProfilesDir: "profiles", LLM: LLM{TransientRetries: 2, RepairAttempts: 2, OperationTimeout: 15 * time.Minute}, Pipeline: Pipeline{Modules: []string{"glossary", "homophones", "glossary", "spoken_word", "grammar"}}, Output: Output{Schema: "bare-segments"}, Validation: Validation{MaxPromptTokens: DefaultValidationMaxPromptTokens}, Chunking: Chunking{MaxSectionTokens: 8192, MinSectionTokens: 2048}, Normalization: Normalization{MaxSegmentGap: 4, EllipsisGap: 3.5, MaxSegmentDuration: 60, MaxSegmentTokens: 2048}, Thresholds: Thresholds{Glossary: .8, Homophones: .8, SpokenWord: .8, Grammar: .8}, Reporting: Reporting{Enabled: true, StdoutDir: "."}, Diagnostics: Diagnostics{WorkDir: "/tmp/audita"}, } } // Load resolves the selected main file, optional overlay, and explicit setting overrides. // Its result retains safe effective output and provenance for CLI composition. func Load(source ck.Source, overlay string, overrides ...ck.Override) (*ck.Result[Config], error) { return ck.Load(source, ck.Options[Config]{ Overlay: overlay, Overrides: overrides, Definition: ck.Definition[Config]{ Defaults: defaults, Version: &ck.VersionRule{Path: ck.Keys("version"), Check: func(version ck.Version, v *ck.Validation) error { if !version.Present || version.Null || version.Text != "2" { v.Error(ck.Keys("version"), "version 2 is required; see docs/migrations/v2.md") } return nil }}, Resolve: func(c *Config, r *ck.Resolution) error { ck.Default(r, ck.Keys("llm", "validation_profile"), &c.LLM.ValidationProfile, c.LLM.ProposalProfile, "validation uses the final proposal profile when omitted", ck.Keys("llm", "proposal_profile")) for id, backend := range c.Backends { changed := false if !r.IsExplicit(ck.Keys("backends", id, "concurrency")) { backend.Concurrency = 1 changed = true } if !r.IsExplicit(ck.Keys("backends", id, "queue_capacity")) { backend.QueueCapacity = 1024 changed = true } if changed { c.Backends[id] = backend r.Record(ck.Keys("backends", id), ck.ChangeDerivedDefault, "finite backend defaults") } } return nil }, Validate: validate, }, }) } func validate(c *Config, v *ck.Validation) { check := func(ok bool, path ck.Path, message string) { if !ok { v.Error(path, message) } } check(strings.TrimSpace(c.ProfilesDir) != "", ck.Keys("profiles_dir"), "profile directory is required") check(strings.TrimSpace(c.LLM.ProposalProfile) != "", ck.Keys("llm", "proposal_profile"), "proposal profile is required") check(strings.TrimSpace(c.LLM.ValidationProfile) != "", ck.Keys("llm", "validation_profile"), "validation profile is required") check(c.LLM.TransientRetries >= 0, ck.Keys("llm", "transient_retries"), "retry budget must be non-negative") check(c.LLM.RepairAttempts >= 0 && c.LLM.RepairAttempts <= 3, ck.Keys("llm", "repair_attempts"), "repair attempts must be between zero and three") check(c.LLM.OperationTimeout > 0, ck.Keys("llm", "operation_timeout"), "operation timeout must be positive") check(len(c.Backends) > 0, ck.Keys("backends"), "at least one backend is required") for id, b := range c.Backends { p := ck.Keys("backends", id) check(strings.TrimSpace(b.Endpoint) != "", p.Key("endpoint"), "backend endpoint is required") check(b.Concurrency > 0, p.Key("concurrency"), "backend concurrency must be positive") check(b.QueueCapacity >= 0, p.Key("queue_capacity"), "queue capacity must be non-negative") } for i, module := range c.Pipeline.Modules { check(modulecatalog.IsSupported(module), ck.Keys("pipeline", "modules").Index(i), "unsupported module key") } check(outputschema.IsSupported(c.Output.Schema), ck.Keys("output", "schema"), "unsupported output schema") check(c.Validation.MaxPromptTokens > 0, ck.Keys("validation", "max_prompt_tokens"), "validation prompt budget must be positive") check(c.Chunking.MaxSectionTokens > 0, ck.Keys("chunking", "max_section_tokens"), "maximum section tokens must be positive") check(c.Chunking.MinSectionTokens > 0 && c.Chunking.MinSectionTokens <= c.Chunking.MaxSectionTokens, ck.Keys("chunking", "min_section_tokens"), "minimum section tokens must be positive and no greater than maximum") check(c.Chunking.TargetSections == nil || *c.Chunking.TargetSections > 0, ck.Keys("chunking", "target_sections"), "target sections must be positive when set") check(c.Normalization.MaxSegmentGap >= 0, ck.Keys("normalization", "max_segment_gap"), "maximum segment gap must be non-negative") check(c.Normalization.EllipsisGap >= 0, ck.Keys("normalization", "ellipsis_gap"), "ellipsis gap must be non-negative") check(c.Normalization.MaxSegmentDuration > 0, ck.Keys("normalization", "max_segment_duration"), "maximum segment duration must be positive") check(c.Normalization.MaxSegmentTokens > 0, ck.Keys("normalization", "max_segment_tokens"), "maximum segment tokens must be positive") for name, threshold := range map[string]float64{"glossary": c.Thresholds.Glossary, "homophones": c.Thresholds.Homophones, "spoken_word": c.Thresholds.SpokenWord, "grammar": c.Thresholds.Grammar} { check(threshold >= 0 && threshold <= 1, ck.Keys("thresholds", name), "confidence threshold must be between zero and one") } check(len(strings.TrimSpace(c.Context.Description)) <= DefaultTranscriptDescriptionMaxChars, ck.Keys("context", "description"), fmt.Sprintf("description must be at most %d bytes after trimming", DefaultTranscriptDescriptionMaxChars)) } // ParseModulesCSV is used only for an explicitly supplied --modules override. func ParseModulesCSV(raw string) ([]string, error) { if raw == "" { return []string{}, nil } parts := strings.Split(raw, ",") for i := range parts { parts[i] = strings.TrimSpace(parts[i]) if parts[i] == "" { return nil, fmt.Errorf("modules list contains an empty value") } } return parts, nil }