From 9fb89057ce487f787773b0ff6bb55178dadb53c7 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 24 Sep 2026 01:33:03 +0000 Subject: [PATCH] Replace legacy configuration loading with ConfigKit schema two --- .woodpecker/release.yml | 2 +- README.md | 2 +- docs/config.md | 232 ++---- docs/development.md | 2 +- docs/roadmap/implementation.md | 8 +- go.mod | 15 +- go.sum | 14 + internal/core/config/apply_helpers.go | 171 ---- internal/core/config/config.go | 328 ++++---- internal/core/config/config_test.go | 766 +++--------------- internal/core/config/effective_config.go | 119 --- internal/core/config/effective_config_test.go | 163 ---- internal/core/config/env.go | 269 ------ internal/core/config/file_config.go | 322 -------- internal/core/config/file_config_test.go | 290 ------- internal/core/config/flags.go | 106 --- internal/core/config/redaction.go | 17 - internal/core/config/validation.go | 136 ---- 18 files changed, 354 insertions(+), 2608 deletions(-) delete mode 100644 internal/core/config/apply_helpers.go delete mode 100644 internal/core/config/effective_config.go delete mode 100644 internal/core/config/effective_config_test.go delete mode 100644 internal/core/config/env.go delete mode 100644 internal/core/config/file_config.go delete mode 100644 internal/core/config/file_config_test.go delete mode 100644 internal/core/config/flags.go delete mode 100644 internal/core/config/redaction.go delete mode 100644 internal/core/config/validation.go diff --git a/.woodpecker/release.yml b/.woodpecker/release.yml index 9ac9fee..9350927 100644 --- a/.woodpecker/release.yml +++ b/.woodpecker/release.yml @@ -3,7 +3,7 @@ when: steps: - name: build-release-assets - image: golang:1.25 + image: golang:1.25.5 commands: - bash scripts/build-release.sh "$CI_COMMIT_TAG" dist diff --git a/README.md b/README.md index 20b3171..40f2e12 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ reports for review and subprocess integration. ## Quickstart -From a checkout, build with Go 1.24 or newer: +From a checkout, build with Go 1.25.5 or newer: ```sh go build -o ./bin/audita ./cmd/audita diff --git a/docs/config.md b/docs/config.md index 1cf7fba..990e898 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1,188 +1,68 @@ # Audita Configuration -This is the canonical reference for discovery, settings, defaults, environment -mapping, and validation. [CLI syntax](cli.md), [artifact lifecycle](operations.md), -and [file formats](integrations/output-files.md) have separate owners. +This reference describes the version-2 configuration loader in `internal/core/config`. +CLI command parsing and profile inspection are being connected in later work; the +current CLI is not yet a usable entry point for this schema. Complete copyable +examples will be migrated with that integration. -## Loading Model +## Loading and precedence -For processing and effective-config inspection, the first applicable source -selects one YAML file: +The application selects one main YAML 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, +or non-integer `version: 2` are errors. Version-1 files require migration; no +compatibility loader or implicit environment configuration remains. -1. Explicit CLI config path. -2. `AUDITA_CONFIG`. -3. `/usr/local/etc/audita/config.yml`, if present. -4. `/etc/audita/config.yml`, if present. +Validation runs after overlays, explicit overrides, and derived settings have +resolved. An override can therefore repair an invalid lower-precedence setting +when that setting can be decoded. ConfigKit retains field origins and derivations +for effective-configuration inspection. Its effective YAML redacts resolved +credentials. -These files are alternatives, not layers merged together. Missing system files -are skipped. An empty or missing explicitly selected path is an error, as is an -inaccessible selected file. Without a selected file, built-in defaults apply. -The validation command requires an explicit file and does not use discovery. +## Settings -## Effective Precedence - -| Operation | Applied sources, lowest to highest precedence | -| --- | --- | -| Process | Built-in defaults, selected file, environment overrides, explicit CLI overrides | -| Print effective configuration | Built-in defaults, selected file, environment overrides | -| Validate configuration file | Built-in defaults, explicit file | - -File validation does not apply configuration environment overrides, but it still -resolves environment variables named by `api_key_env` fields. Each configuration -layer is validated as it is applied: a later override cannot repair an invalid -earlier layer. [Config commands](cli.md#configuration-commands) provide inspection -and validation without processing a transcript or contacting an LLM. - -## File Schema - -The file is YAML with required `version: 1`. Unknown fields are rejected. -The remaining sections and fields below are optional. Omitted values preserve -lower-precedence settings, except for the documented inheritance rules. -Complete copyable files are in [examples](#examples). - -### Pipeline and Output - -| YAML field | Default | Meaning | +| Field | Default | Rule | | --- | --- | --- | -| `pipeline.modules` | `[glossary, homophones, glossary, spoken_word, grammar]` | Ordered module sequence. Supported keys are `glossary`, `homophones`, `spoken_word`, and `grammar`; repeated keys are allowed. An omitted or empty YAML list leaves the sequence unchanged. | -| `output.schema` | `bare-segments` | Output encoder selection: `bare-segments` or `audita-v1`. See [output formats](integrations/output-files.md#corrected-transcript). | +| `profiles_dir` | `profiles` | Nonempty; profile files are resolved relative to the selected main file. | +| `backends..endpoint` | None | Required nonempty endpoint for each backend. At least one backend is required. | +| `backends..concurrency` | `1` | Positive finite capacity; shared by both roles using that backend. | +| `backends..queue_capacity` | `1024` | Nonnegative; zero disables waiting admission. | +| `backends..credential` | Unset | Optional environment-variable secret reference; omission or null means unauthenticated use. | +| `llm.proposal_profile` | None | Required nonempty profile ID. | +| `llm.validation_profile` | Final proposal profile | Omission inherits the final proposal selection; explicit empty or null fails. | +| `llm.transient_retries` | `2` | Nonnegative additional attempts per generation. | +| `llm.repair_attempts` | `2` | Additional structural repair generations, from zero through three. | +| `llm.operation_timeout` | `15m` | Positive Go duration string per logical call. | +| `pipeline.modules` | `[glossary, homophones, glossary, spoken_word, grammar]` | Ordered module keys; repeats are meaningful and an explicit empty list runs no correction modules. | +| `output.schema` | `bare-segments` | `bare-segments` or `audita-v1`. See [output formats](integrations/output-files.md). | +| `validation.max_prompt_tokens` | `2048` | Positive validation batching budget. | +| `chunking.target_sections` | Unset | Optional positive target; omission or null selects automatic chunking. | +| `chunking.max_section_tokens` | `8192` | Positive maximum estimate. | +| `chunking.min_section_tokens` | `2048` | Positive and no greater than maximum. | +| `normalization.max_segment_gap` | `4.0` | Nonnegative numeric seconds. | +| `normalization.ellipsis_gap` | `3.5` | Nonnegative numeric seconds. | +| `normalization.max_segment_duration` | `60.0` | Positive numeric seconds. | +| `normalization.max_segment_tokens` | `2048` | Positive token estimate. | +| `thresholds.glossary`, `.homophones`, `.spoken_word`, `.grammar` | `0.8` each | Confidence thresholds in `[0, 1]`. | +| `context.description` | Empty | Trimmed prompt context, at most 500 bytes. | +| `diagnostics.work_dir` | `/tmp/audita` | Nonempty artifact parent directory. | +| `diagnostics.retention` | `auto` | `auto`, `always`, or `never`; see [operations](operations.md). | -Module keys select correction stages. Validator chains are built into each module; -there is no configuration field for selecting individual validator keys. +Module keys select correction modules; validator chains remain built into them. +The loader preserves module order and repetitions. Normalization time settings +accept numeric seconds only. The operation timeout uses a duration string. -### LLM Settings +## Credentials -The same fields are supported under `llm.proposal` and `llm.validation`. +A backend credential is a ConfigKit secret reference containing an environment +variable name, such as `credential: AUDITA_SERVICE_KEY`. Every reference that +survives overlay and override resolution must name a set, nonempty variable, +including credentials on backends unused by the selected roles. A replacement +reference resolves only the replacement; null removes the reference. General +`AUDITA_*` and `OPENROUTER_API_KEY` variables have no implicit setting effect. +Secret values are available to the runtime through the resolved reference, while +ConfigKit's effective output and provenance contain no resolved secret values. -| Field suffix | Proposal default | Meaning | -| --- | --- | --- | -| `base_url` | `https://openrouter.ai/api/v1` | Endpoint base URL; the [LLM contract](integrations/openai-compatible-llm.md#endpoint-and-authentication) defines how it is used. | -| `model` | `openrouter/google/gemma-4-31b-it` | Model identifier sent to the endpoint. Availability is determined by that endpoint. | -| `api_key_env` | Unset | Name of an environment variable containing the API key; see [secrets](#secrets). | -| `timeout` | `600` seconds | Positive integer seconds or a duration string resolving to whole seconds, such as `120s`. | -| `max_retries` | `3` | Non-negative retry count after the initial attempt; retry conditions belong to the [LLM contract](integrations/openai-compatible-llm.md#retries-and-timeouts). | - -Validation settings inherit the effective proposal settings individually. Empty -validation model, base URL, or API key values inherit the proposal value; omitted -validation timeout and retry count inherit, while an explicit retry count of zero -is retained in configuration. The current HTTP adapter interprets a retry count -of zero as its default of three retries, so zero does not disable retries. -No API key is required by local config validation. Model and base-URL -usability are checked during client setup/requests, not by an online config check. - -### Concurrency - -| YAML field | Default | Meaning | -| --- | --- | --- | -| `concurrency.total_llm` | `1` | Positive total concurrent LLM-call limit. | -| `concurrency.proposal_llm` | `1` | Positive proposal-call limit, no greater than total. | -| `concurrency.validation_llm` | Inherits total | Positive validation-call limit, no greater than total. | - -In a YAML file, setting total alone does not change proposal concurrency. At the -environment or CLI layer, setting total without setting proposal at that same -layer also sets proposal to that total, even if a lower layer supplied a proposal -value. Unset validation concurrency inherits the final total. The canonical total -setting wins over its legacy alias when both appear at the same layer. - -Validation prompt budgeting is available through environment or CLI only: -`AUDITA_VALIDATION_MAX_PROMPT_TOKENS` has default `2048` and must be positive. -There is no YAML field for it. - -### Chunking and Normalization - -| YAML field | Default | Meaning and validation | -| --- | --- | --- | -| `chunking.target_sections` | Unset | Optional positive target count. Processing rejects targets impossible for the normalized transcript and token constraints. | -| `chunking.max_section_tokens` | `8192` | Positive maximum section token estimate. | -| `chunking.min_section_tokens` | `2048` | Positive minimum used in chunk balancing; no greater than maximum. | -| `normalization.max_segment_gap` | `4.0` seconds | Non-negative gap limit for same-speaker merging. | -| `normalization.ellipsis_gap` | `3.5` seconds | Non-negative gap threshold for inserting an ellipsis during merging. | -| `normalization.max_segment_duration` | `60.0` seconds | Positive merged-segment duration limit. | -| `normalization.max_segment_tokens` | `2048` | Positive merged-segment token estimate limit. | - -The three normalization time fields accept numeric seconds or YAML duration -strings. Their environment and CLI overrides use numeric seconds. Section token -counts are estimates; config validation does not test whether a particular input -can satisfy the selected chunking target. - -### Thresholds, Context, and Diagnostics - -| YAML field | Default | Meaning and validation | -| --- | --- | --- | -| `thresholds.glossary` | `0.8` | Glossary correction confidence threshold, in `[0, 1]`. | -| `thresholds.homophones` | `0.8` | Homophone correction confidence threshold, in `[0, 1]`. | -| `thresholds.spoken_word` | `0.8` | Spoken-word correction confidence threshold, in `[0, 1]`. | -| `thresholds.grammar` | `0.8` | Grammar correction confidence threshold, in `[0, 1]`. | -| `context.description` | Empty | Trimmed background context for prompts; limited to 500 bytes of UTF-8 text after trimming. It does not replace transcript content. | -| `diagnostics.work_dir` | `/tmp/audita` | Non-empty parent directory for run artifacts. | -| `diagnostics.retention` | `auto` | Accepted values: `auto`, `always`, `never`. Exact lifecycle behavior, including the current behavior of `never`, belongs to [operations](operations.md#retention-behavior). | - -## Environment Overrides - -Values are read when the variable is set. Empty values are not generally equivalent -to unset values: numeric overrides must parse, and empty strings may clear a -setting or fail validation. Numeric environment values use integers or floating -point seconds as indicated above, not duration strings. - -| Environment variable | Setting | -| --- | --- | -| `AUDITA_CONFIG` | Config file selection | -| `AUDITA_MODULES` | Module sequence as comma-separated keys | -| `AUDITA_LLM_API_KEY` | Direct proposal API key | -| `OPENROUTER_API_KEY` | Proposal API key fallback when `AUDITA_LLM_API_KEY` is unset | -| `AUDITA_MODEL` | Proposal model | -| `AUDITA_BASE_URL` | Proposal endpoint | -| `AUDITA_LLM_TIMEOUT_SECONDS` | Proposal timeout | -| `AUDITA_MAX_RETRIES` | Proposal retries | -| `AUDITA_VALIDATION_LLM_API_KEY` | Direct validation API key | -| `AUDITA_VALIDATION_MODEL` | Validation model | -| `AUDITA_VALIDATION_BASE_URL` | Validation endpoint | -| `AUDITA_VALIDATION_LLM_TIMEOUT_SECONDS` | Validation timeout | -| `AUDITA_VALIDATION_MAX_RETRIES` | Validation retries | -| `AUDITA_TOTAL_LLM_CONCURRENCY` | Total concurrency | -| `AUDITA_LLM_CONCURRENCY` | Legacy total-concurrency alias | -| `AUDITA_PROPOSAL_LLM_CONCURRENCY` | Proposal concurrency | -| `AUDITA_VALIDATION_LLM_CONCURRENCY` | Validation concurrency | -| `AUDITA_VALIDATION_MAX_PROMPT_TOKENS` | Validation prompt budget | -| `AUDITA_TARGET_SECTIONS` | Section target | -| `AUDITA_MAX_SECTION_TOKENS` | Section maximum | -| `AUDITA_MIN_SECTION_TOKENS` | Section minimum | -| `AUDITA_NORMALIZE_MAX_SEGMENT_GAP` | Merge gap | -| `AUDITA_NORMALIZE_ELLIPSIS_GAP` | Ellipsis gap | -| `AUDITA_NORMALIZE_MAX_SEGMENT_DURATION` | Merged duration limit | -| `AUDITA_NORMALIZE_MAX_SEGMENT_TOKENS` | Merged token estimate limit | -| `AUDITA_GLOSSARY_CONFIDENCE_THRESHOLD` | Glossary threshold | -| `AUDITA_HOMOPHONES_CONFIDENCE_THRESHOLD` | Homophone threshold | -| `AUDITA_SPOKEN_WORD_CONFIDENCE_THRESHOLD` | Spoken-word threshold | -| `AUDITA_GRAMMAR_CONFIDENCE_THRESHOLD` | Grammar threshold | -| `AUDITA_WORK_DIR` | Diagnostics directory | -| `AUDITA_WORK_DIR_RETENTION` | Retention selection | - -There is no environment override for output schema or transcript description. -Use file configuration or the corresponding [CLI flags](cli.md#process-flag-reference). - -## Secrets - -File config accepts environment variable names through `api_key_env`, not literal -API-key fields. Names must be non-empty and match an environment-variable -identifier; an unset named variable resolves to an empty value. Runtime environment -key overrides take precedence over keys resolved from a file. CLI key flags supply -direct values and take precedence over environment settings. - -Effective-config inspection redacts API keys. Configured LLM secrets are also -redacted by the runtime's error and diagnostic handling, but transcript content -is still sensitive: see [artifact handling](operations.md#artifact-handling). - -## Examples - -- [Minimal configuration](../examples/minimal-config.yml): starts from defaults - and obtains the proposal key from the environment. -- [Production-oriented configuration](../examples/production-config.yml): illustrates - both LLM targets, concurrency, chunking, normalization, thresholds, and diagnostics. - Its explicit section target is intended for larger inputs, not the tiny sample. -- [Tiny transcript](../examples/tiny-transcript.json) and - [tiny glossary](../examples/tiny-glossary.yaml): inputs for the README quickstart. - -Use the [CLI configuration commands](cli.md#configuration-commands) to validate -files and inspect effective settings. Validation checks local structure and -constraints; it does not verify credentials, model availability, or endpoint access. +The CLI reference will be updated as the shared input parser and profile inspection +commands are connected. Current invocation syntax belongs in [CLI](cli.md). diff --git a/docs/development.md b/docs/development.md index 9cdd45d..f72b376 100644 --- a/docs/development.md +++ b/docs/development.md @@ -20,7 +20,7 @@ ownership. Commit discipline and agent Git restrictions are in the ## Baseline Validation -Use Go 1.24 or newer. From the repository root: +Use Go 1.25.5 or newer. From the repository root: ```sh go build -o ./bin/audita ./cmd/audita diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index d2d7b9a..7b77231 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -108,7 +108,13 @@ user-facing policy belong to the feature roadmaps. ## Stage 1: Replace the Configuration Model and Loader -**Status:** Pending. **Depends on:** the completed prototype only. +**Status:** Complete (2026-09-24). Replaced the version-1 loader with the ConfigKit +schema-2 model, selected overlays, typed secret references, final role inheritance, +and retained domain validation. Pinned both libraries and Go 1.25.5, and aligned +the release build image. `go test ./internal/core/config` and `git diff --check` +passed. `go test ./...` currently fails in old CLI, validator, and diagnostic +callers of removed fields; those connections are assigned to later stages. +**Depends on:** the completed prototype only. **Owns:** root `go.mod`/`go.sum`, `internal/core/config`, and config tests. 1. Pin both modules and the Go minimum above. Align build/CI toolchain declarations diff --git a/go.mod b/go.mod index 2cf1566..4d272fc 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,20 @@ module gitea.maximumdirect.net/eric/audita -go 1.24.0 - -require gopkg.in/yaml.v3 v3.0.1 +go 1.25.5 require ( + gitea.maximumdirect.net/eric/ConfigKit v0.4.0 + gitea.maximumdirect.net/eric/promptkit v0.11.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v2 v2.20260922.1 // indirect + gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2 v2.20260922.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect + golang.org/x/text v0.14.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) diff --git a/go.sum b/go.sum index 00eced4..ed737bb 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,11 @@ +gitea.maximumdirect.net/eric/ConfigKit v0.4.0 h1:fo+vKff3zTGeWqJIA38NtT84b6OUP//9ZmBH3lFuKgk= +gitea.maximumdirect.net/eric/ConfigKit v0.4.0/go.mod h1:tYWpsrbLyu05r8+1MapWp5fN6ZrX9I8hU2xEPedYmYk= +gitea.maximumdirect.net/eric/promptkit v0.11.0 h1:x7JguOUiInPVXK0GEitjyoRFqqvcuSTjYUvqJ+whtik= +gitea.maximumdirect.net/eric/promptkit v0.11.0/go.mod h1:0zEBVlJJC+ldDdXazAgybYBXNSe74vwFx5dyOuSos/w= +gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v2 v2.20260922.1 h1:S2/Q/JDE+m8Ca3pDhAjVbfYrdlEhs9blj0STgNopHo0= +gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v2 v2.20260922.1/go.mod h1:a3VBbh9oaGXD6ruxhtwHj+AWW6DY/SSyby1QtMmYSUo= +gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2 v2.20260922.0 h1:Y0EXc3eh12eKW2NrOviVqdk3oBo6UUjopt1YAa1B3Oc= +gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2 v2.20260922.0/go.mod h1:mtgD1E0SGm0e/I8Ffhz+AYX7GN7RKUWJXehNFIKHs4Y= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -10,6 +18,12 @@ github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsK github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4= +go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/core/config/apply_helpers.go b/internal/core/config/apply_helpers.go deleted file mode 100644 index fd222ae..0000000 --- a/internal/core/config/apply_helpers.go +++ /dev/null @@ -1,171 +0,0 @@ -package config - -import "strings" - -type llmTargetPatch struct { - apiKey *string - model *string - baseURL *string - timeoutSeconds *int - maxRetries *int -} - -type concurrencyPatch struct { - totalLLM *int - legacyTotalLLM *int - proposalLLM *int - validationLLM *int - inheritProposal bool - allowLegacyAlias bool -} - -type chunkingPatch struct { - targetSections *int - maxSectionTokens *int - minSectionTokens *int -} - -type thresholdsPatch struct { - glossary *float64 - grammar *float64 - homophones *float64 - spokenWord *float64 -} - -type normalizationPatch struct { - maxSegmentGap *float64 - ellipsisGap *float64 - maxSegmentDuration *float64 - maxSegmentTokens *int -} - -type contextPatch struct { - transcriptDescription *string -} - -type diagnosticsPatch struct { - workDir *string - workDirRetention *string -} - -func (c *Config) applyPrimaryLLMTargetPatch(patch llmTargetPatch) { - if patch.apiKey != nil { - c.PrimaryLLM.APIKey = *patch.apiKey - } - if patch.model != nil { - c.PrimaryLLM.Model = *patch.model - } - if patch.baseURL != nil { - c.PrimaryLLM.BaseURL = *patch.baseURL - } - if patch.timeoutSeconds != nil { - c.PrimaryLLM.TimeoutSeconds = *patch.timeoutSeconds - } - if patch.maxRetries != nil { - c.PrimaryLLM.MaxRetries = *patch.maxRetries - } -} - -func (c *Config) applyValidationLLMTargetPatch(patch llmTargetPatch) { - if patch.apiKey != nil { - c.ValidationLLM.APIKey = *patch.apiKey - } - if patch.model != nil { - c.ValidationLLM.Model = *patch.model - } - if patch.baseURL != nil { - c.ValidationLLM.BaseURL = *patch.baseURL - } - if patch.timeoutSeconds != nil { - value := *patch.timeoutSeconds - c.ValidationLLM.TimeoutSeconds = &value - } - if patch.maxRetries != nil { - value := *patch.maxRetries - c.ValidationLLM.MaxRetries = &value - } -} - -func (c *Config) applyConcurrencyPatch(patch concurrencyPatch) { - totalSet := false - if patch.totalLLM != nil { - c.TotalLLMConcurrency = *patch.totalLLM - totalSet = true - } - if patch.allowLegacyAlias && patch.legacyTotalLLM != nil && !totalSet { - c.TotalLLMConcurrency = *patch.legacyTotalLLM - totalSet = true - } - - proposalSet := false - if patch.proposalLLM != nil { - c.ProposalLLMConcurrency = *patch.proposalLLM - proposalSet = true - } - if patch.inheritProposal && totalSet && !proposalSet { - c.ProposalLLMConcurrency = c.TotalLLMConcurrency - } - - if patch.validationLLM != nil { - value := *patch.validationLLM - c.ValidationLLMConcurrency = &value - } -} - -func (c *Config) applyChunkingPatch(patch chunkingPatch) { - if patch.targetSections != nil { - value := *patch.targetSections - c.TargetSections = &value - } - if patch.maxSectionTokens != nil { - c.MaxSectionTokens = *patch.maxSectionTokens - } - if patch.minSectionTokens != nil { - c.MinSectionTokens = *patch.minSectionTokens - } -} - -func (c *Config) applyThresholdsPatch(patch thresholdsPatch) { - if patch.glossary != nil { - c.Thresholds.Glossary = *patch.glossary - } - if patch.grammar != nil { - c.Thresholds.Grammar = *patch.grammar - } - if patch.homophones != nil { - c.Thresholds.Homophones = *patch.homophones - } - if patch.spokenWord != nil { - c.Thresholds.SpokenWord = *patch.spokenWord - } -} - -func (c *Config) applyNormalizationPatch(patch normalizationPatch) { - if patch.maxSegmentGap != nil { - c.Normalization.MaxSegmentGap = *patch.maxSegmentGap - } - if patch.ellipsisGap != nil { - c.Normalization.EllipsisGap = *patch.ellipsisGap - } - if patch.maxSegmentDuration != nil { - c.Normalization.MaxSegmentDuration = *patch.maxSegmentDuration - } - if patch.maxSegmentTokens != nil { - c.Normalization.MaxSegmentTokens = *patch.maxSegmentTokens - } -} - -func (c *Config) applyContextPatch(patch contextPatch) { - if patch.transcriptDescription != nil { - c.TranscriptDescription = strings.TrimSpace(*patch.transcriptDescription) - } -} - -func (c *Config) applyDiagnosticsPatch(patch diagnosticsPatch) { - if patch.workDir != nil { - c.WorkDir = *patch.workDir - } - if patch.workDirRetention != nil { - c.WorkDirRetention = WorkDirRetention(*patch.workDirRetention) - } -} diff --git a/internal/core/config/config.go b/internal/core/config/config.go index 4ac0417..bbf98a2 100644 --- a/internal/core/config/config.go +++ b/internal/core/config/config.go @@ -3,197 +3,187 @@ package config import ( "fmt" "strings" + "time" + ck "gitea.maximumdirect.net/eric/ConfigKit" "gitea.maximumdirect.net/eric/audita/internal/core/modulecatalog" + "gitea.maximumdirect.net/eric/audita/internal/core/outputschema" ) type WorkDirRetention string const ( - WorkDirRetentionAuto WorkDirRetention = "auto" - WorkDirRetentionAlways WorkDirRetention = "always" - WorkDirRetentionNever WorkDirRetention = "never" -) - -const ( - DefaultModulesCSV = modulecatalog.KeyGlossary + "," + modulecatalog.KeyHomophones + "," + modulecatalog.KeyGlossary + "," + modulecatalog.KeySpokenWord + "," + modulecatalog.KeyGrammar - DefaultOutputSchema = "bare-segments" - DefaultPrimaryModel = "openrouter/google/gemma-4-31b-it" - DefaultPrimaryBaseURL = "https://openrouter.ai/api/v1" - DefaultPrimaryLLMTimeoutSeconds = 600 - DefaultMaxRetries = 3 - DefaultLLMConcurrency = 1 - DefaultValidationMaxPromptTokens = 2048 - DefaultMaxSectionTokens = 8192 - DefaultMinSectionTokens = 2048 - DefaultConfidenceThreshold = 0.8 - DefaultNormalizeMaxSegmentGap = 4.0 - DefaultNormalizeEllipsisGap = 3.5 - DefaultNormalizeMaxSegmentDuration = 60.0 - DefaultNormalizeMaxSegmentTokens = 2048 + WorkDirRetentionAuto WorkDirRetention = "auto" + WorkDirRetentionAlways WorkDirRetention = "always" + WorkDirRetentionNever WorkDirRetention = "never" + DefaultModulesCSV = "glossary,homophones,glossary,spoken_word,grammar" DefaultTranscriptDescriptionMaxChars = 500 - DefaultWorkDir = "/tmp/audita" - DefaultWorkDirRetention WorkDirRetention = WorkDirRetentionAuto ) +// Config is the version-2 application configuration. Invocation paths are not settings. type Config struct { - Modules []string - OutputSchema string - PrimaryLLM LLMConfig - ValidationLLM ValidationLLMConfig - TotalLLMConcurrency int - ProposalLLMConcurrency int - ValidationLLMConcurrency *int - ValidationMaxPromptTokens int - MaxSectionTokens int - MinSectionTokens int - TargetSections *int - Thresholds ConfidenceThresholds - Normalization NormalizationConfig - TranscriptDescription string - WorkDir string - WorkDirRetention WorkDirRetention + Version int `yaml:"version"` + ProfilesDir string `yaml:"profiles_dir"` + Backends map[string]Backend `yaml:"backends"` + LLM LLM `yaml:"llm"` + Pipeline Pipeline `yaml:"pipeline"` + Output Output `yaml:"output"` + Validation Validation `yaml:"validation"` + Chunking Chunking `yaml:"chunking"` + Normalization Normalization `yaml:"normalization"` + Thresholds Thresholds `yaml:"thresholds"` + Context Context `yaml:"context"` + Diagnostics Diagnostics `yaml:"diagnostics"` } -type LLMConfig struct { - APIKey string - Model string - BaseURL string - TimeoutSeconds int - MaxRetries int - // Concurrency is retained as a backward-compatible alias for - // TotalLLMConcurrency. - Concurrency int +type Backend struct { + Endpoint string `yaml:"endpoint"` + Concurrency int `yaml:"concurrency"` + QueueCapacity int `yaml:"queue_capacity"` + Credential *ck.Secret `yaml:"credential"` +} +type LLM struct { + ProposalProfile string `yaml:"proposal_profile"` + ValidationProfile string `yaml:"validation_profile"` + TransientRetries int `yaml:"transient_retries"` + RepairAttempts int `yaml:"repair_attempts"` + OperationTimeout time.Duration `yaml:"operation_timeout"` +} +type Pipeline struct { + Modules []string `yaml:"modules"` +} +type Output struct { + Schema string `yaml:"schema"` +} +type Validation struct { + MaxPromptTokens int `yaml:"max_prompt_tokens"` +} +type Chunking struct { + TargetSections *int `yaml:"target_sections"` + MaxSectionTokens int `yaml:"max_section_tokens"` + MinSectionTokens int `yaml:"min_section_tokens"` +} +type Normalization struct { + MaxSegmentGap float64 `yaml:"max_segment_gap"` + EllipsisGap float64 `yaml:"ellipsis_gap"` + MaxSegmentDuration float64 `yaml:"max_segment_duration"` + MaxSegmentTokens int `yaml:"max_segment_tokens"` +} +type Thresholds struct { + Glossary float64 `yaml:"glossary"` + Homophones float64 `yaml:"homophones"` + SpokenWord float64 `yaml:"spoken_word"` + Grammar float64 `yaml:"grammar"` +} +type Context struct { + Description string `yaml:"description"` +} +type Diagnostics struct { + WorkDir string `yaml:"work_dir"` + Retention WorkDirRetention `yaml:"retention"` } -type ValidationLLMConfig struct { - APIKey string - Model string - BaseURL string - TimeoutSeconds *int - MaxRetries *int - // Concurrency is retained as a backward-compatible alias for - // ValidationLLMConcurrency. - Concurrency *int -} - -type ConfidenceThresholds struct { - Glossary float64 - Grammar float64 - Homophones float64 - SpokenWord float64 -} - -type NormalizationConfig struct { - MaxSegmentGap float64 - EllipsisGap float64 - MaxSegmentDuration float64 - MaxSegmentTokens int -} - -func Default() Config { - modules, _ := ParseModulesCSV(DefaultModulesCSV) - +func defaults() Config { return Config{ - Modules: modules, - OutputSchema: DefaultOutputSchema, - PrimaryLLM: LLMConfig{ - Model: DefaultPrimaryModel, - BaseURL: DefaultPrimaryBaseURL, - TimeoutSeconds: DefaultPrimaryLLMTimeoutSeconds, - MaxRetries: DefaultMaxRetries, - Concurrency: DefaultLLMConcurrency, - }, - ValidationLLM: ValidationLLMConfig{}, - TotalLLMConcurrency: DefaultLLMConcurrency, - ProposalLLMConcurrency: DefaultLLMConcurrency, - ValidationLLMConcurrency: nil, - ValidationMaxPromptTokens: DefaultValidationMaxPromptTokens, - MaxSectionTokens: DefaultMaxSectionTokens, - MinSectionTokens: DefaultMinSectionTokens, - TargetSections: nil, - Thresholds: ConfidenceThresholds{ - Glossary: DefaultConfidenceThreshold, - Grammar: DefaultConfidenceThreshold, - Homophones: DefaultConfidenceThreshold, - SpokenWord: DefaultConfidenceThreshold, - }, - Normalization: NormalizationConfig{ - MaxSegmentGap: DefaultNormalizeMaxSegmentGap, - EllipsisGap: DefaultNormalizeEllipsisGap, - MaxSegmentDuration: DefaultNormalizeMaxSegmentDuration, - MaxSegmentTokens: DefaultNormalizeMaxSegmentTokens, - }, - WorkDir: DefaultWorkDir, - WorkDirRetention: DefaultWorkDirRetention, + ProfilesDir: "profiles", + LLM: LLM{TransientRetries: 2, RepairAttempts: 2, OperationTimeout: 15 * time.Minute}, + Pipeline: Pipeline{Modules: []string{"glossary", "homophones", "glossary", "spoken_word", "grammar"}}, + Output: Output{Schema: "bare-segments"}, + Validation: Validation{MaxPromptTokens: 2048}, + Chunking: Chunking{MaxSectionTokens: 8192, MinSectionTokens: 2048}, + Normalization: Normalization{MaxSegmentGap: 4, EllipsisGap: 3.5, MaxSegmentDuration: 60, MaxSegmentTokens: 2048}, + Thresholds: Thresholds{Glossary: .8, Homophones: .8, SpokenWord: .8, Grammar: .8}, + Diagnostics: Diagnostics{WorkDir: "/tmp/audita", Retention: WorkDirRetentionAuto}, } } +// Load resolves the selected main file, optional overlay, and explicit setting overrides. +// Its result retains safe effective output and provenance for CLI composition. +func Load(source ck.Source, overlay string, overrides ...ck.Override) (*ck.Result[Config], error) { + return ck.Load(source, ck.Options[Config]{ + Overlay: overlay, Overrides: overrides, + Definition: ck.Definition[Config]{ + Defaults: defaults, + Version: &ck.VersionRule{Path: ck.Keys("version"), Check: func(version ck.Version, v *ck.Validation) error { + if !version.Present || version.Null || version.Text != "2" { + v.Error(ck.Keys("version"), "version 2 is required; see docs/migrations/v2.md") + } + return nil + }}, + Resolve: func(c *Config, r *ck.Resolution) error { + ck.Default(r, ck.Keys("llm", "validation_profile"), &c.LLM.ValidationProfile, c.LLM.ProposalProfile, "validation uses the final proposal profile when omitted", ck.Keys("llm", "proposal_profile")) + for id, backend := range c.Backends { + changed := false + if !r.IsExplicit(ck.Keys("backends", id, "concurrency")) { + backend.Concurrency = 1 + changed = true + } + if !r.IsExplicit(ck.Keys("backends", id, "queue_capacity")) { + backend.QueueCapacity = 1024 + changed = true + } + if changed { + c.Backends[id] = backend + r.Record(ck.Keys("backends", id), ck.ChangeDerivedDefault, "finite backend defaults") + } + } + return nil + }, + Validate: validate, + }, + }) +} + +func validate(c *Config, v *ck.Validation) { + check := func(ok bool, path ck.Path, message string) { + if !ok { + v.Error(path, message) + } + } + check(strings.TrimSpace(c.ProfilesDir) != "", ck.Keys("profiles_dir"), "profile directory is required") + check(strings.TrimSpace(c.LLM.ProposalProfile) != "", ck.Keys("llm", "proposal_profile"), "proposal profile is required") + check(strings.TrimSpace(c.LLM.ValidationProfile) != "", ck.Keys("llm", "validation_profile"), "validation profile is required") + check(c.LLM.TransientRetries >= 0, ck.Keys("llm", "transient_retries"), "retry budget must be non-negative") + check(c.LLM.RepairAttempts >= 0 && c.LLM.RepairAttempts <= 3, ck.Keys("llm", "repair_attempts"), "repair attempts must be between zero and three") + check(c.LLM.OperationTimeout > 0, ck.Keys("llm", "operation_timeout"), "operation timeout must be positive") + check(len(c.Backends) > 0, ck.Keys("backends"), "at least one backend is required") + for id, b := range c.Backends { + p := ck.Keys("backends", id) + check(strings.TrimSpace(b.Endpoint) != "", p.Key("endpoint"), "backend endpoint is required") + check(b.Concurrency > 0, p.Key("concurrency"), "backend concurrency must be positive") + check(b.QueueCapacity >= 0, p.Key("queue_capacity"), "queue capacity must be non-negative") + } + for i, module := range c.Pipeline.Modules { + check(modulecatalog.IsSupported(module), ck.Keys("pipeline", "modules").Index(i), "unsupported module key") + } + check(outputschema.IsSupported(c.Output.Schema), ck.Keys("output", "schema"), "unsupported output schema") + check(c.Validation.MaxPromptTokens > 0, ck.Keys("validation", "max_prompt_tokens"), "validation prompt budget must be positive") + check(c.Chunking.MaxSectionTokens > 0, ck.Keys("chunking", "max_section_tokens"), "maximum section tokens must be positive") + check(c.Chunking.MinSectionTokens > 0 && c.Chunking.MinSectionTokens <= c.Chunking.MaxSectionTokens, ck.Keys("chunking", "min_section_tokens"), "minimum section tokens must be positive and no greater than maximum") + check(c.Chunking.TargetSections == nil || *c.Chunking.TargetSections > 0, ck.Keys("chunking", "target_sections"), "target sections must be positive when set") + check(c.Normalization.MaxSegmentGap >= 0, ck.Keys("normalization", "max_segment_gap"), "maximum segment gap must be non-negative") + check(c.Normalization.EllipsisGap >= 0, ck.Keys("normalization", "ellipsis_gap"), "ellipsis gap must be non-negative") + check(c.Normalization.MaxSegmentDuration > 0, ck.Keys("normalization", "max_segment_duration"), "maximum segment duration must be positive") + check(c.Normalization.MaxSegmentTokens > 0, ck.Keys("normalization", "max_segment_tokens"), "maximum segment tokens must be positive") + for name, threshold := range map[string]float64{"glossary": c.Thresholds.Glossary, "homophones": c.Thresholds.Homophones, "spoken_word": c.Thresholds.SpokenWord, "grammar": c.Thresholds.Grammar} { + check(threshold >= 0 && threshold <= 1, ck.Keys("thresholds", name), "confidence threshold must be between zero and one") + } + check(len(strings.TrimSpace(c.Context.Description)) <= DefaultTranscriptDescriptionMaxChars, ck.Keys("context", "description"), fmt.Sprintf("description must be at most %d bytes after trimming", DefaultTranscriptDescriptionMaxChars)) + check(strings.TrimSpace(c.Diagnostics.WorkDir) != "", ck.Keys("diagnostics", "work_dir"), "work directory is required") + check(c.Diagnostics.Retention == WorkDirRetentionAuto || c.Diagnostics.Retention == WorkDirRetentionAlways || c.Diagnostics.Retention == WorkDirRetentionNever, ck.Keys("diagnostics", "retention"), "retention must be auto, always, or never") +} + +// ParseModulesCSV is used only for an explicitly supplied --modules override. func ParseModulesCSV(raw string) ([]string, error) { + if raw == "" { + return []string{}, nil + } parts := strings.Split(raw, ",") - modules := make([]string, 0, len(parts)) - for _, part := range parts { - trimmed := strings.TrimSpace(part) - if trimmed == "" { + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + if parts[i] == "" { return nil, fmt.Errorf("modules list contains an empty value") } - modules = append(modules, trimmed) } - if len(modules) == 0 { - return nil, fmt.Errorf("modules list must not be empty") - } - return modules, nil -} - -func (c Config) EffectiveValidationLLMConfig() LLMConfig { - effective := c.PrimaryLLM - - if c.ValidationLLM.APIKey != "" { - effective.APIKey = c.ValidationLLM.APIKey - } - if c.ValidationLLM.Model != "" { - effective.Model = c.ValidationLLM.Model - } - if c.ValidationLLM.BaseURL != "" { - effective.BaseURL = c.ValidationLLM.BaseURL - } - if c.ValidationLLM.TimeoutSeconds != nil { - effective.TimeoutSeconds = *c.ValidationLLM.TimeoutSeconds - } - if c.ValidationLLM.MaxRetries != nil { - effective.MaxRetries = *c.ValidationLLM.MaxRetries - } - effective.Concurrency = c.EffectiveValidationLLMConcurrency() - - return effective -} - -func (c Config) EffectiveValidationLLMConcurrency() int { - if c.ValidationLLMConcurrency != nil { - return *c.ValidationLLMConcurrency - } - return c.TotalLLMConcurrency -} - -func (c Config) EffectiveProposalLLMConcurrency() int { - if c.ProposalLLMConcurrency > 0 { - return c.ProposalLLMConcurrency - } - return c.TotalLLMConcurrency -} - -func (c *Config) syncLegacyConcurrencyAliases() { - if c == nil { - return - } - c.PrimaryLLM.Concurrency = c.TotalLLMConcurrency - c.ValidationLLM.Concurrency = intPtr(c.ValidationLLMConcurrency) -} - -func intPtr(v *int) *int { - if v == nil { - return nil - } - x := *v - return &x + return parts, nil } diff --git a/internal/core/config/config_test.go b/internal/core/config/config_test.go index d2289e8..95f992a 100644 --- a/internal/core/config/config_test.go +++ b/internal/core/config/config_test.go @@ -1,705 +1,145 @@ package config import ( + "bytes" + "errors" + "os" + "path/filepath" "reflect" "strings" "testing" - "gitea.maximumdirect.net/eric/audita/internal/core/modulecatalog" - "gitea.maximumdirect.net/eric/audita/internal/core/outputschema" + ck "gitea.maximumdirect.net/eric/ConfigKit" ) -func TestDefaultConfigValues(t *testing.T) { - cfg := Default() +const base = "version: 2\nbackends:\n local:\n endpoint: https://local.invalid/v1\nllm:\n proposal_profile: proposal\n" - if got, want := strings.Join(cfg.Modules, ","), DefaultModulesCSV; got != want { - t.Fatalf("modules mismatch: got %q want %q", got, want) - } - if cfg.OutputSchema != DefaultOutputSchema { - t.Fatalf("unexpected default output schema: %q", cfg.OutputSchema) - } - if cfg.PrimaryLLM.Model != DefaultPrimaryModel { - t.Fatalf("unexpected default primary model: %q", cfg.PrimaryLLM.Model) - } - if cfg.PrimaryLLM.BaseURL != DefaultPrimaryBaseURL { - t.Fatalf("unexpected default primary base url: %q", cfg.PrimaryLLM.BaseURL) - } - if cfg.PrimaryLLM.TimeoutSeconds != DefaultPrimaryLLMTimeoutSeconds { - t.Fatalf("unexpected default timeout seconds: %d", cfg.PrimaryLLM.TimeoutSeconds) - } - if cfg.PrimaryLLM.MaxRetries != DefaultMaxRetries { - t.Fatalf("unexpected default max retries: %d", cfg.PrimaryLLM.MaxRetries) - } - if cfg.TotalLLMConcurrency != DefaultLLMConcurrency { - t.Fatalf("unexpected default total llm concurrency: %d", cfg.TotalLLMConcurrency) - } - if cfg.ProposalLLMConcurrency != DefaultLLMConcurrency { - t.Fatalf("unexpected default proposal llm concurrency: %d", cfg.ProposalLLMConcurrency) - } - if cfg.ValidationLLMConcurrency != nil { - t.Fatalf("expected validation llm concurrency to be unset by default") - } - if cfg.PrimaryLLM.Concurrency != cfg.TotalLLMConcurrency { - t.Fatalf("expected primary llm concurrency alias to mirror total, got primary=%d total=%d", cfg.PrimaryLLM.Concurrency, cfg.TotalLLMConcurrency) - } - if cfg.ValidationLLM.TimeoutSeconds != nil { - t.Fatalf("expected validation timeout to be unset by default") - } - if cfg.ValidationLLM.MaxRetries != nil { - t.Fatalf("expected validation max retries to be unset by default") - } - if cfg.ValidationLLM.Concurrency != nil { - t.Fatalf("expected legacy validation llm concurrency alias to be unset by default") - } - if cfg.TargetSections != nil { - t.Fatalf("expected target sections to be unset by default") - } - if cfg.WorkDir != DefaultWorkDir { - t.Fatalf("unexpected default work dir: %q", cfg.WorkDir) - } - if cfg.TranscriptDescription != "" { - t.Fatalf("expected default transcript description to be empty, got %q", cfg.TranscriptDescription) - } - if cfg.WorkDirRetention != DefaultWorkDirRetention { - t.Fatalf("unexpected default work dir retention: %q", cfg.WorkDirRetention) - } - if err := cfg.Validate(); err != nil { - t.Fatalf("default config should validate: %v", err) - } +func loadText(text, overlay string, overrides ...ck.Override) (*ck.Result[Config], error) { + return Load(ck.Bytes("config.yml", []byte(text)), overlay, overrides...) } -func TestLoadFromEnvOverridesAndFallback(t *testing.T) { - env := map[string]string{ - "AUDITA_MODEL": "openai/gpt-4.1-mini", - "AUDITA_BASE_URL": "https://api.openai.com/v1", - "AUDITA_LLM_TIMEOUT_SECONDS": "120", - "AUDITA_MAX_RETRIES": "7", - "AUDITA_TOTAL_LLM_CONCURRENCY": "6", - "AUDITA_PROPOSAL_LLM_CONCURRENCY": "4", - "AUDITA_VALIDATION_LLM_CONCURRENCY": "2", - "AUDITA_VALIDATION_MAX_PROMPT_TOKENS": "4096", - "AUDITA_MAX_SECTION_TOKENS": "9000", - "AUDITA_MIN_SECTION_TOKENS": "3000", - "AUDITA_TARGET_SECTIONS": "5", - "AUDITA_GLOSSARY_CONFIDENCE_THRESHOLD": "0.9", - "AUDITA_GRAMMAR_CONFIDENCE_THRESHOLD": "0.7", - "AUDITA_HOMOPHONES_CONFIDENCE_THRESHOLD": "0.6", - "AUDITA_SPOKEN_WORD_CONFIDENCE_THRESHOLD": "0.5", - "AUDITA_NORMALIZE_MAX_SEGMENT_GAP": "2.5", - "AUDITA_NORMALIZE_ELLIPSIS_GAP": "2.0", - "AUDITA_NORMALIZE_MAX_SEGMENT_DURATION": "30.0", - "AUDITA_NORMALIZE_MAX_SEGMENT_TOKENS": "1024", - "AUDITA_WORK_DIR": "/var/tmp/audita", - "AUDITA_WORK_DIR_RETENTION": "always", - "OPENROUTER_API_KEY": "fallback-key", - } - - cfg, err := loadFromLookup(mapLookup(env)) +func TestDefaultsAndDomainSettings(t *testing.T) { + loaded, err := loadText(base, "") if err != nil { - t.Fatalf("loadFromLookup returned error: %v", err) + t.Fatal(err) } - - if cfg.PrimaryLLM.APIKey != "fallback-key" { - t.Fatalf("expected OPENROUTER_API_KEY fallback, got %q", cfg.PrimaryLLM.APIKey) + c := loaded.Config + if c.LLM.ValidationProfile != "proposal" || c.Backends["local"].Concurrency != 1 || c.Backends["local"].QueueCapacity != 1024 { + t.Fatalf("derived settings: %+v %+v", c.LLM, c.Backends) } - if cfg.PrimaryLLM.Model != env["AUDITA_MODEL"] { - t.Fatalf("unexpected model: %q", cfg.PrimaryLLM.Model) + if !reflect.DeepEqual(c.Pipeline.Modules, []string{"glossary", "homophones", "glossary", "spoken_word", "grammar"}) { + t.Fatalf("module order: %v", c.Pipeline.Modules) } - if cfg.PrimaryLLM.BaseURL != env["AUDITA_BASE_URL"] { - t.Fatalf("unexpected base url: %q", cfg.PrimaryLLM.BaseURL) + if explanation, ok := loaded.Explain(ck.Keys("llm", "validation_profile")); !ok || len(explanation.History) == 0 { + t.Fatal("validation inheritance has no recorded derivation") } - if cfg.TargetSections == nil || *cfg.TargetSections != 5 { - t.Fatalf("unexpected target sections: %#v", cfg.TargetSections) + if _, err := loadText(base+"pipeline:\n modules: []\n", ""); err != nil { + t.Fatalf("empty pipeline: %v", err) } - if cfg.TotalLLMConcurrency != 6 { - t.Fatalf("unexpected total llm concurrency: %d", cfg.TotalLLMConcurrency) - } - if cfg.ProposalLLMConcurrency != 4 { - t.Fatalf("unexpected proposal llm concurrency: %d", cfg.ProposalLLMConcurrency) - } - if cfg.ValidationLLMConcurrency == nil || *cfg.ValidationLLMConcurrency != 2 { - t.Fatalf("unexpected validation llm concurrency: %#v", cfg.ValidationLLMConcurrency) - } - if cfg.PrimaryLLM.Concurrency != 6 { - t.Fatalf("expected primary alias concurrency 6, got %d", cfg.PrimaryLLM.Concurrency) - } - if cfg.ValidationLLM.Concurrency == nil || *cfg.ValidationLLM.Concurrency != 2 { - t.Fatalf("expected validation alias concurrency 2, got %#v", cfg.ValidationLLM.Concurrency) - } - if cfg.WorkDirRetention != WorkDirRetentionAlways { - t.Fatalf("unexpected work dir retention: %q", cfg.WorkDirRetention) + if _, err := loadText(base+"pipeline:\n modules: [grammar, glossary, grammar]\n", ""); err != nil { + t.Fatalf("repeated modules: %v", err) } } -func TestLoadFromEnvLegacyLLMConcurrencyAliasForTotalAndProposal(t *testing.T) { - env := map[string]string{ - "AUDITA_LLM_CONCURRENCY": "5", - } - - cfg, err := loadFromLookup(mapLookup(env)) - if err != nil { - t.Fatalf("loadFromLookup returned error: %v", err) - } - - if cfg.TotalLLMConcurrency != 5 { - t.Fatalf("expected total concurrency from legacy alias, got %d", cfg.TotalLLMConcurrency) - } - if cfg.ProposalLLMConcurrency != 5 { - t.Fatalf("expected proposal concurrency to inherit legacy total, got %d", cfg.ProposalLLMConcurrency) - } -} - -func TestLoadFromEnvCanonicalTotalWinsLegacyAlias(t *testing.T) { - env := map[string]string{ - "AUDITA_TOTAL_LLM_CONCURRENCY": "4", - "AUDITA_LLM_CONCURRENCY": "9", - } - - cfg, err := loadFromLookup(mapLookup(env)) - if err != nil { - t.Fatalf("loadFromLookup returned error: %v", err) - } - - if cfg.TotalLLMConcurrency != 4 { - t.Fatalf("expected canonical total to win over legacy alias, got %d", cfg.TotalLLMConcurrency) - } - if cfg.ProposalLLMConcurrency != 4 { - t.Fatalf("expected proposal to inherit canonical total when unset, got %d", cfg.ProposalLLMConcurrency) - } -} - -func TestLoadFromEnvUsesAuditaLLMAPIKeyOverFallback(t *testing.T) { - env := map[string]string{ - "AUDITA_LLM_API_KEY": "primary-key", - "OPENROUTER_API_KEY": "fallback-key", - } - - cfg, err := loadFromLookup(mapLookup(env)) - if err != nil { - t.Fatalf("loadFromLookup returned error: %v", err) - } - - if cfg.PrimaryLLM.APIKey != "primary-key" { - t.Fatalf("expected AUDITA_LLM_API_KEY to win, got %q", cfg.PrimaryLLM.APIKey) - } -} - -func TestApplyCLIOverridesPrecedence(t *testing.T) { - cfg := Default() - cfg.PrimaryLLM.Model = "env-model" - cfg.WorkDir = "/env/work" - - model := "cli-model" - workDir := "/cli/work" - modules := "grammar" - outputSchema := "audita-v1" - totalLLMConcurrency := 5 - proposalLLMConcurrency := 3 - overrides := CLIOverrides{ - PrimaryModel: &model, - WorkDir: &workDir, - ModulesCSV: &modules, - OutputSchema: &outputSchema, - TotalLLMConcurrency: &totalLLMConcurrency, - ProposalLLMConcurrency: &proposalLLMConcurrency, - } - - if err := cfg.ApplyCLIOverrides(overrides); err != nil { - t.Fatalf("ApplyCLIOverrides failed: %v", err) - } - - if cfg.PrimaryLLM.Model != "cli-model" { - t.Fatalf("expected CLI model override, got %q", cfg.PrimaryLLM.Model) - } - if cfg.WorkDir != "/cli/work" { - t.Fatalf("expected CLI work dir override, got %q", cfg.WorkDir) - } - if !reflect.DeepEqual(cfg.Modules, []string{"grammar"}) { - t.Fatalf("unexpected modules: %#v", cfg.Modules) - } - if cfg.OutputSchema != "audita-v1" { - t.Fatalf("expected CLI output schema override, got %q", cfg.OutputSchema) - } - if cfg.TotalLLMConcurrency != 5 { - t.Fatalf("expected CLI total concurrency override, got %d", cfg.TotalLLMConcurrency) - } - if cfg.ProposalLLMConcurrency != 3 { - t.Fatalf("expected CLI proposal concurrency override, got %d", cfg.ProposalLLMConcurrency) - } -} - -func TestApplyCLIOverridesTrimsTranscriptDescription(t *testing.T) { - cfg := Default() - description := " background context about speakers " - if err := cfg.ApplyCLIOverrides(CLIOverrides{TranscriptDescription: &description}); err != nil { - t.Fatalf("ApplyCLIOverrides failed: %v", err) - } - if cfg.TranscriptDescription != "background context about speakers" { - t.Fatalf("unexpected transcript description trim result: %q", cfg.TranscriptDescription) - } -} - -func TestConfigSourcesApplySharedEffectiveFieldsConsistently(t *testing.T) { - fileCfg := mustParseFileConfigYAML(t, ` -version: 1 -output: - schema: " audita-v1 " -llm: - proposal: - base_url: https://proposal.example.test/v1 - model: provider/proposal - timeout: 101 - max_retries: 5 - validation: - base_url: https://validation.example.test/v1 - model: provider/validation - timeout: 202 - max_retries: 6 -chunking: - target_sections: 7 - max_section_tokens: 9000 - min_section_tokens: 1000 -thresholds: - glossary: 0.91 - grammar: 0.92 - homophones: 0.93 - spoken_word: 0.94 -normalization: - max_segment_gap: 1.2 - ellipsis_gap: 2.3 - max_segment_duration: 45.6 - max_segment_tokens: 321 -context: - description: " shared context " -diagnostics: - work_dir: /tmp/audita-shared - retention: always -`) - - tests := []struct { - name string - apply func(*Config) error - }{ - { - name: "file", - apply: func(cfg *Config) error { - return cfg.applyFileConfigWithLookup(fileCfg, mapLookup(map[string]string{})) - }, - }, - { - name: "env", - apply: func(cfg *Config) error { - return cfg.applyEnvOverrides(mapLookup(map[string]string{ - "AUDITA_MODEL": "provider/proposal", - "AUDITA_BASE_URL": "https://proposal.example.test/v1", - "AUDITA_LLM_TIMEOUT_SECONDS": "101", - "AUDITA_MAX_RETRIES": "5", - "AUDITA_VALIDATION_MODEL": "provider/validation", - "AUDITA_VALIDATION_BASE_URL": "https://validation.example.test/v1", - "AUDITA_VALIDATION_LLM_TIMEOUT_SECONDS": "202", - "AUDITA_VALIDATION_MAX_RETRIES": "6", - "AUDITA_TARGET_SECTIONS": "7", - "AUDITA_MAX_SECTION_TOKENS": "9000", - "AUDITA_MIN_SECTION_TOKENS": "1000", - "AUDITA_GLOSSARY_CONFIDENCE_THRESHOLD": "0.91", - "AUDITA_GRAMMAR_CONFIDENCE_THRESHOLD": "0.92", - "AUDITA_HOMOPHONES_CONFIDENCE_THRESHOLD": "0.93", - "AUDITA_SPOKEN_WORD_CONFIDENCE_THRESHOLD": "0.94", - "AUDITA_NORMALIZE_MAX_SEGMENT_GAP": "1.2", - "AUDITA_NORMALIZE_ELLIPSIS_GAP": "2.3", - "AUDITA_NORMALIZE_MAX_SEGMENT_DURATION": "45.6", - "AUDITA_NORMALIZE_MAX_SEGMENT_TOKENS": "321", - "AUDITA_WORK_DIR": "/tmp/audita-shared", - "AUDITA_WORK_DIR_RETENTION": "always", - })) - }, - }, - { - name: "cli", - apply: func(cfg *Config) error { - outputSchema := " audita-v1 " - proposalModel := "provider/proposal" - proposalBaseURL := "https://proposal.example.test/v1" - proposalTimeout := 101 - proposalMaxRetries := 5 - validationModel := "provider/validation" - validationBaseURL := "https://validation.example.test/v1" - validationTimeout := 202 - validationMaxRetries := 6 - targetSections := 7 - maxSectionTokens := 9000 - minSectionTokens := 1000 - glossaryThreshold := 0.91 - grammarThreshold := 0.92 - homophonesThreshold := 0.93 - spokenWordThreshold := 0.94 - normalizeMaxSegmentGap := 1.2 - normalizeEllipsisGap := 2.3 - normalizeMaxSegmentDuration := 45.6 - normalizeMaxSegmentTokens := 321 - description := " shared context " - workDir := "/tmp/audita-shared" - workDirRetention := "always" - return cfg.ApplyCLIOverrides(CLIOverrides{ - OutputSchema: &outputSchema, - PrimaryModel: &proposalModel, - PrimaryBaseURL: &proposalBaseURL, - PrimaryLLMTimeoutSeconds: &proposalTimeout, - MaxRetries: &proposalMaxRetries, - ValidationModel: &validationModel, - ValidationBaseURL: &validationBaseURL, - ValidationLLMTimeoutSeconds: &validationTimeout, - ValidationMaxRetries: &validationMaxRetries, - TargetSections: &targetSections, - MaxSectionTokens: &maxSectionTokens, - MinSectionTokens: &minSectionTokens, - GlossaryConfidenceThreshold: &glossaryThreshold, - GrammarConfidenceThreshold: &grammarThreshold, - HomophonesConfidenceThreshold: &homophonesThreshold, - SpokenWordConfidenceThreshold: &spokenWordThreshold, - NormalizeMaxSegmentGap: &normalizeMaxSegmentGap, - NormalizeEllipsisGap: &normalizeEllipsisGap, - NormalizeMaxSegmentDuration: &normalizeMaxSegmentDuration, - NormalizeMaxSegmentTokens: &normalizeMaxSegmentTokens, - TranscriptDescription: &description, - WorkDir: &workDir, - WorkDirRetention: &workDirRetention, - }) - }, - }, - } - - for _, tc := range tests { +func TestStrictInputAndConstraints(t *testing.T) { + for _, tc := range []struct{ name, text string }{ + {"old version", strings.Replace(base, "version: 2", "version: 1", 1)}, + {"missing version", strings.Replace(base, "version: 2\n", "", 1)}, + {"null version", strings.Replace(base, "version: 2", "version: null", 1)}, + {"quoted version", strings.Replace(base, "version: 2", "version: '2'", 1)}, + {"unknown field", base + "mystery: true\n"}, + {"empty validation profile", base + " validation_profile: ''\n"}, + {"null validation profile", base + " validation_profile: null\n"}, + {"null proposal profile", strings.Replace(base, "proposal_profile: proposal", "proposal_profile: null", 1)}, + {"zero backend concurrency", base + " concurrency: 0\n"}, + {"invalid target", base + "chunking:\n target_sections: 0\n"}, + {"invalid section relation", base + "chunking:\n min_section_tokens: 10000\n"}, + {"string normalization", base + "normalization:\n max_segment_gap: 4s\n"}, + {"invalid threshold", base + "thresholds:\n glossary: 1.1\n"}, + {"invalid retention", base + "diagnostics:\n retention: sometimes\n"}, + } { t.Run(tc.name, func(t *testing.T) { - cfg := Default() - if err := tc.apply(&cfg); err != nil { - t.Fatalf("apply config source: %v", err) + if _, err := loadText(tc.text, ""); err == nil { + t.Fatal("invalid config accepted") + } + }) + } + _, err := loadText(strings.Replace(base, "version: 2", "version: 1", 1), "") + var loadErr *ck.LoadError + if !errors.As(err, &loadErr) || len(loadErr.Diagnostics()) == 0 || !strings.Contains(loadErr.Diagnostics()[0].Message, "docs/migrations/v2.md") { + t.Fatalf("migration guidance: %v", err) + } + for _, tc := range []struct{ name, text string }{ + {"optional target null", base + "chunking:\n target_sections: null\n"}, + {"zero queue", strings.Replace(base, "llm:\n", " queue_capacity: 0\nllm:\n", 1)}, + {"zero retries", base + " transient_retries: 0\n repair_attempts: 0\n"}, + {"zero threshold", base + "thresholds:\n grammar: 0\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := loadText(tc.text, ""); err != nil { + t.Fatal(err) } - assertSharedEffectiveFields(t, cfg, sharedEffectiveFieldOptions{ - wantOutputSchemaOverride: tc.name != "env", - wantTranscriptDescriptionPatch: tc.name != "env", - }) }) } } -func TestCLIAPIKeyOverrideIsDirectValue(t *testing.T) { - cfg := Default() - apiKey := "NOT_AN_ENV_VAR_NAME" - validationAPIKey := "also direct" - - if err := cfg.ApplyCLIOverrides(CLIOverrides{PrimaryLLMAPIKey: &apiKey, ValidationLLMAPIKey: &validationAPIKey}); err != nil { - t.Fatalf("ApplyCLIOverrides failed: %v", err) +func TestOverlayOverrideAndInheritance(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, "overlays"), 0700); err != nil { + t.Fatal(err) } - if cfg.PrimaryLLM.APIKey != apiKey { - t.Fatalf("expected direct primary api key, got %q", cfg.PrimaryLLM.APIKey) + main := filepath.Join(dir, "config.yml") + if err := os.WriteFile(main, []byte(base), 0600); err != nil { + t.Fatal(err) } - if cfg.ValidationLLM.APIKey != validationAPIKey { - t.Fatalf("expected direct validation api key, got %q", cfg.ValidationLLM.APIKey) + if err := os.WriteFile(filepath.Join(dir, "overlays", "testing.yml"), []byte("llm:\n proposal_profile: testing\nchunking:\n max_section_tokens: 1000\n"), 0600); err != nil { + t.Fatal(err) } -} - -func TestApplyFileConfigTotalConcurrencyDoesNotChangeProposalWhenProposalUnset(t *testing.T) { - fileCfg := mustParseFileConfigYAML(t, ` -version: 1 -concurrency: - total_llm: 4 -`) - cfg := Default() - - if err := cfg.applyFileConfigWithLookup(fileCfg, mapLookup(map[string]string{})); err != nil { - t.Fatalf("applyFileConfigWithLookup failed: %v", err) - } - - if cfg.TotalLLMConcurrency != 4 { - t.Fatalf("expected file total concurrency 4, got %d", cfg.TotalLLMConcurrency) - } - if cfg.ProposalLLMConcurrency != DefaultLLMConcurrency { - t.Fatalf("expected file config to preserve proposal concurrency when unset, got %d", cfg.ProposalLLMConcurrency) - } -} - -func TestValidationRejectsOverlyLongTranscriptDescription(t *testing.T) { - cfg := Default() - cfg.TranscriptDescription = strings.Repeat("a", DefaultTranscriptDescriptionMaxChars+1) - err := cfg.Validate() - if err == nil { - t.Fatalf("expected transcript description length validation error") - } - if !strings.Contains(err.Error(), "transcript description must be 500 characters or fewer") { - t.Fatalf("unexpected validation error: %v", err) - } -} - -func TestApplyCLIOverridesLegacyLLMConcurrencyAlias(t *testing.T) { - cfg := Default() - aliasConcurrency := 6 - - if err := cfg.ApplyCLIOverrides(CLIOverrides{PrimaryLLMConcurrency: &aliasConcurrency}); err != nil { - t.Fatalf("ApplyCLIOverrides failed: %v", err) - } - - if cfg.TotalLLMConcurrency != 6 { - t.Fatalf("expected legacy --llm-concurrency alias to set total, got %d", cfg.TotalLLMConcurrency) - } - if cfg.ProposalLLMConcurrency != 6 { - t.Fatalf("expected proposal to inherit aliased total when unset, got %d", cfg.ProposalLLMConcurrency) - } -} - -func TestApplyCLIOverridesCanonicalTotalWinsLegacyAlias(t *testing.T) { - cfg := Default() - canonicalTotal := 4 - legacyAlias := 9 - - if err := cfg.ApplyCLIOverrides(CLIOverrides{TotalLLMConcurrency: &canonicalTotal, PrimaryLLMConcurrency: &legacyAlias}); err != nil { - t.Fatalf("ApplyCLIOverrides failed: %v", err) - } - - if cfg.TotalLLMConcurrency != 4 { - t.Fatalf("expected canonical total concurrency to win, got %d", cfg.TotalLLMConcurrency) - } - if cfg.ProposalLLMConcurrency != 4 { - t.Fatalf("expected proposal to inherit canonical total when proposal is unset, got %d", cfg.ProposalLLMConcurrency) - } -} - -func TestValidationFailures(t *testing.T) { - cfg := Default() - cfg.OutputSchema = "unknown-schema" - cfg.PrimaryLLM.TimeoutSeconds = -1 - cfg.TotalLLMConcurrency = 0 - cfg.ProposalLLMConcurrency = 0 - validationConcurrency := 5 - cfg.ValidationLLMConcurrency = &validationConcurrency - cfg.ValidationMaxPromptTokens = 0 - cfg.MaxSectionTokens = 100 - cfg.MinSectionTokens = 200 - cfg.Thresholds.Grammar = 1.5 - cfg.WorkDirRetention = WorkDirRetention("sometimes") - - err := cfg.Validate() - if err == nil { - t.Fatalf("expected validation error") - } - - message := err.Error() - for _, expected := range []string{ - "primary llm timeout seconds", - "total llm concurrency", - "proposal llm concurrency", - "validation llm concurrency must be less than or equal to total llm concurrency", - "validation max prompt tokens", - "min section tokens", - "grammar confidence threshold", - "work dir retention", - "unsupported output schema", - } { - if !strings.Contains(message, expected) { - t.Fatalf("expected error to contain %q, got %q", expected, message) - } - } -} - -func TestValidationRejectsUnsupportedModuleKey(t *testing.T) { - cfg := Default() - cfg.Modules = []string{modulecatalog.KeyGlossary, "made_up"} - - err := cfg.Validate() - if err == nil { - t.Fatalf("expected validation error for unsupported module key") - } - if !strings.Contains(err.Error(), `unsupported module key "made_up"`) { - t.Fatalf("expected unsupported module key error, got %q", err.Error()) - } -} - -func TestValidationAllowsRepeatedSupportedModuleKeys(t *testing.T) { - cfg := Default() - cfg.Modules = []string{modulecatalog.KeyGlossary, modulecatalog.KeyGlossary, modulecatalog.KeyGrammar} - - if err := cfg.Validate(); err != nil { - t.Fatalf("expected repeated supported module keys to validate, got %v", err) - } -} - -func TestValidationAcceptsAllSupportedOutputSchemas(t *testing.T) { - for _, schemaKey := range outputschema.SupportedKeys() { - cfg := Default() - cfg.OutputSchema = schemaKey - if err := cfg.Validate(); err != nil { - t.Fatalf("expected output schema %q to validate, got %v", schemaKey, err) - } - } -} - -func TestEffectiveValidationLLMInheritance(t *testing.T) { - cfg := Default() - cfg.PrimaryLLM.APIKey = "primary-key" - cfg.PrimaryLLM.Model = "primary-model" - cfg.PrimaryLLM.BaseURL = "https://primary.example/v1" - cfg.PrimaryLLM.TimeoutSeconds = 111 - cfg.PrimaryLLM.MaxRetries = 2 - cfg.TotalLLMConcurrency = 7 - cfg.syncLegacyConcurrencyAliases() - - effective := cfg.EffectiveValidationLLMConfig() - if effective.APIKey != "primary-key" || effective.Model != "primary-model" || effective.BaseURL != "https://primary.example/v1" || effective.TimeoutSeconds != 111 || effective.MaxRetries != 2 || effective.Concurrency != 7 { - t.Fatalf("unexpected inherited config: %#v", effective) - } - - validationTimeout := 222 - validationRetries := 9 - cfg.ValidationLLM.APIKey = "validation-key" - cfg.ValidationLLM.Model = "validation-model" - cfg.ValidationLLM.BaseURL = "https://validation.example/v1" - cfg.ValidationLLM.TimeoutSeconds = &validationTimeout - cfg.ValidationLLM.MaxRetries = &validationRetries - validationConcurrency := 4 - cfg.ValidationLLMConcurrency = &validationConcurrency - cfg.syncLegacyConcurrencyAliases() - - effective = cfg.EffectiveValidationLLMConfig() - if effective.APIKey != "validation-key" || effective.Model != "validation-model" || effective.BaseURL != "https://validation.example/v1" || effective.TimeoutSeconds != 222 || effective.MaxRetries != 9 || effective.Concurrency != 4 { - t.Fatalf("unexpected overridden validation config: %#v", effective) - } -} - -func TestValidationLLMConcurrencyCannotExceedTotal(t *testing.T) { - cfg := Default() - cfg.TotalLLMConcurrency = 2 - cfg.ProposalLLMConcurrency = 2 - validationConcurrency := 3 - cfg.ValidationLLMConcurrency = &validationConcurrency - - if err := cfg.Validate(); err == nil { - t.Fatal("expected validation error when validation llm concurrency exceeds total") - } - - validationConcurrency = 2 - cfg.ValidationLLMConcurrency = &validationConcurrency - if err := cfg.Validate(); err != nil { - t.Fatalf("expected equal concurrency to validate, got %v", err) - } -} - -func TestProposalLLMConcurrencyCannotExceedTotal(t *testing.T) { - cfg := Default() - cfg.TotalLLMConcurrency = 2 - cfg.ProposalLLMConcurrency = 3 - - if err := cfg.Validate(); err == nil { - t.Fatal("expected validation error when proposal llm concurrency exceeds total") - } - - cfg.ProposalLLMConcurrency = 2 - if err := cfg.Validate(); err != nil { - t.Fatalf("expected equal concurrency to validate, got %v", err) - } -} - -func TestCLITotalLLMConcurrencyOverrideDrivesEffectiveValidationConcurrencyWhenValidationUnset(t *testing.T) { - cfg := Default() - totalLLMConcurrency := 6 - if err := cfg.ApplyCLIOverrides(CLIOverrides{TotalLLMConcurrency: &totalLLMConcurrency}); err != nil { - t.Fatalf("ApplyCLIOverrides failed: %v", err) - } - if cfg.ValidationLLMConcurrency != nil { - t.Fatalf("expected validation concurrency to remain unset, got %#v", cfg.ValidationLLMConcurrency) - } - if cfg.EffectiveValidationLLMConcurrency() != 6 { - t.Fatalf("expected inherited validation concurrency 6, got %d", cfg.EffectiveValidationLLMConcurrency()) - } -} - -func TestRedactedConfig(t *testing.T) { - cfg := Default() - cfg.PrimaryLLM.APIKey = "secret-primary" - cfg.ValidationLLM.APIKey = "secret-validation" - - redacted := cfg.Redacted() - - if redacted.PrimaryLLM.APIKey != redactedSecret { - t.Fatalf("expected primary api key to be redacted, got %q", redacted.PrimaryLLM.APIKey) - } - if redacted.ValidationLLM.APIKey != redactedSecret { - t.Fatalf("expected validation api key to be redacted, got %q", redacted.ValidationLLM.APIKey) - } - if cfg.PrimaryLLM.APIKey != "secret-primary" { - t.Fatalf("redaction should not mutate original config") - } -} - -func mapLookup(values map[string]string) func(string) (string, bool) { - return func(key string) (string, bool) { - value, ok := values[key] - return value, ok - } -} - -func mustParseFileConfigYAML(t *testing.T, raw string) FileConfig { - t.Helper() - fileCfg, err := ParseFileConfigYAML([]byte(raw)) + loaded, err := Load(ck.File(main), "testing", ck.Override{Path: ck.Keys("llm", "proposal_profile"), Value: "explicit", Flag: "--proposal-profile"}, ck.Override{Path: ck.Keys("chunking", "min_section_tokens"), Value: 500, Flag: "--min-section-tokens"}) if err != nil { - t.Fatalf("ParseFileConfigYAML failed: %v", err) + t.Fatal(err) + } + if loaded.Config.LLM.ProposalProfile != "explicit" || loaded.Config.LLM.ValidationProfile != "explicit" || loaded.Config.Chunking.MaxSectionTokens != 1000 || loaded.Config.Chunking.MinSectionTokens != 500 { + t.Fatalf("resolved: %+v", loaded.Config) + } + if got, _ := loaded.Metadata().Lookup(ck.Keys("llm", "proposal_profile")); got.Origin.Layer != ck.LayerCLI { + t.Fatalf("override origin: %+v", got) + } + if got, _ := loaded.Metadata().Lookup(ck.Keys("chunking", "max_section_tokens")); got.Origin.Layer != ck.LayerOverlay { + t.Fatalf("overlay origin: %+v", got) } - return fileCfg } -type sharedEffectiveFieldOptions struct { - wantOutputSchemaOverride bool - wantTranscriptDescriptionPatch bool -} - -func assertSharedEffectiveFields(t *testing.T, cfg Config, opts sharedEffectiveFieldOptions) { - t.Helper() - wantOutputSchema := DefaultOutputSchema - if opts.wantOutputSchemaOverride { - wantOutputSchema = "audita-v1" +func TestSurvivingCredentials(t *testing.T) { + t.Setenv("BASE_SECRET", "") + t.Setenv("NEW_SECRET", "synthetic-secret") + withSecret := strings.Replace(base, "llm:\n", " other:\n endpoint: https://other.invalid/v1\n credential: BASE_SECRET\nllm:\n", 1) + if _, err := loadText(withSecret, ""); err == nil { + t.Fatal("missing unselected backend secret accepted") } - if cfg.OutputSchema != wantOutputSchema { - t.Fatalf("unexpected output schema: %q", cfg.OutputSchema) + replacement := ck.Override{Path: ck.Keys("backends", "other", "credential"), Value: "NEW_SECRET", Flag: "--credential"} + loaded, err := loadText(withSecret, "", replacement) + if err != nil { + t.Fatal(err) } - if cfg.PrimaryLLM.Model != "provider/proposal" || - cfg.PrimaryLLM.BaseURL != "https://proposal.example.test/v1" || - cfg.PrimaryLLM.TimeoutSeconds != 101 || - cfg.PrimaryLLM.MaxRetries != 5 { - t.Fatalf("unexpected primary llm config: %+v", cfg.PrimaryLLM) + if got := loaded.Config.Backends["other"].Credential.Value(); got != "synthetic-secret" { + t.Fatalf("resolved secret: %q", got) } - if cfg.ValidationLLM.Model != "provider/validation" || - cfg.ValidationLLM.BaseURL != "https://validation.example.test/v1" || - cfg.ValidationLLM.TimeoutSeconds == nil || - *cfg.ValidationLLM.TimeoutSeconds != 202 || - cfg.ValidationLLM.MaxRetries == nil || - *cfg.ValidationLLM.MaxRetries != 6 { - t.Fatalf("unexpected validation llm config: %+v", cfg.ValidationLLM) + var effective bytes.Buffer + if err := loaded.WriteEffective(&effective); err != nil { + t.Fatal(err) } - if cfg.TargetSections == nil || *cfg.TargetSections != 7 || - cfg.MaxSectionTokens != 9000 || - cfg.MinSectionTokens != 1000 { - t.Fatalf("unexpected chunking config: target=%v max=%d min=%d", cfg.TargetSections, cfg.MaxSectionTokens, cfg.MinSectionTokens) + if strings.Contains(effective.String(), "synthetic-secret") { + t.Fatal("effective output leaked a secret") } - if cfg.Thresholds.Glossary != 0.91 || - cfg.Thresholds.Grammar != 0.92 || - cfg.Thresholds.Homophones != 0.93 || - cfg.Thresholds.SpokenWord != 0.94 { - t.Fatalf("unexpected thresholds: %+v", cfg.Thresholds) + null := ck.Override{Path: ck.Keys("backends", "other", "credential"), Value: nil, Flag: "--credential"} + loaded, err = loadText(withSecret, "", null) + if err != nil || loaded.Config.Backends["other"].Credential != nil { + t.Fatalf("null credential: %+v %v", loaded, err) } - if cfg.Normalization.MaxSegmentGap != 1.2 || - cfg.Normalization.EllipsisGap != 2.3 || - cfg.Normalization.MaxSegmentDuration != 45.6 || - cfg.Normalization.MaxSegmentTokens != 321 { - t.Fatalf("unexpected normalization: %+v", cfg.Normalization) - } - wantDescription := "" - if opts.wantTranscriptDescriptionPatch { - wantDescription = "shared context" - } - if cfg.TranscriptDescription != wantDescription { - t.Fatalf("unexpected transcript description: %q", cfg.TranscriptDescription) - } - if cfg.WorkDir != "/tmp/audita-shared" || - cfg.WorkDirRetention != WorkDirRetentionAlways { - t.Fatalf("unexpected diagnostics config: work_dir=%q retention=%q", cfg.WorkDir, cfg.WorkDirRetention) + t.Setenv("AUDITA_MODEL", "legacy-model") + loaded, err = loadText(base, "") + if err != nil || loaded.Config.LLM.ProposalProfile != "proposal" { + t.Fatalf("legacy environment affected loading: %+v %v", loaded, err) } } diff --git a/internal/core/config/effective_config.go b/internal/core/config/effective_config.go deleted file mode 100644 index af2606a..0000000 --- a/internal/core/config/effective_config.go +++ /dev/null @@ -1,119 +0,0 @@ -package config - -import ( - "fmt" - "os" - "strings" -) - -type EffectiveConfigErrorKind string - -const ( - EffectiveConfigErrorResolvePath EffectiveConfigErrorKind = "resolve_path" - EffectiveConfigErrorLoadFile EffectiveConfigErrorKind = "load_file" - EffectiveConfigErrorApplyFile EffectiveConfigErrorKind = "apply_file" - EffectiveConfigErrorApplyEnv EffectiveConfigErrorKind = "apply_env" -) - -type EffectiveConfigError struct { - Kind EffectiveConfigErrorKind - Err error -} - -func (e *EffectiveConfigError) Error() string { - if e == nil || e.Err == nil { - return "" - } - return e.Err.Error() -} - -func (e *EffectiveConfigError) Unwrap() error { - if e == nil { - return nil - } - return e.Err -} - -type EffectiveConfig struct { - Config Config - ConfigPath string - ConfigSource string - ConfigVersion *int -} - -func ResolveConfigPath(cliConfigPath string, cliConfigPathSet bool) (path string, source string, err error) { - return resolveConfigPathWithLookup(cliConfigPath, cliConfigPathSet, os.LookupEnv, os.Stat, DefaultConfigSearchPaths) -} - -func LoadEffectiveConfig(cliConfigPath string, cliConfigPathSet bool) (EffectiveConfig, error) { - return loadEffectiveConfigWithLookup(cliConfigPath, cliConfigPathSet, os.LookupEnv, os.Stat, DefaultConfigSearchPaths) -} - -func loadEffectiveConfigWithLookup(cliConfigPath string, cliConfigPathSet bool, lookup func(string) (string, bool), statPath func(string) (os.FileInfo, error), defaultSearchPaths []string) (EffectiveConfig, error) { - configPath, configSource, err := resolveConfigPathWithLookup(cliConfigPath, cliConfigPathSet, lookup, statPath, defaultSearchPaths) - if err != nil { - return EffectiveConfig{}, &EffectiveConfigError{Kind: EffectiveConfigErrorResolvePath, Err: err} - } - - cfg := Default() - var configVersion *int - if configPath != "" { - fileCfg, fileErr := LoadFileConfig(configPath) - if fileErr != nil { - return EffectiveConfig{}, &EffectiveConfigError{Kind: EffectiveConfigErrorLoadFile, Err: fileErr} - } - if applyErr := cfg.ApplyFileConfig(fileCfg); applyErr != nil { - return EffectiveConfig{}, &EffectiveConfigError{Kind: EffectiveConfigErrorApplyFile, Err: applyErr} - } - configVersion = &fileCfg.Version - } - if applyEnvErr := cfg.applyEnvOverrides(lookup); applyEnvErr != nil { - return EffectiveConfig{}, &EffectiveConfigError{Kind: EffectiveConfigErrorApplyEnv, Err: applyEnvErr} - } - - return EffectiveConfig{ - Config: cfg, - ConfigPath: configPath, - ConfigSource: configSource, - ConfigVersion: configVersion, - }, nil -} - -func resolveConfigPathWithLookup(cliConfigPath string, cliConfigPathSet bool, lookup func(string) (string, bool), statPath func(string) (os.FileInfo, error), defaultSearchPaths []string) (path string, source string, err error) { - if cliConfigPathSet { - path = strings.TrimSpace(cliConfigPath) - if path == "" { - return "", "", fmt.Errorf("--config requires a non-empty path") - } - if _, statErr := statPath(path); statErr != nil { - if os.IsNotExist(statErr) { - return "", "", fmt.Errorf("config file not found: %s", path) - } - return "", "", fmt.Errorf("cannot access config file %s: %w", path, statErr) - } - return path, "flag", nil - } - - if raw, ok := lookup("AUDITA_CONFIG"); ok { - path = strings.TrimSpace(raw) - if path == "" { - return "", "", fmt.Errorf("AUDITA_CONFIG must not be empty") - } - if _, statErr := statPath(path); statErr != nil { - if os.IsNotExist(statErr) { - return "", "", fmt.Errorf("config file not found: %s", path) - } - return "", "", fmt.Errorf("cannot access config file %s: %w", path, statErr) - } - return path, "env", nil - } - - for _, defaultPath := range defaultSearchPaths { - if _, statErr := statPath(defaultPath); statErr == nil { - return defaultPath, "default", nil - } else if !os.IsNotExist(statErr) { - return "", "", fmt.Errorf("cannot access config file %s: %w", defaultPath, statErr) - } - } - return "", "", nil -} diff --git a/internal/core/config/effective_config_test.go b/internal/core/config/effective_config_test.go deleted file mode 100644 index 8211ace..0000000 --- a/internal/core/config/effective_config_test.go +++ /dev/null @@ -1,163 +0,0 @@ -package config - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestResolveConfigPathWithLookupMatrix(t *testing.T) { - statFor := func(existing map[string]bool) func(string) (os.FileInfo, error) { - return func(path string) (os.FileInfo, error) { - if existing[path] { - return nil, nil - } - return nil, os.ErrNotExist - } - } - - tests := []struct { - name string - cliPath string - cliPathSet bool - lookup func(string) (string, bool) - stat func(string) (os.FileInfo, error) - defaultSearchPaths []string - wantPath string - wantSource string - wantErrContains string - }{ - { - name: "explicit config path", - cliPath: "/tmp/explicit.yml", - cliPathSet: true, - lookup: func(string) (string, bool) { return "", false }, - stat: statFor(map[string]bool{"/tmp/explicit.yml": true}), - defaultSearchPaths: []string{ - "/usr/local/etc/audita/config.yml", - "/etc/audita/config.yml", - }, - wantPath: "/tmp/explicit.yml", - wantSource: "flag", - }, - { - name: "env config path", - cliPathSet: false, - lookup: func(key string) (string, bool) { - if key == "AUDITA_CONFIG" { - return "/tmp/from-env.yml", true - } - return "", false - }, - stat: statFor(map[string]bool{"/tmp/from-env.yml": true}), - defaultSearchPaths: []string{"/usr/local/etc/audita/config.yml", "/etc/audita/config.yml"}, - wantPath: "/tmp/from-env.yml", - wantSource: "env", - }, - { - name: "default search path", - cliPathSet: false, - lookup: func(string) (string, bool) { return "", false }, - stat: statFor(map[string]bool{ - "/usr/local/etc/audita/config.yml": true, - "/etc/audita/config.yml": true, - }), - defaultSearchPaths: []string{"/usr/local/etc/audita/config.yml", "/etc/audita/config.yml"}, - wantPath: "/usr/local/etc/audita/config.yml", - wantSource: "default", - }, - { - name: "explicit missing path", - cliPath: "/tmp/missing.yml", - cliPathSet: true, - lookup: func(string) (string, bool) { return "", false }, - stat: statFor(map[string]bool{}), - defaultSearchPaths: []string{ - "/usr/local/etc/audita/config.yml", - "/etc/audita/config.yml", - }, - wantErrContains: "config file not found", - }, - { - name: "missing env path", - cliPathSet: false, - lookup: func(key string) (string, bool) { - if key == "AUDITA_CONFIG" { - return "/tmp/missing-from-env.yml", true - } - return "", false - }, - stat: statFor(map[string]bool{}), - defaultSearchPaths: []string{"/usr/local/etc/audita/config.yml", "/etc/audita/config.yml"}, - wantErrContains: "config file not found", - }, - { - name: "missing default paths", - cliPathSet: false, - lookup: func(string) (string, bool) { return "", false }, - stat: statFor(map[string]bool{}), - defaultSearchPaths: []string{ - "/usr/local/etc/audita/config.yml", - "/etc/audita/config.yml", - }, - wantPath: "", - wantSource: "", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - gotPath, gotSource, err := resolveConfigPathWithLookup(tc.cliPath, tc.cliPathSet, tc.lookup, tc.stat, tc.defaultSearchPaths) - if tc.wantErrContains != "" { - if err == nil || !strings.Contains(err.Error(), tc.wantErrContains) { - t.Fatalf("expected error containing %q, got %v", tc.wantErrContains, err) - } - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if gotPath != tc.wantPath || gotSource != tc.wantSource { - t.Fatalf("unexpected result: got path=%q source=%q, want path=%q source=%q", gotPath, gotSource, tc.wantPath, tc.wantSource) - } - }) - } -} - -func TestLoadEffectiveConfigWithLookupAppliesDefaultsFileThenEnv(t *testing.T) { - tempDir := t.TempDir() - configPath := filepath.Join(tempDir, "config.yml") - configYAML := "version: 1\nllm:\n proposal:\n model: file-model\n" - if err := os.WriteFile(configPath, []byte(configYAML), 0o644); err != nil { - t.Fatalf("write config file: %v", err) - } - - lookup := func(key string) (string, bool) { - switch key { - case "AUDITA_CONFIG": - return configPath, true - case "AUDITA_MODEL": - return "env-model", true - default: - return "", false - } - } - - result, err := loadEffectiveConfigWithLookup("", false, lookup, os.Stat, DefaultConfigSearchPaths) - if err != nil { - t.Fatalf("loadEffectiveConfigWithLookup error: %v", err) - } - if result.ConfigPath != configPath { - t.Fatalf("unexpected config path: %q", result.ConfigPath) - } - if result.ConfigSource != "env" { - t.Fatalf("unexpected config source: %q", result.ConfigSource) - } - if result.ConfigVersion == nil || *result.ConfigVersion != SupportedFileConfigVersion { - t.Fatalf("unexpected config version: %#v", result.ConfigVersion) - } - if result.Config.PrimaryLLM.Model != "env-model" { - t.Fatalf("expected env override to win over file value, got %q", result.Config.PrimaryLLM.Model) - } -} diff --git a/internal/core/config/env.go b/internal/core/config/env.go deleted file mode 100644 index 64f07ff..0000000 --- a/internal/core/config/env.go +++ /dev/null @@ -1,269 +0,0 @@ -package config - -import ( - "fmt" - "os" - "strconv" -) - -const ( - DefaultConfigPath = "/etc/audita/config.yml" - DefaultConfigPathUsrLocal = "/usr/local/etc/audita/config.yml" -) - -var DefaultConfigSearchPaths = []string{ - DefaultConfigPathUsrLocal, - DefaultConfigPath, -} - -func LoadFromEnv() (Config, error) { - cfg := Default() - if err := cfg.applyEnvOverrides(os.LookupEnv); err != nil { - return Config{}, err - } - return cfg, nil -} - -func loadFromLookup(lookup func(string) (string, bool)) (Config, error) { - cfg := Default() - if err := cfg.applyEnvOverrides(lookup); err != nil { - return Config{}, err - } - return cfg, nil -} - -func (c *Config) ApplyEnvOverrides() error { - return c.applyEnvOverrides(os.LookupEnv) -} - -func (c *Config) applyEnvOverrides(lookup func(string) (string, bool)) error { - if c == nil { - return fmt.Errorf("config must not be nil") - } - - cfg := c - if raw, ok := lookup("AUDITA_MODULES"); ok { - modules, err := ParseModulesCSV(raw) - if err != nil { - return fmt.Errorf("AUDITA_MODULES: %w", err) - } - cfg.Modules = modules - } - - primaryLLM := llmTargetPatch{} - if raw, ok := lookup("AUDITA_LLM_API_KEY"); ok { - primaryLLM.apiKey = &raw - } else if raw, ok := lookup("OPENROUTER_API_KEY"); ok { - primaryLLM.apiKey = &raw - } - if raw, ok := lookup("AUDITA_MODEL"); ok { - primaryLLM.model = &raw - } - if raw, ok := lookup("AUDITA_BASE_URL"); ok { - primaryLLM.baseURL = &raw - } - if raw, ok := lookup("AUDITA_LLM_TIMEOUT_SECONDS"); ok { - value, err := parseInt(raw) - if err != nil { - return fmt.Errorf("AUDITA_LLM_TIMEOUT_SECONDS: %w", err) - } - primaryLLM.timeoutSeconds = &value - } - if raw, ok := lookup("AUDITA_MAX_RETRIES"); ok { - value, err := parseInt(raw) - if err != nil { - return fmt.Errorf("AUDITA_MAX_RETRIES: %w", err) - } - primaryLLM.maxRetries = &value - } - cfg.applyPrimaryLLMTargetPatch(primaryLLM) - - validationLLM := llmTargetPatch{} - if raw, ok := lookup("AUDITA_VALIDATION_LLM_API_KEY"); ok { - validationLLM.apiKey = &raw - } - if raw, ok := lookup("AUDITA_VALIDATION_MODEL"); ok { - validationLLM.model = &raw - } - if raw, ok := lookup("AUDITA_VALIDATION_BASE_URL"); ok { - validationLLM.baseURL = &raw - } - if raw, ok := lookup("AUDITA_VALIDATION_LLM_TIMEOUT_SECONDS"); ok { - value, err := parseInt(raw) - if err != nil { - return fmt.Errorf("AUDITA_VALIDATION_LLM_TIMEOUT_SECONDS: %w", err) - } - validationLLM.timeoutSeconds = &value - } - if raw, ok := lookup("AUDITA_VALIDATION_MAX_RETRIES"); ok { - value, err := parseInt(raw) - if err != nil { - return fmt.Errorf("AUDITA_VALIDATION_MAX_RETRIES: %w", err) - } - validationLLM.maxRetries = &value - } - cfg.applyValidationLLMTargetPatch(validationLLM) - - concurrency := concurrencyPatch{ - inheritProposal: true, - allowLegacyAlias: true, - } - if raw, ok := lookup("AUDITA_TOTAL_LLM_CONCURRENCY"); ok { - value, err := parseInt(raw) - if err != nil { - return fmt.Errorf("AUDITA_TOTAL_LLM_CONCURRENCY: %w", err) - } - concurrency.totalLLM = &value - } - if raw, ok := lookup("AUDITA_LLM_CONCURRENCY"); ok { - value, err := parseInt(raw) - if err != nil { - return fmt.Errorf("AUDITA_LLM_CONCURRENCY: %w", err) - } - concurrency.legacyTotalLLM = &value - } - if raw, ok := lookup("AUDITA_PROPOSAL_LLM_CONCURRENCY"); ok { - value, err := parseInt(raw) - if err != nil { - return fmt.Errorf("AUDITA_PROPOSAL_LLM_CONCURRENCY: %w", err) - } - concurrency.proposalLLM = &value - } - if raw, ok := lookup("AUDITA_VALIDATION_LLM_CONCURRENCY"); ok { - value, err := parseInt(raw) - if err != nil { - return fmt.Errorf("AUDITA_VALIDATION_LLM_CONCURRENCY: %w", err) - } - concurrency.validationLLM = &value - } - cfg.applyConcurrencyPatch(concurrency) - - if raw, ok := lookup("AUDITA_VALIDATION_MAX_PROMPT_TOKENS"); ok { - value, err := parseInt(raw) - if err != nil { - return fmt.Errorf("AUDITA_VALIDATION_MAX_PROMPT_TOKENS: %w", err) - } - cfg.ValidationMaxPromptTokens = value - } - - chunking := chunkingPatch{} - if raw, ok := lookup("AUDITA_MAX_SECTION_TOKENS"); ok { - value, err := parseInt(raw) - if err != nil { - return fmt.Errorf("AUDITA_MAX_SECTION_TOKENS: %w", err) - } - chunking.maxSectionTokens = &value - } - - if raw, ok := lookup("AUDITA_MIN_SECTION_TOKENS"); ok { - value, err := parseInt(raw) - if err != nil { - return fmt.Errorf("AUDITA_MIN_SECTION_TOKENS: %w", err) - } - chunking.minSectionTokens = &value - } - - if raw, ok := lookup("AUDITA_TARGET_SECTIONS"); ok { - value, err := parseInt(raw) - if err != nil { - return fmt.Errorf("AUDITA_TARGET_SECTIONS: %w", err) - } - chunking.targetSections = &value - } - cfg.applyChunkingPatch(chunking) - - thresholds := thresholdsPatch{} - if raw, ok := lookup("AUDITA_GLOSSARY_CONFIDENCE_THRESHOLD"); ok { - value, err := parseFloat(raw) - if err != nil { - return fmt.Errorf("AUDITA_GLOSSARY_CONFIDENCE_THRESHOLD: %w", err) - } - thresholds.glossary = &value - } - if raw, ok := lookup("AUDITA_GRAMMAR_CONFIDENCE_THRESHOLD"); ok { - value, err := parseFloat(raw) - if err != nil { - return fmt.Errorf("AUDITA_GRAMMAR_CONFIDENCE_THRESHOLD: %w", err) - } - thresholds.grammar = &value - } - if raw, ok := lookup("AUDITA_HOMOPHONES_CONFIDENCE_THRESHOLD"); ok { - value, err := parseFloat(raw) - if err != nil { - return fmt.Errorf("AUDITA_HOMOPHONES_CONFIDENCE_THRESHOLD: %w", err) - } - thresholds.homophones = &value - } - if raw, ok := lookup("AUDITA_SPOKEN_WORD_CONFIDENCE_THRESHOLD"); ok { - value, err := parseFloat(raw) - if err != nil { - return fmt.Errorf("AUDITA_SPOKEN_WORD_CONFIDENCE_THRESHOLD: %w", err) - } - thresholds.spokenWord = &value - } - cfg.applyThresholdsPatch(thresholds) - - normalization := normalizationPatch{} - if raw, ok := lookup("AUDITA_NORMALIZE_MAX_SEGMENT_GAP"); ok { - value, err := parseFloat(raw) - if err != nil { - return fmt.Errorf("AUDITA_NORMALIZE_MAX_SEGMENT_GAP: %w", err) - } - normalization.maxSegmentGap = &value - } - if raw, ok := lookup("AUDITA_NORMALIZE_ELLIPSIS_GAP"); ok { - value, err := parseFloat(raw) - if err != nil { - return fmt.Errorf("AUDITA_NORMALIZE_ELLIPSIS_GAP: %w", err) - } - normalization.ellipsisGap = &value - } - if raw, ok := lookup("AUDITA_NORMALIZE_MAX_SEGMENT_DURATION"); ok { - value, err := parseFloat(raw) - if err != nil { - return fmt.Errorf("AUDITA_NORMALIZE_MAX_SEGMENT_DURATION: %w", err) - } - normalization.maxSegmentDuration = &value - } - if raw, ok := lookup("AUDITA_NORMALIZE_MAX_SEGMENT_TOKENS"); ok { - value, err := parseInt(raw) - if err != nil { - return fmt.Errorf("AUDITA_NORMALIZE_MAX_SEGMENT_TOKENS: %w", err) - } - normalization.maxSegmentTokens = &value - } - cfg.applyNormalizationPatch(normalization) - - diagnostics := diagnosticsPatch{} - if raw, ok := lookup("AUDITA_WORK_DIR"); ok { - diagnostics.workDir = &raw - } - if raw, ok := lookup("AUDITA_WORK_DIR_RETENTION"); ok { - diagnostics.workDirRetention = &raw - } - cfg.applyDiagnosticsPatch(diagnostics) - - cfg.syncLegacyConcurrencyAliases() - - if err := cfg.Validate(); err != nil { - return err - } - - return nil -} - -func parseInt(raw string) (int, error) { - value, err := strconv.Atoi(raw) - if err != nil { - return 0, fmt.Errorf("must be an integer") - } - return value, nil -} - -func parseFloat(raw string) (float64, error) { - value, err := strconv.ParseFloat(raw, 64) - if err != nil { - return 0, fmt.Errorf("must be a number") - } - return value, nil -} diff --git a/internal/core/config/file_config.go b/internal/core/config/file_config.go deleted file mode 100644 index 5e6ffed..0000000 --- a/internal/core/config/file_config.go +++ /dev/null @@ -1,322 +0,0 @@ -package config - -import ( - "fmt" - "os" - "path/filepath" - "regexp" - "strings" - "time" - - "gopkg.in/yaml.v3" -) - -const SupportedFileConfigVersion = 1 - -var envVarNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) - -type FileConfig struct { - Version int `yaml:"version"` - Pipeline *FileConfigPipeline `yaml:"pipeline,omitempty"` - Output *FileConfigOutput `yaml:"output,omitempty"` - LLM *FileConfigLLM `yaml:"llm,omitempty"` - Concurrency *FileConfigConcurrency `yaml:"concurrency,omitempty"` - Chunking *FileConfigChunking `yaml:"chunking,omitempty"` - Normalization *FileConfigNormalization `yaml:"normalization,omitempty"` - Thresholds *FileConfigThresholds `yaml:"thresholds,omitempty"` - Context *FileConfigContext `yaml:"context,omitempty"` - Diagnostics *FileConfigDiagnostics `yaml:"diagnostics,omitempty"` -} - -type FileConfigPipeline struct { - Modules []string `yaml:"modules,omitempty"` -} - -type FileConfigOutput struct { - Schema *string `yaml:"schema,omitempty"` -} - -type FileConfigLLM struct { - Proposal *FileConfigLLMTarget `yaml:"proposal,omitempty"` - Validation *FileConfigLLMTarget `yaml:"validation,omitempty"` -} - -type FileConfigLLMTarget struct { - BaseURL *string `yaml:"base_url,omitempty"` - Model *string `yaml:"model,omitempty"` - APIKeyEnv *string `yaml:"api_key_env,omitempty"` - Timeout *fileConfigDurationOrInt `yaml:"timeout,omitempty"` - MaxRetries *int `yaml:"max_retries,omitempty"` -} - -type FileConfigConcurrency struct { - TotalLLM *int `yaml:"total_llm,omitempty"` - ProposalLLM *int `yaml:"proposal_llm,omitempty"` - ValidationLLM *int `yaml:"validation_llm,omitempty"` -} - -type FileConfigChunking struct { - TargetSections *int `yaml:"target_sections,omitempty"` - MaxSectionTokens *int `yaml:"max_section_tokens,omitempty"` - MinSectionTokens *int `yaml:"min_section_tokens,omitempty"` -} - -type FileConfigNormalization struct { - MaxSegmentGap *fileConfigDurationOrFloat `yaml:"max_segment_gap,omitempty"` - EllipsisGap *fileConfigDurationOrFloat `yaml:"ellipsis_gap,omitempty"` - MaxSegmentDuration *fileConfigDurationOrFloat `yaml:"max_segment_duration,omitempty"` - MaxSegmentTokens *int `yaml:"max_segment_tokens,omitempty"` -} - -type FileConfigThresholds struct { - Glossary *float64 `yaml:"glossary,omitempty"` - Homophones *float64 `yaml:"homophones,omitempty"` - SpokenWord *float64 `yaml:"spoken_word,omitempty"` - Grammar *float64 `yaml:"grammar,omitempty"` -} - -type FileConfigContext struct { - Description *string `yaml:"description,omitempty"` -} - -type FileConfigDiagnostics struct { - WorkDir *string `yaml:"work_dir,omitempty"` - Retention *string `yaml:"retention,omitempty"` -} - -type fileConfigDurationOrInt struct { - seconds int -} - -func (v *fileConfigDurationOrInt) UnmarshalYAML(node *yaml.Node) error { - switch node.Kind { - case yaml.ScalarNode: - if node.Tag == "!!int" { - var n int - if err := node.Decode(&n); err != nil { - return fmt.Errorf("must be an integer seconds value or duration string") - } - v.seconds = n - return nil - } - - var s string - if err := node.Decode(&s); err != nil { - return fmt.Errorf("must be an integer seconds value or duration string") - } - d, err := time.ParseDuration(strings.TrimSpace(s)) - if err != nil { - return fmt.Errorf("invalid duration %q", s) - } - if d <= 0 { - v.seconds = int(d / time.Second) - return nil - } - if d%time.Second != 0 { - return fmt.Errorf("duration %q must resolve to whole seconds", s) - } - v.seconds = int(d / time.Second) - return nil - default: - return fmt.Errorf("must be an integer seconds value or duration string") - } -} - -func (v fileConfigDurationOrInt) Seconds() int { return v.seconds } - -type fileConfigDurationOrFloat struct { - seconds float64 -} - -func (v *fileConfigDurationOrFloat) UnmarshalYAML(node *yaml.Node) error { - switch node.Kind { - case yaml.ScalarNode: - if node.Tag == "!!int" || node.Tag == "!!float" { - var f float64 - if err := node.Decode(&f); err != nil { - return fmt.Errorf("must be a numeric seconds value or duration string") - } - v.seconds = f - return nil - } - var s string - if err := node.Decode(&s); err != nil { - return fmt.Errorf("must be a numeric seconds value or duration string") - } - d, err := time.ParseDuration(strings.TrimSpace(s)) - if err != nil { - return fmt.Errorf("invalid duration %q", s) - } - v.seconds = d.Seconds() - return nil - default: - return fmt.Errorf("must be a numeric seconds value or duration string") - } -} - -func (v fileConfigDurationOrFloat) Seconds() float64 { return v.seconds } - -func LoadFileConfig(path string) (FileConfig, error) { - b, err := os.ReadFile(path) - if err != nil { - return FileConfig{}, fmt.Errorf("read config file %q: %w", path, err) - } - cfg, err := ParseFileConfigYAML(b) - if err != nil { - return FileConfig{}, fmt.Errorf("parse config file %q: %w", path, err) - } - return cfg, nil -} - -func ParseFileConfigYAML(data []byte) (FileConfig, error) { - var fileCfg FileConfig - dec := yaml.NewDecoder(strings.NewReader(string(data))) - dec.KnownFields(true) - if err := dec.Decode(&fileCfg); err != nil { - return FileConfig{}, fmt.Errorf("decode yaml: %w", err) - } - if fileCfg.Version == 0 { - return FileConfig{}, fmt.Errorf("config version is required") - } - if fileCfg.Version != SupportedFileConfigVersion { - return FileConfig{}, fmt.Errorf("unsupported config version %d", fileCfg.Version) - } - return fileCfg, nil -} - -func (c *Config) ApplyFileConfig(fileCfg FileConfig) error { - return c.applyFileConfigWithLookup(fileCfg, os.LookupEnv) -} - -func (c *Config) applyFileConfigWithLookup(fileCfg FileConfig, lookup func(string) (string, bool)) error { - if c == nil { - return fmt.Errorf("config must not be nil") - } - if fileCfg.Version != SupportedFileConfigVersion { - return fmt.Errorf("unsupported config version %d", fileCfg.Version) - } - - if fileCfg.Pipeline != nil && len(fileCfg.Pipeline.Modules) > 0 { - c.Modules = append([]string(nil), fileCfg.Pipeline.Modules...) - } - if fileCfg.Output != nil && fileCfg.Output.Schema != nil { - c.OutputSchema = strings.TrimSpace(*fileCfg.Output.Schema) - } - - if fileCfg.LLM != nil { - if fileCfg.LLM.Proposal != nil { - patch := llmTargetPatch{ - model: fileCfg.LLM.Proposal.Model, - baseURL: fileCfg.LLM.Proposal.BaseURL, - maxRetries: fileCfg.LLM.Proposal.MaxRetries, - } - if fileCfg.LLM.Proposal.Timeout != nil { - timeoutSeconds := fileCfg.LLM.Proposal.Timeout.Seconds() - patch.timeoutSeconds = &timeoutSeconds - } - if fileCfg.LLM.Proposal.APIKeyEnv != nil { - apiKey, err := resolveAPIKeyEnv(*fileCfg.LLM.Proposal.APIKeyEnv, lookup) - if err != nil { - return fmt.Errorf("llm.proposal.api_key_env: %w", err) - } - patch.apiKey = &apiKey - } - c.applyPrimaryLLMTargetPatch(patch) - } - if fileCfg.LLM.Validation != nil { - patch := llmTargetPatch{ - model: fileCfg.LLM.Validation.Model, - baseURL: fileCfg.LLM.Validation.BaseURL, - maxRetries: fileCfg.LLM.Validation.MaxRetries, - } - if fileCfg.LLM.Validation.Timeout != nil { - timeoutSeconds := fileCfg.LLM.Validation.Timeout.Seconds() - patch.timeoutSeconds = &timeoutSeconds - } - if fileCfg.LLM.Validation.APIKeyEnv != nil { - apiKey, err := resolveAPIKeyEnv(*fileCfg.LLM.Validation.APIKeyEnv, lookup) - if err != nil { - return fmt.Errorf("llm.validation.api_key_env: %w", err) - } - patch.apiKey = &apiKey - } - c.applyValidationLLMTargetPatch(patch) - } - } - - if fileCfg.Concurrency != nil { - c.applyConcurrencyPatch(concurrencyPatch{ - totalLLM: fileCfg.Concurrency.TotalLLM, - proposalLLM: fileCfg.Concurrency.ProposalLLM, - validationLLM: fileCfg.Concurrency.ValidationLLM, - }) - } - - if fileCfg.Chunking != nil { - c.applyChunkingPatch(chunkingPatch{ - targetSections: fileCfg.Chunking.TargetSections, - maxSectionTokens: fileCfg.Chunking.MaxSectionTokens, - minSectionTokens: fileCfg.Chunking.MinSectionTokens, - }) - } - - if fileCfg.Normalization != nil { - patch := normalizationPatch{ - maxSegmentTokens: fileCfg.Normalization.MaxSegmentTokens, - } - if fileCfg.Normalization.MaxSegmentGap != nil { - maxSegmentGap := fileCfg.Normalization.MaxSegmentGap.Seconds() - patch.maxSegmentGap = &maxSegmentGap - } - if fileCfg.Normalization.EllipsisGap != nil { - ellipsisGap := fileCfg.Normalization.EllipsisGap.Seconds() - patch.ellipsisGap = &ellipsisGap - } - if fileCfg.Normalization.MaxSegmentDuration != nil { - maxSegmentDuration := fileCfg.Normalization.MaxSegmentDuration.Seconds() - patch.maxSegmentDuration = &maxSegmentDuration - } - c.applyNormalizationPatch(patch) - } - - if fileCfg.Thresholds != nil { - c.applyThresholdsPatch(thresholdsPatch{ - glossary: fileCfg.Thresholds.Glossary, - grammar: fileCfg.Thresholds.Grammar, - homophones: fileCfg.Thresholds.Homophones, - spokenWord: fileCfg.Thresholds.SpokenWord, - }) - } - - if fileCfg.Context != nil && fileCfg.Context.Description != nil { - c.applyContextPatch(contextPatch{transcriptDescription: fileCfg.Context.Description}) - } - - if fileCfg.Diagnostics != nil { - c.applyDiagnosticsPatch(diagnosticsPatch{ - workDir: fileCfg.Diagnostics.WorkDir, - workDirRetention: fileCfg.Diagnostics.Retention, - }) - } - - c.syncLegacyConcurrencyAliases() - if err := c.Validate(); err != nil { - return err - } - return nil -} - -func resolveAPIKeyEnv(envName string, lookup func(string) (string, bool)) (string, error) { - name := strings.TrimSpace(envName) - if name == "" { - return "", fmt.Errorf("must not be empty") - } - if !envVarNamePattern.MatchString(name) { - return "", fmt.Errorf("must be an environment variable name") - } - if strings.Contains(name, string(filepath.Separator)) { - return "", fmt.Errorf("must be an environment variable name") - } - v, _ := lookup(name) - return v, nil -} diff --git a/internal/core/config/file_config_test.go b/internal/core/config/file_config_test.go deleted file mode 100644 index 13acc81..0000000 --- a/internal/core/config/file_config_test.go +++ /dev/null @@ -1,290 +0,0 @@ -package config - -import ( - "os" - "strings" - "testing" -) - -func TestParseFileConfigYAMLValid(t *testing.T) { - raw := ` -version: 1 -pipeline: - modules: [glossary, homophones, grammar] -output: - schema: audita-v1 -llm: - proposal: - base_url: https://example.test/v1 - model: provider/model-a - api_key_env: AUDITA_PROPOSAL_KEY - timeout: 2m - max_retries: 4 - validation: - base_url: https://example.test/validation - model: provider/model-b - api_key_env: AUDITA_VALIDATION_KEY - timeout: 45 - max_retries: 3 -concurrency: - total_llm: 8 - proposal_llm: 4 - validation_llm: 2 -chunking: - target_sections: 6 - max_section_tokens: 9000 - min_section_tokens: 3000 -normalization: - max_segment_gap: 1.5s - ellipsis_gap: 2 - max_segment_duration: 45s - max_segment_tokens: 1500 -thresholds: - glossary: 0.9 - homophones: 0.7 - spoken_word: 0.8 - grammar: 0.75 -context: - description: " crowd scene with many proper nouns " -diagnostics: - work_dir: /tmp/audita-config - retention: always -` - cfg, err := ParseFileConfigYAML([]byte(raw)) - if err != nil { - t.Fatalf("ParseFileConfigYAML error: %v", err) - } - if cfg.Version != 1 { - t.Fatalf("expected version 1, got %d", cfg.Version) - } - if cfg.Pipeline == nil || len(cfg.Pipeline.Modules) != 3 { - t.Fatalf("unexpected pipeline modules: %#v", cfg.Pipeline) - } - if cfg.Output == nil || cfg.Output.Schema == nil || *cfg.Output.Schema != "audita-v1" { - t.Fatalf("expected output schema audita-v1, got %#v", cfg.Output) - } - if cfg.LLM == nil || cfg.LLM.Proposal == nil || cfg.LLM.Validation == nil { - t.Fatalf("expected llm proposal+validation blocks") - } - if cfg.LLM.Proposal.Timeout == nil || cfg.LLM.Proposal.Timeout.Seconds() != 120 { - t.Fatalf("expected proposal timeout 120s, got %#v", cfg.LLM.Proposal.Timeout) - } - if cfg.LLM.Validation.Timeout == nil || cfg.LLM.Validation.Timeout.Seconds() != 45 { - t.Fatalf("expected validation timeout 45s, got %#v", cfg.LLM.Validation.Timeout) - } - if cfg.Normalization == nil || cfg.Normalization.MaxSegmentGap == nil || cfg.Normalization.MaxSegmentGap.Seconds() != 1.5 { - t.Fatalf("expected parsed duration for normalization max_segment_gap") - } -} - -func TestParseFileConfigYAMLRejectsUnknownField(t *testing.T) { - raw := ` -version: 1 -pipeline: - modules: [grammar] -output: - unknown: v1 -` - _, err := ParseFileConfigYAML([]byte(raw)) - if err == nil { - t.Fatalf("expected unknown field error") - } - if !strings.Contains(err.Error(), "field unknown not found") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestParseFileConfigYAMLRejectsMissingVersion(t *testing.T) { - raw := `pipeline: {modules: [grammar]}` - _, err := ParseFileConfigYAML([]byte(raw)) - if err == nil { - t.Fatalf("expected missing version error") - } - if !strings.Contains(err.Error(), "config version is required") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestParseFileConfigYAMLRejectsUnsupportedVersion(t *testing.T) { - raw := `version: 2` - _, err := ParseFileConfigYAML([]byte(raw)) - if err == nil { - t.Fatalf("expected unsupported version error") - } - if !strings.Contains(err.Error(), "unsupported config version 2") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestApplyFileConfigParsesAndMergesFields(t *testing.T) { - raw := ` -version: 1 -pipeline: - modules: [spoken_word, grammar] -output: - schema: audita-v1 -llm: - proposal: - model: provider/new-proposal - api_key_env: PROPOSAL_KEY_NAME - timeout: 90s - max_retries: 5 - validation: - model: provider/new-validation - api_key_env: VALIDATION_KEY_NAME - timeout: 150 - max_retries: 6 -concurrency: - total_llm: 7 - proposal_llm: 3 - validation_llm: 2 -chunking: - target_sections: 9 -thresholds: - glossary: 0.91 - homophones: 0.61 - spoken_word: 0.71 - grammar: 0.81 -diagnostics: - retention: never -` - fileCfg, err := ParseFileConfigYAML([]byte(raw)) - if err != nil { - t.Fatalf("ParseFileConfigYAML error: %v", err) - } - - cfg := Default() - lookup := func(name string) (string, bool) { - switch name { - case "PROPOSAL_KEY_NAME": - return "proposal-secret", true - case "VALIDATION_KEY_NAME": - return "validation-secret", true - default: - return "", false - } - } - - if err := cfg.applyFileConfigWithLookup(fileCfg, lookup); err != nil { - t.Fatalf("applyFileConfigWithLookup error: %v", err) - } - if strings.Join(cfg.Modules, ",") != "spoken_word,grammar" { - t.Fatalf("unexpected modules: %#v", cfg.Modules) - } - if cfg.OutputSchema != "audita-v1" { - t.Fatalf("unexpected output schema: %q", cfg.OutputSchema) - } - if cfg.PrimaryLLM.Model != "provider/new-proposal" { - t.Fatalf("unexpected proposal model: %q", cfg.PrimaryLLM.Model) - } - if cfg.PrimaryLLM.APIKey != "proposal-secret" { - t.Fatalf("expected proposal key from api_key_env lookup, got %q", cfg.PrimaryLLM.APIKey) - } - if cfg.PrimaryLLM.TimeoutSeconds != 90 { - t.Fatalf("unexpected proposal timeout: %d", cfg.PrimaryLLM.TimeoutSeconds) - } - if cfg.ValidationLLM.Model != "provider/new-validation" { - t.Fatalf("unexpected validation model: %q", cfg.ValidationLLM.Model) - } - if cfg.ValidationLLM.APIKey != "validation-secret" { - t.Fatalf("expected validation key from api_key_env lookup, got %q", cfg.ValidationLLM.APIKey) - } - if cfg.ValidationLLM.TimeoutSeconds == nil || *cfg.ValidationLLM.TimeoutSeconds != 150 { - t.Fatalf("unexpected validation timeout: %#v", cfg.ValidationLLM.TimeoutSeconds) - } - if cfg.TotalLLMConcurrency != 7 || cfg.ProposalLLMConcurrency != 3 { - t.Fatalf("unexpected llm concurrency values: total=%d proposal=%d", cfg.TotalLLMConcurrency, cfg.ProposalLLMConcurrency) - } - if cfg.ValidationLLMConcurrency == nil || *cfg.ValidationLLMConcurrency != 2 { - t.Fatalf("unexpected validation llm concurrency: %#v", cfg.ValidationLLMConcurrency) - } - if cfg.TargetSections == nil || *cfg.TargetSections != 9 { - t.Fatalf("unexpected target sections: %#v", cfg.TargetSections) - } - if cfg.WorkDirRetention != WorkDirRetentionNever { - t.Fatalf("unexpected retention: %q", cfg.WorkDirRetention) - } - if cfg.PrimaryLLM.Concurrency != 7 { - t.Fatalf("expected legacy alias to sync, got %d", cfg.PrimaryLLM.Concurrency) - } - if cfg.ValidationLLM.Concurrency == nil || *cfg.ValidationLLM.Concurrency != 2 { - t.Fatalf("expected validation alias to sync, got %#v", cfg.ValidationLLM.Concurrency) - } -} - -func TestApplyFileConfigContextDescriptionTrim(t *testing.T) { - raw := ` -version: 1 -context: - description: " scene context " -` - fileCfg, err := ParseFileConfigYAML([]byte(raw)) - if err != nil { - t.Fatalf("ParseFileConfigYAML error: %v", err) - } - cfg := Default() - if err := cfg.applyFileConfigWithLookup(fileCfg, mapLookup(map[string]string{})); err != nil { - t.Fatalf("applyFileConfigWithLookup error: %v", err) - } - if cfg.TranscriptDescription != "scene context" { - t.Fatalf("unexpected transcript description: %q", cfg.TranscriptDescription) - } -} - -func TestApplyFileConfigRejectsInvalidAPIKeyEnvName(t *testing.T) { - raw := ` -version: 1 -llm: - proposal: - api_key_env: "not a var name" -` - fileCfg, err := ParseFileConfigYAML([]byte(raw)) - if err != nil { - t.Fatalf("ParseFileConfigYAML error: %v", err) - } - cfg := Default() - err = cfg.applyFileConfigWithLookup(fileCfg, mapLookup(map[string]string{})) - if err == nil { - t.Fatalf("expected api_key_env validation error") - } - if !strings.Contains(err.Error(), "environment variable name") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestParseFileConfigDurationParsingErrors(t *testing.T) { - raw := ` -version: 1 -llm: - proposal: - timeout: "1.5s" -` - _, err := ParseFileConfigYAML([]byte(raw)) - if err == nil { - t.Fatalf("expected duration parse error") - } - if !strings.Contains(err.Error(), "whole seconds") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestLoadFileConfigReadsFromPath(t *testing.T) { - p := writeTempFileConfig(t, "version: 1\n") - cfg, err := LoadFileConfig(p) - if err != nil { - t.Fatalf("LoadFileConfig error: %v", err) - } - if cfg.Version != 1 { - t.Fatalf("expected version 1, got %d", cfg.Version) - } -} - -func writeTempFileConfig(t *testing.T, contents string) string { - t.Helper() - dir := t.TempDir() - path := dir + "/config.yaml" - if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { - t.Fatalf("write config file: %v", err) - } - return path -} diff --git a/internal/core/config/flags.go b/internal/core/config/flags.go deleted file mode 100644 index 52e99ee..0000000 --- a/internal/core/config/flags.go +++ /dev/null @@ -1,106 +0,0 @@ -package config - -import ( - "fmt" - "strings" -) - -type CLIOverrides struct { - ModulesCSV *string - OutputSchema *string - PrimaryLLMAPIKey *string - ValidationLLMAPIKey *string - PrimaryModel *string - ValidationModel *string - PrimaryBaseURL *string - ValidationBaseURL *string - PrimaryLLMTimeoutSeconds *int - TotalLLMConcurrency *int - ProposalLLMConcurrency *int - PrimaryLLMConcurrency *int - ValidationLLMTimeoutSeconds *int - MaxRetries *int - ValidationMaxRetries *int - ValidationLLMConcurrency *int - ValidationMaxPromptTokens *int - MaxSectionTokens *int - MinSectionTokens *int - TargetSections *int - GlossaryConfidenceThreshold *float64 - GrammarConfidenceThreshold *float64 - HomophonesConfidenceThreshold *float64 - SpokenWordConfidenceThreshold *float64 - NormalizeMaxSegmentGap *float64 - NormalizeEllipsisGap *float64 - NormalizeMaxSegmentDuration *float64 - NormalizeMaxSegmentTokens *int - TranscriptDescription *string - WorkDir *string - WorkDirRetention *string -} - -func (c *Config) ApplyCLIOverrides(overrides CLIOverrides) error { - if overrides.ModulesCSV != nil { - modules, err := ParseModulesCSV(*overrides.ModulesCSV) - if err != nil { - return fmt.Errorf("--modules: %w", err) - } - c.Modules = modules - } - if overrides.OutputSchema != nil { - c.OutputSchema = strings.TrimSpace(*overrides.OutputSchema) - } - - c.applyPrimaryLLMTargetPatch(llmTargetPatch{ - apiKey: overrides.PrimaryLLMAPIKey, - model: overrides.PrimaryModel, - baseURL: overrides.PrimaryBaseURL, - timeoutSeconds: overrides.PrimaryLLMTimeoutSeconds, - maxRetries: overrides.MaxRetries, - }) - c.applyValidationLLMTargetPatch(llmTargetPatch{ - apiKey: overrides.ValidationLLMAPIKey, - model: overrides.ValidationModel, - baseURL: overrides.ValidationBaseURL, - timeoutSeconds: overrides.ValidationLLMTimeoutSeconds, - maxRetries: overrides.ValidationMaxRetries, - }) - c.applyConcurrencyPatch(concurrencyPatch{ - totalLLM: overrides.TotalLLMConcurrency, - legacyTotalLLM: overrides.PrimaryLLMConcurrency, - proposalLLM: overrides.ProposalLLMConcurrency, - validationLLM: overrides.ValidationLLMConcurrency, - inheritProposal: true, - allowLegacyAlias: true, - }) - - if overrides.ValidationMaxPromptTokens != nil { - c.ValidationMaxPromptTokens = *overrides.ValidationMaxPromptTokens - } - c.applyChunkingPatch(chunkingPatch{ - targetSections: overrides.TargetSections, - maxSectionTokens: overrides.MaxSectionTokens, - minSectionTokens: overrides.MinSectionTokens, - }) - c.applyThresholdsPatch(thresholdsPatch{ - glossary: overrides.GlossaryConfidenceThreshold, - grammar: overrides.GrammarConfidenceThreshold, - homophones: overrides.HomophonesConfidenceThreshold, - spokenWord: overrides.SpokenWordConfidenceThreshold, - }) - c.applyNormalizationPatch(normalizationPatch{ - maxSegmentGap: overrides.NormalizeMaxSegmentGap, - ellipsisGap: overrides.NormalizeEllipsisGap, - maxSegmentDuration: overrides.NormalizeMaxSegmentDuration, - maxSegmentTokens: overrides.NormalizeMaxSegmentTokens, - }) - c.applyContextPatch(contextPatch{transcriptDescription: overrides.TranscriptDescription}) - c.applyDiagnosticsPatch(diagnosticsPatch{ - workDir: overrides.WorkDir, - workDirRetention: overrides.WorkDirRetention, - }) - - c.syncLegacyConcurrencyAliases() - - return c.Validate() -} diff --git a/internal/core/config/redaction.go b/internal/core/config/redaction.go deleted file mode 100644 index e28a694..0000000 --- a/internal/core/config/redaction.go +++ /dev/null @@ -1,17 +0,0 @@ -package config - -const redactedSecret = "[REDACTED]" - -func (c Config) Redacted() Config { - redacted := c - redacted.PrimaryLLM.APIKey = redactSecret(redacted.PrimaryLLM.APIKey) - redacted.ValidationLLM.APIKey = redactSecret(redacted.ValidationLLM.APIKey) - return redacted -} - -func redactSecret(value string) string { - if value == "" { - return "" - } - return redactedSecret -} diff --git a/internal/core/config/validation.go b/internal/core/config/validation.go deleted file mode 100644 index 0e680f6..0000000 --- a/internal/core/config/validation.go +++ /dev/null @@ -1,136 +0,0 @@ -package config - -import ( - "fmt" - "strings" - - "gitea.maximumdirect.net/eric/audita/internal/core/modulecatalog" - "gitea.maximumdirect.net/eric/audita/internal/core/outputschema" -) - -func (c Config) Validate() error { - var issues []string - - if len(c.Modules) == 0 { - issues = append(issues, "modules must not be empty") - } - for _, module := range c.Modules { - moduleKey := strings.TrimSpace(module) - if moduleKey == "" { - issues = append(issues, "modules must not contain empty values") - break - } - if !modulecatalog.IsSupported(moduleKey) { - issues = append(issues, fmt.Sprintf("unsupported module key %q", moduleKey)) - } - } - if strings.TrimSpace(c.OutputSchema) == "" { - issues = append(issues, "output schema must not be empty") - } else if !outputschema.IsSupported(c.OutputSchema) { - issues = append(issues, fmt.Sprintf("unsupported output schema %q", c.OutputSchema)) - } - - if c.PrimaryLLM.TimeoutSeconds <= 0 { - issues = append(issues, "primary llm timeout seconds must be greater than zero") - } - if c.PrimaryLLM.MaxRetries < 0 { - issues = append(issues, "max retries must be zero or greater") - } - if c.TotalLLMConcurrency <= 0 { - issues = append(issues, "total llm concurrency must be greater than zero") - } - if c.ProposalLLMConcurrency <= 0 { - issues = append(issues, "proposal llm concurrency must be greater than zero") - } - if c.ProposalLLMConcurrency > c.TotalLLMConcurrency { - issues = append(issues, "proposal llm concurrency must be less than or equal to total llm concurrency") - } - - if c.ValidationLLM.TimeoutSeconds != nil && *c.ValidationLLM.TimeoutSeconds <= 0 { - issues = append(issues, "validation llm timeout seconds must be greater than zero") - } - if c.ValidationLLM.MaxRetries != nil && *c.ValidationLLM.MaxRetries < 0 { - issues = append(issues, "validation max retries must be zero or greater") - } - if c.ValidationLLMConcurrency != nil && *c.ValidationLLMConcurrency <= 0 { - issues = append(issues, "validation llm concurrency must be greater than zero") - } - if c.ValidationLLMConcurrency != nil && *c.ValidationLLMConcurrency > c.TotalLLMConcurrency { - issues = append(issues, "validation llm concurrency must be less than or equal to total llm concurrency") - } - - if c.ValidationMaxPromptTokens <= 0 { - issues = append(issues, "validation max prompt tokens must be greater than zero") - } - if c.MaxSectionTokens <= 0 { - issues = append(issues, "max section tokens must be greater than zero") - } - if c.MinSectionTokens <= 0 { - issues = append(issues, "min section tokens must be greater than zero") - } - if c.MinSectionTokens > c.MaxSectionTokens { - issues = append(issues, "min section tokens must be less than or equal to max section tokens") - } - if c.TargetSections != nil && *c.TargetSections <= 0 { - issues = append(issues, "target sections must be greater than zero when set") - } - - if err := validateConfidence("glossary", c.Thresholds.Glossary); err != nil { - issues = append(issues, err.Error()) - } - if err := validateConfidence("grammar", c.Thresholds.Grammar); err != nil { - issues = append(issues, err.Error()) - } - if err := validateConfidence("homophones", c.Thresholds.Homophones); err != nil { - issues = append(issues, err.Error()) - } - if err := validateConfidence("spoken-word", c.Thresholds.SpokenWord); err != nil { - issues = append(issues, err.Error()) - } - - if c.Normalization.MaxSegmentGap < 0 { - issues = append(issues, "normalize max segment gap must be zero or greater") - } - if c.Normalization.EllipsisGap < 0 { - issues = append(issues, "normalize ellipsis gap must be zero or greater") - } - if c.Normalization.MaxSegmentDuration <= 0 { - issues = append(issues, "normalize max segment duration must be greater than zero") - } - if c.Normalization.MaxSegmentTokens <= 0 { - issues = append(issues, "normalize max segment tokens must be greater than zero") - } - if len(strings.TrimSpace(c.TranscriptDescription)) > DefaultTranscriptDescriptionMaxChars { - issues = append(issues, fmt.Sprintf("transcript description must be %d characters or fewer", DefaultTranscriptDescriptionMaxChars)) - } - - if strings.TrimSpace(c.WorkDir) == "" { - issues = append(issues, "work dir must not be empty") - } - - if err := validateRetention(c.WorkDirRetention); err != nil { - issues = append(issues, err.Error()) - } - - if len(issues) > 0 { - return fmt.Errorf("invalid config: %s", strings.Join(issues, "; ")) - } - - return nil -} - -func validateConfidence(name string, threshold float64) error { - if threshold < 0.0 || threshold > 1.0 { - return fmt.Errorf("%s confidence threshold must be between 0.0 and 1.0", name) - } - return nil -} - -func validateRetention(retention WorkDirRetention) error { - switch retention { - case WorkDirRetentionAuto, WorkDirRetentionAlways, WorkDirRetentionNever: - return nil - default: - return fmt.Errorf("work dir retention must be one of: auto, always, never") - } -}