Add versioned Audita config support

This commit is contained in:
2026-05-13 12:36:20 +00:00
parent ebbd2c8a63
commit 9a77a0cd0b
11 changed files with 1539 additions and 32 deletions

View File

@@ -6,17 +6,38 @@ import (
"strconv"
)
const DefaultConfigPath = "/etc/audita/config.yml"
func LoadFromEnv() (Config, error) {
return loadFromLookup(os.LookupEnv)
cfg := Default()
if err := cfg.applyEnvOverrides(os.LookupEnv); err != nil {
return Config{}, err
}
return cfg, nil
}
func loadFromLookup(lookup func(string) (string, bool)) (Config, error) {
cfg := Default()
if err := cfg.applyEnvOverrides(lookup); err != nil {
return Config{}, err
}
return cfg, nil
}
func (c *Config) ApplyEnvOverrides() error {
return c.applyEnvOverrides(os.LookupEnv)
}
func (c *Config) applyEnvOverrides(lookup func(string) (string, bool)) error {
if c == nil {
return fmt.Errorf("config must not be nil")
}
cfg := c
if raw, ok := lookup("AUDITA_MODULES"); ok {
modules, err := ParseModulesCSV(raw)
if err != nil {
return Config{}, fmt.Errorf("AUDITA_MODULES: %w", err)
return fmt.Errorf("AUDITA_MODULES: %w", err)
}
cfg.Modules = modules
}
@@ -48,7 +69,7 @@ func loadFromLookup(lookup func(string) (string, bool)) (Config, error) {
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)
return fmt.Errorf("AUDITA_LLM_TIMEOUT_SECONDS: %w", err)
}
cfg.PrimaryLLM.TimeoutSeconds = value
}
@@ -56,7 +77,7 @@ func loadFromLookup(lookup func(string) (string, bool)) (Config, error) {
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)
return fmt.Errorf("AUDITA_VALIDATION_LLM_TIMEOUT_SECONDS: %w", err)
}
cfg.ValidationLLM.TimeoutSeconds = &value
}
@@ -64,7 +85,7 @@ func loadFromLookup(lookup func(string) (string, bool)) (Config, error) {
if raw, ok := lookup("AUDITA_MAX_RETRIES"); ok {
value, err := parseInt(raw)
if err != nil {
return Config{}, fmt.Errorf("AUDITA_MAX_RETRIES: %w", err)
return fmt.Errorf("AUDITA_MAX_RETRIES: %w", err)
}
cfg.PrimaryLLM.MaxRetries = value
}
@@ -72,7 +93,7 @@ func loadFromLookup(lookup func(string) (string, bool)) (Config, error) {
if raw, ok := lookup("AUDITA_TOTAL_LLM_CONCURRENCY"); ok {
value, err := parseInt(raw)
if err != nil {
return Config{}, fmt.Errorf("AUDITA_TOTAL_LLM_CONCURRENCY: %w", err)
return fmt.Errorf("AUDITA_TOTAL_LLM_CONCURRENCY: %w", err)
}
cfg.TotalLLMConcurrency = value
totalConcurrencySet = true
@@ -80,7 +101,7 @@ func loadFromLookup(lookup func(string) (string, bool)) (Config, error) {
if raw, ok := lookup("AUDITA_LLM_CONCURRENCY"); ok {
value, err := parseInt(raw)
if err != nil {
return Config{}, fmt.Errorf("AUDITA_LLM_CONCURRENCY: %w", err)
return fmt.Errorf("AUDITA_LLM_CONCURRENCY: %w", err)
}
if !totalConcurrencySet {
cfg.TotalLLMConcurrency = value
@@ -92,7 +113,7 @@ func loadFromLookup(lookup func(string) (string, bool)) (Config, error) {
if raw, ok := lookup("AUDITA_PROPOSAL_LLM_CONCURRENCY"); ok {
value, err := parseInt(raw)
if err != nil {
return Config{}, fmt.Errorf("AUDITA_PROPOSAL_LLM_CONCURRENCY: %w", err)
return fmt.Errorf("AUDITA_PROPOSAL_LLM_CONCURRENCY: %w", err)
}
cfg.ProposalLLMConcurrency = value
proposalConcurrencySet = true
@@ -104,14 +125,14 @@ func loadFromLookup(lookup func(string) (string, bool)) (Config, error) {
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)
return fmt.Errorf("AUDITA_VALIDATION_MAX_RETRIES: %w", err)
}
cfg.ValidationLLM.MaxRetries = &value
}
if raw, ok := lookup("AUDITA_VALIDATION_LLM_CONCURRENCY"); ok {
value, err := parseInt(raw)
if err != nil {
return Config{}, fmt.Errorf("AUDITA_VALIDATION_LLM_CONCURRENCY: %w", err)
return fmt.Errorf("AUDITA_VALIDATION_LLM_CONCURRENCY: %w", err)
}
cfg.ValidationLLMConcurrency = &value
}
@@ -119,7 +140,7 @@ func loadFromLookup(lookup func(string) (string, bool)) (Config, error) {
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)
return fmt.Errorf("AUDITA_VALIDATION_MAX_PROMPT_TOKENS: %w", err)
}
cfg.ValidationMaxPromptTokens = value
}
@@ -127,7 +148,7 @@ func loadFromLookup(lookup func(string) (string, bool)) (Config, error) {
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)
return fmt.Errorf("AUDITA_MAX_SECTION_TOKENS: %w", err)
}
cfg.MaxSectionTokens = value
}
@@ -135,7 +156,7 @@ func loadFromLookup(lookup func(string) (string, bool)) (Config, error) {
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)
return fmt.Errorf("AUDITA_MIN_SECTION_TOKENS: %w", err)
}
cfg.MinSectionTokens = value
}
@@ -143,7 +164,7 @@ func loadFromLookup(lookup func(string) (string, bool)) (Config, error) {
if raw, ok := lookup("AUDITA_TARGET_SECTIONS"); ok {
value, err := parseInt(raw)
if err != nil {
return Config{}, fmt.Errorf("AUDITA_TARGET_SECTIONS: %w", err)
return fmt.Errorf("AUDITA_TARGET_SECTIONS: %w", err)
}
cfg.TargetSections = &value
}
@@ -151,28 +172,28 @@ func loadFromLookup(lookup func(string) (string, bool)) (Config, error) {
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)
return 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)
return 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)
return 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)
return fmt.Errorf("AUDITA_SPOKEN_WORD_CONFIDENCE_THRESHOLD: %w", err)
}
cfg.Thresholds.SpokenWord = value
}
@@ -180,28 +201,28 @@ func loadFromLookup(lookup func(string) (string, bool)) (Config, error) {
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)
return 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)
return 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)
return 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)
return fmt.Errorf("AUDITA_NORMALIZE_MAX_SEGMENT_TOKENS: %w", err)
}
cfg.Normalization.MaxSegmentTokens = value
}
@@ -216,10 +237,10 @@ func loadFromLookup(lookup func(string) (string, bool)) (Config, error) {
cfg.syncLegacyConcurrencyAliases()
if err := cfg.Validate(); err != nil {
return Config{}, err
return err
}
return cfg, nil
return nil
}
func parseInt(raw string) (int, error) {

View File

@@ -0,0 +1,334 @@
package config
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"gopkg.in/yaml.v3"
)
const SupportedFileConfigVersion = 1
var envVarNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
type FileConfig struct {
Version int `yaml:"version"`
Pipeline *FileConfigPipeline `yaml:"pipeline,omitempty"`
LLM *FileConfigLLM `yaml:"llm,omitempty"`
Concurrency *FileConfigConcurrency `yaml:"concurrency,omitempty"`
Chunking *FileConfigChunking `yaml:"chunking,omitempty"`
Normalization *FileConfigNormalization `yaml:"normalization,omitempty"`
Thresholds *FileConfigThresholds `yaml:"thresholds,omitempty"`
Context *FileConfigContext `yaml:"context,omitempty"`
Diagnostics *FileConfigDiagnostics `yaml:"diagnostics,omitempty"`
}
type FileConfigPipeline struct {
Modules []string `yaml:"modules,omitempty"`
}
type FileConfigLLM struct {
Proposal *FileConfigLLMTarget `yaml:"proposal,omitempty"`
Validation *FileConfigLLMTarget `yaml:"validation,omitempty"`
}
type FileConfigLLMTarget struct {
BaseURL *string `yaml:"base_url,omitempty"`
Model *string `yaml:"model,omitempty"`
APIKeyEnv *string `yaml:"api_key_env,omitempty"`
Timeout *fileConfigDurationOrInt `yaml:"timeout,omitempty"`
MaxRetries *int `yaml:"max_retries,omitempty"`
}
type FileConfigConcurrency struct {
TotalLLM *int `yaml:"total_llm,omitempty"`
ProposalLLM *int `yaml:"proposal_llm,omitempty"`
ValidationLLM *int `yaml:"validation_llm,omitempty"`
}
type FileConfigChunking struct {
TargetSections *int `yaml:"target_sections,omitempty"`
MaxSectionTokens *int `yaml:"max_section_tokens,omitempty"`
MinSectionTokens *int `yaml:"min_section_tokens,omitempty"`
}
type FileConfigNormalization struct {
MaxSegmentGap *fileConfigDurationOrFloat `yaml:"max_segment_gap,omitempty"`
EllipsisGap *fileConfigDurationOrFloat `yaml:"ellipsis_gap,omitempty"`
MaxSegmentDuration *fileConfigDurationOrFloat `yaml:"max_segment_duration,omitempty"`
MaxSegmentTokens *int `yaml:"max_segment_tokens,omitempty"`
}
type FileConfigThresholds struct {
Glossary *float64 `yaml:"glossary,omitempty"`
Homophones *float64 `yaml:"homophones,omitempty"`
SpokenWord *float64 `yaml:"spoken_word,omitempty"`
Grammar *float64 `yaml:"grammar,omitempty"`
}
type FileConfigContext struct {
Description *string `yaml:"description,omitempty"`
}
type FileConfigDiagnostics struct {
WorkDir *string `yaml:"work_dir,omitempty"`
Retention *string `yaml:"retention,omitempty"`
}
type fileConfigDurationOrInt struct {
seconds int
}
func (v *fileConfigDurationOrInt) UnmarshalYAML(node *yaml.Node) error {
switch node.Kind {
case yaml.ScalarNode:
if node.Tag == "!!int" {
var n int
if err := node.Decode(&n); err != nil {
return fmt.Errorf("must be an integer seconds value or duration string")
}
v.seconds = n
return nil
}
var s string
if err := node.Decode(&s); err != nil {
return fmt.Errorf("must be an integer seconds value or duration string")
}
d, err := time.ParseDuration(strings.TrimSpace(s))
if err != nil {
return fmt.Errorf("invalid duration %q", s)
}
if d <= 0 {
v.seconds = int(d / time.Second)
return nil
}
if d%time.Second != 0 {
return fmt.Errorf("duration %q must resolve to whole seconds", s)
}
v.seconds = int(d / time.Second)
return nil
default:
return fmt.Errorf("must be an integer seconds value or duration string")
}
}
func (v fileConfigDurationOrInt) Seconds() int { return v.seconds }
type fileConfigDurationOrFloat struct {
seconds float64
}
func (v *fileConfigDurationOrFloat) UnmarshalYAML(node *yaml.Node) error {
switch node.Kind {
case yaml.ScalarNode:
if node.Tag == "!!int" || node.Tag == "!!float" {
var f float64
if err := node.Decode(&f); err != nil {
return fmt.Errorf("must be a numeric seconds value or duration string")
}
v.seconds = f
return nil
}
var s string
if err := node.Decode(&s); err != nil {
return fmt.Errorf("must be a numeric seconds value or duration string")
}
d, err := time.ParseDuration(strings.TrimSpace(s))
if err != nil {
return fmt.Errorf("invalid duration %q", s)
}
v.seconds = d.Seconds()
return nil
default:
return fmt.Errorf("must be a numeric seconds value or duration string")
}
}
func (v fileConfigDurationOrFloat) Seconds() float64 { return v.seconds }
func LoadFileConfig(path string) (FileConfig, error) {
b, err := os.ReadFile(path)
if err != nil {
return FileConfig{}, fmt.Errorf("read config file %q: %w", path, err)
}
cfg, err := ParseFileConfigYAML(b)
if err != nil {
return FileConfig{}, fmt.Errorf("parse config file %q: %w", path, err)
}
return cfg, nil
}
func ParseFileConfigYAML(data []byte) (FileConfig, error) {
var fileCfg FileConfig
dec := yaml.NewDecoder(strings.NewReader(string(data)))
dec.KnownFields(true)
if err := dec.Decode(&fileCfg); err != nil {
return FileConfig{}, fmt.Errorf("decode yaml: %w", err)
}
if fileCfg.Version == 0 {
return FileConfig{}, fmt.Errorf("config version is required")
}
if fileCfg.Version != SupportedFileConfigVersion {
return FileConfig{}, fmt.Errorf("unsupported config version %d", fileCfg.Version)
}
return fileCfg, nil
}
func (c *Config) ApplyFileConfig(fileCfg FileConfig) error {
return c.applyFileConfigWithLookup(fileCfg, os.LookupEnv)
}
func (c *Config) applyFileConfigWithLookup(fileCfg FileConfig, lookup func(string) (string, bool)) error {
if c == nil {
return fmt.Errorf("config must not be nil")
}
if fileCfg.Version != SupportedFileConfigVersion {
return fmt.Errorf("unsupported config version %d", fileCfg.Version)
}
if fileCfg.Pipeline != nil && len(fileCfg.Pipeline.Modules) > 0 {
c.Modules = append([]string(nil), fileCfg.Pipeline.Modules...)
}
if fileCfg.LLM != nil {
if fileCfg.LLM.Proposal != nil {
if fileCfg.LLM.Proposal.BaseURL != nil {
c.PrimaryLLM.BaseURL = *fileCfg.LLM.Proposal.BaseURL
}
if fileCfg.LLM.Proposal.Model != nil {
c.PrimaryLLM.Model = *fileCfg.LLM.Proposal.Model
}
if fileCfg.LLM.Proposal.Timeout != nil {
c.PrimaryLLM.TimeoutSeconds = fileCfg.LLM.Proposal.Timeout.Seconds()
}
if fileCfg.LLM.Proposal.MaxRetries != nil {
c.PrimaryLLM.MaxRetries = *fileCfg.LLM.Proposal.MaxRetries
}
if fileCfg.LLM.Proposal.APIKeyEnv != nil {
apiKey, err := resolveAPIKeyEnv(*fileCfg.LLM.Proposal.APIKeyEnv, lookup)
if err != nil {
return fmt.Errorf("llm.proposal.api_key_env: %w", err)
}
c.PrimaryLLM.APIKey = apiKey
}
}
if fileCfg.LLM.Validation != nil {
if fileCfg.LLM.Validation.BaseURL != nil {
c.ValidationLLM.BaseURL = *fileCfg.LLM.Validation.BaseURL
}
if fileCfg.LLM.Validation.Model != nil {
c.ValidationLLM.Model = *fileCfg.LLM.Validation.Model
}
if fileCfg.LLM.Validation.Timeout != nil {
v := fileCfg.LLM.Validation.Timeout.Seconds()
c.ValidationLLM.TimeoutSeconds = &v
}
if fileCfg.LLM.Validation.MaxRetries != nil {
v := *fileCfg.LLM.Validation.MaxRetries
c.ValidationLLM.MaxRetries = &v
}
if fileCfg.LLM.Validation.APIKeyEnv != nil {
apiKey, err := resolveAPIKeyEnv(*fileCfg.LLM.Validation.APIKeyEnv, lookup)
if err != nil {
return fmt.Errorf("llm.validation.api_key_env: %w", err)
}
c.ValidationLLM.APIKey = apiKey
}
}
}
if fileCfg.Concurrency != nil {
if fileCfg.Concurrency.TotalLLM != nil {
c.TotalLLMConcurrency = *fileCfg.Concurrency.TotalLLM
}
if fileCfg.Concurrency.ProposalLLM != nil {
c.ProposalLLMConcurrency = *fileCfg.Concurrency.ProposalLLM
}
if fileCfg.Concurrency.ValidationLLM != nil {
v := *fileCfg.Concurrency.ValidationLLM
c.ValidationLLMConcurrency = &v
}
}
if fileCfg.Chunking != nil {
if fileCfg.Chunking.TargetSections != nil {
v := *fileCfg.Chunking.TargetSections
c.TargetSections = &v
}
if fileCfg.Chunking.MaxSectionTokens != nil {
c.MaxSectionTokens = *fileCfg.Chunking.MaxSectionTokens
}
if fileCfg.Chunking.MinSectionTokens != nil {
c.MinSectionTokens = *fileCfg.Chunking.MinSectionTokens
}
}
if fileCfg.Normalization != nil {
if fileCfg.Normalization.MaxSegmentGap != nil {
c.Normalization.MaxSegmentGap = fileCfg.Normalization.MaxSegmentGap.Seconds()
}
if fileCfg.Normalization.EllipsisGap != nil {
c.Normalization.EllipsisGap = fileCfg.Normalization.EllipsisGap.Seconds()
}
if fileCfg.Normalization.MaxSegmentDuration != nil {
c.Normalization.MaxSegmentDuration = fileCfg.Normalization.MaxSegmentDuration.Seconds()
}
if fileCfg.Normalization.MaxSegmentTokens != nil {
c.Normalization.MaxSegmentTokens = *fileCfg.Normalization.MaxSegmentTokens
}
}
if fileCfg.Thresholds != nil {
if fileCfg.Thresholds.Glossary != nil {
c.Thresholds.Glossary = *fileCfg.Thresholds.Glossary
}
if fileCfg.Thresholds.Homophones != nil {
c.Thresholds.Homophones = *fileCfg.Thresholds.Homophones
}
if fileCfg.Thresholds.SpokenWord != nil {
c.Thresholds.SpokenWord = *fileCfg.Thresholds.SpokenWord
}
if fileCfg.Thresholds.Grammar != nil {
c.Thresholds.Grammar = *fileCfg.Thresholds.Grammar
}
}
if fileCfg.Context != nil && fileCfg.Context.Description != nil {
c.TranscriptDescription = strings.TrimSpace(*fileCfg.Context.Description)
}
if fileCfg.Diagnostics != nil {
if fileCfg.Diagnostics.WorkDir != nil {
c.WorkDir = *fileCfg.Diagnostics.WorkDir
}
if fileCfg.Diagnostics.Retention != nil {
c.WorkDirRetention = WorkDirRetention(*fileCfg.Diagnostics.Retention)
}
}
c.syncLegacyConcurrencyAliases()
if err := c.Validate(); err != nil {
return err
}
return nil
}
func resolveAPIKeyEnv(envName string, lookup func(string) (string, bool)) (string, error) {
name := strings.TrimSpace(envName)
if name == "" {
return "", fmt.Errorf("must not be empty")
}
if !envVarNamePattern.MatchString(name) {
return "", fmt.Errorf("must be an environment variable name")
}
if strings.Contains(name, string(filepath.Separator)) {
return "", fmt.Errorf("must be an environment variable name")
}
v, _ := lookup(name)
return v, nil
}

