diff --git a/docs/config.md b/docs/config.md index 8dd8b9f..3c133a0 100644 --- a/docs/config.md +++ b/docs/config.md @@ -334,19 +334,14 @@ Rules: | `pipeline.seriatim.env.filler_max_duration` | float | No | unset | | `pipeline.audita.binary` | string | No | `audita` | | `pipeline.audita.timeout` | duration | No | `3h` | -| `pipeline.audita.llm_api_key` | secret reference | No | unset | -| `pipeline.audita.modules[]` | list[string] | No | empty | -| `pipeline.audita.base_url` | string | No | empty | -| `pipeline.audita.model` | string | No | empty | -| `pipeline.audita.total_llm_concurrency` | int | No | unset | -| `pipeline.audita.proposal_llm_concurrency` | int | No | unset | -| `pipeline.audita.validation_model` | string | No | empty | -| `pipeline.audita.validation_llm_concurrency` | int | No | unset | +| `pipeline.audita.credentials[]` | list[secret reference] | No | empty; exact environment-variable names forwarded to Audita | +| `pipeline.audita.modules[]` | list[string] | No | unset; omit to use Audita defaults, set `[]` for no modules | +| `pipeline.audita.overlay` | string | No | empty; no downstream overlay override | +| `pipeline.audita.proposal_profile` | string | No | empty; no downstream proposal-profile override | +| `pipeline.audita.validation_profile` | string | No | empty; no downstream validation-profile override | | `pipeline.audita.transcript_description` | string | No | empty | | `pipeline.audita.config_path` | string | No | empty | | `pipeline.audita.output_schema` | string | No | empty | -| `pipeline.audita.work_dir_retention` | string | No | empty | -| `pipeline.audita.report` | bool | No | `true` | | `pipeline.normalize.output_path` | string | No | `transcripts/final.json` | | `pipeline.normalize.output_schema` | string | No | `seriatim-intermediate` | | `pipeline.normalize.report` | bool | No | `true` | diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index de0b981..475729a 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -274,7 +274,11 @@ credential path remains. ## Stage 4: Cut Audita configuration and adapter over to v2 -Status: Not started. +Status: Complete (2026-09-25). + +Verification: `go test ./internal/config ./internal/adapters/audita ./internal/stage ./internal/app`, +`go test -race ./internal/adapters/audita`, `go test ./...`, and +`git diff --check` passed. Preparation: Audita v2.0.0 `docs/migrations/v2.md`, `docs/cli.md`, `docs/integrations/subprocess.md`, and `docs/integrations/output-files.md` in diff --git a/examples/pipeline.full.annotated.yml b/examples/pipeline.full.annotated.yml index a906753..c2a4ba1 100644 --- a/examples/pipeline.full.annotated.yml +++ b/examples/pipeline.full.annotated.yml @@ -93,19 +93,14 @@ audita: # Optional overrides; defaults shown explicitly where applicable. binary: audita timeout: 3h - llm_api_key: AUDITA_LLM_API_KEY + credentials: [AUDITA_LLM_API_KEY] modules: [glossary, homophones, spoken_word, grammar] - base_url: "" - model: "" - total_llm_concurrency: 2 - proposal_llm_concurrency: 1 - validation_model: "" - validation_llm_concurrency: 1 + overlay: "" + proposal_profile: "" + validation_profile: "" transcript_description: "" config_path: /usr/local/etc/audita/config.yml output_schema: audita-v1 - work_dir_retention: auto - report: true normalize: # Optional; defaults shown explicitly. diff --git a/examples/pipeline.production.yml b/examples/pipeline.production.yml index d5e688a..2094687 100644 --- a/examples/pipeline.production.yml +++ b/examples/pipeline.production.yml @@ -57,14 +57,9 @@ seriatim: audita: binary: audita timeout: 3h - llm_api_key: AUDITA_LLM_API_KEY + credentials: [AUDITA_LLM_API_KEY] modules: [glossary, homophones, spoken_word, grammar] output_schema: audita-v1 - work_dir_retention: auto - total_llm_concurrency: 2 - proposal_llm_concurrency: 1 - validation_llm_concurrency: 1 - report: true normalize: output_path: transcripts/final.json diff --git a/examples/production-testing/conf.d/transcript.yml b/examples/production-testing/conf.d/transcript.yml index 2d91c9d..260221f 100644 --- a/examples/production-testing/conf.d/transcript.yml +++ b/examples/production-testing/conf.d/transcript.yml @@ -10,8 +10,8 @@ audita: binary: audita modules: [glossary, grammar] output_schema: audita-v1 - model: narratio-production-model-placeholder - validation_model: narratio-production-validator-placeholder + proposal_profile: narratio-production-model-placeholder + validation_profile: narratio-production-validator-placeholder normalize: output_path: transcripts/final.json diff --git a/examples/production-testing/overlays/testing.yml b/examples/production-testing/overlays/testing.yml index 41a4950..4cb1dda 100644 --- a/examples/production-testing/overlays/testing.yml +++ b/examples/production-testing/overlays/testing.yml @@ -1,6 +1,6 @@ audita: - model: narratio-testing-model-placeholder - validation_model: narratio-testing-validator-placeholder + proposal_profile: narratio-testing-model-placeholder + validation_profile: narratio-testing-validator-placeholder scriptorium: artifacts: diff --git a/internal/adapters/audita/fake.go b/internal/adapters/audita/fake.go index 00376fd..f9d8283 100644 --- a/internal/adapters/audita/fake.go +++ b/internal/adapters/audita/fake.go @@ -22,8 +22,6 @@ func (n *NoopRunner) Run(ctx context.Context, req PolishRequest) (PolishResult, } return PolishResult{ ProcessedTranscriptPath: req.OutputProcessedPath, - ReportPath: req.ReportPath, - WorkDir: req.WorkDir, StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath, GeneratedConfigPath: req.GeneratedConfigPath, @@ -54,12 +52,6 @@ func (f *FakeRunner) Run(ctx context.Context, req PolishRequest) (PolishResult, if res.ProcessedTranscriptPath == "" { res.ProcessedTranscriptPath = req.OutputProcessedPath } - if res.ReportPath == "" { - res.ReportPath = req.ReportPath - } - if res.WorkDir == "" { - res.WorkDir = req.WorkDir - } if res.StdoutLogPath == "" { res.StdoutLogPath = req.StdoutLogPath } @@ -78,7 +70,7 @@ func (f *FakeRunner) Run(ctx context.Context, req PolishRequest) (PolishResult, func materializePlaceholders(req PolishRequest) error { if req.GeneratedConfigPath != "" { payload := map[string]any{ - "schema": "audita.generated.v1", + "schema": "audita.generated.v2", "placeholder": true, "merged_transcript_path": req.MergedTranscriptPath, "output_path": req.OutputProcessedPath, @@ -103,12 +95,6 @@ func materializePlaceholders(req PolishRequest) error { }); err != nil { return err } - if err := writeJSONIfRequested(req.ReportPath, map[string]any{ - "schema": "audita.report.v1", - "steps": []any{}, - }); err != nil { - return err - } return nil } diff --git a/internal/adapters/audita/runner.go b/internal/adapters/audita/runner.go index 84fdc45..b914dfc 100644 --- a/internal/adapters/audita/runner.go +++ b/internal/adapters/audita/runner.go @@ -11,24 +11,29 @@ type Runner interface { Run(ctx context.Context, req PolishRequest) (PolishResult, error) } -// PolishRequest describes an audita invocation. +// PolishRequest describes an Audita invocation. Nil Modules uses the runner's +// configured selection; a non-nil empty slice selects no modules. type PolishRequest struct { GeneratedConfigPath string MergedTranscriptPath string OutputProcessedPath string GlossaryPath string - ReportPath string - WorkDir string Modules []string StdoutLogPath string StderrLogPath string } -// PolishResult describes a polish output. +// ReportBundle contains optional reports adjacent to the requested output. +type ReportBundle struct { + ErrorsPath string + RecoveriesPath string + OverviewPath string +} + +// PolishResult describes a polish output and available diagnostics. type PolishResult struct { ProcessedTranscriptPath string - ReportPath string - WorkDir string + Reports ReportBundle StdoutLogPath string StderrLogPath string GeneratedConfigPath string diff --git a/internal/adapters/audita/subprocess.go b/internal/adapters/audita/subprocess.go index 75b83e0..0a03813 100644 --- a/internal/adapters/audita/subprocess.go +++ b/internal/adapters/audita/subprocess.go @@ -3,12 +3,14 @@ package audita import ( "context" "encoding/json" + "errors" "fmt" - "net/url" - "strconv" + "os" + "path/filepath" "strings" "time" + configkit "gitea.maximumdirect.net/eric/ConfigKit" "gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess" "gitea.maximumdirect.net/eric/narratio/internal/fileops" ) @@ -16,93 +18,27 @@ import ( // MaxProcessedOutputBytes bounds Audita's processed-transcript JSON result. const MaxProcessedOutputBytes int64 = 64 * 1024 * 1024 -// MaxReportOutputBytes bounds Audita's optional report JSON result. +// MaxReportOutputBytes bounds each optional Audita report. const MaxReportOutputBytes int64 = 16 * 1024 * 1024 // SubprocessRunnerConfig defines deterministic settings for Audita CLI execution. type SubprocessRunnerConfig struct { - Binary string - Timeout time.Duration - LLMAPIKey string - LLMAPIKeyReference string - Modules []string - BaseURL string - Model string - TranscriptDescription string - ConfigPath string - OutputSchema string - WorkDirRetention string - TotalLLMConcurrency *int - ProposalLLMConcurrency *int - ValidationModel string - ValidationLLMConcurrency *int - Report bool + Binary string + Timeout time.Duration + Credentials []configkit.Secret + Modules []string + Overlay string + ProposalProfile string + ValidationProfile string + TranscriptDescription string + ConfigPath string + OutputSchema string } // SubprocessRunner invokes Audita via subprocess. type SubprocessRunner struct { - binary string - timeout time.Duration - llmAPIKey string - llmAPIKeyReference string - modules []string - baseURL string - model string - transcriptDescription string - configPath string - outputSchema string - workDirRetention string - totalLLMConcurrency *int - proposalLLMConcurrency *int - validationModel string - validationLLMConcurrency *int - report bool -} - -// NewSubprocessRunnerFromConfigValues parses config-derived values once. -func NewSubprocessRunnerFromConfigValues( - binary string, - timeout string, - llmAPIKey string, - llmAPIKeyReference string, - modules []string, - baseURL string, - model string, - transcriptDescription string, - configPath string, - outputSchema string, - workDirRetention string, - totalLLMConcurrency *int, - proposalLLMConcurrency *int, - validationModel string, - validationLLMConcurrency *int, - report bool, -) (*SubprocessRunner, error) { - if strings.TrimSpace(timeout) == "" { - return nil, fmt.Errorf("audita timeout is required") - } - parsedTimeout, err := time.ParseDuration(timeout) - if err != nil { - return nil, fmt.Errorf("parse audita timeout %q: %w", timeout, err) - } - return NewSubprocessRunner(SubprocessRunnerConfig{ - Binary: binary, - Timeout: parsedTimeout, - LLMAPIKey: llmAPIKey, - LLMAPIKeyReference: llmAPIKeyReference, - Modules: modules, - BaseURL: baseURL, - Model: model, - TranscriptDescription: transcriptDescription, - ConfigPath: configPath, - OutputSchema: outputSchema, - WorkDirRetention: workDirRetention, - TotalLLMConcurrency: totalLLMConcurrency, - ProposalLLMConcurrency: proposalLLMConcurrency, - ValidationModel: validationModel, - ValidationLLMConcurrency: validationLLMConcurrency, - Report: report, - }) + cfg SubprocessRunnerConfig + run func(context.Context, subprocess.RunRequest) (subprocess.RunResult, error) } // NewSubprocessRunner constructs a validated Audita subprocess runner. @@ -113,281 +49,177 @@ func NewSubprocessRunner(cfg SubprocessRunnerConfig) (*SubprocessRunner, error) if cfg.Timeout <= 0 { return nil, fmt.Errorf("audita timeout must be > 0") } - if (cfg.LLMAPIKey == "") != (strings.TrimSpace(cfg.LLMAPIKeyReference) == "") { - return nil, fmt.Errorf("audita credential value and reference must be provided together") - } for i, module := range cfg.Modules { if strings.TrimSpace(module) == "" { return nil, fmt.Errorf("audita module at index %d is empty", i) } } - if strings.TrimSpace(cfg.BaseURL) != "" { - u, err := url.Parse(cfg.BaseURL) - if err != nil || u.Scheme == "" || u.Host == "" { - if err != nil { - return nil, fmt.Errorf("audita base url %q is invalid: %w", cfg.BaseURL, err) - } - return nil, fmt.Errorf("audita base url %q is invalid", cfg.BaseURL) - } - } - if cfg.TotalLLMConcurrency != nil && *cfg.TotalLLMConcurrency <= 0 { - return nil, fmt.Errorf("audita total llm concurrency must be > 0 when provided") - } - if cfg.ProposalLLMConcurrency != nil && *cfg.ProposalLLMConcurrency <= 0 { - return nil, fmt.Errorf("audita proposal llm concurrency must be > 0 when provided") - } - if cfg.ValidationLLMConcurrency != nil && *cfg.ValidationLLMConcurrency <= 0 { - return nil, fmt.Errorf("audita validation llm concurrency must be > 0 when provided") - } switch strings.TrimSpace(cfg.OutputSchema) { case "", "bare-segments", "audita-v1": default: return nil, fmt.Errorf("audita output schema must be one of: bare-segments, audita-v1") } - switch strings.TrimSpace(cfg.WorkDirRetention) { - case "", "always", "auto", "never": - default: - return nil, fmt.Errorf("audita work dir retention must be one of: always, auto, never") + cfg.Binary = strings.TrimSpace(cfg.Binary) + cfg.Credentials = append([]configkit.Secret(nil), cfg.Credentials...) + cfg.Modules = cloneModules(cfg.Modules) + for i := range cfg.Modules { + cfg.Modules[i] = strings.TrimSpace(cfg.Modules[i]) } - - modules := make([]string, len(cfg.Modules)) - for i, m := range cfg.Modules { - modules[i] = strings.TrimSpace(m) - } - - return &SubprocessRunner{ - binary: strings.TrimSpace(cfg.Binary), - timeout: cfg.Timeout, - llmAPIKey: cfg.LLMAPIKey, - llmAPIKeyReference: strings.TrimSpace(cfg.LLMAPIKeyReference), - modules: modules, - baseURL: strings.TrimSpace(cfg.BaseURL), - model: strings.TrimSpace(cfg.Model), - transcriptDescription: strings.TrimSpace(cfg.TranscriptDescription), - configPath: strings.TrimSpace(cfg.ConfigPath), - outputSchema: strings.TrimSpace(cfg.OutputSchema), - workDirRetention: strings.TrimSpace(cfg.WorkDirRetention), - totalLLMConcurrency: cfg.TotalLLMConcurrency, - proposalLLMConcurrency: cfg.ProposalLLMConcurrency, - validationModel: strings.TrimSpace(cfg.ValidationModel), - validationLLMConcurrency: cfg.ValidationLLMConcurrency, - report: cfg.Report, - }, nil + return &SubprocessRunner{cfg: cfg, run: subprocess.Run}, nil } -// Run executes Audita process with deterministic flags and validates output artifacts. +func cloneModules(modules []string) []string { + if modules == nil { + return nil + } + return append([]string{}, modules...) +} + +// Run executes Audita process and validates its corrected transcript. func (r *SubprocessRunner) Run(ctx context.Context, req PolishRequest) (PolishResult, error) { if r == nil { return PolishResult{}, fmt.Errorf("audita subprocess runner is nil") } - if strings.TrimSpace(req.MergedTranscriptPath) == "" { - return PolishResult{}, fmt.Errorf("audita merged transcript path is required") + for _, field := range []struct{ name, value string }{ + {"merged transcript path", req.MergedTranscriptPath}, + {"glossary path", req.GlossaryPath}, + {"output processed path", req.OutputProcessedPath}, + } { + if strings.TrimSpace(field.value) == "" { + return PolishResult{}, fmt.Errorf("audita %s is required", field.name) + } } - if strings.TrimSpace(req.GlossaryPath) == "" { - return PolishResult{}, fmt.Errorf("audita glossary path is required") + modules := req.Modules + if modules == nil { + modules = r.cfg.Modules } - if strings.TrimSpace(req.OutputProcessedPath) == "" { - return PolishResult{}, fmt.Errorf("audita output processed path is required") + for i, module := range modules { + if strings.TrimSpace(module) == "" { + return PolishResult{}, fmt.Errorf("audita module at index %d is empty", i) + } } - if strings.TrimSpace(req.WorkDir) == "" { - return PolishResult{}, fmt.Errorf("audita work dir is required") + modules = cloneModules(modules) + for i := range modules { + modules[i] = strings.TrimSpace(modules[i]) } - if r.report && strings.TrimSpace(req.ReportPath) == "" { - return PolishResult{}, fmt.Errorf("audita report is enabled but report path is missing") + args := r.buildArgs(req, modules) + env, names, err := subprocess.CredentialEnvironment(r.cfg.Credentials) + if err != nil { + return PolishResult{}, fmt.Errorf("audita credentials: %w", err) } - - reqModules := req.Modules - if reqModules == nil { - reqModules = append([]string(nil), r.modules...) - } - args := r.buildArgs(req, reqModules) - - credentialPresent := r.llmAPIKeyReference != "" - env := map[string]string{} - if credentialPresent { - env["AUDITA_LLM_API_KEY"] = r.llmAPIKey - } - if req.GeneratedConfigPath != "" { - if err := r.writeInvocationConfig(req, args, reqModules, credentialPresent); err != nil { + if err := r.writeInvocationConfig(req, args, modules, names); err != nil { return PolishResult{}, fmt.Errorf("write audita invocation config %q: %w", req.GeneratedConfigPath, err) } } - - runRes, err := subprocess.Run(ctx, subprocess.RunRequest{ - Executable: r.binary, - Args: args, - Timeout: r.timeout, - EnvOverrides: env, - DiagnosticOwner: "audita", - StdoutLogPath: req.StdoutLogPath, - StderrLogPath: req.StderrLogPath, + run := r.run + if run == nil { + run = subprocess.Run + } + runRes, runErr := run(ctx, subprocess.RunRequest{ + Executable: r.cfg.Binary, Args: args, Timeout: r.cfg.Timeout, + EnvOverrides: env, SensitiveEnvNames: names, DiagnosticOwner: "audita", + StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath, }) - if err != nil { - wrappedMessage := fmt.Sprintf( - "run audita process (binary=%q, stdout_log=%q, stderr_log=%q)", - r.binary, - req.StdoutLogPath, - req.StderrLogPath, - ) - wrappedMessage = addSubprocessStreamHint(wrappedMessage, err) - return r.failureResult(req, reqModules, runRes, credentialPresent), fmt.Errorf( - "%s: %w", - wrappedMessage, - err, - ) + result := r.result(req, modules, names, runRes) + if runErr != nil { + result.Reports, _ = discoverReports(req.OutputProcessedPath) + return result, fmt.Errorf("run audita process (binary=%q, output_dir=%q, stdout_log=%q, stderr_log=%q): %w", + r.cfg.Binary, filepath.Dir(req.OutputProcessedPath), req.StdoutLogPath, req.StderrLogPath, runErr) } - if err := validateProcessedOutput(req.OutputProcessedPath); err != nil { - return r.failureResult(req, reqModules, runRes, credentialPresent), fmt.Errorf("validate audita processed output %q: %w", req.OutputProcessedPath, err) + result.Reports, _ = discoverReports(req.OutputProcessedPath) + return result, fmt.Errorf("validate audita processed output %q: %w", req.OutputProcessedPath, err) } - if r.report { - if err := validateJSONFile(req.ReportPath); err != nil { - return r.failureResult(req, reqModules, runRes, credentialPresent), fmt.Errorf("validate audita report output %q: %w", req.ReportPath, err) - } + reports, err := discoverReports(req.OutputProcessedPath) + if err != nil { + return result, err } - - return PolishResult{ - ProcessedTranscriptPath: req.OutputProcessedPath, - ReportPath: req.ReportPath, - WorkDir: req.WorkDir, - StdoutLogPath: req.StdoutLogPath, - StderrLogPath: req.StderrLogPath, - GeneratedConfigPath: req.GeneratedConfigPath, - ExitCode: runRes.ExitCode, - Duration: runRes.Duration, - InvokedBinary: r.binary, - Metadata: map[string]any{ - "adapter": "audita_subprocess", - "modules": reqModules, - "base_url": r.baseURL, - "model": r.model, - "transcript_description": r.transcriptDescription, - "config_path": r.configPath, - "output_schema": r.outputSchema, - "work_dir_retention": r.workDirRetention, - "validation_model": r.validationModel, - "total_llm_concurrency": r.totalLLMConcurrency, - "proposal_llm_concurrency": r.proposalLLMConcurrency, - "validation_llm_concurrency": r.validationLLMConcurrency, - "credential_env_var": r.llmAPIKeyReference, - "credential_present": credentialPresent, - }, - }, nil + result.Reports = reports + return result, nil } -func (r *SubprocessRunner) failureResult(req PolishRequest, modules []string, runRes subprocess.RunResult, credentialPresent bool) PolishResult { +func (r *SubprocessRunner) result(req PolishRequest, modules, names []string, runRes subprocess.RunResult) PolishResult { return PolishResult{ ProcessedTranscriptPath: req.OutputProcessedPath, - ReportPath: req.ReportPath, - WorkDir: req.WorkDir, - StdoutLogPath: req.StdoutLogPath, - StderrLogPath: req.StderrLogPath, - GeneratedConfigPath: req.GeneratedConfigPath, - ExitCode: runRes.ExitCode, - Duration: runRes.Duration, - InvokedBinary: r.binary, + StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath, + GeneratedConfigPath: req.GeneratedConfigPath, ExitCode: runRes.ExitCode, + Duration: runRes.Duration, InvokedBinary: r.cfg.Binary, Metadata: map[string]any{ - "adapter": "audita_subprocess", - "modules": modules, - "base_url": r.baseURL, - "model": r.model, - "transcript_description": r.transcriptDescription, - "config_path": r.configPath, - "output_schema": r.outputSchema, - "work_dir_retention": r.workDirRetention, - "validation_model": r.validationModel, - "total_llm_concurrency": r.totalLLMConcurrency, - "proposal_llm_concurrency": r.proposalLLMConcurrency, - "validation_llm_concurrency": r.validationLLMConcurrency, - "credential_env_var": r.llmAPIKeyReference, - "credential_present": credentialPresent, + "adapter": "audita_subprocess", "modules": modules, + "overlay": r.cfg.Overlay, "proposal_profile": r.cfg.ProposalProfile, + "validation_profile": r.cfg.ValidationProfile, + "transcript_description": r.cfg.TranscriptDescription, + "config_path": r.cfg.ConfigPath, "output_schema": r.cfg.OutputSchema, + "credential_names": names, }, } } func (r *SubprocessRunner) buildArgs(req PolishRequest, modules []string) []string { - args := []string{ - "process", - req.MergedTranscriptPath, - "--glossary", req.GlossaryPath, - "--output", req.OutputProcessedPath, - "--work-dir", req.WorkDir, - } - if r.baseURL != "" { - args = append(args, "--base-url", r.baseURL) - } - if r.model != "" { - args = append(args, "--model", r.model) - } - if len(modules) > 0 { + args := []string{"process", req.MergedTranscriptPath, "--glossary", req.GlossaryPath, "--output", req.OutputProcessedPath} + if modules != nil { args = append(args, "--modules", strings.Join(modules, ",")) } - if r.report { - args = append(args, "--report-json", req.ReportPath) - } - if r.transcriptDescription != "" { - args = append(args, "--transcript-description", r.transcriptDescription) - } - if r.configPath != "" { - args = append(args, "--config", r.configPath) - } - if r.outputSchema != "" { - args = append(args, "--output-schema", r.outputSchema) - } - if r.workDirRetention != "" { - args = append(args, "--work-dir-retention", r.workDirRetention) - } - if r.totalLLMConcurrency != nil { - args = append(args, "--total-llm-concurrency", strconv.Itoa(*r.totalLLMConcurrency)) - } - if r.proposalLLMConcurrency != nil { - args = append(args, "--proposal-llm-concurrency", strconv.Itoa(*r.proposalLLMConcurrency)) - } - if r.validationModel != "" { - args = append(args, "--validation-model", r.validationModel) - } - if r.validationLLMConcurrency != nil { - args = append(args, "--validation-llm-concurrency", strconv.Itoa(*r.validationLLMConcurrency)) + for _, setting := range []struct{ flag, value string }{ + {"--config", r.cfg.ConfigPath}, {"--overlay", r.cfg.Overlay}, + {"--proposal-profile", r.cfg.ProposalProfile}, {"--validation-profile", r.cfg.ValidationProfile}, + {"--transcript-description", r.cfg.TranscriptDescription}, {"--output-schema", r.cfg.OutputSchema}, + } { + if setting.value != "" { + args = append(args, setting.flag, setting.value) + } } return args } -func (r *SubprocessRunner) writeInvocationConfig(req PolishRequest, args []string, modules []string, credentialPresent bool) error { - payload := map[string]any{ - "schema": "audita.generated.v1", - "binary": r.binary, - "args": args, - "timeout": r.timeout.String(), - "modules": modules, - "base_url": r.baseURL, - "model": r.model, - "transcript_description": r.transcriptDescription, - "config_path": r.configPath, - "output_schema": r.outputSchema, - "work_dir_retention": r.workDirRetention, - "validation_model": r.validationModel, - "total_llm_concurrency": r.totalLLMConcurrency, - "proposal_llm_concurrency": r.proposalLLMConcurrency, - "validation_llm_concurrency": r.validationLLMConcurrency, - "report_enabled": r.report, - "merged_transcript_path": req.MergedTranscriptPath, - "glossary_path": req.GlossaryPath, - "output_path": req.OutputProcessedPath, - "report_path": req.ReportPath, - "work_dir": req.WorkDir, - "credential_env_var": r.llmAPIKeyReference, - "credential_present": credentialPresent, +func (r *SubprocessRunner) writeInvocationConfig(req PolishRequest, args, modules, names []string) error { + return writeGeneratedYAML(req.GeneratedConfigPath, map[string]any{ + "schema": "audita.generated.v2", "binary": r.cfg.Binary, "args": args, + "timeout": r.cfg.Timeout.String(), "modules": modules, + "overlay": r.cfg.Overlay, "proposal_profile": r.cfg.ProposalProfile, + "validation_profile": r.cfg.ValidationProfile, + "transcript_description": r.cfg.TranscriptDescription, + "config_path": r.cfg.ConfigPath, "output_schema": r.cfg.OutputSchema, + "merged_transcript_path": req.MergedTranscriptPath, "glossary_path": req.GlossaryPath, + "output_path": req.OutputProcessedPath, "credential_names": names, + }) +} + +func discoverReports(outputPath string) (ReportBundle, error) { + var reports ReportBundle + for _, report := range []struct { + name string + dest *string + }{ + {"errors.yml", &reports.ErrorsPath}, + {"recoveries.yml", &reports.RecoveriesPath}, + {"report.md", &reports.OverviewPath}, + } { + path := filepath.Join(filepath.Dir(outputPath), report.name) + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return reports, fmt.Errorf("inspect audita report %q: %w", path, err) + } + if !info.Mode().IsRegular() { + return reports, fmt.Errorf("audita report %q is not a regular file", path) + } + if _, err := fileops.ReadFileBounded(path, MaxReportOutputBytes); err != nil { + return reports, fmt.Errorf("audita report %q exceeds or cannot be read within %d-byte limit: %w", path, MaxReportOutputBytes, err) + } + *report.dest = path } - return writeGeneratedYAML(req.GeneratedConfigPath, payload) + return reports, nil } func validateProcessedOutput(path string) error { - data, err := readAuditaResult(path, MaxProcessedOutputBytes, "processed transcript") + data, err := fileops.ReadFileBounded(path, MaxProcessedOutputBytes) if err != nil { - return err + return fmt.Errorf("audita processed transcript result exceeds or cannot be read within %d-byte limit: %w", MaxProcessedOutputBytes, err) } - var payload map[string]any if err := json.Unmarshal(data, &payload); err != nil { return fmt.Errorf("parse json: %w", err) @@ -401,34 +233,3 @@ func validateProcessedOutput(path string) error { } return nil } - -func addSubprocessStreamHint(message string, runErr error) string { - if runErr == nil { - return message - } - lower := strings.ToLower(runErr.Error()) - if strings.Contains(lower, "bad file descriptor") || strings.Contains(lower, "exit code 120") { - return message + "; hint=audita child process may have started with invalid stderr/stdout descriptors" - } - return message -} - -func validateJSONFile(path string) error { - data, err := readAuditaResult(path, MaxReportOutputBytes, "report") - if err != nil { - return err - } - var v any - if err := json.Unmarshal(data, &v); err != nil { - return fmt.Errorf("parse json: %w", err) - } - return nil -} - -func readAuditaResult(path string, limit int64, category string) ([]byte, error) { - data, err := fileops.ReadFileBounded(path, limit) - if err != nil { - return nil, fmt.Errorf("audita %s result exceeds or cannot be read within %d-byte limit: %w", category, limit, err) - } - return data, nil -} diff --git a/internal/adapters/audita/subprocess_test.go b/internal/adapters/audita/subprocess_test.go index 8c55cc2..7abea31 100644 --- a/internal/adapters/audita/subprocess_test.go +++ b/internal/adapters/audita/subprocess_test.go @@ -2,630 +2,262 @@ package audita import ( "context" - "encoding/json" - "fmt" + "errors" "os" "path/filepath" + "reflect" "strings" "testing" "time" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess" ) -func TestSubprocessRunnerSuccessArgsEnvAndValidation(t *testing.T) { - - t.Setenv("GO_WANT_AUDITA_HELPER", "1") - t.Setenv("AUDITA_HELPER_MODE", "success") - t.Setenv("OPENAI_KEY_SOURCE", "super-secret") - - recordPath := filepath.Join(t.TempDir(), "record.json") - t.Setenv("AUDITA_HELPER_RECORD_PATH", recordPath) - - wrapper := writeAuditaHelperWrapper(t) - totalLLMConcurrency := 3 - proposalLLMConcurrency := 2 - validationLLMConcurrency := 2 - runner, err := NewSubprocessRunner(SubprocessRunnerConfig{ - Binary: wrapper, - Timeout: mustParseAuditaDuration(t, "2s"), - LLMAPIKey: "super-secret", - LLMAPIKeyReference: "OPENAI_KEY_SOURCE", - Modules: []string{"glossary", "homophones", "glossary"}, - BaseURL: "https://openrouter.ai/api/v1", - Model: "openrouter/google/gemma-4-31b-it", - TranscriptDescription: "Campaign Session 42", - ConfigPath: "/etc/audita/config.yml", - OutputSchema: "audita-v1", - WorkDirRetention: "auto", - TotalLLMConcurrency: &totalLLMConcurrency, - ProposalLLMConcurrency: &proposalLLMConcurrency, - ValidationModel: "openrouter/google/gemma-4-31b-it", - ValidationLLMConcurrency: &validationLLMConcurrency, - Report: true, - }) - if err != nil { - t.Fatalf("NewSubprocessRunner() error = %v", err) - } - t.Setenv("OPENAI_KEY_SOURCE", "changed-after-resolution") - +func auditaTestRequest(t *testing.T) PolishRequest { + t.Helper() dir := t.TempDir() - req := PolishRequest{ - GeneratedConfigPath: filepath.Join(dir, "audita.generated.yml"), - MergedTranscriptPath: filepath.Join(dir, "base.json"), + return PolishRequest{ + MergedTranscriptPath: filepath.Join(dir, "merged.json"), GlossaryPath: filepath.Join(dir, "glossary.yml"), OutputProcessedPath: filepath.Join(dir, "polished.json"), - ReportPath: filepath.Join(dir, "audita.report.json"), - WorkDir: filepath.Join(dir, "artifacts", "audita-work"), - StdoutLogPath: filepath.Join(dir, "audita.stdout.log"), - StderrLogPath: filepath.Join(dir, "audita.stderr.log"), - } - writeAuditaTestFile(t, req.MergedTranscriptPath, `{"segments":[]}`) - writeAuditaTestFile(t, req.GlossaryPath, "terms: []\n") - - res, err := runner.Run(context.Background(), req) - if err != nil { - t.Fatalf("Run() error = %v", err) - } - if res.ProcessedTranscriptPath != req.OutputProcessedPath { - t.Fatalf("ProcessedTranscriptPath = %q, want %q", res.ProcessedTranscriptPath, req.OutputProcessedPath) - } - if res.ReportPath != req.ReportPath { - t.Fatalf("ReportPath = %q, want %q", res.ReportPath, req.ReportPath) - } - if res.WorkDir != req.WorkDir { - t.Fatalf("WorkDir = %q, want %q", res.WorkDir, req.WorkDir) - } - if res.InvokedBinary != wrapper { - t.Fatalf("InvokedBinary = %q, want %q", res.InvokedBinary, wrapper) - } - if res.ExitCode != 0 { - t.Fatalf("ExitCode = %d, want 0", res.ExitCode) - } - if res.Duration <= 0 { - t.Fatalf("Duration = %s, want > 0", res.Duration) - } - assertJSONFileAudita(t, req.OutputProcessedPath) - assertJSONFileAudita(t, req.ReportPath) - if _, err := os.Stat(req.StdoutLogPath); err != nil { - t.Fatalf("stdout log missing: %v", err) - } - if _, err := os.Stat(req.StderrLogPath); err != nil { - t.Fatalf("stderr log missing: %v", err) - } - - rec := readAuditaHelperRecord(t, recordPath) - wantArgs := []string{ - "process", req.MergedTranscriptPath, - "--glossary", req.GlossaryPath, - "--output", req.OutputProcessedPath, - "--work-dir", req.WorkDir, - "--base-url", "https://openrouter.ai/api/v1", - "--model", "openrouter/google/gemma-4-31b-it", - "--modules", "glossary,homophones,glossary", - "--report-json", req.ReportPath, - "--transcript-description", "Campaign Session 42", - "--config", "/etc/audita/config.yml", - "--output-schema", "audita-v1", - "--work-dir-retention", "auto", - "--total-llm-concurrency", "3", - "--proposal-llm-concurrency", "2", - "--validation-model", "openrouter/google/gemma-4-31b-it", - "--validation-llm-concurrency", "2", - } - if strings.Join(rec.Args, "\n") != strings.Join(wantArgs, "\n") { - t.Fatalf("args = %#v, want %#v", rec.Args, wantArgs) - } - if rec.Env["AUDITA_LLM_API_KEY"] != "super-secret" { - t.Fatalf("AUDITA_LLM_API_KEY = %q, want propagated secret", rec.Env["AUDITA_LLM_API_KEY"]) - } - if rec.Env["AUDITA_LLM_CONCURRENCY"] != "" { - t.Fatalf("AUDITA_LLM_CONCURRENCY = %q, want empty/omitted", rec.Env["AUDITA_LLM_CONCURRENCY"]) - } - - cfgData, err := os.ReadFile(req.GeneratedConfigPath) - if err != nil { - t.Fatalf("read generated config: %v", err) - } - if strings.Contains(string(cfgData), "super-secret") { - t.Fatalf("generated config must not contain credential value") - } -} - -func TestSubprocessRunnerRejectsIncompleteResolvedCredential(t *testing.T) { - _, err := NewSubprocessRunner(SubprocessRunnerConfig{ - Binary: writeAuditaHelperWrapper(t), - Timeout: mustParseAuditaDuration(t, "2s"), - LLMAPIKeyReference: "MISSING_AUDITA_KEY", - }) - if err == nil || !strings.Contains(err.Error(), "credential value and reference must be provided together") { - t.Fatalf("NewSubprocessRunner() error = %v, want credential pair error", err) - } -} - -func TestSubprocessRunnerUnconfiguredCredentialEnvOmitsCredential(t *testing.T) { - t.Setenv("GO_WANT_AUDITA_HELPER", "1") - t.Setenv("AUDITA_HELPER_MODE", "success") - recordPath := filepath.Join(t.TempDir(), "record.json") - t.Setenv("AUDITA_HELPER_RECORD_PATH", recordPath) - - runner := mustAuditaRunner(t, SubprocessRunnerConfig{ - Binary: writeAuditaHelperWrapper(t), - Timeout: mustParseAuditaDuration(t, "2s"), - LLMAPIKey: "", - Modules: []string{"glossary"}, - BaseURL: "https://openrouter.ai/api/v1", - Model: "openrouter/google/gemma-4-31b-it", - Report: false, - }) - req := auditaReqForTest(t, false) - - res, err := runner.Run(context.Background(), req) - if err != nil { - t.Fatalf("Run() error = %v", err) - } - if res.Metadata["credential_present"] != false { - t.Fatalf("credential_present = %#v, want false", res.Metadata["credential_present"]) - } - rec := readAuditaHelperRecord(t, recordPath) - if rec.Env["AUDITA_LLM_API_KEY"] != "" { - t.Fatalf("AUDITA_LLM_API_KEY = %q, want omitted/empty", rec.Env["AUDITA_LLM_API_KEY"]) - } -} - -func TestSubprocessRunnerOmitsUnspecifiedParentEnvironment(t *testing.T) { - t.Setenv("GO_WANT_AUDITA_HELPER", "1") - t.Setenv("AUDITA_HELPER_MODE", "success") - t.Setenv("AUDITA_INHERITED_MARKER", "inherited-from-parent") - recordPath := filepath.Join(t.TempDir(), "record.json") - t.Setenv("AUDITA_HELPER_RECORD_PATH", recordPath) - - runner := mustAuditaRunner(t, SubprocessRunnerConfig{ - Binary: writeAuditaHelperWrapper(t), - Timeout: mustParseAuditaDuration(t, "2s"), - Modules: []string{"glossary"}, - BaseURL: "https://openrouter.ai/api/v1", - Model: "openrouter/google/gemma-4-31b-it", - Report: false, - LLMAPIKey: "", - }) - req := auditaReqForTest(t, false) - if _, err := runner.Run(context.Background(), req); err != nil { - t.Fatalf("Run() error = %v", err) - } - - rec := readAuditaHelperRecord(t, recordPath) - if rec.Env["AUDITA_INHERITED_MARKER"] != "" { - t.Fatalf("AUDITA_INHERITED_MARKER = %q, want omitted from the child environment", rec.Env["AUDITA_INHERITED_MARKER"]) - } -} - -func TestSubprocessRunnerOmitsModulesFlagWhenNotConfigured(t *testing.T) { - t.Setenv("GO_WANT_AUDITA_HELPER", "1") - t.Setenv("AUDITA_HELPER_MODE", "success") - recordPath := filepath.Join(t.TempDir(), "record.json") - t.Setenv("AUDITA_HELPER_RECORD_PATH", recordPath) - - runner := mustAuditaRunner(t, SubprocessRunnerConfig{ - Binary: writeAuditaHelperWrapper(t), - Timeout: mustParseAuditaDuration(t, "2s"), - LLMAPIKey: "", - BaseURL: "https://openrouter.ai/api/v1", - Model: "openrouter/google/gemma-4-31b-it", - Report: false, - }) - req := auditaReqForTest(t, false) - if _, err := runner.Run(context.Background(), req); err != nil { - t.Fatalf("Run() error = %v", err) - } - rec := readAuditaHelperRecord(t, recordPath) - for i := 0; i < len(rec.Args); i++ { - if rec.Args[i] == "--modules" { - t.Fatalf("args contained --modules unexpectedly: %#v", rec.Args) - } - } -} - -func TestSubprocessRunnerOmitsBaseURLAndModelFlagsWhenNotConfigured(t *testing.T) { - t.Setenv("GO_WANT_AUDITA_HELPER", "1") - t.Setenv("AUDITA_HELPER_MODE", "success") - recordPath := filepath.Join(t.TempDir(), "record.json") - t.Setenv("AUDITA_HELPER_RECORD_PATH", recordPath) - - runner := mustAuditaRunner(t, SubprocessRunnerConfig{ - Binary: writeAuditaHelperWrapper(t), - Timeout: mustParseAuditaDuration(t, "2s"), - LLMAPIKey: "", - Report: false, - }) - req := auditaReqForTest(t, false) - if _, err := runner.Run(context.Background(), req); err != nil { - t.Fatalf("Run() error = %v", err) - } - rec := readAuditaHelperRecord(t, recordPath) - for i := 0; i < len(rec.Args); i++ { - if rec.Args[i] == "--base-url" { - t.Fatalf("args contained --base-url unexpectedly: %#v", rec.Args) - } - if rec.Args[i] == "--model" { - t.Fatalf("args contained --model unexpectedly: %#v", rec.Args) - } - } -} - -func TestSubprocessRunnerSubprocessFailure(t *testing.T) { - t.Setenv("GO_WANT_AUDITA_HELPER", "1") - t.Setenv("AUDITA_HELPER_MODE", "fail") - t.Setenv("OPENAI_KEY_SOURCE", "super-secret") - t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) - - runner := mustAuditaRunner(t, SubprocessRunnerConfig{ - Binary: writeAuditaHelperWrapper(t), - Timeout: mustParseAuditaDuration(t, "2s"), - LLMAPIKey: "super-secret", - LLMAPIKeyReference: "OPENAI_KEY_SOURCE", - Modules: []string{"glossary"}, - BaseURL: "https://openrouter.ai/api/v1", - Model: "openrouter/google/gemma-4-31b-it", - Report: true, - }) - req := auditaReqForTest(t, true) - _, err := runner.Run(context.Background(), req) - if err == nil { - t.Fatal("expected error, got nil") - } - if !strings.Contains(err.Error(), "run audita process") { - t.Fatalf("error = %q, want subprocess context", err.Error()) - } - if !strings.Contains(err.Error(), "exit code") { - t.Fatalf("error = %q, want exit code context", err.Error()) - } - if !strings.Contains(err.Error(), req.StdoutLogPath) || !strings.Contains(err.Error(), req.StderrLogPath) { - t.Fatalf("error = %q, want stdout/stderr log paths", err.Error()) - } - if strings.Contains(err.Error(), "super-secret") { - t.Fatalf("failure diagnostic exposed credential value: %q", err.Error()) - } -} - -func TestSubprocessRunnerSubprocessFailureAddsStderrDescriptorHint(t *testing.T) { - t.Setenv("GO_WANT_AUDITA_HELPER", "1") - t.Setenv("AUDITA_HELPER_MODE", "fail_bad_fd") - t.Setenv("OPENAI_KEY_SOURCE", "super-secret") - t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) - - runner := mustAuditaRunner(t, SubprocessRunnerConfig{ - Binary: writeAuditaHelperWrapper(t), - Timeout: mustParseAuditaDuration(t, "2s"), - LLMAPIKey: "super-secret", - LLMAPIKeyReference: "OPENAI_KEY_SOURCE", - Modules: []string{"glossary"}, - BaseURL: "https://openrouter.ai/api/v1", - Model: "openrouter/google/gemma-4-31b-it", - Report: true, - }) - req := auditaReqForTest(t, true) - _, err := runner.Run(context.Background(), req) - if err == nil { - t.Fatal("expected error, got nil") - } - if !strings.Contains(err.Error(), "hint=audita child process may have started with invalid stderr/stdout descriptors") { - t.Fatalf("error = %q, want stderr descriptor hint", err.Error()) - } -} - -func TestSubprocessRunnerMissingOutputFails(t *testing.T) { - t.Setenv("GO_WANT_AUDITA_HELPER", "1") - t.Setenv("AUDITA_HELPER_MODE", "missing_output") - t.Setenv("OPENAI_KEY_SOURCE", "super-secret") - t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) - - runner := mustAuditaRunner(t, SubprocessRunnerConfig{ - Binary: writeAuditaHelperWrapper(t), - Timeout: mustParseAuditaDuration(t, "2s"), - LLMAPIKey: "super-secret", - LLMAPIKeyReference: "OPENAI_KEY_SOURCE", - Modules: []string{"glossary"}, - BaseURL: "https://openrouter.ai/api/v1", - Model: "openrouter/google/gemma-4-31b-it", - Report: false, - }) - req := auditaReqForTest(t, false) - _, err := runner.Run(context.Background(), req) - if err == nil { - t.Fatal("expected error, got nil") - } - if !strings.Contains(err.Error(), "validate audita processed output") { - t.Fatalf("error = %q, want output validation context", err.Error()) - } -} - -func TestSubprocessRunnerInvalidOutputJSONFails(t *testing.T) { - t.Setenv("GO_WANT_AUDITA_HELPER", "1") - t.Setenv("AUDITA_HELPER_MODE", "invalid_output") - t.Setenv("OPENAI_KEY_SOURCE", "super-secret") - t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) - - runner := mustAuditaRunner(t, SubprocessRunnerConfig{ - Binary: writeAuditaHelperWrapper(t), - Timeout: mustParseAuditaDuration(t, "2s"), - LLMAPIKey: "super-secret", - LLMAPIKeyReference: "OPENAI_KEY_SOURCE", - Modules: []string{"glossary"}, - BaseURL: "https://openrouter.ai/api/v1", - Model: "openrouter/google/gemma-4-31b-it", - Report: false, - }) - req := auditaReqForTest(t, false) - _, err := runner.Run(context.Background(), req) - if err == nil { - t.Fatal("expected error, got nil") - } - if !strings.Contains(err.Error(), "parse json") { - t.Fatalf("error = %q, want parse json context", err.Error()) - } -} - -func TestSubprocessRunnerSegmentsMissingFails(t *testing.T) { - t.Setenv("GO_WANT_AUDITA_HELPER", "1") - t.Setenv("AUDITA_HELPER_MODE", "segments_missing") - t.Setenv("OPENAI_KEY_SOURCE", "super-secret") - t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) - - runner := mustAuditaRunner(t, SubprocessRunnerConfig{ - Binary: writeAuditaHelperWrapper(t), - Timeout: mustParseAuditaDuration(t, "2s"), - LLMAPIKey: "super-secret", - LLMAPIKeyReference: "OPENAI_KEY_SOURCE", - Modules: []string{"glossary"}, - BaseURL: "https://openrouter.ai/api/v1", - Model: "openrouter/google/gemma-4-31b-it", - Report: false, - }) - req := auditaReqForTest(t, false) - _, err := runner.Run(context.Background(), req) - if err == nil { - t.Fatal("expected error, got nil") - } - if !strings.Contains(err.Error(), "segments") { - t.Fatalf("error = %q, want segments validation context", err.Error()) - } -} - -func TestSubprocessRunnerInvalidReportJSONFails(t *testing.T) { - t.Setenv("GO_WANT_AUDITA_HELPER", "1") - t.Setenv("AUDITA_HELPER_MODE", "invalid_report") - t.Setenv("OPENAI_KEY_SOURCE", "super-secret") - t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) - - runner := mustAuditaRunner(t, SubprocessRunnerConfig{ - Binary: writeAuditaHelperWrapper(t), - Timeout: mustParseAuditaDuration(t, "2s"), - LLMAPIKey: "super-secret", - LLMAPIKeyReference: "OPENAI_KEY_SOURCE", - Modules: []string{"glossary"}, - BaseURL: "https://openrouter.ai/api/v1", - Model: "openrouter/google/gemma-4-31b-it", - Report: true, - }) - req := auditaReqForTest(t, true) - _, err := runner.Run(context.Background(), req) - if err == nil { - t.Fatal("expected error, got nil") - } - if !strings.Contains(err.Error(), "validate audita report output") { - t.Fatalf("error = %q, want report validation context", err.Error()) - } -} - -func TestSubprocessRunnerConstructorValidation(t *testing.T) { - _, err := NewSubprocessRunnerFromConfigValues("", "3h", "synthetic-secret", "AUDITA_LLM_API_KEY", []string{"glossary"}, "https://openrouter.ai/api/v1", "openrouter/google/gemma-4-31b-it", "", "", "", "", nil, nil, "", nil, true) - if err == nil { - t.Fatal("expected binary validation error") - } - _, err = NewSubprocessRunnerFromConfigValues("audita", "bad", "synthetic-secret", "AUDITA_LLM_API_KEY", []string{"glossary"}, "https://openrouter.ai/api/v1", "openrouter/google/gemma-4-31b-it", "", "", "", "", nil, nil, "", nil, true) - if err == nil { - t.Fatal("expected timeout parse error") - } - _, err = NewSubprocessRunner(SubprocessRunnerConfig{ - Binary: "audita", - Timeout: mustParseAuditaDuration(t, "1s"), - LLMAPIKey: "synthetic-secret", - LLMAPIKeyReference: "AUDITA_LLM_API_KEY", - Modules: []string{"glossary"}, - BaseURL: "://", - Model: "openrouter/google/gemma-4-31b-it", - }) - if err == nil { - t.Fatal("expected base url validation error") - } -} - -type auditaHelperRecord struct { - Args []string `json:"args"` - Env map[string]string `json:"env"` -} - -func TestAuditaSubprocessHelper(t *testing.T) { - if os.Getenv("GO_WANT_AUDITA_HELPER") != "1" { - return - } - - args := os.Args - start := -1 - for i := range args { - if args[i] == "--" { - start = i + 1 - break - } - } - if start < 0 || start >= len(args) { - _, _ = os.Stderr.WriteString("missing -- args separator\n") - os.Exit(2) - } - procArgs := args[start:] - outPath := auditaFlagValue(procArgs, "--output") - reportPath := auditaFlagValue(procArgs, "--report-json") - recordPath := os.Getenv("AUDITA_HELPER_RECORD_PATH") - if strings.TrimSpace(recordPath) != "" { - rec := auditaHelperRecord{ - Args: procArgs, - Env: map[string]string{ - "AUDITA_LLM_API_KEY": os.Getenv("AUDITA_LLM_API_KEY"), - "AUDITA_LLM_CONCURRENCY": os.Getenv("AUDITA_LLM_CONCURRENCY"), - "AUDITA_INHERITED_MARKER": os.Getenv("AUDITA_INHERITED_MARKER"), - }, - } - data, _ := json.Marshal(rec) - _ = os.MkdirAll(filepath.Dir(recordPath), 0o755) - _ = os.WriteFile(recordPath, data, 0o644) - } - - mode := os.Getenv("AUDITA_HELPER_MODE") - switch mode { - case "success": - writeAuditaHelperFile(outPath, `{"schema":"audita.processed.v1","segments":[]}`) - if reportPath != "" { - writeAuditaHelperFile(reportPath, `{"schema":"audita.report.v1","steps":[]}`) - } - _, _ = os.Stdout.WriteString("audita helper success stdout\n") - _, _ = os.Stderr.WriteString("audita helper success stderr\n") - os.Exit(0) - case "fail": - _, _ = os.Stderr.WriteString("audita helper failure\n") - os.Exit(8) - case "fail_bad_fd": - _, _ = os.Stderr.WriteString("OSError: [Errno 9] Bad file descriptor\n") - os.Exit(120) - case "missing_output": - if reportPath != "" { - writeAuditaHelperFile(reportPath, `{"schema":"audita.report.v1","steps":[]}`) - } - os.Exit(0) - case "invalid_output": - writeAuditaHelperFile(outPath, `not-json`) - if reportPath != "" { - writeAuditaHelperFile(reportPath, `{"schema":"audita.report.v1","steps":[]}`) - } - os.Exit(0) - case "segments_missing": - writeAuditaHelperFile(outPath, `{"schema":"audita.processed.v1"}`) - if reportPath != "" { - writeAuditaHelperFile(reportPath, `{"schema":"audita.report.v1","steps":[]}`) - } - os.Exit(0) - case "invalid_report": - writeAuditaHelperFile(outPath, `{"schema":"audita.processed.v1","segments":[]}`) - if reportPath != "" { - writeAuditaHelperFile(reportPath, `not-json`) - } - os.Exit(0) - default: - _, _ = os.Stderr.WriteString(fmt.Sprintf("unknown helper mode %q\n", mode)) - os.Exit(2) - } -} - -func mustAuditaRunner(t *testing.T, cfg SubprocessRunnerConfig) *SubprocessRunner { - t.Helper() - r, err := NewSubprocessRunner(cfg) - if err != nil { - t.Fatalf("NewSubprocessRunner() error = %v", err) - } - return r -} - -func auditaReqForTest(t *testing.T, withReport bool) PolishRequest { - t.Helper() - dir := t.TempDir() - merged := filepath.Join(dir, "base.json") - glossary := filepath.Join(dir, "glossary.yml") - writeAuditaTestFile(t, merged, `{"segments":[]}`) - writeAuditaTestFile(t, glossary, "terms: []\n") - req := PolishRequest{ GeneratedConfigPath: filepath.Join(dir, "audita.generated.yml"), - MergedTranscriptPath: merged, - GlossaryPath: glossary, - OutputProcessedPath: filepath.Join(dir, "polished.json"), - WorkDir: filepath.Join(dir, "artifacts", "audita-work"), - StdoutLogPath: filepath.Join(dir, "audita.stdout.log"), - StderrLogPath: filepath.Join(dir, "audita.stderr.log"), + StdoutLogPath: filepath.Join(dir, "stdout.log"), + StderrLogPath: filepath.Join(dir, "stderr.log"), } - if withReport { - req.ReportPath = filepath.Join(dir, "audita.report.json") - } - return req } -func writeAuditaHelperWrapper(t *testing.T) string { +func auditaTestRunner(t *testing.T, cfg SubprocessRunnerConfig) *SubprocessRunner { t.Helper() - exe, err := os.Executable() + if cfg.Binary == "" { + cfg.Binary = "audita" + } + if cfg.Timeout == 0 { + cfg.Timeout = time.Second + } + runner, err := NewSubprocessRunner(cfg) if err != nil { - t.Fatalf("os.Executable() error = %v", err) + t.Fatal(err) } - path := filepath.Join(t.TempDir(), "audita-helper-wrapper.sh") - content := "#!/bin/sh\nexec \"" + exe + "\" -test.run=TestAuditaSubprocessHelper -- \"$@\"\n" - if err := os.WriteFile(path, []byte(content), 0o755); err != nil { - t.Fatalf("WriteFile(%q) error = %v", path, err) - } - return path + return runner } -func writeAuditaTestFile(t *testing.T, path, contents string) { +func auditaTestSecret(t *testing.T, name, value string) configkit.Secret { t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("MkdirAll(%q): %v", path, err) + t.Setenv(name, value) + type loaded struct { + Credential configkit.Secret `yaml:"credential"` } - if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { - t.Fatalf("WriteFile(%q): %v", path, err) + result, err := configkit.Load(configkit.Bytes("credential.yml", []byte("credential: "+name+"\n")), configkit.Options[loaded]{}) + if err != nil { + t.Fatal(err) + } + return result.Config.Credential +} + +func writeAuditaTestFile(t *testing.T, path, value string) { + t.Helper() + if err := os.WriteFile(path, []byte(value), 0o600); err != nil { + t.Fatal(err) } } -func writeAuditaHelperFile(path, contents string) { - if strings.TrimSpace(path) == "" { - return - } - _ = os.MkdirAll(filepath.Dir(path), 0o755) - _ = os.WriteFile(path, []byte(contents), 0o644) -} - -func auditaFlagValue(args []string, name string) string { - for i := 0; i < len(args)-1; i++ { - if args[i] == name { - return args[i+1] +func TestSubprocessRunnerUsesV2InvocationAndCapturedCredentials(t *testing.T) { + const first = "NARRATIO_AUDITA_CUSTOM_ONE" + const second = "NARRATIO_AUDITA_CUSTOM_TWO" + firstSecret := auditaTestSecret(t, first, "loaded-one") + secondSecret := auditaTestSecret(t, second, "loaded-two") + t.Setenv(first, "parent-changed") + t.Setenv(second, "parent-changed-too") + t.Setenv("NARRATIO_AUDITA_UNSELECTED", "not-forwarded") + runner := auditaTestRunner(t, SubprocessRunnerConfig{ + Credentials: []configkit.Secret{firstSecret, secondSecret}, + Modules: []string{"grammar", "glossary", "grammar"}, + Overlay: "testing", ProposalProfile: "proposal", ValidationProfile: "validation", + TranscriptDescription: "campaign", ConfigPath: "/etc/audita/config.yml", OutputSchema: "audita-v1", + }) + req := auditaTestRequest(t) + var captured subprocess.RunRequest + runner.run = func(_ context.Context, processReq subprocess.RunRequest) (subprocess.RunResult, error) { + captured = processReq + writeAuditaTestFile(t, req.OutputProcessedPath, `{"segments":[]}`) + for _, name := range []string{"errors.yml", "recoveries.yml", "report.md"} { + writeAuditaTestFile(t, filepath.Join(filepath.Dir(req.OutputProcessedPath), name), "recoverable diagnostic\n") } + return subprocess.RunResult{}, nil } - return "" -} - -func readAuditaHelperRecord(t *testing.T, path string) auditaHelperRecord { - t.Helper() - data, err := os.ReadFile(path) + result, err := runner.Run(context.Background(), req) if err != nil { - t.Fatalf("ReadFile(%q): %v", path, err) + t.Fatal(err) } - var rec auditaHelperRecord - if err := json.Unmarshal(data, &rec); err != nil { - t.Fatalf("json unmarshal helper record: %v", err) + want := []string{"process", req.MergedTranscriptPath, "--glossary", req.GlossaryPath, "--output", req.OutputProcessedPath, + "--modules", "grammar,glossary,grammar", "--config", "/etc/audita/config.yml", "--overlay", "testing", + "--proposal-profile", "proposal", "--validation-profile", "validation", "--transcript-description", "campaign", "--output-schema", "audita-v1"} + if !reflect.DeepEqual(captured.Args, want) { + t.Fatalf("args = %#v, want %#v", captured.Args, want) } - return rec -} - -func assertJSONFileAudita(t *testing.T, path string) { - t.Helper() - data, err := os.ReadFile(path) + if len(captured.EnvOverrides) != 2 || captured.EnvOverrides[first] != "loaded-one" || captured.EnvOverrides[second] != "loaded-two" || captured.EnvOverrides["NARRATIO_AUDITA_UNSELECTED"] != "" { + t.Fatalf("credential forwarding = %#v", captured.EnvOverrides) + } + if !reflect.DeepEqual(captured.SensitiveEnvNames, []string{first, second}) { + t.Fatalf("sensitive names = %#v", captured.SensitiveEnvNames) + } + if result.Reports.ErrorsPath == "" || result.Reports.RecoveriesPath == "" || result.Reports.OverviewPath == "" { + t.Fatalf("reports = %#v", result.Reports) + } + generated, err := os.ReadFile(req.GeneratedConfigPath) if err != nil { - t.Fatalf("ReadFile(%q): %v", path, err) + t.Fatal(err) } - var v any - if err := json.Unmarshal(data, &v); err != nil { - t.Fatalf("json unmarshal %q: %v", path, err) + if !strings.Contains(string(generated), "audita.generated.v2") || !strings.Contains(string(generated), first) || strings.Contains(string(generated), "loaded-one") { + t.Fatalf("unsafe or incomplete generated invocation: %s", generated) } } -func mustParseAuditaDuration(t *testing.T, value string) time.Duration { - t.Helper() - d, err := time.ParseDuration(value) - if err != nil { - t.Fatalf("time.ParseDuration(%q) error = %v", value, err) +func TestSubprocessRunnerModulePresenceAndReportAbsence(t *testing.T) { + for _, test := range []struct { + name string + configured []string + requested []string + wantFlag bool + wantValue string + }{ + {"omitted", nil, nil, false, ""}, + {"configured", []string{"grammar", "grammar"}, nil, true, "grammar,grammar"}, + {"empty override", []string{"grammar"}, []string{}, true, ""}, + } { + t.Run(test.name, func(t *testing.T) { + runner := auditaTestRunner(t, SubprocessRunnerConfig{Modules: test.configured}) + req := auditaTestRequest(t) + req.Modules = test.requested + runner.run = func(_ context.Context, processReq subprocess.RunRequest) (subprocess.RunResult, error) { + index := -1 + for i, arg := range processReq.Args { + if arg == "--modules" { + index = i + } + if strings.HasPrefix(arg, "--report") || arg == "--debug" || arg == "--work-dir" { + t.Fatalf("retired or unwanted flag %q", arg) + } + } + if (index >= 0) != test.wantFlag || (index >= 0 && processReq.Args[index+1] != test.wantValue) { + t.Fatalf("args = %#v", processReq.Args) + } + writeAuditaTestFile(t, req.OutputProcessedPath, `{"segments":[]}`) + return subprocess.RunResult{}, nil + } + result, err := runner.Run(context.Background(), req) + if err != nil || result.Reports != (ReportBundle{}) { + t.Fatalf("result = %#v, error = %v", result, err) + } + }) + } +} + +func TestSubprocessRunnerRejectsMissingOrMalformedTranscript(t *testing.T) { + for _, output := range []string{"", `{"segments":"wrong"}`} { + t.Run(output, func(t *testing.T) { + runner := auditaTestRunner(t, SubprocessRunnerConfig{}) + req := auditaTestRequest(t) + runner.run = func(context.Context, subprocess.RunRequest) (subprocess.RunResult, error) { + if output != "" { + writeAuditaTestFile(t, req.OutputProcessedPath, output) + } + return subprocess.RunResult{}, nil + } + if _, err := runner.Run(context.Background(), req); err == nil { + t.Fatal("missing or malformed transcript was accepted") + } + }) + } +} + +func TestSubprocessRunnerPreservesFailureAndAvailableReports(t *testing.T) { + runner := auditaTestRunner(t, SubprocessRunnerConfig{}) + req := auditaTestRequest(t) + processFailure := errors.New("audita exited after writing output") + runner.run = func(context.Context, subprocess.RunRequest) (subprocess.RunResult, error) { + writeAuditaTestFile(t, req.OutputProcessedPath, `{"segments":[]}`) + writeAuditaTestFile(t, filepath.Join(filepath.Dir(req.OutputProcessedPath), "errors.yml"), "status: failed\n") + return subprocess.RunResult{ExitCode: 7}, processFailure + } + result, err := runner.Run(context.Background(), req) + if !errors.Is(err, processFailure) || !strings.Contains(err.Error(), filepath.Dir(req.OutputProcessedPath)) { + t.Fatalf("error = %v", err) + } + if result.Reports.ErrorsPath == "" || result.Reports.RecoveriesPath != "" || result.Reports.OverviewPath != "" { + t.Fatalf("reports = %#v", result.Reports) + } +} + +func TestSubprocessRunnerDoesNotMaskProcessFailureWithReportError(t *testing.T) { + runner := auditaTestRunner(t, SubprocessRunnerConfig{}) + req := auditaTestRequest(t) + processFailure := errors.New("audita process failed") + runner.run = func(context.Context, subprocess.RunRequest) (subprocess.RunResult, error) { + path := filepath.Join(filepath.Dir(req.OutputProcessedPath), "errors.yml") + if err := os.WriteFile(path, make([]byte, MaxReportOutputBytes+1), 0o600); err != nil { + t.Fatal(err) + } + return subprocess.RunResult{ExitCode: 7}, processFailure + } + if _, err := runner.Run(context.Background(), req); !errors.Is(err, processFailure) { + t.Fatalf("error = %v, want original process error", err) + } +} + +func TestSubprocessRunnerBoundsAdjacentReports(t *testing.T) { + runner := auditaTestRunner(t, SubprocessRunnerConfig{}) + req := auditaTestRequest(t) + runner.run = func(context.Context, subprocess.RunRequest) (subprocess.RunResult, error) { + writeAuditaTestFile(t, req.OutputProcessedPath, `{"segments":[]}`) + path := filepath.Join(filepath.Dir(req.OutputProcessedPath), "errors.yml") + if err := os.WriteFile(path, make([]byte, MaxReportOutputBytes+1), 0o600); err != nil { + t.Fatal(err) + } + return subprocess.RunResult{}, nil + } + if _, err := runner.Run(context.Background(), req); err == nil || !strings.Contains(err.Error(), "errors.yml") || !strings.Contains(err.Error(), "16777216") { + t.Fatalf("error = %v, want named report limit", err) + } +} + +func TestSubprocessRunnerRedactsCustomCredentialStreams(t *testing.T) { + const first = "NARRATIO_AUDITA_STREAM_ONE" + const second = "NARRATIO_AUDITA_STREAM_TWO" + for _, fail := range []bool{false, true} { + t.Run(map[bool]string{false: "success", true: "failure"}[fail], func(t *testing.T) { + firstSecret := auditaTestSecret(t, first, "audita-first-secret") + secondSecret := auditaTestSecret(t, second, "audita-second-secret") + req := auditaTestRequest(t) + body := "#!/bin/sh\nprintf '%s\\n' \"$" + first + "\"\nprintf '%s\\n' \"$" + second + "\" >&2\n" + if fail { + body += "exit 7\n" + } else { + body += "printf '{\"segments\":[]}' > \"" + req.OutputProcessedPath + "\"\n" + } + binary := filepath.Join(t.TempDir(), "audita-helper") + writeAuditaTestFile(t, binary, body) + if err := os.Chmod(binary, 0o700); err != nil { + t.Fatal(err) + } + runner := auditaTestRunner(t, SubprocessRunnerConfig{Binary: binary, Credentials: []configkit.Secret{firstSecret, secondSecret}}) + _, err := runner.Run(context.Background(), req) + if (err != nil) != fail { + t.Fatalf("error = %v, fail = %v", err, fail) + } + for _, path := range []string{req.StdoutLogPath, req.StderrLogPath} { + data, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatal(readErr) + } + if strings.Contains(string(data), "audita-first-secret") || strings.Contains(string(data), "audita-second-secret") || !strings.Contains(string(data), "") { + t.Fatalf("unredacted stream %q: %s", path, data) + } + } + if fail && (strings.Contains(err.Error(), "audita-first-secret") || strings.Contains(err.Error(), "audita-second-secret")) { + t.Fatalf("unredacted failure: %v", err) + } + }) } - return d } diff --git a/internal/app/assembled_workflow_test.go b/internal/app/assembled_workflow_test.go index 8d5e732..1f63323 100644 --- a/internal/app/assembled_workflow_test.go +++ b/internal/app/assembled_workflow_test.go @@ -272,8 +272,8 @@ func TestAssembledSplitBundleCommandsUseOneEffectiveFamilyConfiguration(t *testi }, &diff); err != nil { t.Fatalf("compare profiles: %v", err) } - if !strings.Contains(diff.String(), "audita.model") || !strings.Contains(diff.String(), "character_items_arannis.profile_id") { - t.Fatalf("profile diff = %q, want model and expanded family changes", diff.String()) + if !strings.Contains(diff.String(), "audita.proposal_profile") || !strings.Contains(diff.String(), "character_items_arannis.profile_id") { + t.Fatalf("profile diff = %q, want proposal profile and expanded family changes", diff.String()) } common := []string{ @@ -373,7 +373,7 @@ func TestAssembledSplitBundleProfileSwitchRecordsProvenanceAndLimitsReuse(t *tes if err != nil { t.Fatalf("switch to testing profile: %v", err) } - if got, want := testingRuns, []int{0, 0, 0, 1, 1, 1, 1}; !reflect.DeepEqual(got, want) { + if got, want := testingRuns, []int{0, 0, 0, 0, 0, 0, 0}; !reflect.DeepEqual(got, want) { t.Fatalf("testing profile runs = %v, want %v", got, want) } assertAssembledProvenance(t, store, testingSummary, "testing", config.EffectivePipelineDigest(testingCfg.Pipeline)) diff --git a/internal/app/commands_test.go b/internal/app/commands_test.go index f723f89..07a1836 100644 --- a/internal/app/commands_test.go +++ b/internal/app/commands_test.go @@ -229,7 +229,7 @@ seriatim: binary: ` + seriatimBinary + ` audita: binary: ` + auditaBinary + ` - llm_api_key: OPENROUTER_API_KEY + credentials: [OPENROUTER_API_KEY] notification: mode: noop ` @@ -778,10 +778,18 @@ func TestAuditaAppHelper(t *testing.T) { os.Exit(2) } processArgs := args[start:] + if len(processArgs) < 2 || processArgs[0] != "process" { + _, _ = os.Stderr.WriteString("expected Audita v2 process invocation\n") + os.Exit(2) + } + for _, arg := range processArgs { + if arg == "--report-json" || arg == "--work-dir" || arg == "--debug" || arg == "--reporting=false" { + _, _ = os.Stderr.WriteString("retired or unwanted Audita flag\n") + os.Exit(2) + } + } outputPath := appSeriatimFlagValue(processArgs, "--output") - reportPath := appSeriatimFlagValue(processArgs, "--report-json") - workDir := appSeriatimFlagValue(processArgs, "--work-dir") if strings.TrimSpace(outputPath) == "" { _, _ = os.Stderr.WriteString("missing --output\n") os.Exit(2) @@ -794,19 +802,9 @@ func TestAuditaAppHelper(t *testing.T) { _, _ = os.Stderr.WriteString(fmt.Sprintf("write output: %v\n", err)) os.Exit(2) } - if strings.TrimSpace(reportPath) != "" { - if err := os.MkdirAll(filepath.Dir(reportPath), 0o755); err != nil { - _, _ = os.Stderr.WriteString(fmt.Sprintf("mkdir report dir: %v\n", err)) - os.Exit(2) - } - if err := os.WriteFile(reportPath, []byte(`{"report":true}`), 0o644); err != nil { - _, _ = os.Stderr.WriteString(fmt.Sprintf("write report: %v\n", err)) - os.Exit(2) - } - } - if strings.TrimSpace(workDir) != "" { - if err := os.MkdirAll(workDir, 0o755); err != nil { - _, _ = os.Stderr.WriteString(fmt.Sprintf("mkdir work dir: %v\n", err)) + for _, name := range []string{"errors.yml", "recoveries.yml", "report.md"} { + if err := os.WriteFile(filepath.Join(filepath.Dir(outputPath), name), []byte("status: completed\n"), 0o644); err != nil { + _, _ = os.Stderr.WriteString(fmt.Sprintf("write %s: %v\n", name, err)) os.Exit(2) } } diff --git a/internal/app/config_commands_test.go b/internal/app/config_commands_test.go index d7705ed..8a707f6 100644 --- a/internal/app/config_commands_test.go +++ b/internal/app/config_commands_test.go @@ -395,8 +395,8 @@ func TestConfigInspectionOutputsNeverExposeResolvedCredentialValues(t *testing.T t.Fatal(err) } writeInspectionOverlays(t, pipelinePath, - "audita:\n model: production\n llm_api_key: NARRATIO_INSPECT_LLM_ONE\n", - "audita:\n model: testing\n llm_api_key: NARRATIO_INSPECT_LLM_TWO\nnotarius:\n credentials: [NARRATIO_INSPECT_NOTARIUS_TWO]\n", + "audita:\n proposal_profile: production\n credentials: [NARRATIO_INSPECT_LLM_ONE]\n", + "audita:\n proposal_profile: testing\n credentials: [NARRATIO_INSPECT_LLM_TWO]\nnotarius:\n credentials: [NARRATIO_INSPECT_NOTARIUS_TWO]\n", ) mustWriteTestFile(t, filepath.Join(filepath.Dir(pipelinePath), "overlays", "without-notarius-key.yml"), "notarius:\n credentials: []\n") @@ -476,8 +476,8 @@ func TestConfigCommandsInspectMaintainedSplitBundleWithoutRuntimeState(t *testin if err := ConfigDiff(context.Background(), []string{"default", "testing", "--config", pipelinePath, "--campaign-file", campaignPath}, &diff); err != nil { t.Fatalf("ConfigDiff() error = %v", err) } - if !strings.Contains(diff.String(), "audita.model") { - t.Fatalf("split bundle overlay diff = %q, want model change", diff.String()) + if !strings.Contains(diff.String(), "audita.proposal_profile") { + t.Fatalf("split bundle overlay diff = %q, want proposal profile change", diff.String()) } if _, err := os.Stat(workspacePath); !os.IsNotExist(err) { t.Fatalf("inspection created example workspace: %v", err) diff --git a/internal/app/runner.go b/internal/app/runner.go index 58fb041..2c28d37 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -207,35 +207,18 @@ func buildDefaultAuditaRunner(cfg *config.Config) (audita.Runner, error) { return &audita.NoopRunner{}, nil } - report := false - if a.Report != nil { - report = *a.Report + timeout, err := time.ParseDuration(a.Timeout) + if err != nil { + return nil, fmt.Errorf("parse pipeline.audita.timeout %q: %w", a.Timeout, err) } - llmAPIKey := "" - llmAPIKeyReference := "" - if a.LLMAPIKey != nil { - llmAPIKey = a.LLMAPIKey.Value() - llmAPIKeyReference = a.LLMAPIKey.EnvName() - } - - runner, err := audita.NewSubprocessRunnerFromConfigValues( - a.Binary, - a.Timeout, - llmAPIKey, - llmAPIKeyReference, - append([]string(nil), a.Modules...), - a.BaseURL, - a.Model, - a.TranscriptDescription, - a.ConfigPath, - a.OutputSchema, - a.WorkDirRetention, - a.TotalLLMConcurrency, - a.ProposalLLMConcurrency, - a.ValidationModel, - a.ValidationLLMConcurrency, - report, - ) + runner, err := audita.NewSubprocessRunner(audita.SubprocessRunnerConfig{ + Binary: a.Binary, Timeout: timeout, Credentials: a.Credentials, + Modules: append(a.Modules[:0:0], a.Modules...), + Overlay: a.Overlay, ProposalProfile: a.ProposalProfile, + ValidationProfile: a.ValidationProfile, + TranscriptDescription: a.TranscriptDescription, ConfigPath: a.ConfigPath, + OutputSchema: a.OutputSchema, + }) if err != nil { return nil, fmt.Errorf("from pipeline.audita: %w", err) } diff --git a/internal/app/semantic_resume_test.go b/internal/app/semantic_resume_test.go index 352d118..61785be 100644 --- a/internal/app/semantic_resume_test.go +++ b/internal/app/semantic_resume_test.go @@ -364,8 +364,8 @@ func TestRefinementSemanticChangesRespectDependencyBranches(t *testing.T) { wantRuns [7]int wantExtractStatus manifest.StageStatus }{ - {name: "polish model", mutate: func(cfg *config.Config) { - cfg.Pipeline.Audita.Model = "production" + {name: "polish description", mutate: func(cfg *config.Config) { + cfg.Pipeline.Audita.TranscriptDescription = "production" }, wantRuns: [7]int{0, 0, 0, 1, 1, 1, 1}, wantExtractStatus: manifest.StatusStale}, {name: "normalize schema", mutate: func(cfg *config.Config) { cfg.Pipeline.Normalize = &config.NormalizeConfig{OutputSchema: "seriatim.transcript.v2"} diff --git a/internal/config/config.go b/internal/config/config.go index 3cb7b78..2027a90 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -165,21 +165,16 @@ type SeriatimEnvConfig struct { // AuditaConfig configures audita adapter settings. type AuditaConfig struct { - Binary string `yaml:"binary" json:"binary"` - Timeout string `yaml:"timeout" json:"timeout"` - LLMAPIKey *configkit.Secret `yaml:"llm_api_key,omitempty" json:"llm_api_key,omitempty"` - Modules []string `yaml:"modules" json:"modules"` - BaseURL string `yaml:"base_url" json:"base_url"` - Model string `yaml:"model" json:"model"` - TotalLLMConcurrency *int `yaml:"total_llm_concurrency" json:"total_llm_concurrency"` - ProposalLLMConcurrency *int `yaml:"proposal_llm_concurrency" json:"proposal_llm_concurrency"` - ValidationModel string `yaml:"validation_model" json:"validation_model"` - ValidationLLMConcurrency *int `yaml:"validation_llm_concurrency" json:"validation_llm_concurrency"` - TranscriptDescription string `yaml:"transcript_description" json:"transcript_description"` - ConfigPath string `yaml:"config_path" json:"config_path"` - OutputSchema string `yaml:"output_schema" json:"output_schema"` - WorkDirRetention string `yaml:"work_dir_retention" json:"work_dir_retention"` - Report *bool `yaml:"report" json:"report"` + Binary string `yaml:"binary" json:"binary"` + Timeout string `yaml:"timeout" json:"timeout"` + Credentials []configkit.Secret `yaml:"credentials,omitempty" json:"credentials,omitempty"` + Modules []string `yaml:"modules" json:"modules"` + Overlay string `yaml:"overlay" json:"overlay"` + ProposalProfile string `yaml:"proposal_profile" json:"proposal_profile"` + ValidationProfile string `yaml:"validation_profile" json:"validation_profile"` + TranscriptDescription string `yaml:"transcript_description" json:"transcript_description"` + ConfigPath string `yaml:"config_path" json:"config_path"` + OutputSchema string `yaml:"output_schema" json:"output_schema"` } // NormalizeConfig configures normalize-stage transcript schema/output behavior. diff --git a/internal/config/credential_projection.go b/internal/config/credential_projection.go index 3f889dd..9169ee8 100644 --- a/internal/config/credential_projection.go +++ b/internal/config/credential_projection.go @@ -21,32 +21,25 @@ func (cfg StorageS3Config) MarshalYAML() (any, error) { }, nil } -// MarshalYAML exposes only the public Audita credential reference name. +// MarshalYAML exposes only public Audita credential reference names. func (cfg AuditaConfig) MarshalYAML() (any, error) { type safeAudita struct { - Binary string `yaml:"binary"` - Timeout string `yaml:"timeout"` - LLMAPIKey *string `yaml:"llm_api_key,omitempty"` - Modules []string `yaml:"modules"` - BaseURL string `yaml:"base_url"` - Model string `yaml:"model"` - TotalLLMConcurrency *int `yaml:"total_llm_concurrency"` - ProposalLLMConcurrency *int `yaml:"proposal_llm_concurrency"` - ValidationModel string `yaml:"validation_model"` - ValidationLLMConcurrency *int `yaml:"validation_llm_concurrency"` - TranscriptDescription string `yaml:"transcript_description"` - ConfigPath string `yaml:"config_path"` - OutputSchema string `yaml:"output_schema"` - WorkDirRetention string `yaml:"work_dir_retention"` - Report *bool `yaml:"report"` + Binary string `yaml:"binary"` + Timeout string `yaml:"timeout"` + Credentials []string `yaml:"credentials,omitempty"` + Modules []string `yaml:"modules"` + Overlay string `yaml:"overlay"` + ProposalProfile string `yaml:"proposal_profile"` + ValidationProfile string `yaml:"validation_profile"` + TranscriptDescription string `yaml:"transcript_description"` + ConfigPath string `yaml:"config_path"` + OutputSchema string `yaml:"output_schema"` } return safeAudita{ - Binary: cfg.Binary, Timeout: cfg.Timeout, LLMAPIKey: secretReferenceName(cfg.LLMAPIKey), - Modules: cfg.Modules, BaseURL: cfg.BaseURL, Model: cfg.Model, - TotalLLMConcurrency: cfg.TotalLLMConcurrency, ProposalLLMConcurrency: cfg.ProposalLLMConcurrency, - ValidationModel: cfg.ValidationModel, ValidationLLMConcurrency: cfg.ValidationLLMConcurrency, - TranscriptDescription: cfg.TranscriptDescription, ConfigPath: cfg.ConfigPath, - OutputSchema: cfg.OutputSchema, WorkDirRetention: cfg.WorkDirRetention, Report: cfg.Report, + Binary: cfg.Binary, Timeout: cfg.Timeout, Credentials: secretReferenceNames(cfg.Credentials), + Modules: cfg.Modules, Overlay: cfg.Overlay, ProposalProfile: cfg.ProposalProfile, + ValidationProfile: cfg.ValidationProfile, TranscriptDescription: cfg.TranscriptDescription, + ConfigPath: cfg.ConfigPath, OutputSchema: cfg.OutputSchema, }, nil } diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 5906ef2..e950b7a 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -30,7 +30,6 @@ const ( DefaultAuditaBinary = "audita" DefaultAuditaTimeout = "3h" - DefaultAuditaReport = true DefaultNotariusBinary = "notarius" DefaultNotariusTimeout = "3h" // MaxNotariusReferenceBindings is the maximum number of CLI reference diff --git a/internal/config/load_validate_test.go b/internal/config/load_validate_test.go index 5aa1465..462f17e 100644 --- a/internal/config/load_validate_test.go +++ b/internal/config/load_validate_test.go @@ -93,7 +93,7 @@ func TestCredentialValuesNeverEnterEffectiveProjectionOrDigest(t *testing.T) { t.Setenv("NARRATIO_TEST_SECRET_KEY", firstCanary) t.Setenv("NARRATIO_TEST_AUDITA_KEY", firstCanary) t.Setenv("NARRATIO_TEST_NOTARIUS_KEY", firstCanary) - pipelineYAML := strings.Replace(testPipelineBaseYAML, "audita:\n binary: audita\n", "audita:\n binary: audita\n llm_api_key: NARRATIO_TEST_AUDITA_KEY\n", 1) + ` + pipelineYAML := strings.Replace(testPipelineBaseYAML, "audita:\n binary: audita\n", "audita:\n binary: audita\n credentials: [NARRATIO_TEST_AUDITA_KEY]\n", 1) + ` storage: backend: s3 s3: @@ -375,10 +375,10 @@ func TestExamplesLoadAndValidate(t *testing.T) { if err := Validate(cfg); err != nil { t.Fatalf("validate split bundle: %v", err) } - if cfg.Pipeline.Audita.Model != profile.model { - t.Fatalf("audita model = %q, want %q", cfg.Pipeline.Audita.Model, profile.model) + if cfg.Pipeline.Audita.ProposalProfile != profile.model { + t.Fatalf("audita proposal profile = %q, want %q", cfg.Pipeline.Audita.ProposalProfile, profile.model) } - if cfg.Pipeline.Audita.LLMAPIKey != nil || cfg.Pipeline.Storage.Backend != StorageBackendLocal || cfg.Pipeline.Storage.S3 != nil { + if len(cfg.Pipeline.Audita.Credentials) != 0 || cfg.Pipeline.Storage.Backend != StorageBackendLocal || cfg.Pipeline.Storage.S3 != nil { t.Fatalf("split bundle must remain offline and secret-free: audita=%#v storage=%#v", cfg.Pipeline.Audita, cfg.Pipeline.Storage) } selectedOverlay, selected := SelectedPipelineOverlay(cfg.Pipeline) diff --git a/internal/config/pipeline_configkit.go b/internal/config/pipeline_configkit.go index 1fbdfde..b2832c4 100644 --- a/internal/config/pipeline_configkit.go +++ b/internal/config/pipeline_configkit.go @@ -132,7 +132,7 @@ func pipelineConfigDefaults() PipelineConfig { OutputSchema: DefaultSeriatimOutputSchema, CoalesceGap: float64Ptr(DefaultSeriatimCoalesceGap), Report: boolPtr(DefaultSeriatimReport), }, - Audita: AuditaConfig{Binary: DefaultAuditaBinary, Timeout: DefaultAuditaTimeout, Report: boolPtr(DefaultAuditaReport)}, + Audita: AuditaConfig{Binary: DefaultAuditaBinary, Timeout: DefaultAuditaTimeout}, Normalize: &NormalizeConfig{OutputPath: DefaultNormalizeOutputPath, OutputSchema: DefaultNormalizeOutputSchema, Report: boolPtr(DefaultNormalizeReport)}, Trim: &TrimConfig{ Enabled: boolPtr(DefaultTrimEnabled), OutputPath: DefaultTrimOutputPath, diff --git a/internal/config/pipeline_configkit_test.go b/internal/config/pipeline_configkit_test.go index f57a8f0..2bf3497 100644 --- a/internal/config/pipeline_configkit_test.go +++ b/internal/config/pipeline_configkit_test.go @@ -129,24 +129,71 @@ func TestPipelineOverlayDoesNotRequireUnusedBaseCredentials(t *testing.T) { for _, credential := range []string{testingKey, "null"} { t.Run(credential, func(t *testing.T) { dir := t.TempDir() - root := writePipelineFixture(t, dir, "pipeline.yml", "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\naudita:\n llm_api_key: "+production+"\n") - writePipelineFixture(t, dir, "overlays/testing.yml", "audita:\n llm_api_key: "+credential+"\n") + root := writePipelineFixture(t, dir, "pipeline.yml", "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\naudita:\n credentials: ["+production+"]\n") + selected := "[" + credential + "]" + if credential == "null" { + selected = credential + } + writePipelineFixture(t, dir, "overlays/testing.yml", "audita:\n credentials: "+selected+"\n") profile := "testing" cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Overlay: &profile}) if err != nil { t.Fatal(err) } if credential == "null" { - if cfg.Audita.LLMAPIKey != nil { + if len(cfg.Audita.Credentials) != 0 { t.Fatal("profile did not remove the base credential") } - } else if cfg.Audita.LLMAPIKey == nil || cfg.Audita.LLMAPIKey.EnvName() != testingKey || !cfg.Audita.LLMAPIKey.IsResolved() { + } else if len(cfg.Audita.Credentials) != 1 || cfg.Audita.Credentials[0].EnvName() != testingKey || !cfg.Audita.Credentials[0].IsResolved() { t.Fatal("profile credential was not resolved") } }) } } +func TestAuditaModulesPreserveOmittedEmptyAndReplacementSelections(t *testing.T) { + for _, test := range []struct { + name, base, overlay string + wantNil bool + want []string + }{ + {name: "omitted", wantNil: true}, + {name: "null", base: " modules: null\n", wantNil: true}, + {name: "empty", base: " modules: []\n", want: []string{}}, + {name: "overlay empty", base: " modules: [glossary]\n", overlay: "audita:\n modules: []\n", want: []string{}}, + {name: "overlay order", base: " modules: []\n", overlay: "audita:\n modules: [grammar, glossary, grammar]\n", want: []string{"grammar", "glossary", "grammar"}}, + } { + t.Run(test.name, func(t *testing.T) { + dir := t.TempDir() + root := writePipelineFixture(t, dir, "pipeline.yml", "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\naudita:\n binary: audita\n"+test.base) + options := PipelineLoadOptions{} + if test.overlay != "" { + writePipelineFixture(t, dir, "overlays/testing.yml", test.overlay) + selection := "testing" + options.Overlay = &selection + } + cfg, err := LoadPipelineWithOptions(root, options) + if err != nil { + t.Fatal(err) + } + if (cfg.Audita.Modules == nil) != test.wantNil || !reflect.DeepEqual(cfg.Audita.Modules, test.want) { + t.Fatalf("modules = %#v, want %#v (nil=%v)", cfg.Audita.Modules, test.want, test.wantNil) + } + }) + } +} + +func TestAuditaRejectsRetiredConfigurationFields(t *testing.T) { + for _, field := range []string{"llm_api_key", "base_url", "model", "validation_model", "total_llm_concurrency", "proposal_llm_concurrency", "validation_llm_concurrency", "work_dir_retention", "report"} { + t.Run(field, func(t *testing.T) { + root := writePipelineFixture(t, t.TempDir(), "pipeline.yml", "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\naudita:\n "+field+": retired\n") + if _, err := LoadPipeline(root); err == nil || !strings.Contains(err.Error(), "decode.unknown_field") { + t.Fatalf("error = %v, want unknown field", err) + } + }) + } +} + func TestNotariusCredentialOverlayReplacementAndProvenance(t *testing.T) { const baseKey = "NARRATIO_NOTARIUS_UNUSED_BASE_KEY" const profileKey = "NARRATIO_NOTARIUS_PROFILE_KEY" diff --git a/internal/config/validate_adapters.go b/internal/config/validate_adapters.go index d0b6ed7..e655538 100644 --- a/internal/config/validate_adapters.go +++ b/internal/config/validate_adapters.go @@ -176,24 +176,6 @@ func validateAudita(cfg AuditaConfig) error { return fmt.Errorf("pipeline.audita.modules[%d] must be one of: glossary, homophones, spoken_word, grammar", i) } } - if strings.TrimSpace(cfg.BaseURL) != "" { - u, err := url.Parse(cfg.BaseURL) - if err != nil || u.Scheme == "" || u.Host == "" { - if err != nil { - return fmt.Errorf("pipeline.audita.base_url must be a valid URL: %w", err) - } - return fmt.Errorf("pipeline.audita.base_url must be a valid URL") - } - } - if cfg.TotalLLMConcurrency != nil && *cfg.TotalLLMConcurrency <= 0 { - return fmt.Errorf("pipeline.audita.total_llm_concurrency must be > 0") - } - if cfg.ProposalLLMConcurrency != nil && *cfg.ProposalLLMConcurrency <= 0 { - return fmt.Errorf("pipeline.audita.proposal_llm_concurrency must be > 0") - } - if cfg.ValidationLLMConcurrency != nil && *cfg.ValidationLLMConcurrency <= 0 { - return fmt.Errorf("pipeline.audita.validation_llm_concurrency must be > 0") - } if strings.TrimSpace(cfg.TranscriptDescription) == "" && cfg.TranscriptDescription != "" { return fmt.Errorf("pipeline.audita.transcript_description must be non-empty when provided") } @@ -205,10 +187,5 @@ func validateAudita(cfg AuditaConfig) error { default: return fmt.Errorf("pipeline.audita.output_schema must be one of: bare-segments, audita-v1") } - switch strings.TrimSpace(cfg.WorkDirRetention) { - case "", "always", "auto", "never": - default: - return fmt.Errorf("pipeline.audita.work_dir_retention must be one of: always, auto, never") - } return nil } diff --git a/internal/stage/polish.go b/internal/stage/polish.go index d6420f7..e365934 100644 --- a/internal/stage/polish.go +++ b/internal/stage/polish.go @@ -67,39 +67,23 @@ func (polishStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*St if err != nil { return nil, fmt.Errorf("polish: resolve run-local processed transcript path: %w", err) } - canonicalReportPath := filepath.Join(paths.ArtifactsDir, "audita.report.json") - reportPath := canonicalReportPath - if runLayout.Enabled { - reportPath, err = runLocalPathForCanonical(runLayout, paths, canonicalReportPath) - if err != nil { - return nil, fmt.Errorf("polish: resolve run-local report path: %w", err) - } - } - workDir := filepath.Join(paths.ArtifactsDir, "audita-work") stdoutPath := filepath.Join(paths.LogsDir, "audita.stdout.log") stderrPath := filepath.Join(paths.LogsDir, "audita.stderr.log") generatedConfigPath := filepath.Join(paths.ConfigDir, "audita.generated.yml") if runLayout.Enabled { - workDir = filepath.Join(runLayout.ScratchDir, "audita-work") stdoutPath = filepath.Join(runLayout.LogsDir, "audita.stdout.log") stderrPath = filepath.Join(runLayout.LogsDir, "audita.stderr.log") generatedConfigPath = filepath.Join(runLayout.ConfigDir, "audita.generated.yml") } - reportEnabled := env.Config.Pipeline.Audita.Report != nil && *env.Config.Pipeline.Audita.Report req := audita.PolishRequest{ GeneratedConfigPath: generatedConfigPath, MergedTranscriptPath: mergedPath, OutputProcessedPath: processedPath, GlossaryPath: glossaryPath, - ReportPath: "", - WorkDir: workDir, StdoutLogPath: stdoutPath, StderrLogPath: stderrPath, } - if reportEnabled { - req.ReportPath = reportPath - } res, err := env.Audita.Run(ctx, req) if err != nil { @@ -114,16 +98,6 @@ func (polishStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*St return nil, fmt.Errorf("polish: processed transcript %q invalid: %w", finalProcessedPath, err) } - finalReportPath, err := authoritativeOutputPath(req.ReportPath, res.ReportPath) - if err != nil { - return nil, fmt.Errorf("polish: %w", err) - } - if reportEnabled { - if err := validateTranscriptJSONFile(finalReportPath); err != nil { - return nil, fmt.Errorf("polish: report %q invalid: %w", finalReportPath, err) - } - } - materializedProcessed, err := materializeRunLocalOutput(env.ArtifactStore, finalProcessedPath, canonicalProcessedPath, artifacts.Ref{ Kind: "transcript_polished", Category: "transcripts", @@ -133,39 +107,6 @@ func (polishStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*St return nil, fmt.Errorf("polish: materialize canonical polished transcript: %w", err) } outputs := []artifacts.Ref{materializedProcessed} - if reportEnabled { - materializedReport, err := materializeRunLocalOutput(env.ArtifactStore, finalReportPath, canonicalReportPath, artifacts.Ref{ - Kind: "audita_report", - Category: "artifacts", - SessionID: sessionID, - }) - if err != nil { - return nil, fmt.Errorf("polish: materialize canonical report: %w", err) - } - outputs = append(outputs, materializedReport) - } - - var validationConcurrency any - if env.Config.Pipeline.Audita.ValidationLLMConcurrency != nil { - validationConcurrency = *env.Config.Pipeline.Audita.ValidationLLMConcurrency - } - var totalLLMConcurrency any - if env.Config.Pipeline.Audita.TotalLLMConcurrency != nil { - totalLLMConcurrency = *env.Config.Pipeline.Audita.TotalLLMConcurrency - } - var proposalLLMConcurrency any - if env.Config.Pipeline.Audita.ProposalLLMConcurrency != nil { - proposalLLMConcurrency = *env.Config.Pipeline.Audita.ProposalLLMConcurrency - } - reportCanonicalPath := "" - if reportEnabled { - reportCanonicalPath = canonicalReportPath - } - credentialReference := "" - if env.Config.Pipeline.Audita.LLMAPIKey != nil { - credentialReference = env.Config.Pipeline.Audita.LLMAPIKey.EnvName() - } - meta := map[string]any{ "stage": "polish", "merged_transcript_path": mergedPath, @@ -173,22 +114,13 @@ func (polishStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*St "glossary_path": glossaryPath, "run_output_path": finalProcessedPath, "output_path": canonicalProcessedPath, - "run_report_path": finalReportPath, - "report_path": reportCanonicalPath, - "audita_work_dir": workDir, - "report_enabled": reportEnabled, - "modules": append([]string(nil), env.Config.Pipeline.Audita.Modules...), - "base_url": env.Config.Pipeline.Audita.BaseURL, - "model": env.Config.Pipeline.Audita.Model, + "modules": append(env.Config.Pipeline.Audita.Modules[:0:0], env.Config.Pipeline.Audita.Modules...), + "overlay": env.Config.Pipeline.Audita.Overlay, + "proposal_profile": env.Config.Pipeline.Audita.ProposalProfile, + "validation_profile": env.Config.Pipeline.Audita.ValidationProfile, "transcript_description": env.Config.Pipeline.Audita.TranscriptDescription, "config_path": env.Config.Pipeline.Audita.ConfigPath, "output_schema": env.Config.Pipeline.Audita.OutputSchema, - "work_dir_retention": env.Config.Pipeline.Audita.WorkDirRetention, - "validation_model": env.Config.Pipeline.Audita.ValidationModel, - "total_llm_concurrency": totalLLMConcurrency, - "proposal_llm_concurrency": proposalLLMConcurrency, - "validation_llm_concurrency": validationConcurrency, - "llm_api_key": credentialReference, "timeout": env.Config.Pipeline.Audita.Timeout, "binary": env.Config.Pipeline.Audita.Binary, "generated_config_path": generatedConfigPath, @@ -198,22 +130,12 @@ func (polishStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*St "adapter_exit_code": res.ExitCode, "adapter_invoked_binary": res.InvokedBinary, "adapter_processed_output_path": res.ProcessedTranscriptPath, - "adapter_report_path": res.ReportPath, "adapter_generated_config_path": res.GeneratedConfigPath, - "adapter_work_dir": res.WorkDir, "adapter_stdout_log_path": res.StdoutLogPath, "adapter_stderr_log_path": res.StderrLogPath, - "credential_env_var": credentialReference, - "credential_present": false, } if res.Metadata != nil { meta["adapter_metadata"] = res.Metadata - if value, ok := res.Metadata["credential_present"]; ok { - meta["credential_present"] = value - } - if value, ok := res.Metadata["credential_env_var"]; ok { - meta["credential_env_var"] = value - } } return &StageResult{ diff --git a/internal/stage/polish_test.go b/internal/stage/polish_test.go index 32768f8..b97c62a 100644 --- a/internal/stage/polish_test.go +++ b/internal/stage/polish_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + configkit "gitea.maximumdirect.net/eric/ConfigKit" "gitea.maximumdirect.net/eric/narratio/internal/adapters/audita" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" @@ -43,18 +44,12 @@ func TestPolishStagePolishesMergedTranscriptAndRecordsMetadata(t *testing.T) { if req.GlossaryPath != filepath.Join(paths.InputsDir, "glossary.yml") { t.Fatalf("glossary path = %q", req.GlossaryPath) } - if req.WorkDir != filepath.Join(paths.ArtifactsDir, "audita-work") { - t.Fatalf("work dir = %q", req.WorkDir) - } - if len(result.Outputs) != 2 { - t.Fatalf("outputs len = %d, want 2", len(result.Outputs)) + if len(result.Outputs) != 1 { + t.Fatalf("outputs len = %d, want 1", len(result.Outputs)) } if result.Outputs[0].Kind != "transcript_polished" { t.Fatalf("output[0] kind = %q, want transcript_polished", result.Outputs[0].Kind) } - if result.Outputs[1].Kind != "audita_report" { - t.Fatalf("output[1] kind = %q, want audita_report", result.Outputs[1].Kind) - } if len(result.Logs) != 2 { t.Fatalf("logs = %#v, want 2", result.Logs) } @@ -65,18 +60,6 @@ func TestPolishStagePolishesMergedTranscriptAndRecordsMetadata(t *testing.T) { if result.Metadata["stage"] != "polish" { t.Fatalf("metadata stage = %#v, want polish", result.Metadata["stage"]) } - if result.Metadata["report_enabled"] != true { - t.Fatalf("metadata report_enabled = %#v, want true", result.Metadata["report_enabled"]) - } - if result.Metadata["audita_work_dir"] != filepath.Join(paths.ArtifactsDir, "audita-work") { - t.Fatalf("metadata audita_work_dir = %#v", result.Metadata["audita_work_dir"]) - } - if result.Metadata["total_llm_concurrency"] != 3 { - t.Fatalf("metadata total_llm_concurrency = %#v, want 3", result.Metadata["total_llm_concurrency"]) - } - if result.Metadata["proposal_llm_concurrency"] != 2 { - t.Fatalf("metadata proposal_llm_concurrency = %#v, want 2", result.Metadata["proposal_llm_concurrency"]) - } if result.Metadata["output_schema"] != "audita-v1" { t.Fatalf("metadata output_schema = %#v, want audita-v1", result.Metadata["output_schema"]) } @@ -185,25 +168,6 @@ func TestPolishStageFailsWhenProcessedOutputInvalid(t *testing.T) { } } -func TestPolishStageFailsWhenReportInvalid(t *testing.T) { - env, m := setupPolishEnv(t) - paths := sessionPathsForEnv(env, m.SessionID) - writeFile(t, filepath.Join(paths.TranscriptsDir, "base.json"), `{"segments":[]}`) - setCurrentBuiltInEvidence(t, m, "merge", artifacts.TranscriptOutputKindBase, artifacts.ArtifactTranscriptBase, filepath.Join(paths.TranscriptsDir, "base.json")) - writeFile(t, filepath.Join(paths.InputsDir, "glossary.yml"), "terms: []\n") - badReport := filepath.Join(paths.ArtifactsDir, "bad.report.json") - writeFile(t, badReport, "not-json") - env.Audita = &audita.FakeRunner{Result: audita.PolishResult{ReportPath: badReport}} - - _, err := (polishStage{}).Run(context.Background(), env, m) - if err == nil { - t.Fatal("expected error, got nil") - } - if !strings.Contains(err.Error(), "report") { - t.Fatalf("error = %q", err.Error()) - } -} - func TestPolishStageUsesRunLocalPathsAndMaterializesCanonical(t *testing.T) { env, m := setupPolishEnv(t) env.Config.Session.Campaign = "sample-campaign" @@ -244,32 +208,19 @@ func setupPolishEnv(t *testing.T) (*Env, *manifest.Manifest) { writeFile(t, sessionPath, "session_id: 2026-05-03\n") writeFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n") - report := true - totalLLMConcurrency := 3 - proposalLLMConcurrency := 2 - validationLLMConcurrency := 2 - cfg := &config.Config{ PipelinePath: pipelinePath, SessionPath: sessionPath, Pipeline: &config.PipelineConfig{ Workspace: config.WorkspaceConfig{Root: workspace}, Audita: config.AuditaConfig{ - Binary: "audita", - Timeout: "3h", - LLMAPIKey: testSecretReference("AUDITA_LLM_API_KEY"), - Modules: []string{"glossary", "homophones", "grammar"}, - BaseURL: "https://openrouter.ai/api/v1", - Model: "openrouter/google/gemma-4-31b-it", - TranscriptDescription: "Campaign Session 42", - ConfigPath: "/etc/audita/config.yml", - OutputSchema: "audita-v1", - WorkDirRetention: "auto", - ValidationModel: "openrouter/google/gemma-4-31b-it", - TotalLLMConcurrency: &totalLLMConcurrency, - ProposalLLMConcurrency: &proposalLLMConcurrency, - ValidationLLMConcurrency: &validationLLMConcurrency, - Report: &report, + Binary: "audita", + Timeout: "3h", + Credentials: []configkit.Secret{*testSecretReference("AUDITA_LLM_API_KEY")}, + Modules: []string{"glossary", "homophones", "grammar"}, + TranscriptDescription: "Campaign Session 42", + ConfigPath: "/etc/audita/config.yml", + OutputSchema: "audita-v1", }, }, Session: &config.SessionConfig{ diff --git a/internal/stage/semantic_contracts_refinement.go b/internal/stage/semantic_contracts_refinement.go index 9ca6977..1e9c744 100644 --- a/internal/stage/semantic_contracts_refinement.go +++ b/internal/stage/semantic_contracts_refinement.go @@ -18,9 +18,6 @@ const ( ) type polishSemanticConfig struct { - ServiceURL string `json:"service_url"` - Model string `json:"model"` - ValidationModel string `json:"validation_model"` Modules []string `json:"modules"` TranscriptDescription string `json:"transcript_description"` OutputSchema string `json:"output_schema"` @@ -74,9 +71,6 @@ func (polishStage) SemanticConfigFingerprint(env *Env) (manifest.SemanticConfigF } sort.Strings(modules) return FingerprintSemanticConfig(polishSemanticConfigVersion, polishSemanticConfig{ - ServiceURL: strings.TrimSpace(audita.BaseURL), - Model: strings.TrimSpace(audita.Model), - ValidationModel: strings.TrimSpace(audita.ValidationModel), Modules: modules, TranscriptDescription: strings.TrimSpace(audita.TranscriptDescription), OutputSchema: strings.TrimSpace(audita.OutputSchema), diff --git a/internal/stage/semantic_contracts_refinement_test.go b/internal/stage/semantic_contracts_refinement_test.go index ddeecc0..b6a7eeb 100644 --- a/internal/stage/semantic_contracts_refinement_test.go +++ b/internal/stage/semantic_contracts_refinement_test.go @@ -3,6 +3,7 @@ package stage import ( "testing" + configkit "gitea.maximumdirect.net/eric/ConfigKit" "gitea.maximumdirect.net/eric/narratio/internal/config" ) @@ -11,13 +12,10 @@ func TestPolishSemanticConfigSensitivity(t *testing.T) { name string mutate func(*Env) }{ - {name: "model", mutate: func(env *Env) { env.Config.Pipeline.Audita.Model = "production" }}, - {name: "validation model", mutate: func(env *Env) { env.Config.Pipeline.Audita.ValidationModel = "validator-v2" }}, {name: "modules", mutate: func(env *Env) { env.Config.Pipeline.Audita.Modules = []string{"terms", "speakers", "style"} }}, {name: "description", mutate: func(env *Env) { env.Config.Pipeline.Audita.TranscriptDescription = "live campaign" }}, {name: "schema", mutate: func(env *Env) { env.Config.Pipeline.Audita.OutputSchema = "audita.transcript.v2" }}, {name: "external config", mutate: func(env *Env) { env.Config.Pipeline.Audita.ConfigPath = "configs/other.yml" }}, - {name: "service", mutate: func(env *Env) { env.Config.Pipeline.Audita.BaseURL = "https://other.example" }}, } assertRefinementChanges(t, polishStage{}, semantic) assertRefinementUnchanged(t, polishStage{}, []struct { @@ -28,12 +26,7 @@ func TestPolishSemanticConfigSensitivity(t *testing.T) { {name: "runner settings", mutate: func(env *Env) { env.Config.Pipeline.Audita.Binary = "/opt/audita" env.Config.Pipeline.Audita.Timeout = "1h" - env.Config.Pipeline.Audita.TotalLLMConcurrency = intPointer(20) - env.Config.Pipeline.Audita.ProposalLLMConcurrency = intPointer(10) - env.Config.Pipeline.Audita.ValidationLLMConcurrency = intPointer(10) - env.Config.Pipeline.Audita.LLMAPIKey = testSecretReference("OTHER_KEY") - env.Config.Pipeline.Audita.WorkDirRetention = "always" - env.Config.Pipeline.Audita.Report = boolPointer(false) + env.Config.Pipeline.Audita.Credentials = []configkit.Secret{*testSecretReference("OTHER_KEY")} }}, }) } @@ -119,12 +112,9 @@ func refinementSemanticEnv() *Env { env := initialSemanticEnv() env.Config.Session.Title = "Session title" env.Config.Pipeline.Audita = config.AuditaConfig{ - Binary: "audita", Timeout: "10m", LLMAPIKey: testSecretReference("AUDITA_KEY"), - Modules: []string{"speakers", "terms"}, BaseURL: "https://audita.example", - Model: "testing", ValidationModel: "validator", TranscriptDescription: "tabletop session", + Binary: "audita", Timeout: "10m", Credentials: []configkit.Secret{*testSecretReference("AUDITA_KEY")}, + Modules: []string{"speakers", "terms"}, TranscriptDescription: "tabletop session", ConfigPath: "configs/audita.yml", OutputSchema: "audita.transcript.v1", - TotalLLMConcurrency: intPointer(4), ProposalLLMConcurrency: intPointer(2), - ValidationLLMConcurrency: intPointer(2), WorkDirRetention: "failure", Report: boolPointer(true), } env.Config.Pipeline.Normalize = &config.NormalizeConfig{ OutputPath: "transcripts/final.json", OutputSchema: "seriatim.transcript.v1", Report: boolPointer(true),