diff --git a/README.md b/README.md index 1224e71..90757ad 100644 --- a/README.md +++ b/README.md @@ -162,21 +162,40 @@ Archive run-upload details and boundaries are documented in [docs/archive-storag - `transcripts/normalized.json`: Seriatim-normalized transcript from the normalize stage - `transcripts/trimmed.json`: gameplay-only normalized polished transcript from trim stage +## Seriatim Configuration + +`pipeline.seriatim` configures the Seriatim subprocess adapter used by `merge`, `normalize`, and `trim`. + +Minimal behavior: + +- `pipeline.seriatim` may be omitted entirely. +- when omitted, Narratio defaults to: + - `binary: seriatim` + - `timeout: 10m` + - `output_schema: seriatim-intermediate` + - `coalesce_gap: 3.0` + - `report: true` + +Optional overrides in `pipeline.seriatim` continue to work, including explicit binary paths and advanced `env` tuning values. + ## Audita Configuration `pipeline.audita` configures the real Audita subprocess adapter used by `polish`. -Required: +Minimal behavior: -- `binary` -- `timeout` -- `base_url` -- `model` +- `pipeline.audita` may be omitted entirely. +- when omitted, Narratio defaults to: + - `binary: audita` + - `timeout: 3h` + - `report: true` Optional: - `llm_api_key_env` (when set, Narratio requires that env var and passes it to Audita as `AUDITA_LLM_API_KEY`) - `modules` override list (when empty/omitted, Narratio does not pass `--modules`) +- `base_url` (when omitted, Narratio does not pass `--base-url`; Audita runtime defaults/config may apply) +- `model` (when omitted, Narratio does not pass `--model`; Audita runtime defaults/config may apply) - `transcript_description` - `config_path` - `output_schema` (`bare-segments` or `audita-v1`) @@ -187,7 +206,7 @@ Optional: - `validation_llm_concurrency` (> 0 when provided) - `report` (defaults to `true`) -Narratio passes only configured optional Audita flags. Omitted optional values are left to Audita runtime defaults. +Narratio passes only configured optional Audita flags. Omitted optional values are left to Audita runtime defaults/config. ## Normalize Configuration @@ -282,7 +301,7 @@ Render-debug files are diagnostics and are not treated as canonical stage output Key points: -- `scriptorium.binary` is required when section is present +- `scriptorium.binary` defaults to `scriptorium` when section is present - `scriptorium.config_path` is optional - `scriptorium.timeout` defaults to `10m` when omitted - `scriptorium.render_debug` enables render diagnostics globally @@ -370,6 +389,7 @@ Expected session output paths: Starter files: - `examples/pipeline.minimal.yml` +- `examples/pipeline.audita-overrides.yml` - `examples/session.minimal.yml` - `examples/session.template.yml` - `examples/speakers.yml` diff --git a/architecture.md b/architecture.md index b091d1e..5907dc9 100644 --- a/architecture.md +++ b/architecture.md @@ -247,17 +247,18 @@ Archive publishing behavior (implemented): `pipeline.audita` drives the real Audita subprocess adapter for the `polish` stage. -Audita required fields: +Audita defaulted fields: -- `binary` -- `timeout` -- `base_url` -- `model` +- `binary` defaults to `audita` +- `timeout` defaults to `3h` +- `report` defaults to `true` Audita optional fields: - `llm_api_key_env` (enforced only when configured) - `modules` override list (when omitted/empty, Narratio does not pass `--modules`) +- `base_url` (when omitted, Narratio does not pass `--base-url`) +- `model` (when omitted, Narratio does not pass `--model`) - `transcript_description` - `config_path` - `output_schema` (`bare-segments` or `audita-v1`) @@ -266,9 +267,18 @@ Audita optional fields: - `proposal_llm_concurrency` (> 0 when provided) - `validation_model` - `validation_llm_concurrency` (> 0 when provided) -- `report` (default `true`) +- `report` override -Narratio passes only configured optional Audita flags; omitted optional values defer to Audita defaults. +Narratio passes only configured optional Audita flags; omitted optional values defer to Audita runtime defaults/config. + +Seriatim defaults: + +- `pipeline.seriatim` may be omitted +- `binary` defaults to `seriatim` +- `timeout` defaults to `10m` +- `output_schema` defaults to `seriatim-intermediate` +- `coalesce_gap` defaults to `3.0` +- `report` defaults to `true` When `pipeline.normalize` is omitted, defaults are applied: @@ -302,7 +312,7 @@ When `pipeline.trim.enabled: true`: When `pipeline.scriptorium` is present: -- `binary` is required and non-empty +- `binary` defaults to `scriptorium` when omitted - `config_path` is optional; when provided it must be non-empty - `timeout` is optional; when provided it must parse as a Go duration - default `timeout` is `10m` diff --git a/examples/pipeline.audita-overrides.yml b/examples/pipeline.audita-overrides.yml new file mode 100644 index 0000000..635ec48 --- /dev/null +++ b/examples/pipeline.audita-overrides.yml @@ -0,0 +1,26 @@ +workspace: + root: ./tmp/narratio-workspace + +whisperx: + transcribe_url: "https://transcription.example.com/transcribe" + +seriatim: + binary: "seriatim" + timeout: "10m" + output_schema: "seriatim-intermediate" + coalesce_gap: 3.0 + +audita: + binary: "audita" + timeout: "3h" + base_url: "https://openrouter.ai/api/v1" + model: "openrouter/google/gemma-4-31b-it" + llm_api_key_env: "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_model: "openrouter/google/gemma-4-31b-it" + validation_llm_concurrency: 1 + report: true diff --git a/examples/pipeline.minimal.yml b/examples/pipeline.minimal.yml index 0de6b36..6e92f80 100644 --- a/examples/pipeline.minimal.yml +++ b/examples/pipeline.minimal.yml @@ -3,13 +3,11 @@ workspace: cleanup_after_archive: false storage: - backend: local + backend: s3 s3: bucket: "my-dnd-archive" root_prefix: "dnd" region: "us-east-1" - endpoint: "" - force_path_style: false spool: root: "/var/spool/narratio" @@ -18,106 +16,33 @@ spool: archive: enabled: true upload_run: true - promote_artifacts: - - from: "transcripts/trimmed.json" - to: "transcripts/trimmed.json" - required: true - - from: "artifacts/session_recap.md" - to: "artifacts/session_recap.md" - required: true - -secrets: - # Optional: load environment variables from files in this directory. - # File name = env var name; file contents = env var value. - env_dir: /var/local/narratio/secrets whisperx: transcribe_url: "https://transcription.example.com/transcribe" - language: "en" - timeout: "30m" - retries: 3 - retry_delay: "2s" - concurrency: 2 -seriatim: - binary: "seriatim" - timeout: "10m" - output_schema: "seriatim-intermediate" - coalesce_gap: 3.0 - report: true - env: - overlap_word_run_gap: 1.0 - overlap_word_run_reorder_window: 1.0 - backchannel_max_duration: 2.0 - filler_max_duration: 1.25 +# Optional. When omitted entirely, Narratio defaults to seriatim binary + runtime defaults. +seriatim: {} +# Optional runtime overrides. Model/provider can be owned by Audita runtime config. audita: - binary: "audita" - timeout: "3h" + config_path: "/usr/local/etc/audita/config.yml" llm_api_key_env: "AUDITA_LLM_API_KEY" - # Optional: pass only when overriding Audita's default module sequence. - modules: [] - base_url: "https://openrouter.ai/api/v1" - model: "openrouter/google/gemma-4-31b-it" - transcript_description: "" - config_path: "" - output_schema: "audita-v1" - work_dir_retention: "auto" - total_llm_concurrency: 1 - proposal_llm_concurrency: 1 - validation_model: "" - validation_llm_concurrency: 1 - report: true - -normalize: - # Session-workdir-relative when not absolute. - output_path: "transcripts/normalized.json" - output_schema: "seriatim-intermediate" - report: true - -trim: - enabled: true - # Session-workdir-relative when not absolute. - output_path: "transcripts/trimmed.json" - bounds: - prompt_id: "dnd_session.bounds" - # Empty means use prompt default profile. - profile_id: "" - transcript_input_name: "transcript" - output_path: "artifacts/session_bounds.json" - timeout: "10m" - render_debug: false - render_output_path: "artifacts/session_bounds.render.json" - seriatim: - report: false +# Optional Scriptorium integration for analyze artifacts. scriptorium: - binary: "scriptorium" - config_path: "/etc/scriptorium/config.yml" - timeout: "10m" - render_debug: false + config_path: "/usr/local/etc/scriptorium/config.yml" artifacts: session_recap: enabled: true prompt_id: "dnd.session_recap" - profile_id: "local-quality" output_path: "artifacts/session_recap.md" - timeout: "10m" - # Optional per-artifact override of global scriptorium.render_debug. - # render_debug: true inputs: transcript: - # Available transcript sources: - # - trimmed_transcript (recommended for session_recap) - # - normalized_transcript (recommended for future full-session analysis) - # - processed_transcript (raw Audita-polished output) source: "trimmed_transcript" required: true previous_recap: source: "previous_session_artifact" artifact: "session_recap" - # Optional: set when previous recap is available. - path: "" required: false vars: session_id: true @@ -125,11 +50,3 @@ scriptorium: campaign_name: true previous_session_id: true output_kind: "session_recap" - -analyzer: - timeout: 20m - artifacts: - output_dir: artifacts - -notification: - timeout: 10s diff --git a/internal/adapters/audita/subprocess.go b/internal/adapters/audita/subprocess.go index 0cca016..9ae735f 100644 --- a/internal/adapters/audita/subprocess.go +++ b/internal/adapters/audita/subprocess.go @@ -108,18 +108,14 @@ func NewSubprocessRunner(cfg SubprocessRunnerConfig) (*SubprocessRunner, error) 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) + 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) } - 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.TotalLLMConcurrency != nil && *cfg.TotalLLMConcurrency <= 0 { return nil, fmt.Errorf("audita total llm concurrency must be > 0 when provided") @@ -307,10 +303,14 @@ func (r *SubprocessRunner) buildArgs(req PolishRequest, modules []string) []stri req.MergedTranscriptPath, "--glossary", req.GlossaryPath, "--output", req.OutputProcessedPath, - "--base-url", r.baseURL, - "--model", r.model, "--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 = append(args, "--modules", strings.Join(modules, ",")) } diff --git a/internal/adapters/audita/subprocess_test.go b/internal/adapters/audita/subprocess_test.go index b648fe0..2b12ea0 100644 --- a/internal/adapters/audita/subprocess_test.go +++ b/internal/adapters/audita/subprocess_test.go @@ -99,9 +99,9 @@ func TestSubprocessRunnerSuccessArgsEnvAndValidation(t *testing.T) { "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", - "--work-dir", req.WorkDir, "--modules", "glossary,homophones,glossary", "--report-json", req.ReportPath, "--transcript-description", "Campaign Session 42", @@ -248,6 +248,36 @@ func TestSubprocessRunnerOmitsModulesFlagWhenNotConfigured(t *testing.T) { } } +func TestSubprocessRunnerOmitsBaseURLAndModelFlagsWhenNotConfigured(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("helper wrapper script uses /bin/sh") + } + 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"), + LLMAPIKeyEnv: "", + 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) { if runtime.GOOS == "windows" { t.Skip("helper wrapper script uses /bin/sh") diff --git a/internal/app/runner.go b/internal/app/runner.go index 47cd59f..6efdf0d 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -250,7 +250,7 @@ func buildDefaultAuditaRunner(cfg *config.Config) (audita.Runner, error) { } a := cfg.Pipeline.Audita - if strings.TrimSpace(a.Binary) == "" || strings.TrimSpace(a.Timeout) == "" || strings.TrimSpace(a.BaseURL) == "" || strings.TrimSpace(a.Model) == "" { + if strings.TrimSpace(a.Binary) == "" || strings.TrimSpace(a.Timeout) == "" { // Compatibility fallback for tests or internal call paths that bypass config validation/defaults. return &audita.NoopRunner{}, nil } diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go index 1b615d7..d729252 100644 --- a/internal/app/runner_test.go +++ b/internal/app/runner_test.go @@ -497,6 +497,55 @@ func testConfig(t *testing.T) *config.Config { } } +func TestBuildDefaultRunnersWithOmittedToolSections(t *testing.T) { + dir := t.TempDir() + pipelinePath := filepath.Join(dir, "pipeline.yml") + sessionPath := filepath.Join(dir, "session.yml") + pipelineYAML := `workspace: + root: ` + t.TempDir() + ` +whisperx: + transcribe_url: https://example.com/transcribe +analyzer: + timeout: 20m +notification: + timeout: 10s +` + sessionYAML := `session_id: 2026-05-03 +campaign: sample-campaign +inputs: + audio_dir: ./audio + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml +` + mustWriteFile(t, pipelinePath, pipelineYAML) + mustWriteFile(t, sessionPath, sessionYAML) + + cfg, err := config.Load(pipelinePath, sessionPath) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if err := config.Validate(cfg); err != nil { + t.Fatalf("Validate() error = %v", err) + } + + serRunner, err := buildDefaultSeriatimRunner(cfg) + if err != nil { + t.Fatalf("buildDefaultSeriatimRunner() error = %v", err) + } + if _, ok := serRunner.(*seriatim.SubprocessRunner); !ok { + t.Fatalf("seriatim runner type = %T, want *seriatim.SubprocessRunner", serRunner) + } + + audRunner, err := buildDefaultAuditaRunner(cfg) + if err != nil { + t.Fatalf("buildDefaultAuditaRunner() error = %v", err) + } + if _, ok := audRunner.(*audita.SubprocessRunner); !ok { + t.Fatalf("audita runner type = %T, want *audita.SubprocessRunner", audRunner) + } +} + func mustWriteFile(t *testing.T, path, contents string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { diff --git a/internal/config/load.go b/internal/config/load.go index 0c36e7d..9d3351d 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -244,6 +244,9 @@ func applySeriatimDefaults(cfg *SeriatimConfig) { if cfg == nil { return } + if cfg.Binary == "" { + cfg.Binary = "seriatim" + } if cfg.Timeout == "" { cfg.Timeout = "10m" } @@ -262,15 +265,12 @@ func applyAuditaDefaults(cfg *AuditaConfig) { if cfg == nil { return } + if cfg.Binary == "" { + cfg.Binary = "audita" + } if cfg.Timeout == "" { cfg.Timeout = "3h" } - if cfg.BaseURL == "" { - cfg.BaseURL = "https://openrouter.ai/api/v1" - } - if cfg.Model == "" { - cfg.Model = "openrouter/google/gemma-4-31b-it" - } if cfg.Report == nil { cfg.Report = boolPtr(true) } @@ -280,6 +280,9 @@ func applyScriptoriumDefaults(cfg *ScriptoriumConfig) { if cfg == nil { return } + if cfg.Binary == "" { + cfg.Binary = "scriptorium" + } if cfg.Timeout == "" { cfg.Timeout = "10m" } diff --git a/internal/config/load_validate_test.go b/internal/config/load_validate_test.go index a1230b9..deb3e41 100644 --- a/internal/config/load_validate_test.go +++ b/internal/config/load_validate_test.go @@ -37,6 +37,26 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml +`, + checkDefault: true, + }, + { + name: "seriatim and audita sections can be omitted", + pipelineYAML: `workspace: + root: /tmp/narratio +whisperx: + transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe +analyzer: + timeout: 20m +notification: + timeout: 15s +`, + sessionYAML: `session_id: 2026-05-03 +inputs: + audio_dir: ./audio + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml `, checkDefault: true, }, @@ -296,7 +316,7 @@ inputs: wantLoadErr: "strict decode failed", }, { - name: "missing seriatim binary fails", + name: "missing seriatim binary uses default", pipelineYAML: `workspace: root: /tmp/narratio whisperx: @@ -311,7 +331,6 @@ inputs: autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, - wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.seriatim.binary is required", }, { name: "invalid seriatim timeout fails", @@ -412,7 +431,7 @@ inputs: wantLoadErr: "strict decode failed", }, { - name: "missing audita binary fails", + name: "missing audita binary uses default", pipelineYAML: `workspace: root: /tmp/narratio whisperx: @@ -429,7 +448,6 @@ inputs: autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, - wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.binary is required", }, { name: "invalid audita timeout fails", @@ -713,6 +731,9 @@ inputs: if cfg.Pipeline.Seriatim.Timeout != "10m" { t.Fatalf("seriatim.timeout = %q, want %q", cfg.Pipeline.Seriatim.Timeout, "10m") } + if cfg.Pipeline.Seriatim.Binary != "seriatim" { + t.Fatalf("seriatim.binary = %q, want %q", cfg.Pipeline.Seriatim.Binary, "seriatim") + } if cfg.Pipeline.Seriatim.OutputSchema != "seriatim-intermediate" { t.Fatalf("seriatim.output_schema = %q, want %q", cfg.Pipeline.Seriatim.OutputSchema, "seriatim-intermediate") } @@ -725,17 +746,20 @@ inputs: if cfg.Pipeline.Audita.Timeout != "3h" { t.Fatalf("audita.timeout = %q, want %q", cfg.Pipeline.Audita.Timeout, "3h") } + if cfg.Pipeline.Audita.Binary != "audita" { + t.Fatalf("audita.binary = %q, want %q", cfg.Pipeline.Audita.Binary, "audita") + } if cfg.Pipeline.Audita.LLMAPIKeyEnv != "" { t.Fatalf("audita.llm_api_key_env = %q, want empty by default", cfg.Pipeline.Audita.LLMAPIKeyEnv) } if cfg.Pipeline.Audita.Modules != nil { t.Fatalf("audita.modules = %#v, want nil default (optional override)", cfg.Pipeline.Audita.Modules) } - if cfg.Pipeline.Audita.BaseURL != "https://openrouter.ai/api/v1" { - t.Fatalf("audita.base_url = %q, want %q", cfg.Pipeline.Audita.BaseURL, "https://openrouter.ai/api/v1") + if cfg.Pipeline.Audita.BaseURL != "" { + t.Fatalf("audita.base_url = %q, want empty default", cfg.Pipeline.Audita.BaseURL) } - if cfg.Pipeline.Audita.Model != "openrouter/google/gemma-4-31b-it" { - t.Fatalf("audita.model = %q, want %q", cfg.Pipeline.Audita.Model, "openrouter/google/gemma-4-31b-it") + if cfg.Pipeline.Audita.Model != "" { + t.Fatalf("audita.model = %q, want empty default", cfg.Pipeline.Audita.Model) } if cfg.Pipeline.Audita.ValidationModel != "" { t.Fatalf("audita.validation_model = %q, want empty default", cfg.Pipeline.Audita.ValidationModel) diff --git a/internal/config/scriptorium_test.go b/internal/config/scriptorium_test.go index 5876955..236fbb2 100644 --- a/internal/config/scriptorium_test.go +++ b/internal/config/scriptorium_test.go @@ -49,11 +49,19 @@ func TestScriptoriumLoadAndValidate(t *testing.T) { wantLoadErr: "strict decode failed", }, { - name: "missing binary fails when section present", + name: "missing binary defaults when section present", scriptoriumYAML: `scriptorium: timeout: 10m `, - wantValidateErr: "pipeline.scriptorium.binary is required", + assert: func(t *testing.T, cfg *Config) { + t.Helper() + if cfg.Pipeline.Scriptorium == nil { + t.Fatal("scriptorium config should be present") + } + if cfg.Pipeline.Scriptorium.Binary != "scriptorium" { + t.Fatalf("scriptorium.binary = %q, want scriptorium", cfg.Pipeline.Scriptorium.Binary) + } + }, }, { name: "enabled artifact missing prompt id fails", diff --git a/internal/config/validate.go b/internal/config/validate.go index dda8c1d..99faee7 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -276,9 +276,6 @@ func validateAudita(cfg AuditaConfig) error { return fmt.Errorf("pipeline.audita.base_url must be a valid URL") } } - if strings.TrimSpace(cfg.Model) == "" { - return fmt.Errorf("pipeline.audita.model is required") - } if cfg.TotalLLMConcurrency != nil && *cfg.TotalLLMConcurrency <= 0 { return fmt.Errorf("pipeline.audita.total_llm_concurrency must be > 0") }