Replace legacy configuration loading with ConfigKit schema two
This commit is contained in:
@@ -3,7 +3,7 @@ when:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: build-release-assets
|
- name: build-release-assets
|
||||||
image: golang:1.25
|
image: golang:1.25.5
|
||||||
commands:
|
commands:
|
||||||
- bash scripts/build-release.sh "$CI_COMMIT_TAG" dist
|
- bash scripts/build-release.sh "$CI_COMMIT_TAG" dist
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ reports for review and subprocess integration.
|
|||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
From a checkout, build with Go 1.24 or newer:
|
From a checkout, build with Go 1.25.5 or newer:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go build -o ./bin/audita ./cmd/audita
|
go build -o ./bin/audita ./cmd/audita
|
||||||
|
|||||||
232
docs/config.md
232
docs/config.md
@@ -1,188 +1,68 @@
|
|||||||
# Audita Configuration
|
# Audita Configuration
|
||||||
|
|
||||||
This is the canonical reference for discovery, settings, defaults, environment
|
This reference describes the version-2 configuration loader in `internal/core/config`.
|
||||||
mapping, and validation. [CLI syntax](cli.md), [artifact lifecycle](operations.md),
|
CLI command parsing and profile inspection are being connected in later work; the
|
||||||
and [file formats](integrations/output-files.md) have separate owners.
|
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
|
The application selects one main YAML file. ConfigKit loads built-in defaults,
|
||||||
selects one YAML file:
|
the selected file, an optional named overlay from `overlays/<name>.yml` beside the
|
||||||
|
main file, and only explicitly supplied setting overrides, in that order. Imports
|
||||||
|
and secret-directory bootstrap are disabled. Unknown fields and missing, null,
|
||||||
|
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.
|
Validation runs after overlays, explicit overrides, and derived settings have
|
||||||
2. `AUDITA_CONFIG`.
|
resolved. An override can therefore repair an invalid lower-precedence setting
|
||||||
3. `/usr/local/etc/audita/config.yml`, if present.
|
when that setting can be decoded. ConfigKit retains field origins and derivations
|
||||||
4. `/etc/audita/config.yml`, if present.
|
for effective-configuration inspection. Its effective YAML redacts resolved
|
||||||
|
credentials.
|
||||||
|
|
||||||
These files are alternatives, not layers merged together. Missing system files
|
## Settings
|
||||||
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.
|
|
||||||
|
|
||||||
## Effective Precedence
|
| Field | Default | Rule |
|
||||||
|
|
||||||
| 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 |
|
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `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. |
|
| `profiles_dir` | `profiles` | Nonempty; profile files are resolved relative to the selected main file. |
|
||||||
| `output.schema` | `bare-segments` | Output encoder selection: `bare-segments` or `audita-v1`. See [output formats](integrations/output-files.md#corrected-transcript). |
|
| `backends.<id>.endpoint` | None | Required nonempty endpoint for each backend. At least one backend is required. |
|
||||||
|
| `backends.<id>.concurrency` | `1` | Positive finite capacity; shared by both roles using that backend. |
|
||||||
|
| `backends.<id>.queue_capacity` | `1024` | Nonnegative; zero disables waiting admission. |
|
||||||
|
| `backends.<id>.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;
|
Module keys select correction modules; validator chains remain built into them.
|
||||||
there is no configuration field for selecting individual validator keys.
|
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 |
|
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).
|
||||||
| `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.
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ ownership. Commit discipline and agent Git restrictions are in the
|
|||||||
|
|
||||||
## Baseline Validation
|
## Baseline Validation
|
||||||
|
|
||||||
Use Go 1.24 or newer. From the repository root:
|
Use Go 1.25.5 or newer. From the repository root:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go build -o ./bin/audita ./cmd/audita
|
go build -o ./bin/audita ./cmd/audita
|
||||||
|
|||||||
@@ -108,7 +108,13 @@ user-facing policy belong to the feature roadmaps.
|
|||||||
|
|
||||||
## Stage 1: Replace the Configuration Model and Loader
|
## 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.
|
**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
|
1. Pin both modules and the Go minimum above. Align build/CI toolchain declarations
|
||||||
|
|||||||
15
go.mod
15
go.mod
@@ -1,11 +1,20 @@
|
|||||||
module gitea.maximumdirect.net/eric/audita
|
module gitea.maximumdirect.net/eric/audita
|
||||||
|
|
||||||
go 1.24.0
|
go 1.25.5
|
||||||
|
|
||||||
require gopkg.in/yaml.v3 v3.0.1
|
|
||||||
|
|
||||||
require (
|
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/kr/pretty v0.3.1 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.14.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
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
14
go.sum
14
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/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.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
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.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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,197 +3,187 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
ck "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
"gitea.maximumdirect.net/eric/audita/internal/core/modulecatalog"
|
"gitea.maximumdirect.net/eric/audita/internal/core/modulecatalog"
|
||||||
|
"gitea.maximumdirect.net/eric/audita/internal/core/outputschema"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WorkDirRetention string
|
type WorkDirRetention string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
WorkDirRetentionAuto WorkDirRetention = "auto"
|
WorkDirRetentionAuto WorkDirRetention = "auto"
|
||||||
WorkDirRetentionAlways WorkDirRetention = "always"
|
WorkDirRetentionAlways WorkDirRetention = "always"
|
||||||
WorkDirRetentionNever WorkDirRetention = "never"
|
WorkDirRetentionNever WorkDirRetention = "never"
|
||||||
)
|
DefaultModulesCSV = "glossary,homophones,glossary,spoken_word,grammar"
|
||||||
|
|
||||||
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
|
|
||||||
DefaultTranscriptDescriptionMaxChars = 500
|
DefaultTranscriptDescriptionMaxChars = 500
|
||||||
DefaultWorkDir = "/tmp/audita"
|
|
||||||
DefaultWorkDirRetention WorkDirRetention = WorkDirRetentionAuto
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Config is the version-2 application configuration. Invocation paths are not settings.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Modules []string
|
Version int `yaml:"version"`
|
||||||
OutputSchema string
|
ProfilesDir string `yaml:"profiles_dir"`
|
||||||
PrimaryLLM LLMConfig
|
Backends map[string]Backend `yaml:"backends"`
|
||||||
ValidationLLM ValidationLLMConfig
|
LLM LLM `yaml:"llm"`
|
||||||
TotalLLMConcurrency int
|
Pipeline Pipeline `yaml:"pipeline"`
|
||||||
ProposalLLMConcurrency int
|
Output Output `yaml:"output"`
|
||||||
ValidationLLMConcurrency *int
|
Validation Validation `yaml:"validation"`
|
||||||
ValidationMaxPromptTokens int
|
Chunking Chunking `yaml:"chunking"`
|
||||||
MaxSectionTokens int
|
Normalization Normalization `yaml:"normalization"`
|
||||||
MinSectionTokens int
|
Thresholds Thresholds `yaml:"thresholds"`
|
||||||
TargetSections *int
|
Context Context `yaml:"context"`
|
||||||
Thresholds ConfidenceThresholds
|
Diagnostics Diagnostics `yaml:"diagnostics"`
|
||||||
Normalization NormalizationConfig
|
|
||||||
TranscriptDescription string
|
|
||||||
WorkDir string
|
|
||||||
WorkDirRetention WorkDirRetention
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type LLMConfig struct {
|
type Backend struct {
|
||||||
APIKey string
|
Endpoint string `yaml:"endpoint"`
|
||||||
Model string
|
Concurrency int `yaml:"concurrency"`
|
||||||
BaseURL string
|
QueueCapacity int `yaml:"queue_capacity"`
|
||||||
TimeoutSeconds int
|
Credential *ck.Secret `yaml:"credential"`
|
||||||
MaxRetries int
|
}
|
||||||
// Concurrency is retained as a backward-compatible alias for
|
type LLM struct {
|
||||||
// TotalLLMConcurrency.
|
ProposalProfile string `yaml:"proposal_profile"`
|
||||||
Concurrency int
|
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 {
|
func defaults() Config {
|
||||||
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)
|
|
||||||
|
|
||||||
return Config{
|
return Config{
|
||||||
Modules: modules,
|
ProfilesDir: "profiles",
|
||||||
OutputSchema: DefaultOutputSchema,
|
LLM: LLM{TransientRetries: 2, RepairAttempts: 2, OperationTimeout: 15 * time.Minute},
|
||||||
PrimaryLLM: LLMConfig{
|
Pipeline: Pipeline{Modules: []string{"glossary", "homophones", "glossary", "spoken_word", "grammar"}},
|
||||||
Model: DefaultPrimaryModel,
|
Output: Output{Schema: "bare-segments"},
|
||||||
BaseURL: DefaultPrimaryBaseURL,
|
Validation: Validation{MaxPromptTokens: 2048},
|
||||||
TimeoutSeconds: DefaultPrimaryLLMTimeoutSeconds,
|
Chunking: Chunking{MaxSectionTokens: 8192, MinSectionTokens: 2048},
|
||||||
MaxRetries: DefaultMaxRetries,
|
Normalization: Normalization{MaxSegmentGap: 4, EllipsisGap: 3.5, MaxSegmentDuration: 60, MaxSegmentTokens: 2048},
|
||||||
Concurrency: DefaultLLMConcurrency,
|
Thresholds: Thresholds{Glossary: .8, Homophones: .8, SpokenWord: .8, Grammar: .8},
|
||||||
},
|
Diagnostics: Diagnostics{WorkDir: "/tmp/audita", Retention: WorkDirRetentionAuto},
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
func ParseModulesCSV(raw string) ([]string, error) {
|
||||||
|
if raw == "" {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
parts := strings.Split(raw, ",")
|
parts := strings.Split(raw, ",")
|
||||||
modules := make([]string, 0, len(parts))
|
for i := range parts {
|
||||||
for _, part := range parts {
|
parts[i] = strings.TrimSpace(parts[i])
|
||||||
trimmed := strings.TrimSpace(part)
|
if parts[i] == "" {
|
||||||
if trimmed == "" {
|
|
||||||
return nil, fmt.Errorf("modules list contains an empty value")
|
return nil, fmt.Errorf("modules list contains an empty value")
|
||||||
}
|
}
|
||||||
modules = append(modules, trimmed)
|
|
||||||
}
|
}
|
||||||
if len(modules) == 0 {
|
return parts, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,705 +1,145 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/audita/internal/core/modulecatalog"
|
ck "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
"gitea.maximumdirect.net/eric/audita/internal/core/outputschema"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDefaultConfigValues(t *testing.T) {
|
const base = "version: 2\nbackends:\n local:\n endpoint: https://local.invalid/v1\nllm:\n proposal_profile: proposal\n"
|
||||||
cfg := Default()
|
|
||||||
|
|
||||||
if got, want := strings.Join(cfg.Modules, ","), DefaultModulesCSV; got != want {
|
func loadText(text, overlay string, overrides ...ck.Override) (*ck.Result[Config], error) {
|
||||||
t.Fatalf("modules mismatch: got %q want %q", got, want)
|
return Load(ck.Bytes("config.yml", []byte(text)), overlay, overrides...)
|
||||||
}
|
|
||||||
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 TestLoadFromEnvOverridesAndFallback(t *testing.T) {
|
func TestDefaultsAndDomainSettings(t *testing.T) {
|
||||||
env := map[string]string{
|
loaded, err := loadText(base, "")
|
||||||
"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))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("loadFromLookup returned error: %v", err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
c := loaded.Config
|
||||||
if cfg.PrimaryLLM.APIKey != "fallback-key" {
|
if c.LLM.ValidationProfile != "proposal" || c.Backends["local"].Concurrency != 1 || c.Backends["local"].QueueCapacity != 1024 {
|
||||||
t.Fatalf("expected OPENROUTER_API_KEY fallback, got %q", cfg.PrimaryLLM.APIKey)
|
t.Fatalf("derived settings: %+v %+v", c.LLM, c.Backends)
|
||||||
}
|
}
|
||||||
if cfg.PrimaryLLM.Model != env["AUDITA_MODEL"] {
|
if !reflect.DeepEqual(c.Pipeline.Modules, []string{"glossary", "homophones", "glossary", "spoken_word", "grammar"}) {
|
||||||
t.Fatalf("unexpected model: %q", cfg.PrimaryLLM.Model)
|
t.Fatalf("module order: %v", c.Pipeline.Modules)
|
||||||
}
|
}
|
||||||
if cfg.PrimaryLLM.BaseURL != env["AUDITA_BASE_URL"] {
|
if explanation, ok := loaded.Explain(ck.Keys("llm", "validation_profile")); !ok || len(explanation.History) == 0 {
|
||||||
t.Fatalf("unexpected base url: %q", cfg.PrimaryLLM.BaseURL)
|
t.Fatal("validation inheritance has no recorded derivation")
|
||||||
}
|
}
|
||||||
if cfg.TargetSections == nil || *cfg.TargetSections != 5 {
|
if _, err := loadText(base+"pipeline:\n modules: []\n", ""); err != nil {
|
||||||
t.Fatalf("unexpected target sections: %#v", cfg.TargetSections)
|
t.Fatalf("empty pipeline: %v", err)
|
||||||
}
|
}
|
||||||
if cfg.TotalLLMConcurrency != 6 {
|
if _, err := loadText(base+"pipeline:\n modules: [grammar, glossary, grammar]\n", ""); err != nil {
|
||||||
t.Fatalf("unexpected total llm concurrency: %d", cfg.TotalLLMConcurrency)
|
t.Fatalf("repeated modules: %v", err)
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadFromEnvLegacyLLMConcurrencyAliasForTotalAndProposal(t *testing.T) {
|
func TestStrictInputAndConstraints(t *testing.T) {
|
||||||
env := map[string]string{
|
for _, tc := range []struct{ name, text string }{
|
||||||
"AUDITA_LLM_CONCURRENCY": "5",
|
{"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)},
|
||||||
cfg, err := loadFromLookup(mapLookup(env))
|
{"quoted version", strings.Replace(base, "version: 2", "version: '2'", 1)},
|
||||||
if err != nil {
|
{"unknown field", base + "mystery: true\n"},
|
||||||
t.Fatalf("loadFromLookup returned error: %v", err)
|
{"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)},
|
||||||
if cfg.TotalLLMConcurrency != 5 {
|
{"zero backend concurrency", base + " concurrency: 0\n"},
|
||||||
t.Fatalf("expected total concurrency from legacy alias, got %d", cfg.TotalLLMConcurrency)
|
{"invalid target", base + "chunking:\n target_sections: 0\n"},
|
||||||
}
|
{"invalid section relation", base + "chunking:\n min_section_tokens: 10000\n"},
|
||||||
if cfg.ProposalLLMConcurrency != 5 {
|
{"string normalization", base + "normalization:\n max_segment_gap: 4s\n"},
|
||||||
t.Fatalf("expected proposal concurrency to inherit legacy total, got %d", cfg.ProposalLLMConcurrency)
|
{"invalid threshold", base + "thresholds:\n glossary: 1.1\n"},
|
||||||
}
|
{"invalid retention", base + "diagnostics:\n retention: sometimes\n"},
|
||||||
}
|
} {
|
||||||
|
|
||||||
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 {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
cfg := Default()
|
if _, err := loadText(tc.text, ""); err == nil {
|
||||||
if err := tc.apply(&cfg); err != nil {
|
t.Fatal("invalid config accepted")
|
||||||
t.Fatalf("apply config source: %v", err)
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_, 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) {
|
func TestOverlayOverrideAndInheritance(t *testing.T) {
|
||||||
cfg := Default()
|
dir := t.TempDir()
|
||||||
apiKey := "NOT_AN_ENV_VAR_NAME"
|
if err := os.Mkdir(filepath.Join(dir, "overlays"), 0700); err != nil {
|
||||||
validationAPIKey := "also direct"
|
t.Fatal(err)
|
||||||
|
|
||||||
if err := cfg.ApplyCLIOverrides(CLIOverrides{PrimaryLLMAPIKey: &apiKey, ValidationLLMAPIKey: &validationAPIKey}); err != nil {
|
|
||||||
t.Fatalf("ApplyCLIOverrides failed: %v", err)
|
|
||||||
}
|
}
|
||||||
if cfg.PrimaryLLM.APIKey != apiKey {
|
main := filepath.Join(dir, "config.yml")
|
||||||
t.Fatalf("expected direct primary api key, got %q", cfg.PrimaryLLM.APIKey)
|
if err := os.WriteFile(main, []byte(base), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if cfg.ValidationLLM.APIKey != validationAPIKey {
|
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.Fatalf("expected direct validation api key, got %q", cfg.ValidationLLM.APIKey)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
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"})
|
||||||
|
|
||||||
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))
|
|
||||||
if err != nil {
|
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 {
|
func TestSurvivingCredentials(t *testing.T) {
|
||||||
wantOutputSchemaOverride bool
|
t.Setenv("BASE_SECRET", "")
|
||||||
wantTranscriptDescriptionPatch bool
|
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 {
|
||||||
func assertSharedEffectiveFields(t *testing.T, cfg Config, opts sharedEffectiveFieldOptions) {
|
t.Fatal("missing unselected backend secret accepted")
|
||||||
t.Helper()
|
|
||||||
wantOutputSchema := DefaultOutputSchema
|
|
||||||
if opts.wantOutputSchemaOverride {
|
|
||||||
wantOutputSchema = "audita-v1"
|
|
||||||
}
|
}
|
||||||
if cfg.OutputSchema != wantOutputSchema {
|
replacement := ck.Override{Path: ck.Keys("backends", "other", "credential"), Value: "NEW_SECRET", Flag: "--credential"}
|
||||||
t.Fatalf("unexpected output schema: %q", cfg.OutputSchema)
|
loaded, err := loadText(withSecret, "", replacement)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if cfg.PrimaryLLM.Model != "provider/proposal" ||
|
if got := loaded.Config.Backends["other"].Credential.Value(); got != "synthetic-secret" {
|
||||||
cfg.PrimaryLLM.BaseURL != "https://proposal.example.test/v1" ||
|
t.Fatalf("resolved secret: %q", got)
|
||||||
cfg.PrimaryLLM.TimeoutSeconds != 101 ||
|
|
||||||
cfg.PrimaryLLM.MaxRetries != 5 {
|
|
||||||
t.Fatalf("unexpected primary llm config: %+v", cfg.PrimaryLLM)
|
|
||||||
}
|
}
|
||||||
if cfg.ValidationLLM.Model != "provider/validation" ||
|
var effective bytes.Buffer
|
||||||
cfg.ValidationLLM.BaseURL != "https://validation.example.test/v1" ||
|
if err := loaded.WriteEffective(&effective); err != nil {
|
||||||
cfg.ValidationLLM.TimeoutSeconds == nil ||
|
t.Fatal(err)
|
||||||
*cfg.ValidationLLM.TimeoutSeconds != 202 ||
|
|
||||||
cfg.ValidationLLM.MaxRetries == nil ||
|
|
||||||
*cfg.ValidationLLM.MaxRetries != 6 {
|
|
||||||
t.Fatalf("unexpected validation llm config: %+v", cfg.ValidationLLM)
|
|
||||||
}
|
}
|
||||||
if cfg.TargetSections == nil || *cfg.TargetSections != 7 ||
|
if strings.Contains(effective.String(), "synthetic-secret") {
|
||||||
cfg.MaxSectionTokens != 9000 ||
|
t.Fatal("effective output leaked a secret")
|
||||||
cfg.MinSectionTokens != 1000 {
|
|
||||||
t.Fatalf("unexpected chunking config: target=%v max=%d min=%d", cfg.TargetSections, cfg.MaxSectionTokens, cfg.MinSectionTokens)
|
|
||||||
}
|
}
|
||||||
if cfg.Thresholds.Glossary != 0.91 ||
|
null := ck.Override{Path: ck.Keys("backends", "other", "credential"), Value: nil, Flag: "--credential"}
|
||||||
cfg.Thresholds.Grammar != 0.92 ||
|
loaded, err = loadText(withSecret, "", null)
|
||||||
cfg.Thresholds.Homophones != 0.93 ||
|
if err != nil || loaded.Config.Backends["other"].Credential != nil {
|
||||||
cfg.Thresholds.SpokenWord != 0.94 {
|
t.Fatalf("null credential: %+v %v", loaded, err)
|
||||||
t.Fatalf("unexpected thresholds: %+v", cfg.Thresholds)
|
|
||||||
}
|
}
|
||||||
if cfg.Normalization.MaxSegmentGap != 1.2 ||
|
t.Setenv("AUDITA_MODEL", "legacy-model")
|
||||||
cfg.Normalization.EllipsisGap != 2.3 ||
|
loaded, err = loadText(base, "")
|
||||||
cfg.Normalization.MaxSegmentDuration != 45.6 ||
|
if err != nil || loaded.Config.LLM.ProposalProfile != "proposal" {
|
||||||
cfg.Normalization.MaxSegmentTokens != 321 {
|
t.Fatalf("legacy environment affected loading: %+v %v", loaded, err)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user