Unify CLI configuration selection and overrides

This commit is contained in:
2026-09-24 01:38:49 +00:00
parent 9fb89057ce
commit 51e370b4c8
8 changed files with 486 additions and 1011 deletions

View File

@@ -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 <transcript.json> --glossary <glossary.yml> [flags]
audita config validate --config <config.yml> [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 <path>` and `--overlay <name>`. `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 <transcript.json> [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 <path>` 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>`: path to versioned YAML config file.
- `--glossary <path>`: glossary YAML input path (required).
- `--output <path>`: corrected transcript JSON output file path.
- `--report-json <path>`: machine-readable report JSON output path.
- `--output-schema <key>`: output encoder selection; see [configuration](config.md#pipeline-and-output).
- `--modules <csv>`: comma-separated module sequence override.
Primary LLM flags:
- `--llm-api-key <value>`: primary LLM API key.
- `--model <name>`: primary LLM model name.
- `--base-url <url>`: primary OpenAI-compatible base URL.
- `--llm-timeout-seconds <int>`: primary timeout in seconds.
- `--max-retries <int>`: primary structured-output retries.
Validation LLM flags:
- `--validation-llm-api-key <value>`: validation LLM API key.
- `--validation-model <name>`: validation LLM model name.
- `--validation-base-url <url>`: validation OpenAI-compatible base URL.
- `--validation-llm-timeout-seconds <int>`: validation timeout in seconds.
- `--validation-max-retries <int>`: validation structured-output retries.
- `--validation-max-prompt-tokens <int>`: validation max prompt tokens.
Concurrency flags:
- `--total-llm-concurrency <int>`: total concurrent proposal+validation LLM calls.
- `--proposal-llm-concurrency <int>`: concurrent proposal-generation LLM calls.
- `--validation-llm-concurrency <int>`: concurrent validation LLM calls.
- `--llm-concurrency <int>`: alias for `--total-llm-concurrency`.
Chunking and normalization flags:
- `--target-sections <int>`: target number of transcript sections.
- `--max-section-tokens <int>`: maximum section tokens.
- `--min-section-tokens <int>`: minimum section tokens.
- `--normalize-max-segment-gap <float>`: maximum same-speaker merge gap.
- `--normalize-ellipsis-gap <float>`: gap threshold for ellipsis insertion.
- `--normalize-max-segment-duration <float>`: maximum merged segment duration.
- `--normalize-max-segment-tokens <int>`: maximum merged segment token estimate.
Threshold flags:
- `--glossary-confidence-threshold <float>`
- `--homophones-confidence-threshold <float>`
- `--spoken-word-confidence-threshold <float>`
- `--grammar-confidence-threshold <float>`
Context and diagnostics flags:
- `--transcript-description <text>`: background context for prompts; does not override transcript content.
- `--work-dir <path>`: per-run diagnostics work directory.
- `--work-dir-retention <value>`: 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 <path>`, 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 <path>`. 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.

View File

@@ -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/<name>.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,

View File

@@ -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.

View File

@@ -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, "; ")
}

View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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 "<nil>"
}
if stringer, ok := any(*ptr).(interface{ String() string }); ok {
return strings.TrimSpace(stringer.String())
}
return *ptr
}

View File

@@ -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 <path>")
fmt.Fprintln(w, " audita config validate --config <path> [--overlay <name>] [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 <path>]")
fmt.Fprintln(w, " audita config print-effective [--config <path>] [--overlay <name>] [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 <transcript.json> [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")