203 lines
9.3 KiB
Go
203 lines
9.3 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/audita/v3/internal/core/modulecatalog"
|
|
"gitea.maximumdirect.net/eric/audita/v3/internal/core/outputschema"
|
|
ck "go.maximumdirect.net/configkit"
|
|
)
|
|
|
|
const (
|
|
DefaultModulesCSV = "glossary,homophones,glossary,spoken_word,grammar"
|
|
DefaultTranscriptDescriptionMaxChars = 500
|
|
DefaultValidationMaxPromptTokens = 2048
|
|
)
|
|
|
|
// Config contains application settings. Invocation paths are not settings.
|
|
type Config struct {
|
|
Version int `yaml:"version"`
|
|
Glossaries []string `yaml:"glossaries"`
|
|
ProtectedTerms []string `yaml:"protected_terms"`
|
|
GlossaryCollections map[string]GlossaryCollection `yaml:"glossary_collections"`
|
|
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 GlossaryCollection struct {
|
|
Entries []GlossaryEntry `yaml:"entries"`
|
|
}
|
|
type GlossaryEntry struct {
|
|
ID string `yaml:"id"`
|
|
Name string `yaml:"name"`
|
|
Category string `yaml:"category"`
|
|
Description string `yaml:"description"`
|
|
Aliases []string `yaml:"aliases"`
|
|
Plural *string `yaml:"plural"`
|
|
Extra map[string]any `yaml:",inline"`
|
|
}
|
|
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 {
|
|
Stages []Stage `yaml:"stages"`
|
|
}
|
|
type Stage struct {
|
|
Module string `yaml:"module"`
|
|
ID *string `yaml:"id"`
|
|
Glossaries []string `yaml:"glossaries"`
|
|
ProtectedTerms []string `yaml:"protected_terms"`
|
|
ConfidenceThreshold float64 `yaml:"confidence_threshold"`
|
|
LLM LLM `yaml:"llm"`
|
|
Validation Validation `yaml:"validation"`
|
|
Context Context `yaml:"context"`
|
|
}
|
|
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 {
|
|
WorkDir string `yaml:"work_dir"`
|
|
}
|
|
type Reporting struct {
|
|
Enabled bool `yaml:"enabled"`
|
|
StdoutDir string `yaml:"stdout_dir"`
|
|
}
|
|
|
|
func defaults() Config {
|
|
return Config{
|
|
Version: 3,
|
|
LLM: LLM{TransientRetries: 2, RepairAttempts: 2, OperationTimeout: 15 * time.Minute},
|
|
Pipeline: Pipeline{Stages: []Stage{{Module: "glossary"}, {Module: "homophones"}, {Module: "glossary"}, {Module: "spoken_word"}, {Module: "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"},
|
|
}
|
|
}
|
|
|
|
const glossaryImports = `config:
|
|
imports:
|
|
- path: glossary.yml
|
|
required: false
|
|
destination: [glossary_collections]
|
|
- dir: glossary
|
|
required: false
|
|
recursive: true
|
|
pattern: ["*.yml", "*.yaml"]
|
|
destination: [glossary_collections]
|
|
`
|
|
|
|
// Load resolves the selected main source and preserves native source evidence.
|
|
func Load(input ck.Input, overlay string, overrides ...ck.Override) (*ck.Result[Config], error) {
|
|
input.Imports = true
|
|
return ck.LoadLayered(input, ck.LayeredOptions[Config]{
|
|
Overlay: overlay, Overrides: overrides,
|
|
Underlays: []ck.Underlay{{Inputs: []ck.Input{{Source: ck.Bytes("Audita glossary imports", []byte(glossaryImports)), Imports: true, MainRelative: ck.MainRelative()}}}},
|
|
Definition: ck.Definition[Config]{
|
|
Defaults: defaults,
|
|
SequenceIdentities: []ck.SequenceIdentity{ck.SequenceIdentityMatching(ck.SequenceSelector{ck.SequenceLiteralKey("glossary_collections"), ck.SequenceAnyMappingKey(), ck.SequenceLiteralKey("entries")}, "id")},
|
|
Version: &ck.VersionRule{Path: ck.Keys("version"), Check: func(version ck.Version, v *ck.Validation) error {
|
|
if version.Present && (version.Null || version.Text != "3") {
|
|
v.Error(ck.Keys("version"), "use application configuration version 3; see docs/migrations/v3.md")
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
for name, collection := range c.GlossaryCollections {
|
|
for i, entry := range collection.Entries {
|
|
path := ck.Keys("glossary_collections", name, "entries").Index(i)
|
|
check(entry.ID != "", path.Key("id"), "entry id must not be empty")
|
|
check(entry.Name != "", path.Key("name"), "entry name must not be empty")
|
|
check(entry.Category != "", path.Key("category"), "entry category must not be empty")
|
|
for index, alias := range entry.Aliases {
|
|
check(alias != "", path.Key("aliases").Index(index), "alias must not be empty")
|
|
}
|
|
check(entry.Plural == nil || *entry.Plural != "", path.Key("plural"), "plural must not be empty")
|
|
}
|
|
}
|
|
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")
|
|
for i, stage := range c.Pipeline.Stages {
|
|
check(modulecatalog.IsSupported(stage.Module), ck.Keys("pipeline", "stages").Index(i).Key("module"), "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
|
|
}
|