From 51e370b4c8d1ded377326bb588408191607f711b Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 24 Sep 2026 01:38:49 +0000 Subject: [PATCH] Unify CLI configuration selection and overrides --- docs/cli.md | 237 ++++------------ docs/config.md | 6 +- docs/roadmap/implementation.md | 10 +- internal/cli/config_input.go | 193 +++++++++++++ internal/cli/config_input_test.go | 156 +++++++++++ internal/cli/process_flags.go | 121 -------- internal/cli/process_flags_test.go | 433 ----------------------------- internal/cli/run.go | 341 +++++------------------ 8 files changed, 486 insertions(+), 1011 deletions(-) create mode 100644 internal/cli/config_input.go create mode 100644 internal/cli/config_input_test.go delete mode 100644 internal/cli/process_flags.go delete mode 100644 internal/cli/process_flags_test.go diff --git a/docs/cli.md b/docs/cli.md index 4a66835..5e04255 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,198 +1,71 @@ # Audita CLI Reference -## Shortest Useful Command +The shared version-2 configuration parser is implemented for `process`, `config +validate`, and `config print-effective`. Profile assembly and process execution are +being reconnected in later work, so the CLI is not yet a usable assembled command. +The [configuration reference](config.md) owns fields, defaults, and validation. + +## Commands and help ```sh -audita process transcript.json --glossary glossary.yaml --output corrected.json +audita process --glossary [flags] +audita config validate --config [flags] +audita config print-effective [flags] ``` -This command runs the core workflow with your input files. For a copyable example -using maintained inputs and explicit prerequisites, see the [README](../README.md). -Commands here assume `audita` is on `PATH`; use `./bin/audita` after a local build. +Root invocation, `--help`, `-h`, and command help succeed without loading a config +file or resolving secrets. Unknown commands and flags return exit code `2`. +`process` accepts exactly one transcript path, either before the flags or after +them. With flags first, put the transcript last. Both config commands reject +positional arguments and process-only flags. -## Command Overview +All three commands accept `--config ` and `--overlay `. `config validate` +requires an explicit nonempty `--config`; the other commands select the explicit +file, then `/usr/local/etc/audita/config.yml`, then `/etc/audita/config.yml`. +Missing system candidates are skipped; inaccessible selected files are errors. +No file means a configuration error. `AUDITA_CONFIG` is ignored. The selected +overlay is loaded beside the main file, not used to locate profile files. -- `audita process`: process one transcript JSON file. -- `audita config validate`: validate a versioned YAML config file. -- `audita config print-effective`: print redacted effective config JSON. +## Setting overrides -General help: +Only flags actually supplied override file or overlay settings. `--modules ''` +clears the module pipeline, and an explicit numeric zero reaches configuration +validation. The shared setting flags are: -```sh -audita --help -audita process --help -audita config --help -``` +| Setting | Flags | +| --- | --- | +| Profile selection | `--proposal-profile`, `--validation-profile` | +| Pipeline and output | `--modules`, `--output-schema` | +| Validation budget | `--validation-max-prompt-tokens` | +| Chunking | `--target-sections`, `--max-section-tokens`, `--min-section-tokens` | +| Normalization (numeric seconds) | `--normalize-max-segment-gap`, `--normalize-ellipsis-gap`, `--normalize-max-segment-duration`, `--normalize-max-segment-tokens` | +| Confidence thresholds | `--glossary-confidence-threshold`, `--homophones-confidence-threshold`, `--spoken-word-confidence-threshold`, `--grammar-confidence-threshold` | +| Context | `--transcript-description` | +| Diagnostics | `--work-dir`, `--work-dir-retention` | -## `process` +`process` alone accepts `--glossary` (required), `--output`, and `--report-json`. +Per-role model, base URL, key, timeout, retry, and concurrency flags have been +removed. Models and generation controls belong to PromptKit profiles, backend +credentials and capacity to configuration, and retry/deadline settings to the +shared LLM section. Legacy configuration environment overrides are ignored. -Usage: +## Streams and exit status -```sh -audita process [flags] -``` +`config validate` prints `config is valid` on success. `config print-effective` +currently writes ConfigKit's redacted effective-settings YAML; selected-profile +and provenance views are scheduled with profile assembly. The effective YAML is +for inspection and is not guaranteed to be a reloadable input file. -Input requirements: -- exactly one transcript JSON positional argument is required; -- `--glossary ` is required. +For `process`, `--output` writes the corrected transcript to a file and leaves +stdout empty on success. Without it, stdout carries transcript JSON only on +success. `--report-json` writes a separate report file. Stderr carries readable +errors and diagnostic locations. A successful run can contain skipped corrections +or warnings; see the [report contract](integrations/output-files.md). -Place the transcript path first, followed by flags, as shown above. Flag values -may use `--name value` or `--name=value`. Unknown flags or extra positional -arguments are errors. Flags before a final transcript path are also accepted; -do not interleave further flags after a positional argument consumed by the flag -parser. +Exit codes are `0` for success, `1` for runtime or output failure, and `2` for +usage or configuration input errors. Configuration commands never contact a +provider merely to parse settings; profile validation is being connected next. -[Configuration discovery and precedence](config.md#loading-model) determine the -initial settings; explicit process flags supply overrides. Configuration is loaded -before process help is printed, so invalid discovered configuration can also make -process help fail. Input formats belong to the -[input-file contract](integrations/transcript-glossary-files.md). - -### `process` Flag Reference - -Core I/O flags: -- `--config `: path to versioned YAML config file. -- `--glossary `: glossary YAML input path (required). -- `--output `: corrected transcript JSON output file path. -- `--report-json `: machine-readable report JSON output path. -- `--output-schema `: output encoder selection; see [configuration](config.md#pipeline-and-output). -- `--modules `: comma-separated module sequence override. - -Primary LLM flags: -- `--llm-api-key `: primary LLM API key. -- `--model `: primary LLM model name. -- `--base-url `: primary OpenAI-compatible base URL. -- `--llm-timeout-seconds `: primary timeout in seconds. -- `--max-retries `: primary structured-output retries. - -Validation LLM flags: -- `--validation-llm-api-key `: validation LLM API key. -- `--validation-model `: validation LLM model name. -- `--validation-base-url `: validation OpenAI-compatible base URL. -- `--validation-llm-timeout-seconds `: validation timeout in seconds. -- `--validation-max-retries `: validation structured-output retries. -- `--validation-max-prompt-tokens `: validation max prompt tokens. - -Concurrency flags: -- `--total-llm-concurrency `: total concurrent proposal+validation LLM calls. -- `--proposal-llm-concurrency `: concurrent proposal-generation LLM calls. -- `--validation-llm-concurrency `: concurrent validation LLM calls. -- `--llm-concurrency `: alias for `--total-llm-concurrency`. - -Chunking and normalization flags: -- `--target-sections `: target number of transcript sections. -- `--max-section-tokens `: maximum section tokens. -- `--min-section-tokens `: minimum section tokens. -- `--normalize-max-segment-gap `: maximum same-speaker merge gap. -- `--normalize-ellipsis-gap `: gap threshold for ellipsis insertion. -- `--normalize-max-segment-duration `: maximum merged segment duration. -- `--normalize-max-segment-tokens `: maximum merged segment token estimate. - -Threshold flags: -- `--glossary-confidence-threshold ` -- `--homophones-confidence-threshold ` -- `--spoken-word-confidence-threshold ` -- `--grammar-confidence-threshold ` - -Context and diagnostics flags: -- `--transcript-description `: background context for prompts; does not override transcript content. -- `--work-dir `: per-run diagnostics work directory. -- `--work-dir-retention `: retention selection; see [configuration](config.md#thresholds-context-and-diagnostics). - -Flag descriptions identify their settings; defaults, accepted keys, numeric -constraints, and inheritance rules are owned by [configuration](config.md). -Normalization time flags use numeric seconds; LLM timeout flags use integer seconds. - -### `process` Output and Exit Behavior - -- With `--output`: stdout is expected to be empty on success. -- Without `--output`: stdout contains transcript JSON only on success. -- `--report-json` writes a file and is never printed to stdout. -- Stderr is human-readable diagnostics/errors. -- Processing failures include the diagnostics directory path when initialization - succeeded. Later report-write or retention errors can omit that path. -- Output files may already exist when a later reporting or cleanup failure occurs. -- Rejected/skipped corrections and warnings can occur in a successful run; inspect - the [report format](integrations/output-files.md#process-report) for details. -- Diagnostic wording is human-readable and is not a machine-stable JSON channel. - -Exit behavior: -- `0`: success. -- `1`: runtime failure during processing/reporting/output or retention handling. -- `2`: CLI usage or configuration input error. - -Integration references: -- subprocess contract: [`docs/integrations/subprocess.md`](integrations/subprocess.md) -- transcript/glossary file contract: [`docs/integrations/transcript-glossary-files.md`](integrations/transcript-glossary-files.md) - -### `process` Examples - -Write corrected transcript to a file: - -```sh -audita process transcript.json \ - --glossary glossary.yaml \ - --output corrected.json -``` - -Emit transcript JSON to stdout: - -```sh -audita process transcript.json --glossary glossary.yaml -``` - -Use explicit config and write report JSON: - -```sh -audita process transcript.json \ - --glossary glossary.yaml \ - --config audita.yml \ - --output corrected.json \ - --report-json report.json -``` - -Override the module sequence: - -```sh -audita process transcript.json \ - --glossary glossary.yaml \ - --modules glossary,homophones,grammar \ - --output corrected.json -``` - -## Configuration Commands - -### `config validate` - -```sh -audita config validate --config examples/minimal-config.yml -audita config validate --config examples/production-config.yml -``` - -The only command-specific flag is `--config `, which is required. -Positional arguments are rejected. Success prints `config is valid` to stdout -and returns zero; usage, file-read, parse, and validation errors go to stderr and -return `2`. See [applied configuration sources](config.md#effective-precedence) -and [validation boundaries](config.md#examples). - -### `config print-effective` - -```sh -audita config print-effective --config examples/minimal-config.yml -``` - -The only command-specific flag is optional `--config `. Positional -arguments are rejected. Without an explicit file, [discovery](config.md#loading-model) -applies. Success prints redacted configuration JSON to stdout and returns zero. -Usage and configuration errors return `2`; serialization or stdout-write failures -return `1`. - -The printed JSON reflects the effective in-memory configuration; it is not a YAML -configuration file and is not the process-report schema. Use it to inspect merged -settings, not as a file to pass back to the YAML loader. - -## Help - -Root invocation without arguments and root help print usage successfully. Use -`--help` or `-h` for subcommand help. Help normally returns zero; process help has -the configuration-loading caveat described above. Unknown commands return `2`. +See the [input files](integrations/transcript-glossary-files.md), +[subprocess contract](integrations/subprocess.md), and +[artifact lifecycle](operations.md) for their respective details. diff --git a/docs/config.md b/docs/config.md index 990e898..6bd94b3 100644 --- a/docs/config.md +++ b/docs/config.md @@ -7,7 +7,11 @@ examples will be migrated with that integration. ## Loading and precedence -The application selects one main YAML file. ConfigKit loads built-in defaults, +The application selects one main YAML file: an explicit `--config`, then +`/usr/local/etc/audita/config.yml` if present, then `/etc/audita/config.yml` +if present. Missing system candidates are skipped; an explicit missing or empty +path, inaccessible selected file, or absence of any selected file is an error. +`config validate` requires an explicit file. ConfigKit loads built-in defaults, the selected file, an optional named overlay from `overlays/.yml` beside the main file, and only explicitly supplied setting overrides, in that order. Imports and secret-directory bootstrap are disabled. Unknown fields and missing, null, diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 7b77231..289aefb 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -147,7 +147,15 @@ Do not duplicate ConfigKit's exhaustive YAML/merge tests. ## Stage 2: Replace CLI Configuration Selection and Overrides -**Status:** Pending. **Depends on:** Stage 1. +**Status:** Complete (2026-09-24). Added one parser for all three commands, +strict file discovery, optional overlay selection, and visited-only setting +overrides; removed retired flags and legacy process flag extraction. Updated CLI +and configuration references. Isolated parser/loading tests passed with +`go test internal/cli/config_input.go internal/cli/config_input_test.go`, and +`git diff --check` passed. `go test ./internal/cli` remains unbuildable because +the old runtime, validators, and diagnostics still use removed configuration +fields; assembled command checks remain deferred to later stages. +**Depends on:** Stage 1. **Owns:** `internal/cli/run.go`, `process_flags.go`, configuration command parsing, and corresponding CLI/subprocess tests. diff --git a/internal/cli/config_input.go b/internal/cli/config_input.go new file mode 100644 index 0000000..558717c --- /dev/null +++ b/internal/cli/config_input.go @@ -0,0 +1,193 @@ +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 + 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") + 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 + 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, "; ") +} diff --git a/internal/cli/config_input_test.go b/internal/cli/config_input_test.go new file mode 100644 index 0000000..cc8af75 --- /dev/null +++ b/internal/cli/config_input_test.go @@ -0,0 +1,156 @@ +package cli + +import ( + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + ck "gitea.maximumdirect.net/eric/ConfigKit" +) + +func TestSharedConfigInput(t *testing.T) { + for _, command := range []string{"process", "validate", "print-effective"} { + t.Run(command, func(t *testing.T) { + args := []string{"--config", "main.yml", "--overlay", "testing", "--proposal-profile", "economy", "--modules", "", "--target-sections", "0"} + if command == "process" { + args = append([]string{"transcript.json", "--glossary", "glossary.yml"}, args...) + } + input, help, err := parseConfigInput(command, args, io.Discard) + if err != nil || help { + t.Fatalf("parse: %+v %v %v", input, help, err) + } + if !input.ConfigPathSet || input.ConfigPath != "main.yml" || input.Overlay != "testing" || len(input.Overrides) != 3 { + t.Fatalf("selection: %+v", input) + } + for _, override := range input.Overrides { + switch override.Path.String() { + case ck.Keys("pipeline", "modules").String(): + if values, ok := override.Value.([]any); !ok || len(values) != 0 { + t.Fatalf("empty modules: %#v", override.Value) + } + case ck.Keys("chunking", "target_sections").String(): + if override.Value != 0 { + t.Fatalf("zero target: %#v", override.Value) + } + } + } + }) + } +} + +func TestConfigInputRejectsRemovedAndProcessOnlyFlags(t *testing.T) { + for _, command := range []string{"process", "validate", "print-effective"} { + for _, flag := range []string{"--model", "--llm-api-key", "--max-retries", "--llm-concurrency"} { + args := []string{flag, "value"} + if command == "process" { + args = append([]string{"transcript.json", "--glossary", "glossary.yml"}, args...) + } + if _, _, err := parseConfigInput(command, args, io.Discard); err == nil { + t.Fatalf("%s accepted %s", command, flag) + } + } + } + for _, command := range []string{"validate", "print-effective"} { + if _, _, err := parseConfigInput(command, []string{"--glossary", "glossary.yml"}, io.Discard); err == nil { + t.Fatalf("%s accepted process path", command) + } + if _, _, err := parseConfigInput(command, []string{"transcript.json"}, io.Discard); err == nil { + t.Fatalf("%s accepted positional", command) + } + } + for _, command := range []string{"process", "validate", "print-effective"} { + _, help, err := parseConfigInput(command, []string{"--help"}, io.Discard) + if err != nil || !help { + t.Fatalf("%s help: %v %v", command, help, err) + } + } +} + +func TestConfigSelection(t *testing.T) { + dir := t.TempDir() + first, second := filepath.Join(dir, "first.yml"), filepath.Join(dir, "second.yml") + if err := os.WriteFile(second, []byte("version: 2\n"), 0600); err != nil { + t.Fatal(err) + } + path, source, err := selectConfigPath("", false, os.Stat, []string{first, second}) + if err != nil || path != second || source != "default" { + t.Fatalf("discovery: %q %q %v", path, source, err) + } + for _, tc := range []struct { + explicit string + set bool + candidates []string + }{ + {"", true, []string{second}}, {first, true, []string{second}}, {"", false, []string{first}}, + } { + if _, _, err := selectConfigPath(tc.explicit, tc.set, os.Stat, tc.candidates); err == nil { + t.Fatalf("accepted unavailable selection: %+v", tc) + } + } + statDenied := func(string) (os.FileInfo, error) { return nil, os.ErrPermission } + if _, _, err := selectConfigPath("", false, statDenied, []string{first}); !errors.Is(err, os.ErrPermission) { + t.Fatalf("inaccessible candidate: %v", err) + } + path, source, err = selectConfigPath(second, true, os.Stat, []string{first}) + if err != nil || path != second || source != "flag" { + t.Fatalf("explicit path: %q %q %v", path, source, err) + } +} + +func TestLegacyEnvironmentDoesNotSelectConfig(t *testing.T) { + t.Setenv("AUDITA_CONFIG", filepath.Join(t.TempDir(), "legacy.yml")) + _, _, err := selectConfigPath("", false, os.Stat, []string{filepath.Join(t.TempDir(), "missing.yml")}) + if err == nil || !strings.Contains(err.Error(), "no configuration file") { + t.Fatalf("legacy environment changed discovery: %v", err) + } +} + +func TestSharedLoadingUsesSelectedFileOverlayAndVisitedOverrides(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, "overlays"), 0700); err != nil { + t.Fatal(err) + } + main := filepath.Join(dir, "config.yml") + base := "version: 2\nbackends:\n local:\n endpoint: https://local.invalid/v1\nllm:\n proposal_profile: base\nchunking:\n max_section_tokens: 900\n" + if err := os.WriteFile(main, []byte(base), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "overlays", "testing.yml"), []byte("llm:\n proposal_profile: testing\n"), 0600); err != nil { + t.Fatal(err) + } + for _, command := range []string{"process", "validate", "print-effective"} { + args := []string{"--config", main, "--overlay", "testing", "--min-section-tokens", "500"} + if command == "process" { + args = append([]string{"input.json", "--glossary", "glossary.yml"}, args...) + } + input, _, err := parseConfigInput(command, args, io.Discard) + if err != nil { + t.Fatal(err) + } + selected, err := loadConfigInput(input, command) + if err != nil { + t.Fatal(err) + } + c := selected.Result.Config + if c.LLM.ProposalProfile != "testing" || c.LLM.ValidationProfile != "testing" || c.Chunking.MinSectionTokens != 500 || c.Chunking.MaxSectionTokens != 900 { + t.Fatalf("%s resolved %+v", command, c) + } + } + input, _, err := parseConfigInput("validate", []string{"--overlay", "testing"}, io.Discard) + if err != nil { + t.Fatal(err) + } + if _, err := loadConfigInput(input, "validate"); err == nil { + t.Fatal("validate accepted discovery") + } + input, _, err = parseConfigInput("validate", []string{"--config", main}, io.Discard) + if err != nil { + t.Fatal(err) + } + if _, err := loadConfigInput(input, "validate"); err == nil { + t.Fatal("unmodified invalid lower-precedence section relation accepted") + } +} diff --git a/internal/cli/process_flags.go b/internal/cli/process_flags.go deleted file mode 100644 index e87bf85..0000000 --- a/internal/cli/process_flags.go +++ /dev/null @@ -1,121 +0,0 @@ -package cli - -import ( - "flag" - - "gitea.maximumdirect.net/eric/audita/internal/core/config" -) - -type processOverrideBinding func(*config.CLIOverrides, processFlags) - -var processOverrideBindings = map[string]processOverrideBinding{ - "modules": func(overrides *config.CLIOverrides, flags processFlags) { - overrides.ModulesCSV = flags.modules - }, - "output-schema": func(overrides *config.CLIOverrides, flags processFlags) { - overrides.OutputSchema = flags.outputSchema - }, - "llm-api-key": func(overrides *config.CLIOverrides, flags processFlags) { - overrides.PrimaryLLMAPIKey = flags.llmAPIKey - }, - "validation-llm-api-key": func(overrides *config.CLIOverrides, flags processFlags) { - overrides.ValidationLLMAPIKey = flags.validationLLMAPIKey - }, - "model": func(overrides *config.CLIOverrides, flags processFlags) { - overrides.PrimaryModel = flags.model - }, - "validation-model": func(overrides *config.CLIOverrides, flags processFlags) { - overrides.ValidationModel = flags.validationModel - }, - "base-url": func(overrides *config.CLIOverrides, flags processFlags) { - overrides.PrimaryBaseURL = flags.baseURL - }, - "validation-base-url": func(overrides *config.CLIOverrides, flags processFlags) { - overrides.ValidationBaseURL = flags.validationBaseURL - }, - "llm-timeout-seconds": func(overrides *config.CLIOverrides, flags processFlags) { - overrides.PrimaryLLMTimeoutSeconds = flags.llmTimeoutSeconds - }, - "total-llm-concurrency": func(overrides *config.CLIOverrides, flags processFlags) { - overrides.TotalLLMConcurrency = flags.totalLLMConcurrency - }, - "proposal-llm-concurrency": func(overrides *config.CLIOverrides, flags processFlags) { - overrides.ProposalLLMConcurrency = flags.proposalLLMConcurrency - }, - "llm-concurrency": func(overrides *config.CLIOverrides, flags processFlags) { - overrides.PrimaryLLMConcurrency = flags.llmConcurrency - }, - "validation-llm-timeout-seconds": func(overrides *config.CLIOverrides, flags processFlags) { - overrides.ValidationLLMTimeoutSeconds = flags.validationLLMTimeoutSeconds - }, - "max-retries": func(overrides *config.CLIOverrides, flags processFlags) { - overrides.MaxRetries = flags.maxRetries - }, - "validation-max-retries": func(overrides *config.CLIOverrides, flags processFlags) { - overrides.ValidationMaxRetries = flags.validationMaxRetries - }, - "validation-llm-concurrency": func(overrides *config.CLIOverrides, flags processFlags) { - overrides.ValidationLLMConcurrency = flags.validationLLMConcurrency - }, - "validation-max-prompt-tokens": func(overrides *config.CLIOverrides, flags processFlags) { - overrides.ValidationMaxPromptTokens = flags.validationMaxPromptTokens - }, - "max-section-tokens": func(overrides *config.CLIOverrides, flags processFlags) { - overrides.MaxSectionTokens = flags.maxSectionTokens - }, - "min-section-tokens": func(overrides *config.CLIOverrides, flags processFlags) { - overrides.MinSectionTokens = flags.minSectionTokens - }, - "target-sections": func(overrides *config.CLIOverrides, flags processFlags) { - overrides.TargetSections = flags.targetSections - }, - "glossary-confidence-threshold": func(overrides *config.CLIOverrides, flags processFlags) { - overrides.GlossaryConfidenceThreshold = flags.glossaryConfidenceThreshold - }, - "grammar-confidence-threshold": func(overrides *config.CLIOverrides, flags processFlags) { - overrides.GrammarConfidenceThreshold = flags.grammarConfidenceThreshold - }, - "homophones-confidence-threshold": func(overrides *config.CLIOverrides, flags processFlags) { - overrides.HomophonesConfidenceThreshold = flags.homophonesConfidenceThreshold - }, - "spoken-word-confidence-threshold": func(overrides *config.CLIOverrides, flags processFlags) { - overrides.SpokenWordConfidenceThreshold = flags.spokenWordConfidenceThreshold - }, - "normalize-max-segment-gap": func(overrides *config.CLIOverrides, flags processFlags) { - overrides.NormalizeMaxSegmentGap = flags.normalizeMaxSegmentGap - }, - "normalize-ellipsis-gap": func(overrides *config.CLIOverrides, flags processFlags) { - overrides.NormalizeEllipsisGap = flags.normalizeEllipsisGap - }, - "normalize-max-segment-duration": func(overrides *config.CLIOverrides, flags processFlags) { - overrides.NormalizeMaxSegmentDuration = flags.normalizeMaxSegmentDuration - }, - "normalize-max-segment-tokens": func(overrides *config.CLIOverrides, flags processFlags) { - overrides.NormalizeMaxSegmentTokens = flags.normalizeMaxSegmentTokens - }, - "transcript-description": func(overrides *config.CLIOverrides, flags processFlags) { - overrides.TranscriptDescription = flags.transcriptDescription - }, - "work-dir": func(overrides *config.CLIOverrides, flags processFlags) { - overrides.WorkDir = flags.workDir - }, - "work-dir-retention": func(overrides *config.CLIOverrides, flags processFlags) { - overrides.WorkDirRetention = flags.workDirRetention - }, -} - -func processCLIOverrides(fs *flag.FlagSet, flags processFlags) (config.CLIOverrides, bool) { - overrides := config.CLIOverrides{} - explicitModules := false - fs.Visit(func(f *flag.Flag) { - if f.Name == "modules" { - explicitModules = true - } - binding, ok := processOverrideBindings[f.Name] - if !ok { - return - } - binding(&overrides, flags) - }) - return overrides, explicitModules -} diff --git a/internal/cli/process_flags_test.go b/internal/cli/process_flags_test.go deleted file mode 100644 index 309ce78..0000000 --- a/internal/cli/process_flags_test.go +++ /dev/null @@ -1,433 +0,0 @@ -package cli - -import ( - "io" - "reflect" - "strings" - "testing" - - "gitea.maximumdirect.net/eric/audita/internal/core/config" -) - -func TestProcessCLIOverridesMapsEveryConfigMutatingFlag(t *testing.T) { - tests := []struct { - name string - flagName string - value string - wantExplicitModules bool - assertOverrideFields func(t *testing.T, overrides config.CLIOverrides) - }{ - { - name: "modules", - flagName: "modules", - value: "grammar,glossary", - wantExplicitModules: true, - assertOverrideFields: func(t *testing.T, overrides config.CLIOverrides) { - assertStringOverride(t, "ModulesCSV", overrides.ModulesCSV, "grammar,glossary") - }, - }, - { - name: "output schema", - flagName: "output-schema", - value: "audita-v1", - assertOverrideFields: func(t *testing.T, overrides config.CLIOverrides) { - assertStringOverride(t, "OutputSchema", overrides.OutputSchema, "audita-v1") - }, - }, - { - name: "primary api key", - flagName: "llm-api-key", - value: "primary-key", - assertOverrideFields: func(t *testing.T, overrides config.CLIOverrides) { - assertStringOverride(t, "PrimaryLLMAPIKey", overrides.PrimaryLLMAPIKey, "primary-key") - }, - }, - { - name: "validation api key", - flagName: "validation-llm-api-key", - value: "validation-key", - assertOverrideFields: func(t *testing.T, overrides config.CLIOverrides) { - assertStringOverride(t, "ValidationLLMAPIKey", overrides.ValidationLLMAPIKey, "validation-key") - }, - }, - { - name: "primary model", - flagName: "model", - value: "primary-model", - assertOverrideFields: func(t *testing.T, overrides config.CLIOverrides) { - assertStringOverride(t, "PrimaryModel", overrides.PrimaryModel, "primary-model") - }, - }, - { - name: "validation model", - flagName: "validation-model", - value: "validation-model", - assertOverrideFields: func(t *testing.T, overrides config.CLIOverrides) { - assertStringOverride(t, "ValidationModel", overrides.ValidationModel, "validation-model") - }, - }, - { - name: "primary base url", - flagName: "base-url", - value: "https://primary.example.test", - assertOverrideFields: func(t *testing.T, overrides config.CLIOverrides) { - assertStringOverride(t, "PrimaryBaseURL", overrides.PrimaryBaseURL, "https://primary.example.test") - }, - }, - { - name: "validation base url", - flagName: "validation-base-url", - value: "https://validation.example.test", - assertOverrideFields: func(t *testing.T, overrides config.CLIOverrides) { - assertStringOverride(t, "ValidationBaseURL", overrides.ValidationBaseURL, "https://validation.example.test") - }, - }, - { - name: "primary timeout", - flagName: "llm-timeout-seconds", - value: "101", - assertOverrideFields: func(t *testing.T, overrides config.CLIOverrides) { - assertIntOverride(t, "PrimaryLLMTimeoutSeconds", overrides.PrimaryLLMTimeoutSeconds, 101) - }, - }, - { - name: "total concurrency", - flagName: "total-llm-concurrency", - value: "5", - assertOverrideFields: func(t *testing.T, overrides config.CLIOverrides) { - assertIntOverride(t, "TotalLLMConcurrency", overrides.TotalLLMConcurrency, 5) - }, - }, - { - name: "proposal concurrency", - flagName: "proposal-llm-concurrency", - value: "3", - assertOverrideFields: func(t *testing.T, overrides config.CLIOverrides) { - assertIntOverride(t, "ProposalLLMConcurrency", overrides.ProposalLLMConcurrency, 3) - }, - }, - { - name: "legacy concurrency alias", - flagName: "llm-concurrency", - value: "4", - assertOverrideFields: func(t *testing.T, overrides config.CLIOverrides) { - assertIntOverride(t, "PrimaryLLMConcurrency", overrides.PrimaryLLMConcurrency, 4) - }, - }, - { - name: "validation timeout", - flagName: "validation-llm-timeout-seconds", - value: "202", - assertOverrideFields: func(t *testing.T, overrides config.CLIOverrides) { - assertIntOverride(t, "ValidationLLMTimeoutSeconds", overrides.ValidationLLMTimeoutSeconds, 202) - }, - }, - { - name: "max retries", - flagName: "max-retries", - value: "6", - assertOverrideFields: func(t *testing.T, overrides config.CLIOverrides) { - assertIntOverride(t, "MaxRetries", overrides.MaxRetries, 6) - }, - }, - { - name: "validation max retries", - flagName: "validation-max-retries", - value: "7", - assertOverrideFields: func(t *testing.T, overrides config.CLIOverrides) { - assertIntOverride(t, "ValidationMaxRetries", overrides.ValidationMaxRetries, 7) - }, - }, - { - name: "validation concurrency", - flagName: "validation-llm-concurrency", - value: "8", - assertOverrideFields: func(t *testing.T, overrides config.CLIOverrides) { - assertIntOverride(t, "ValidationLLMConcurrency", overrides.ValidationLLMConcurrency, 8) - }, - }, - { - name: "validation max prompt tokens", - flagName: "validation-max-prompt-tokens", - value: "4096", - assertOverrideFields: func(t *testing.T, overrides config.CLIOverrides) { - assertIntOverride(t, "ValidationMaxPromptTokens", overrides.ValidationMaxPromptTokens, 4096) - }, - }, - { - name: "max section tokens", - flagName: "max-section-tokens", - value: "9000", - assertOverrideFields: func(t *testing.T, overrides config.CLIOverrides) { - assertIntOverride(t, "MaxSectionTokens", overrides.MaxSectionTokens, 9000) - }, - }, - { - name: "min section tokens", - flagName: "min-section-tokens", - value: "1000", - assertOverrideFields: func(t *testing.T, overrides config.CLIOverrides) { - assertIntOverride(t, "MinSectionTokens", overrides.MinSectionTokens, 1000) - }, - }, - { - name: "target sections", - flagName: "target-sections", - value: "12", - assertOverrideFields: func(t *testing.T, overrides config.CLIOverrides) { - assertIntOverride(t, "TargetSections", overrides.TargetSections, 12) - }, - }, - { - name: "glossary threshold", - flagName: "glossary-confidence-threshold", - value: "0.91", - assertOverrideFields: func(t *testing.T, overrides config.CLIOverrides) { - assertFloatOverride(t, "GlossaryConfidenceThreshold", overrides.GlossaryConfidenceThreshold, 0.91) - }, - }, - { - name: "grammar threshold", - flagName: "grammar-confidence-threshold", - value: "0.92", - assertOverrideFields: func(t *testing.T, overrides config.CLIOverrides) { - assertFloatOverride(t, "GrammarConfidenceThreshold", overrides.GrammarConfidenceThreshold, 0.92) - }, - }, - { - name: "homophones threshold", - flagName: "homophones-confidence-threshold", - value: "0.93", - assertOverrideFields: func(t *testing.T, overrides config.CLIOverrides) { - assertFloatOverride(t, "HomophonesConfidenceThreshold", overrides.HomophonesConfidenceThreshold, 0.93) - }, - }, - { - name: "spoken word threshold", - flagName: "spoken-word-confidence-threshold", - value: "0.94", - assertOverrideFields: func(t *testing.T, overrides config.CLIOverrides) { - assertFloatOverride(t, "SpokenWordConfidenceThreshold", overrides.SpokenWordConfidenceThreshold, 0.94) - }, - }, - { - name: "normalize max segment gap", - flagName: "normalize-max-segment-gap", - value: "1.2", - assertOverrideFields: func(t *testing.T, overrides config.CLIOverrides) { - assertFloatOverride(t, "NormalizeMaxSegmentGap", overrides.NormalizeMaxSegmentGap, 1.2) - }, - }, - { - name: "normalize ellipsis gap", - flagName: "normalize-ellipsis-gap", - value: "2.3", - assertOverrideFields: func(t *testing.T, overrides config.CLIOverrides) { - assertFloatOverride(t, "NormalizeEllipsisGap", overrides.NormalizeEllipsisGap, 2.3) - }, - }, - { - name: "normalize max segment duration", - flagName: "normalize-max-segment-duration", - value: "45.6", - assertOverrideFields: func(t *testing.T, overrides config.CLIOverrides) { - assertFloatOverride(t, "NormalizeMaxSegmentDuration", overrides.NormalizeMaxSegmentDuration, 45.6) - }, - }, - { - name: "normalize max segment tokens", - flagName: "normalize-max-segment-tokens", - value: "321", - assertOverrideFields: func(t *testing.T, overrides config.CLIOverrides) { - assertIntOverride(t, "NormalizeMaxSegmentTokens", overrides.NormalizeMaxSegmentTokens, 321) - }, - }, - { - name: "transcript description", - flagName: "transcript-description", - value: "podcast episode", - assertOverrideFields: func(t *testing.T, overrides config.CLIOverrides) { - assertStringOverride(t, "TranscriptDescription", overrides.TranscriptDescription, "podcast episode") - }, - }, - { - name: "work dir", - flagName: "work-dir", - value: "/tmp/custom-audita", - assertOverrideFields: func(t *testing.T, overrides config.CLIOverrides) { - assertStringOverride(t, "WorkDir", overrides.WorkDir, "/tmp/custom-audita") - }, - }, - { - name: "work dir retention", - flagName: "work-dir-retention", - value: "always", - assertOverrideFields: func(t *testing.T, overrides config.CLIOverrides) { - assertStringOverride(t, "WorkDirRetention", overrides.WorkDirRetention, "always") - }, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - fs, flags := newProcessFlagSet(config.Default(), io.Discard) - if err := fs.Parse([]string{"--" + tc.flagName, tc.value}); err != nil { - t.Fatalf("parse flag: %v", err) - } - - overrides, explicitModules := processCLIOverrides(fs, flags) - if explicitModules != tc.wantExplicitModules { - t.Fatalf("explicitModules=%v, want %v", explicitModules, tc.wantExplicitModules) - } - tc.assertOverrideFields(t, overrides) - }) - } -} - -func TestProcessCLIOverridesIgnoresNonConfigFlags(t *testing.T) { - fs, flags := newProcessFlagSet(config.Default(), io.Discard) - if err := fs.Parse([]string{ - "--config", "/tmp/config.yml", - "--glossary", "/tmp/glossary.yml", - "--output", "/tmp/output.json", - "--report-json", "/tmp/report.json", - }); err != nil { - t.Fatalf("parse flags: %v", err) - } - - overrides, explicitModules := processCLIOverrides(fs, flags) - if explicitModules { - t.Fatal("non-config flags should not mark modules explicit") - } - assertNoCLIOverrides(t, overrides) -} - -func TestNewProcessFlagSetDefaultsReflectEffectiveConfig(t *testing.T) { - cfg := config.Default() - cfg.Modules = []string{"grammar", "glossary"} - cfg.OutputSchema = "audita-v1" - cfg.PrimaryLLM.APIKey = "primary-key" - cfg.ValidationLLM.APIKey = "validation-key" - cfg.PrimaryLLM.Model = "primary-model" - cfg.ValidationLLM.Model = "validation-model" - cfg.PrimaryLLM.BaseURL = "https://primary.example.test" - cfg.ValidationLLM.BaseURL = "https://validation.example.test" - cfg.PrimaryLLM.TimeoutSeconds = 101 - cfg.TotalLLMConcurrency = 5 - cfg.ProposalLLMConcurrency = 3 - cfg.PrimaryLLM.MaxRetries = 6 - cfg.ValidationMaxPromptTokens = 4096 - cfg.MaxSectionTokens = 9000 - cfg.MinSectionTokens = 1000 - cfg.Thresholds.Glossary = 0.91 - cfg.Thresholds.Grammar = 0.92 - cfg.Thresholds.Homophones = 0.93 - cfg.Thresholds.SpokenWord = 0.94 - cfg.Normalization.MaxSegmentGap = 1.2 - cfg.Normalization.EllipsisGap = 2.3 - cfg.Normalization.MaxSegmentDuration = 45.6 - cfg.Normalization.MaxSegmentTokens = 321 - cfg.TranscriptDescription = "podcast episode" - cfg.WorkDir = "/tmp/custom-audita" - cfg.WorkDirRetention = config.WorkDirRetentionAlways - - validationTimeout := 202 - validationRetries := 7 - validationConcurrency := 8 - targetSections := 12 - cfg.ValidationLLM.TimeoutSeconds = &validationTimeout - cfg.ValidationLLM.MaxRetries = &validationRetries - cfg.ValidationLLMConcurrency = &validationConcurrency - cfg.TargetSections = &targetSections - - _, flags := newProcessFlagSet(cfg, io.Discard) - - assertStringOverride(t, "modules default", flags.modules, "grammar,glossary") - assertStringOverride(t, "output schema default", flags.outputSchema, "audita-v1") - assertStringOverride(t, "primary api key default", flags.llmAPIKey, "primary-key") - assertStringOverride(t, "validation api key default", flags.validationLLMAPIKey, "validation-key") - assertStringOverride(t, "primary model default", flags.model, "primary-model") - assertStringOverride(t, "validation model default", flags.validationModel, "validation-model") - assertStringOverride(t, "primary base url default", flags.baseURL, "https://primary.example.test") - assertStringOverride(t, "validation base url default", flags.validationBaseURL, "https://validation.example.test") - assertIntOverride(t, "primary timeout default", flags.llmTimeoutSeconds, 101) - assertIntOverride(t, "total concurrency default", flags.totalLLMConcurrency, 5) - assertIntOverride(t, "proposal concurrency default", flags.proposalLLMConcurrency, 3) - assertIntOverride(t, "legacy concurrency alias default", flags.llmConcurrency, 5) - assertIntOverride(t, "validation timeout default", flags.validationLLMTimeoutSeconds, validationTimeout) - assertIntOverride(t, "max retries default", flags.maxRetries, 6) - assertIntOverride(t, "validation max retries default", flags.validationMaxRetries, validationRetries) - assertIntOverride(t, "validation concurrency default", flags.validationLLMConcurrency, validationConcurrency) - assertIntOverride(t, "validation max prompt tokens default", flags.validationMaxPromptTokens, 4096) - assertIntOverride(t, "max section tokens default", flags.maxSectionTokens, 9000) - assertIntOverride(t, "min section tokens default", flags.minSectionTokens, 1000) - assertIntOverride(t, "target sections default", flags.targetSections, targetSections) - assertFloatOverride(t, "glossary threshold default", flags.glossaryConfidenceThreshold, 0.91) - assertFloatOverride(t, "grammar threshold default", flags.grammarConfidenceThreshold, 0.92) - assertFloatOverride(t, "homophones threshold default", flags.homophonesConfidenceThreshold, 0.93) - assertFloatOverride(t, "spoken word threshold default", flags.spokenWordConfidenceThreshold, 0.94) - assertFloatOverride(t, "normalize max segment gap default", flags.normalizeMaxSegmentGap, 1.2) - assertFloatOverride(t, "normalize ellipsis gap default", flags.normalizeEllipsisGap, 2.3) - assertFloatOverride(t, "normalize max segment duration default", flags.normalizeMaxSegmentDuration, 45.6) - assertIntOverride(t, "normalize max segment tokens default", flags.normalizeMaxSegmentTokens, 321) - assertStringOverride(t, "transcript description default", flags.transcriptDescription, "podcast episode") - assertStringOverride(t, "work dir default", flags.workDir, "/tmp/custom-audita") - assertStringOverride(t, "work dir retention default", flags.workDirRetention, "always") -} - -func TestNewProcessFlagSetUsesFallbackDefaultsForUnsetOptionalConfig(t *testing.T) { - cfg := config.Default() - - _, flags := newProcessFlagSet(cfg, io.Discard) - - assertIntOverride(t, "validation timeout fallback", flags.validationLLMTimeoutSeconds, cfg.PrimaryLLM.TimeoutSeconds) - assertIntOverride(t, "validation retries fallback", flags.validationMaxRetries, cfg.PrimaryLLM.MaxRetries) - assertIntOverride(t, "validation concurrency fallback", flags.validationLLMConcurrency, cfg.TotalLLMConcurrency) - assertIntOverride(t, "target sections fallback", flags.targetSections, 0) -} - -func assertStringOverride(t *testing.T, name string, got *string, want string) { - t.Helper() - if got == nil || *got != want { - t.Fatalf("%s=%v, want %q", name, pointerValue(got), want) - } -} - -func assertIntOverride(t *testing.T, name string, got *int, want int) { - t.Helper() - if got == nil || *got != want { - t.Fatalf("%s=%v, want %d", name, pointerValue(got), want) - } -} - -func assertFloatOverride(t *testing.T, name string, got *float64, want float64) { - t.Helper() - if got == nil || *got != want { - t.Fatalf("%s=%v, want %v", name, pointerValue(got), want) - } -} - -func assertNoCLIOverrides(t *testing.T, overrides config.CLIOverrides) { - t.Helper() - value := reflect.ValueOf(overrides) - typ := value.Type() - for i := 0; i < value.NumField(); i++ { - field := value.Field(i) - if field.Kind() != reflect.Ptr { - t.Fatalf("unexpected non-pointer CLIOverrides field %s", typ.Field(i).Name) - } - if !field.IsNil() { - t.Fatalf("expected no CLI overrides, field %s was set", typ.Field(i).Name) - } - } -} - -func pointerValue[T any](ptr *T) any { - if ptr == nil { - return "" - } - if stringer, ok := any(*ptr).(interface{ String() string }); ok { - return strings.TrimSpace(stringer.String()) - } - return *ptr -} diff --git a/internal/cli/run.go b/internal/cli/run.go index 243f156..bfdd472 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -2,9 +2,6 @@ package cli import ( "context" - "encoding/json" - "errors" - "flag" "fmt" "io" "os" @@ -12,6 +9,7 @@ import ( "strings" "time" + ck "gitea.maximumdirect.net/eric/ConfigKit" "gitea.maximumdirect.net/eric/audita/internal/core/chunking" "gitea.maximumdirect.net/eric/audita/internal/core/config" "gitea.maximumdirect.net/eric/audita/internal/core/diagnostics" @@ -53,6 +51,7 @@ type processInvocation struct { OutputPath string ReportJSONPath string Config config.Config + ConfigResult *ck.Result[config.Config] ConfigPath string ConfigSource string ConfigVersion *int @@ -349,6 +348,16 @@ func Run(args []string, stdout, stderr io.Writer) int { writeRootUsage(stdout) return 0 } + if len(args) == 2 && args[0] == "help" { + switch args[1] { + case "process": + writeProcessUsage(stdout) + return 0 + case "config": + writeConfigUsage(stdout) + return 0 + } + } if isHelpCommand(args) { writeRootUsage(stdout) @@ -369,89 +378,38 @@ func Run(args []string, stdout, stderr io.Writer) int { func runProcess(args []string, stdout, stderr io.Writer) int { startedAt := time.Now().UTC() - - configPathOverride, configPathOverrideSet, err := findConfigPathOverride(args) + input, help, err := parseConfigInput("process", args, stderr) if err != nil { - fmt.Fprintf(stderr, "audita process: invalid CLI configuration: %v\n", err) + fmt.Fprintf(stderr, "audita process: %s\n", configInputError(err)) return 2 } - effectiveConfig, err := config.LoadEffectiveConfig(configPathOverride, configPathOverrideSet) - if err != nil { - var effectiveConfigErr *config.EffectiveConfigError - if errors.As(err, &effectiveConfigErr) { - switch effectiveConfigErr.Kind { - case config.EffectiveConfigErrorLoadFile, config.EffectiveConfigErrorApplyFile: - fmt.Fprintf(stderr, "audita process: invalid config file: %v\n", effectiveConfigErr) - case config.EffectiveConfigErrorApplyEnv: - fmt.Fprintf(stderr, "audita process: invalid environment configuration: %v\n", effectiveConfigErr) - default: - fmt.Fprintf(stderr, "audita process: %v\n", effectiveConfigErr) - } - } else { - fmt.Fprintf(stderr, "audita process: %v\n", err) - } - return 2 - } - - cfg := effectiveConfig.Config - configPath := effectiveConfig.ConfigPath - configSource := effectiveConfig.ConfigSource - configVersion := effectiveConfig.ConfigVersion - - fs, pFlags := newProcessFlagSet(cfg, stderr) - - if isHelpCommand(args) || hasHelpFlag(args) { - writeProcessUsage(stdout, fs) + if help { + writeProcessUsage(stdout) return 0 } - - parseArgs := args - transcriptFromFront := "" - if len(args) > 0 && !strings.HasPrefix(args[0], "-") { - transcriptFromFront = args[0] - parseArgs = args[1:] + selected, err := loadConfigInput(input, "process") + if err != nil { + fmt.Fprintf(stderr, "audita process: %s\n", configInputError(err)) + return 2 } - - if err := fs.Parse(parseArgs); err != nil { - if errors.Is(err, flag.ErrHelp) { - writeProcessUsage(stdout, fs) - return 0 + version := selected.Result.Config.Version + explicitModules := false + for _, override := range input.Overrides { + if override.Path.String() == ck.Keys("pipeline", "modules").String() { + explicitModules = true } - return 2 - } - - overrides, explicitModules := processCLIOverrides(fs, pFlags) - - if err := cfg.ApplyCLIOverrides(overrides); err != nil { - fmt.Fprintf(stderr, "audita process: invalid CLI configuration: %v\n", err) - return 2 - } - - remaining := fs.Args() - positional := make([]string, 0, len(remaining)+1) - if transcriptFromFront != "" { - positional = append(positional, transcriptFromFront) - } - positional = append(positional, remaining...) - - if len(positional) != 1 { - fmt.Fprintln(stderr, "audita process: expected exactly 1 transcript JSON path argument") - return 2 - } - if strings.TrimSpace(*pFlags.glossaryPath) == "" { - fmt.Fprintln(stderr, "audita process: --glossary is required") - return 2 } inv := processInvocation{ - TranscriptPath: positional[0], - GlossaryPath: *pFlags.glossaryPath, - OutputPath: *pFlags.outputPath, - ReportJSONPath: *pFlags.reportJSONPath, - Config: cfg, - ConfigPath: configPath, - ConfigSource: configSource, - ConfigVersion: configVersion, + TranscriptPath: input.TranscriptPath, + GlossaryPath: input.GlossaryPath, + OutputPath: input.OutputPath, + ReportJSONPath: input.ReportPath, + Config: selected.Result.Config, + ConfigResult: selected.Result, + ConfigPath: selected.Path, + ConfigSource: selected.Source, + ConfigVersion: &version, ExplicitModules: explicitModules, } @@ -531,7 +489,7 @@ func runProcess(args []string, stdout, stderr io.Writer) int { } func runConfig(args []string, stdout, stderr io.Writer) int { - if len(args) == 0 || isHelpCommand(args) || hasHelpFlag(args) { + if len(args) == 0 || isHelpCommand(args) || args[0] == "--help" || args[0] == "-h" { writeConfigUsage(stdout) return 0 } @@ -549,38 +507,17 @@ func runConfig(args []string, stdout, stderr io.Writer) int { } func runConfigValidate(args []string, stdout, stderr io.Writer) int { - fs := flag.NewFlagSet("config validate", flag.ContinueOnError) - fs.SetOutput(stderr) - configPath := fs.String("config", "", "Path to versioned YAML config file") - - if err := fs.Parse(args); err != nil { - if errors.Is(err, flag.ErrHelp) { - writeConfigValidateUsage(stdout) - return 0 - } - return 2 - } - if strings.TrimSpace(*configPath) == "" { - fmt.Fprintln(stderr, "audita config validate: --config is required") - return 2 - } - if len(fs.Args()) != 0 { - fmt.Fprintln(stderr, "audita config validate: unexpected positional arguments") - return 2 - } - - fileCfg, err := config.LoadFileConfig(strings.TrimSpace(*configPath)) + input, help, err := parseConfigInput("validate", args, stderr) if err != nil { - fmt.Fprintf(stderr, "audita config validate: %v\n", err) + fmt.Fprintf(stderr, "audita config validate: %s\n", configInputError(err)) return 2 } - cfg := config.Default() - if err := cfg.ApplyFileConfig(fileCfg); err != nil { - fmt.Fprintf(stderr, "audita config validate: %v\n", err) - return 2 + if help { + writeConfigValidateUsage(stdout) + return 0 } - if err := cfg.Validate(); err != nil { - fmt.Fprintf(stderr, "audita config validate: %v\n", err) + if _, err := loadConfigInput(input, "validate"); err != nil { + fmt.Fprintf(stderr, "audita config validate: %s\n", configInputError(err)) return 2 } fmt.Fprintln(stdout, "config is valid") @@ -588,39 +525,21 @@ func runConfigValidate(args []string, stdout, stderr io.Writer) int { } func runConfigPrintEffective(args []string, stdout, stderr io.Writer) int { - fs := flag.NewFlagSet("config print-effective", flag.ContinueOnError) - fs.SetOutput(stderr) - configPath := fs.String("config", "", "Path to versioned YAML config file") - if err := fs.Parse(args); err != nil { - if errors.Is(err, flag.ErrHelp) { - writeConfigPrintEffectiveUsage(stdout) - return 0 - } - return 2 - } - if len(fs.Args()) != 0 { - fmt.Fprintln(stderr, "audita config print-effective: unexpected positional arguments") - return 2 - } - - configPathValue := strings.TrimSpace(*configPath) - configPathSet := configPathValue != "" - effectiveConfig, err := config.LoadEffectiveConfig(configPathValue, configPathSet) + input, help, err := parseConfigInput("print-effective", args, stderr) if err != nil { - fmt.Fprintf(stderr, "audita config print-effective: %v\n", err) + fmt.Fprintf(stderr, "audita config print-effective: %s\n", configInputError(err)) return 2 } - - cfg := effectiveConfig.Config - - redacted := cfg.Redacted() - out, err := json.MarshalIndent(redacted, "", " ") - if err != nil { - fmt.Fprintf(stderr, "audita config print-effective: %v\n", err) - return 1 + if help { + writeConfigPrintEffectiveUsage(stdout) + return 0 } - out = append(out, '\n') - if _, err := stdout.Write(out); err != nil { + selected, err := loadConfigInput(input, "print-effective") + if err != nil { + fmt.Fprintf(stderr, "audita config print-effective: %s\n", configInputError(err)) + return 2 + } + if err := selected.Result.WriteEffective(stdout); err != nil { fmt.Fprintf(stderr, "audita config print-effective: %v\n", err) return 1 } @@ -662,128 +581,6 @@ func processReportInput(status string, inv processInvocation, runDir *diagnostic } } -type processFlags struct { - configPath *string - glossaryPath *string - outputPath *string - reportJSONPath *string - modules *string - outputSchema *string - llmAPIKey *string - validationLLMAPIKey *string - model *string - validationModel *string - baseURL *string - validationBaseURL *string - llmTimeoutSeconds *int - totalLLMConcurrency *int - proposalLLMConcurrency *int - llmConcurrency *int - validationLLMTimeoutSeconds *int - validationMaxPromptTokens *int - targetSections *int - maxRetries *int - validationMaxRetries *int - validationLLMConcurrency *int - maxSectionTokens *int - minSectionTokens *int - glossaryConfidenceThreshold *float64 - grammarConfidenceThreshold *float64 - homophonesConfidenceThreshold *float64 - spokenWordConfidenceThreshold *float64 - normalizeMaxSegmentGap *float64 - normalizeEllipsisGap *float64 - normalizeMaxSegmentDuration *float64 - normalizeMaxSegmentTokens *int - transcriptDescription *string - workDir *string - workDirRetention *string -} - -func newProcessFlagSet(cfg config.Config, stderr io.Writer) (*flag.FlagSet, processFlags) { - fs := flag.NewFlagSet("process", flag.ContinueOnError) - fs.SetOutput(stderr) - - validationTimeoutSecondsDefault := cfg.PrimaryLLM.TimeoutSeconds - if cfg.ValidationLLM.TimeoutSeconds != nil { - validationTimeoutSecondsDefault = *cfg.ValidationLLM.TimeoutSeconds - } - - validationMaxRetriesDefault := cfg.PrimaryLLM.MaxRetries - if cfg.ValidationLLM.MaxRetries != nil { - validationMaxRetriesDefault = *cfg.ValidationLLM.MaxRetries - } - - validationLLMConcurrencyDefault := cfg.TotalLLMConcurrency - if cfg.ValidationLLMConcurrency != nil { - validationLLMConcurrencyDefault = *cfg.ValidationLLMConcurrency - } - - targetSectionsDefault := 0 - if cfg.TargetSections != nil { - targetSectionsDefault = *cfg.TargetSections - } - - pFlags := processFlags{ - configPath: fs.String("config", "", "Path to versioned YAML config file"), - 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", strings.Join(cfg.Modules, ","), "Comma-separated module sequence override"), - outputSchema: fs.String("output-schema", cfg.OutputSchema, "Output schema: bare-segments|audita-v1"), - 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"), - totalLLMConcurrency: fs.Int("total-llm-concurrency", cfg.TotalLLMConcurrency, "Total concurrent LLM calls across proposal and validation"), - proposalLLMConcurrency: fs.Int("proposal-llm-concurrency", cfg.EffectiveProposalLLMConcurrency(), "Concurrent proposal-generation LLM calls"), - llmConcurrency: fs.Int("llm-concurrency", cfg.TotalLLMConcurrency, "Alias for --total-llm-concurrency"), - validationLLMTimeoutSeconds: fs.Int("validation-llm-timeout-seconds", validationTimeoutSecondsDefault, "Validation LLM timeout in seconds"), - validationMaxPromptTokens: fs.Int("validation-max-prompt-tokens", cfg.ValidationMaxPromptTokens, "Validation max prompt tokens"), - targetSections: fs.Int("target-sections", targetSectionsDefault, "Target number of transcript sections"), - maxRetries: fs.Int("max-retries", cfg.PrimaryLLM.MaxRetries, "Maximum structured-output retries"), - validationMaxRetries: fs.Int("validation-max-retries", validationMaxRetriesDefault, "Validation structured-output retries"), - validationLLMConcurrency: fs.Int("validation-llm-concurrency", validationLLMConcurrencyDefault, "Concurrent validation LLM calls (inherits total when unset)"), - maxSectionTokens: fs.Int("max-section-tokens", cfg.MaxSectionTokens, "Maximum section tokens"), - minSectionTokens: fs.Int("min-section-tokens", cfg.MinSectionTokens, "Minimum section tokens"), - 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"), - transcriptDescription: fs.String("transcript-description", cfg.TranscriptDescription, "Brief background context for LLM prompts; does not override transcript content"), - 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"), - } - - return fs, pFlags -} - -func findConfigPathOverride(args []string) (path string, set bool, err error) { - for i := 0; i < len(args); i++ { - arg := strings.TrimSpace(args[i]) - if arg == "" { - continue - } - if arg == "--config" { - if i+1 >= len(args) { - return "", false, fmt.Errorf("--config requires a path") - } - return strings.TrimSpace(args[i+1]), true, nil - } - if strings.HasPrefix(arg, "--config=") { - return strings.TrimSpace(strings.TrimPrefix(arg, "--config=")), true, nil - } - } - return "", false, nil -} - func isHelpCommand(args []string) bool { if len(args) == 0 { return false @@ -803,15 +600,6 @@ func isHelpCommand(args []string) bool { return false } -func hasHelpFlag(args []string) bool { - for _, arg := range args { - if arg == "-h" || arg == "--help" { - return true - } - } - return false -} - func writeRootUsage(w io.Writer) { fmt.Fprintln(w, "Audita is a transcript processing CLI.") fmt.Fprintln(w) @@ -834,33 +622,40 @@ func writeConfigUsage(w io.Writer) { fmt.Fprintln(w) fmt.Fprintln(w, "Commands:") fmt.Fprintln(w, " validate Validate a versioned YAML config file") - fmt.Fprintln(w, " print-effective Print redacted effective config JSON (defaults + config file + env)") + fmt.Fprintln(w, " print-effective Print redacted effective settings YAML") } func writeConfigValidateUsage(w io.Writer) { fmt.Fprintln(w, "Validate a versioned YAML config file.") fmt.Fprintln(w) fmt.Fprintln(w, "Usage:") - fmt.Fprintln(w, " audita config validate --config ") + fmt.Fprintln(w, " audita config validate --config [--overlay ] [setting flags]") } func writeConfigPrintEffectiveUsage(w io.Writer) { - fmt.Fprintln(w, "Print redacted effective config JSON.") + fmt.Fprintln(w, "Print redacted effective settings YAML.") fmt.Fprintln(w) fmt.Fprintln(w, "Usage:") - fmt.Fprintln(w, " audita config print-effective [--config ]") + fmt.Fprintln(w, " audita config print-effective [--config ] [--overlay ] [setting flags]") } -func writeProcessUsage(w io.Writer, fs *flag.FlagSet) { +func writeProcessUsage(w io.Writer) { fmt.Fprintln(w, "Process a transcript JSON file.") fmt.Fprintln(w) fmt.Fprintln(w, "Usage:") fmt.Fprintln(w, " audita process [flags]") fmt.Fprintln(w) fmt.Fprintln(w, "Flags:") - fs.VisitAll(func(f *flag.Flag) { - fmt.Fprintf(w, " --%s\n", f.Name) - }) + for _, name := range []string{ + "config", "overlay", "glossary", "output", "report-json", + "proposal-profile", "validation-profile", "modules", "output-schema", + "validation-max-prompt-tokens", "target-sections", "max-section-tokens", "min-section-tokens", + "normalize-max-segment-gap", "normalize-ellipsis-gap", "normalize-max-segment-duration", "normalize-max-segment-tokens", + "glossary-confidence-threshold", "homophones-confidence-threshold", "spoken-word-confidence-threshold", "grammar-confidence-threshold", + "transcript-description", "work-dir", "work-dir-retention", + } { + fmt.Fprintf(w, " --%s\n", name) + } fmt.Fprintln(w) fmt.Fprintln(w, "Example:") fmt.Fprintln(w, " audita process transcript.json --glossary glossary.yaml --output corrected.json")