package audita import ( "context" "encoding/json" "fmt" "net/url" "os" "strconv" "strings" "time" "gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess" ) // SubprocessRunnerConfig defines deterministic settings for Audita CLI execution. type SubprocessRunnerConfig struct { Binary string Timeout time.Duration LLMAPIKeyEnv string Modules []string BaseURL string Model string LLMConcurrency *int ValidationModel string ValidationLLMConcurrency *int Report bool } // SubprocessRunner invokes Audita via subprocess. type SubprocessRunner struct { binary string timeout time.Duration llmAPIKeyEnv string modules []string baseURL string model string llmConcurrency *int validationModel string validationLLMConcurrency *int report bool } // NewSubprocessRunnerFromConfigValues parses config-derived values once. func NewSubprocessRunnerFromConfigValues( binary string, timeout string, llmAPIKeyEnv string, modules []string, baseURL string, model string, llmConcurrency *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, LLMAPIKeyEnv: llmAPIKeyEnv, Modules: modules, BaseURL: baseURL, Model: model, LLMConcurrency: llmConcurrency, ValidationModel: validationModel, ValidationLLMConcurrency: validationLLMConcurrency, Report: report, }) } // NewSubprocessRunner constructs a validated Audita subprocess runner. func NewSubprocessRunner(cfg SubprocessRunnerConfig) (*SubprocessRunner, error) { if strings.TrimSpace(cfg.Binary) == "" { return nil, fmt.Errorf("audita binary is required") } if cfg.Timeout <= 0 { return nil, fmt.Errorf("audita timeout must be > 0") } if len(cfg.Modules) == 0 { return nil, fmt.Errorf("audita modules must include at least one module") } 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) == "" { return nil, fmt.Errorf("audita base url is required") } 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 strings.TrimSpace(cfg.Model) == "" { return nil, fmt.Errorf("audita model is required") } if cfg.LLMConcurrency != nil && *cfg.LLMConcurrency <= 0 { return nil, fmt.Errorf("audita 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") } 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, llmAPIKeyEnv: strings.TrimSpace(cfg.LLMAPIKeyEnv), modules: modules, baseURL: strings.TrimSpace(cfg.BaseURL), model: strings.TrimSpace(cfg.Model), llmConcurrency: cfg.LLMConcurrency, validationModel: strings.TrimSpace(cfg.ValidationModel), validationLLMConcurrency: cfg.ValidationLLMConcurrency, report: cfg.Report, }, nil } // Run executes Audita process with deterministic flags and validates output artifacts. 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") } if strings.TrimSpace(req.GlossaryPath) == "" { return PolishResult{}, fmt.Errorf("audita glossary path is required") } if strings.TrimSpace(req.OutputProcessedPath) == "" { return PolishResult{}, fmt.Errorf("audita output processed path is required") } if strings.TrimSpace(req.WorkDir) == "" { return PolishResult{}, fmt.Errorf("audita work dir is required") } if r.report && strings.TrimSpace(req.ReportPath) == "" { return PolishResult{}, fmt.Errorf("audita report is enabled but report path is missing") } reqModules := req.Modules if len(reqModules) == 0 { reqModules = append([]string(nil), r.modules...) } args := r.buildArgs(req, reqModules) credentialPresent := false env := map[string]string{} if strings.TrimSpace(r.llmAPIKeyEnv) != "" { credential, ok := os.LookupEnv(r.llmAPIKeyEnv) if ok && strings.TrimSpace(credential) != "" { env["AUDITA_LLM_API_KEY"] = credential credentialPresent = true } } primaryConcurrencyViaEnv := false if r.llmConcurrency != nil { env["AUDITA_LLM_CONCURRENCY"] = strconv.Itoa(*r.llmConcurrency) primaryConcurrencyViaEnv = true } if req.GeneratedConfigPath != "" { if err := r.writeInvocationConfig(req, args, reqModules, credentialPresent, primaryConcurrencyViaEnv); 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, StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath, }) if err != nil { return r.failureResult(req, reqModules, runRes, credentialPresent, primaryConcurrencyViaEnv), fmt.Errorf("run audita process (binary=%q): %w", r.binary, err) } if err := validateProcessedOutput(req.OutputProcessedPath); err != nil { return r.failureResult(req, reqModules, runRes, credentialPresent, primaryConcurrencyViaEnv), 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, primaryConcurrencyViaEnv), fmt.Errorf("validate audita report output %q: %w", req.ReportPath, 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, "validation_model": r.validationModel, "validation_llm_concurrency": r.validationLLMConcurrency, "credential_env_var": r.llmAPIKeyEnv, "credential_present": credentialPresent, "primary_llm_concurrency_via_env": primaryConcurrencyViaEnv, "primary_llm_concurrency_env_name": "AUDITA_LLM_CONCURRENCY", }, }, nil } func (r *SubprocessRunner) failureResult(req PolishRequest, modules []string, runRes subprocess.RunResult, credentialPresent bool, primaryConcurrencyViaEnv bool) 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, Metadata: map[string]any{ "adapter": "audita_subprocess", "modules": modules, "base_url": r.baseURL, "model": r.model, "validation_model": r.validationModel, "validation_llm_concurrency": r.validationLLMConcurrency, "credential_env_var": r.llmAPIKeyEnv, "credential_present": credentialPresent, "primary_llm_concurrency_via_env": primaryConcurrencyViaEnv, "primary_llm_concurrency_env_name": "AUDITA_LLM_CONCURRENCY", }, } } func (r *SubprocessRunner) buildArgs(req PolishRequest, modules []string) []string { args := []string{ "process", req.MergedTranscriptPath, "--glossary", req.GlossaryPath, "--output", req.OutputProcessedPath, "--modules", strings.Join(modules, ","), "--base-url", r.baseURL, "--model", r.model, "--work-dir", req.WorkDir, } if r.report { args = append(args, "--report-json", req.ReportPath) } if r.validationModel != "" { args = append(args, "--validation-model", r.validationModel) } if r.validationLLMConcurrency != nil { args = append(args, "--validation-llm-concurrency", strconv.Itoa(*r.validationLLMConcurrency)) } return args } func (r *SubprocessRunner) writeInvocationConfig(req PolishRequest, args []string, modules []string, credentialPresent bool, primaryConcurrencyViaEnv 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, "validation_model": r.validationModel, "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.llmAPIKeyEnv, "credential_present": credentialPresent, "primary_llm_concurrency_via_env": primaryConcurrencyViaEnv, } if r.llmConcurrency != nil { payload["llm_concurrency"] = *r.llmConcurrency } return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644) } func validateProcessedOutput(path string) error { data, err := os.ReadFile(path) if err != nil { return fmt.Errorf("read file: %w", err) } var payload map[string]any if err := json.Unmarshal(data, &payload); err != nil { return fmt.Errorf("parse json: %w", err) } segments, ok := payload["segments"] if !ok { return fmt.Errorf("top-level segments is required") } if _, ok := segments.([]any); !ok { return fmt.Errorf("top-level segments must be an array") } return nil } func validateJSONFile(path string) error { data, err := os.ReadFile(path) if err != nil { return fmt.Errorf("read file: %w", err) } var v any if err := json.Unmarshal(data, &v); err != nil { return fmt.Errorf("parse json: %w", err) } return nil }