Files
audita/internal/cli/config_input.go

206 lines
7.5 KiB
Go

package cli
import (
"errors"
"flag"
"fmt"
"io"
"os"
"strconv"
"strings"
ck "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/audita/internal/core/config"
)
var systemConfigPaths = []string{"/usr/local/etc/audita/config.yml", "/etc/audita/config.yml"}
type configInput struct {
ConfigPath string
ConfigPathSet bool
Overlay string
View string
TranscriptPath string
GlossaryPath string
OutputPath string
ReportPath string
Overrides []ck.Override
}
type selectedConfig struct {
Path string
Source string
Result *ck.Result[config.Config]
}
// parseConfigInput owns the common setting flags and process-only path flags.
// A transcript before flags and one after flags are both accepted.
func parseConfigInput(command string, args []string, output io.Writer) (configInput, bool, error) {
var input configInput
fs := flag.NewFlagSet(command, flag.ContinueOnError)
fs.SetOutput(output)
configPath := fs.String("config", "", "Main configuration YAML file")
overlay := fs.String("overlay", "", "Selected configuration overlay")
if command == "print-effective" {
fs.String("view", "settings", "Effective view: settings, profiles, or explain")
}
settingPaths := map[string]ck.Path{
"proposal-profile": ck.Keys("llm", "proposal_profile"),
"validation-profile": ck.Keys("llm", "validation_profile"),
"output-schema": ck.Keys("output", "schema"),
"validation-max-prompt-tokens": ck.Keys("validation", "max_prompt_tokens"),
"target-sections": ck.Keys("chunking", "target_sections"),
"max-section-tokens": ck.Keys("chunking", "max_section_tokens"),
"min-section-tokens": ck.Keys("chunking", "min_section_tokens"),
"normalize-max-segment-gap": ck.Keys("normalization", "max_segment_gap"),
"normalize-ellipsis-gap": ck.Keys("normalization", "ellipsis_gap"),
"normalize-max-segment-duration": ck.Keys("normalization", "max_segment_duration"),
"normalize-max-segment-tokens": ck.Keys("normalization", "max_segment_tokens"),
"glossary-confidence-threshold": ck.Keys("thresholds", "glossary"),
"homophones-confidence-threshold": ck.Keys("thresholds", "homophones"),
"spoken-word-confidence-threshold": ck.Keys("thresholds", "spoken_word"),
"grammar-confidence-threshold": ck.Keys("thresholds", "grammar"),
"transcript-description": ck.Keys("context", "description"),
"work-dir": ck.Keys("diagnostics", "work_dir"),
"work-dir-retention": ck.Keys("diagnostics", "retention"),
"modules": ck.Keys("pipeline", "modules"),
}
for name := range settingPaths {
switch name {
case "validation-max-prompt-tokens", "target-sections", "max-section-tokens", "min-section-tokens", "normalize-max-segment-tokens":
fs.Int(name, 0, "Setting override")
case "normalize-max-segment-gap", "normalize-ellipsis-gap", "normalize-max-segment-duration", "glossary-confidence-threshold", "homophones-confidence-threshold", "spoken-word-confidence-threshold", "grammar-confidence-threshold":
fs.Float64(name, 0, "Setting override")
default:
fs.String(name, "", "Setting override")
}
}
if command == "process" {
fs.String("glossary", "", "Glossary YAML file")
fs.String("output", "", "Corrected transcript output file")
fs.String("report-json", "", "Process report output file")
}
parseArgs := args
front := ""
if command == "process" && len(args) > 0 && !strings.HasPrefix(args[0], "-") {
front, parseArgs = args[0], args[1:]
}
if err := fs.Parse(parseArgs); err != nil {
if errors.Is(err, flag.ErrHelp) {
return configInput{}, true, nil
}
return configInput{}, false, err
}
positions := fs.Args()
if front != "" {
positions = append([]string{front}, positions...)
}
if command == "process" {
if len(positions) != 1 {
return configInput{}, false, fmt.Errorf("expected exactly one transcript JSON path")
}
input.TranscriptPath = positions[0]
input.GlossaryPath = fs.Lookup("glossary").Value.String()
input.OutputPath = fs.Lookup("output").Value.String()
input.ReportPath = fs.Lookup("report-json").Value.String()
if strings.TrimSpace(input.GlossaryPath) == "" {
return configInput{}, false, fmt.Errorf("--glossary is required")
}
} else if len(positions) != 0 {
return configInput{}, false, fmt.Errorf("unexpected positional arguments")
}
input.ConfigPath, input.Overlay = *configPath, *overlay
if command == "print-effective" {
input.View = fs.Lookup("view").Value.String()
switch input.View {
case "settings", "profiles", "explain":
default:
return configInput{}, false, fmt.Errorf("unknown effective view %q", input.View)
}
}
fs.Visit(func(f *flag.Flag) {
if f.Name == "config" {
input.ConfigPathSet = true
}
path, ok := settingPaths[f.Name]
if !ok {
return
}
var value any = f.Value.String()
switch f.Name {
case "modules":
modules, err := config.ParseModulesCSV(f.Value.String())
if err != nil {
value = err
} else {
values := make([]any, len(modules))
for i, module := range modules {
values[i] = module
}
value = values
}
case "validation-max-prompt-tokens", "target-sections", "max-section-tokens", "min-section-tokens", "normalize-max-segment-tokens":
value, _ = strconv.Atoi(f.Value.String())
case "normalize-max-segment-gap", "normalize-ellipsis-gap", "normalize-max-segment-duration", "glossary-confidence-threshold", "homophones-confidence-threshold", "spoken-word-confidence-threshold", "grammar-confidence-threshold":
value, _ = strconv.ParseFloat(f.Value.String(), 64)
}
input.Overrides = append(input.Overrides, ck.Override{Path: path, Value: value, Flag: "--" + f.Name})
})
for _, override := range input.Overrides {
if err, ok := override.Value.(error); ok {
return configInput{}, false, fmt.Errorf("%s: %w", override.Flag, err)
}
}
return input, false, nil
}
func selectConfigPath(explicit string, set bool, stat func(string) (os.FileInfo, error), candidates []string) (string, string, error) {
if set {
if strings.TrimSpace(explicit) == "" {
return "", "", fmt.Errorf("--config requires a nonempty path")
}
if _, err := stat(explicit); err != nil {
return "", "", fmt.Errorf("config file %q: %w", explicit, err)
}
return explicit, "flag", nil
}
for _, path := range candidates {
if _, err := stat(path); err == nil {
return path, "default", nil
} else if !os.IsNotExist(err) {
return "", "", fmt.Errorf("config file %q: %w", path, err)
}
}
return "", "", fmt.Errorf("no configuration file found; select a config with backend and profile settings")
}
func loadConfigInput(input configInput, command string) (selectedConfig, error) {
if command == "validate" && (!input.ConfigPathSet || strings.TrimSpace(input.ConfigPath) == "") {
return selectedConfig{}, fmt.Errorf("--config is required")
}
path, source, err := selectConfigPath(input.ConfigPath, input.ConfigPathSet, os.Stat, systemConfigPaths)
if err != nil {
return selectedConfig{}, err
}
loaded, err := config.Load(ck.File(path), input.Overlay, input.Overrides...)
if err != nil {
return selectedConfig{}, err
}
return selectedConfig{Path: path, Source: source, Result: loaded}, nil
}
func configInputError(err error) string {
var loadErr *ck.LoadError
if !errors.As(err, &loadErr) {
return err.Error()
}
var messages []string
for _, diagnostic := range loadErr.Diagnostics() {
messages = append(messages, diagnostic.Message)
}
if len(messages) == 0 {
return err.Error()
}
return strings.Join(messages, "; ")
}