View File

@@ -0,0 +1,280 @@
package config
import (
"os"
"strings"
"testing"
)
func TestParseFileConfigYAMLValid(t *testing.T) {
raw := `
version: 1
pipeline:
modules: [glossary, homophones, grammar]
llm:
proposal:
base_url: https://example.test/v1
model: provider/model-a
api_key_env: AUDITA_PROPOSAL_KEY
timeout: 2m
max_retries: 4
validation:
base_url: https://example.test/validation
model: provider/model-b
api_key_env: AUDITA_VALIDATION_KEY
timeout: 45
max_retries: 3
concurrency:
total_llm: 8
proposal_llm: 4
validation_llm: 2
chunking:
target_sections: 6
max_section_tokens: 9000
min_section_tokens: 3000
normalization:
max_segment_gap: 1.5s
ellipsis_gap: 2
max_segment_duration: 45s
max_segment_tokens: 1500
thresholds:
glossary: 0.9
homophones: 0.7
spoken_word: 0.8
grammar: 0.75
context:
description: " crowd scene with many proper nouns "
diagnostics:
work_dir: /tmp/audita-config
retention: always
`
cfg, err := ParseFileConfigYAML([]byte(raw))
if err != nil {
t.Fatalf("ParseFileConfigYAML error: %v", err)
}
if cfg.Version != 1 {
t.Fatalf("expected version 1, got %d", cfg.Version)
}
if cfg.Pipeline == nil || len(cfg.Pipeline.Modules) != 3 {
t.Fatalf("unexpected pipeline modules: %#v", cfg.Pipeline)
}
if cfg.LLM == nil || cfg.LLM.Proposal == nil || cfg.LLM.Validation == nil {
t.Fatalf("expected llm proposal+validation blocks")
}
if cfg.LLM.Proposal.Timeout == nil || cfg.LLM.Proposal.Timeout.Seconds() != 120 {
t.Fatalf("expected proposal timeout 120s, got %#v", cfg.LLM.Proposal.Timeout)
}
if cfg.LLM.Validation.Timeout == nil || cfg.LLM.Validation.Timeout.Seconds() != 45 {
t.Fatalf("expected validation timeout 45s, got %#v", cfg.LLM.Validation.Timeout)
}
if cfg.Normalization == nil || cfg.Normalization.MaxSegmentGap == nil || cfg.Normalization.MaxSegmentGap.Seconds() != 1.5 {
t.Fatalf("expected parsed duration for normalization max_segment_gap")
}
}
func TestParseFileConfigYAMLRejectsUnknownField(t *testing.T) {
raw := `
version: 1
pipeline:
modules: [grammar]
output:
schema: v1
`
_, err := ParseFileConfigYAML([]byte(raw))
if err == nil {
t.Fatalf("expected unknown field error")
}
if !strings.Contains(err.Error(), "field output not found") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestParseFileConfigYAMLRejectsMissingVersion(t *testing.T) {
raw := `pipeline: {modules: [grammar]}`
_, err := ParseFileConfigYAML([]byte(raw))
if err == nil {
t.Fatalf("expected missing version error")
}
if !strings.Contains(err.Error(), "config version is required") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestParseFileConfigYAMLRejectsUnsupportedVersion(t *testing.T) {
raw := `version: 2`
_, err := ParseFileConfigYAML([]byte(raw))
if err == nil {
t.Fatalf("expected unsupported version error")
}
if !strings.Contains(err.Error(), "unsupported config version 2") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestApplyFileConfigParsesAndMergesFields(t *testing.T) {
raw := `
version: 1
pipeline:
modules: [spoken_word, grammar]
llm:
proposal:
model: provider/new-proposal
api_key_env: PROPOSAL_KEY_NAME
timeout: 90s
max_retries: 5
validation:
model: provider/new-validation
api_key_env: VALIDATION_KEY_NAME
timeout: 150
max_retries: 6
concurrency:
total_llm: 7
proposal_llm: 3
validation_llm: 2
chunking:
target_sections: 9
thresholds:
glossary: 0.91
homophones: 0.61
spoken_word: 0.71
grammar: 0.81
diagnostics:
retention: never
`
fileCfg, err := ParseFileConfigYAML([]byte(raw))
if err != nil {
t.Fatalf("ParseFileConfigYAML error: %v", err)
}
cfg := Default()
lookup := func(name string) (string, bool) {
switch name {
case "PROPOSAL_KEY_NAME":
return "proposal-secret", true
case "VALIDATION_KEY_NAME":
return "validation-secret", true
default:
return "", false
}
}
if err := cfg.applyFileConfigWithLookup(fileCfg, lookup); err != nil {
t.Fatalf("applyFileConfigWithLookup error: %v", err)
}
if strings.Join(cfg.Modules, ",") != "spoken_word,grammar" {
t.Fatalf("unexpected modules: %#v", cfg.Modules)
}
if cfg.PrimaryLLM.Model != "provider/new-proposal" {
t.Fatalf("unexpected proposal model: %q", cfg.PrimaryLLM.Model)
}
if cfg.PrimaryLLM.APIKey != "proposal-secret" {
t.Fatalf("expected proposal key from api_key_env lookup, got %q", cfg.PrimaryLLM.APIKey)
}
if cfg.PrimaryLLM.TimeoutSeconds != 90 {
t.Fatalf("unexpected proposal timeout: %d", cfg.PrimaryLLM.TimeoutSeconds)
}
if cfg.ValidationLLM.Model != "provider/new-validation" {
t.Fatalf("unexpected validation model: %q", cfg.ValidationLLM.Model)
}
if cfg.ValidationLLM.APIKey != "validation-secret" {
t.Fatalf("expected validation key from api_key_env lookup, got %q", cfg.ValidationLLM.APIKey)
}
if cfg.ValidationLLM.TimeoutSeconds == nil || *cfg.ValidationLLM.TimeoutSeconds != 150 {
t.Fatalf("unexpected validation timeout: %#v", cfg.ValidationLLM.TimeoutSeconds)
}
if cfg.TotalLLMConcurrency != 7 || cfg.ProposalLLMConcurrency != 3 {
t.Fatalf("unexpected llm concurrency values: total=%d proposal=%d", cfg.TotalLLMConcurrency, cfg.ProposalLLMConcurrency)
}
if cfg.ValidationLLMConcurrency == nil || *cfg.ValidationLLMConcurrency != 2 {
t.Fatalf("unexpected validation llm concurrency: %#v", cfg.ValidationLLMConcurrency)
}
if cfg.TargetSections == nil || *cfg.TargetSections != 9 {
t.Fatalf("unexpected target sections: %#v", cfg.TargetSections)
}
if cfg.WorkDirRetention != WorkDirRetentionNever {
t.Fatalf("unexpected retention: %q", cfg.WorkDirRetention)
}
if cfg.PrimaryLLM.Concurrency != 7 {
t.Fatalf("expected legacy alias to sync, got %d", cfg.PrimaryLLM.Concurrency)
}
if cfg.ValidationLLM.Concurrency == nil || *cfg.ValidationLLM.Concurrency != 2 {
t.Fatalf("expected validation alias to sync, got %#v", cfg.ValidationLLM.Concurrency)
}
}
func TestApplyFileConfigContextDescriptionTrim(t *testing.T) {
raw := `
version: 1
context:
description: " scene context "
`
fileCfg, err := ParseFileConfigYAML([]byte(raw))
if err != nil {
t.Fatalf("ParseFileConfigYAML error: %v", err)
}
cfg := Default()
if err := cfg.applyFileConfigWithLookup(fileCfg, mapLookup(map[string]string{})); err != nil {
t.Fatalf("applyFileConfigWithLookup error: %v", err)
}
if cfg.TranscriptDescription != "scene context" {
t.Fatalf("unexpected transcript description: %q", cfg.TranscriptDescription)
}
}
func TestApplyFileConfigRejectsInvalidAPIKeyEnvName(t *testing.T) {
raw := `
version: 1
llm:
proposal:
api_key_env: "not a var name"
`
fileCfg, err := ParseFileConfigYAML([]byte(raw))
if err != nil {
t.Fatalf("ParseFileConfigYAML error: %v", err)
}
cfg := Default()
err = cfg.applyFileConfigWithLookup(fileCfg, mapLookup(map[string]string{}))
if err == nil {
t.Fatalf("expected api_key_env validation error")
}
if !strings.Contains(err.Error(), "environment variable name") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestParseFileConfigDurationParsingErrors(t *testing.T) {
raw := `
version: 1
llm:
proposal:
timeout: "1.5s"
`
_, err := ParseFileConfigYAML([]byte(raw))
if err == nil {
t.Fatalf("expected duration parse error")
}
if !strings.Contains(err.Error(), "whole seconds") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestLoadFileConfigReadsFromPath(t *testing.T) {
p := writeTempFileConfig(t, "version: 1\n")
cfg, err := LoadFileConfig(p)
if err != nil {
t.Fatalf("LoadFileConfig error: %v", err)
}
if cfg.Version != 1 {
t.Fatalf("expected version 1, got %d", cfg.Version)
}
}
func writeTempFileConfig(t *testing.T, contents string) string {
t.Helper()
dir := t.TempDir()
path := dir + "/config.yaml"
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatalf("write config file: %v", err)
}
return path
}