From 84b0f6fe0e33dc6863f4663b35a2b3c310068f46 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 6 May 2026 21:12:22 +0000 Subject: [PATCH] Add Scriptorium configuration contract --- README.md | 20 ++- examples/pipeline.minimal.yml | 28 ++++ internal/config/config.go | 29 ++++ internal/config/load.go | 10 ++ internal/config/scriptorium_test.go | 203 ++++++++++++++++++++++++++++ internal/config/validate.go | 55 ++++++++ 6 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 internal/config/scriptorium_test.go diff --git a/README.md b/README.md index 3866ff1..60edc37 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ This repository currently contains a **working scaffold** with strict config loa `narratio` expects two YAML files: -- `pipeline.yml`: pipeline/workspace settings (`workspace`, `storage`, `whisperx`, `seriatim`, `audita`, `analyzer`, `notification`) +- `pipeline.yml`: pipeline/workspace settings (`workspace`, `storage`, `whisperx`, `seriatim`, `audita`, optional `scriptorium`, `analyzer`, `notification`) - `session.yml`: per-session settings (`session_id`, `inputs`, optional metadata) WhisperX config contract in `pipeline.yml`: @@ -43,6 +43,24 @@ Audita runtime note: - Narratio currently passes primary LLM concurrency via `AUDITA_LLM_CONCURRENCY` subprocess environment override, not a `--llm-concurrency` flag. +Scriptorium config contract in `pipeline.yml` (optional until `analyze` is implemented): + +- optional section: `scriptorium` (when omitted, current pipeline behavior is unchanged) +- required when `scriptorium` is present: `scriptorium.binary` (name on PATH or full path) +- optional: `scriptorium.config_path` (if set, it must be non-empty; file existence is validated at execution time, not basic config validation time) +- defaulted when omitted inside present `scriptorium` section: `scriptorium.timeout` (`10m`) +- optional: `scriptorium.render_debug` (`false` by default through YAML zero-value behavior) +- artifact definitions live under `scriptorium.artifacts.` and support multiple configured artifacts +- enabled artifacts require `prompt_id` and `output_path` +- artifact `timeout` values must be valid Go durations when provided +- input definitions live under `scriptorium.artifacts..inputs.` and require non-empty `source` +- `vars` values currently accept booleans and strings only +- Narratio does not store secrets in Scriptorium config; keep credentials in environment management, not YAML + +Scriptorium initial artifact note: + +- this pass does not auto-create `session_recap`; configure `scriptorium.artifacts.session_recap` explicitly in `pipeline.yml` + `speakers.yml` note: - use Seriatim’s documented `match:` format (not the legacy direct mapping style used by older scripts/scaffolds) diff --git a/examples/pipeline.minimal.yml b/examples/pipeline.minimal.yml index 9180751..1e34697 100644 --- a/examples/pipeline.minimal.yml +++ b/examples/pipeline.minimal.yml @@ -43,6 +43,34 @@ audita: validation_llm_concurrency: 1 report: true +scriptorium: + binary: "scriptorium" + config_path: "/etc/scriptorium/config.yml" + timeout: "10m" + render_debug: false + artifacts: + session_recap: + enabled: true + prompt_id: "dnd.session_recap" + profile_id: "local-quality" + output_path: "artifacts/session_recap.md" + timeout: "10m" + inputs: + transcript: + source: "processed_transcript" + required: true + previous_recap: + source: "previous_session_artifact" + artifact: "session_recap" + path: "" + required: false + vars: + session_id: true + session_date: true + campaign_name: true + previous_session_id: true + output_kind: "session_recap" + analyzer: timeout: 20m artifacts: diff --git a/internal/config/config.go b/internal/config/config.go index bac2821..5b696eb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,6 +15,7 @@ type PipelineConfig struct { WhisperX WhisperXConfig `yaml:"whisperx"` Seriatim SeriatimConfig `yaml:"seriatim"` Audita AuditaConfig `yaml:"audita"` + Scriptorium *ScriptoriumConfig `yaml:"scriptorium"` Analyzer AnalyzerConfig `yaml:"analyzer"` Notification NotificationConfig `yaml:"notification"` } @@ -82,6 +83,34 @@ type AuditaConfig struct { Report *bool `yaml:"report"` } +// ScriptoriumConfig configures Scriptorium-backed artifact generation. +type ScriptoriumConfig struct { + Binary string `yaml:"binary"` + ConfigPath string `yaml:"config_path"` + Timeout string `yaml:"timeout"` + RenderDebug bool `yaml:"render_debug"` + Artifacts map[string]ScriptoriumArtifactConfig `yaml:"artifacts"` +} + +// ScriptoriumArtifactConfig configures one named output artifact workflow. +type ScriptoriumArtifactConfig struct { + Enabled bool `yaml:"enabled"` + PromptID string `yaml:"prompt_id"` + ProfileID string `yaml:"profile_id"` + OutputPath string `yaml:"output_path"` + Timeout string `yaml:"timeout"` + Inputs map[string]ScriptoriumInputConfig `yaml:"inputs"` + Vars map[string]any `yaml:"vars"` +} + +// ScriptoriumInputConfig configures one named prompt input source. +type ScriptoriumInputConfig struct { + Source string `yaml:"source"` + Artifact string `yaml:"artifact"` + Path string `yaml:"path"` + Required bool `yaml:"required"` +} + // AnalyzerConfig configures analyzer adapter settings. type AnalyzerConfig struct { BinaryPath string `yaml:"binary_path"` diff --git a/internal/config/load.go b/internal/config/load.go index f707af7..53e94ed 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -84,6 +84,7 @@ func applyPipelineDefaults(cfg *PipelineConfig) { applyWhisperXDefaults(&cfg.WhisperX) applySeriatimDefaults(&cfg.Seriatim) applyAuditaDefaults(&cfg.Audita) + applyScriptoriumDefaults(cfg.Scriptorium) } func applyWhisperXDefaults(cfg *WhisperXConfig) { @@ -165,6 +166,15 @@ func applyAuditaDefaults(cfg *AuditaConfig) { } } +func applyScriptoriumDefaults(cfg *ScriptoriumConfig) { + if cfg == nil { + return + } + if cfg.Timeout == "" { + cfg.Timeout = "10m" + } +} + func float64Ptr(v float64) *float64 { p := v return &p diff --git a/internal/config/scriptorium_test.go b/internal/config/scriptorium_test.go new file mode 100644 index 0000000..7141b59 --- /dev/null +++ b/internal/config/scriptorium_test.go @@ -0,0 +1,203 @@ +package config + +import ( + "strings" + "testing" +) + +func TestScriptoriumLoadAndValidate(t *testing.T) { + tests := []struct { + name string + scriptoriumYAML string + wantLoadErr string + wantValidateErr string + assert func(t *testing.T, cfg *Config) + }{ + { + name: "valid minimal scriptorium config", + scriptoriumYAML: `scriptorium: + binary: scriptorium + artifacts: + session_recap: + enabled: true + prompt_id: dnd.session_recap + output_path: artifacts/session_recap.md + inputs: + transcript: + source: processed_transcript + required: true + vars: + session_id: true + output_kind: session_recap +`, + 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.Timeout != "10m" { + t.Fatalf("scriptorium.timeout default = %q, want 10m", cfg.Pipeline.Scriptorium.Timeout) + } + }, + }, + { + name: "unknown scriptorium field fails", + scriptoriumYAML: `scriptorium: + binary: scriptorium + bogus: true +`, + wantLoadErr: "strict decode failed", + }, + { + name: "missing binary fails when section present", + scriptoriumYAML: `scriptorium: + timeout: 10m +`, + wantValidateErr: "pipeline.scriptorium.binary is required", + }, + { + name: "enabled artifact missing prompt id fails", + scriptoriumYAML: `scriptorium: + binary: scriptorium + artifacts: + session_recap: + enabled: true + output_path: artifacts/session_recap.md +`, + wantValidateErr: "pipeline.scriptorium.artifacts.session_recap.prompt_id is required when enabled", + }, + { + name: "enabled artifact missing output path fails", + scriptoriumYAML: `scriptorium: + binary: scriptorium + artifacts: + session_recap: + enabled: true + prompt_id: dnd.session_recap +`, + wantValidateErr: "pipeline.scriptorium.artifacts.session_recap.output_path is required when enabled", + }, + { + name: "invalid timeout fails", + scriptoriumYAML: `scriptorium: + binary: scriptorium + timeout: definitely-not-a-duration +`, + wantValidateErr: "pipeline.scriptorium.timeout must be a valid duration", + }, + { + name: "optional previous recap input is accepted", + scriptoriumYAML: `scriptorium: + binary: scriptorium + artifacts: + session_recap: + enabled: true + prompt_id: dnd.session_recap + output_path: artifacts/session_recap.md + inputs: + transcript: + source: processed_transcript + required: true + previous_recap: + source: previous_session_artifact + artifact: session_recap + path: "" + required: false + vars: + session_id: true + output_kind: session_recap +`, + }, + { + name: "multiple artifact definitions can be decoded", + scriptoriumYAML: `scriptorium: + binary: scriptorium + artifacts: + session_recap: + enabled: true + prompt_id: dnd.session_recap + output_path: artifacts/session_recap.md + inputs: + transcript: + source: processed_transcript + required: true + player_summary: + enabled: true + prompt_id: dnd.player_summary + profile_id: local-fast + output_path: artifacts/player_summary.md + timeout: 3m + inputs: + transcript: + source: processed_transcript + required: true +`, + assert: func(t *testing.T, cfg *Config) { + t.Helper() + if cfg.Pipeline.Scriptorium == nil { + t.Fatal("scriptorium config should be present") + } + if len(cfg.Pipeline.Scriptorium.Artifacts) != 2 { + t.Fatalf("scriptorium.artifacts count = %d, want 2", len(cfg.Pipeline.Scriptorium.Artifacts)) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pipelineYAML := testPipelineBaseYAML + "\n" + tt.scriptoriumYAML + pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, testSessionBaseYAML) + + cfg, err := Load(pipelinePath, sessionPath) + if tt.wantLoadErr != "" { + if err == nil { + t.Fatalf("expected load error containing %q, got nil", tt.wantLoadErr) + } + if !strings.Contains(err.Error(), tt.wantLoadErr) { + t.Fatalf("load error = %q, want to contain %q", err.Error(), tt.wantLoadErr) + } + return + } + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if tt.assert != nil { + tt.assert(t, cfg) + } + + err = Validate(cfg) + if tt.wantValidateErr != "" { + if err == nil { + t.Fatalf("expected validation error containing %q, got nil", tt.wantValidateErr) + } + if !strings.Contains(err.Error(), tt.wantValidateErr) { + t.Fatalf("validation error = %q, want to contain %q", err.Error(), tt.wantValidateErr) + } + return + } + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + }) + } +} + +const testPipelineBaseYAML = `workspace: + root: /tmp/narratio +whisperx: + transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe +seriatim: + binary: seriatim +audita: + binary: audita +` + +const testSessionBaseYAML = `session_id: 2026-05-03 +inputs: + audio_dir: ./audio + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml +` diff --git a/internal/config/validate.go b/internal/config/validate.go index b767c93..ec9ae36 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -42,6 +42,9 @@ func validatePipeline(cfg *PipelineConfig) error { if err := validateAudita(cfg.Audita); err != nil { return err } + if err := validateScriptorium(cfg.Scriptorium); err != nil { + return err + } if err := validateDuration("pipeline.analyzer.timeout", cfg.Analyzer.Timeout); err != nil { return err } @@ -170,6 +173,58 @@ func validateAudita(cfg AuditaConfig) error { return nil } +func validateScriptorium(cfg *ScriptoriumConfig) error { + if cfg == nil { + return nil + } + if strings.TrimSpace(cfg.Binary) == "" { + return fmt.Errorf("pipeline.scriptorium.binary is required when pipeline.scriptorium is configured") + } + if cfg.ConfigPath != "" && strings.TrimSpace(cfg.ConfigPath) == "" { + return fmt.Errorf("pipeline.scriptorium.config_path must be non-empty when provided") + } + if err := validateDuration("pipeline.scriptorium.timeout", cfg.Timeout); err != nil { + return err + } + + for artifactName, artifactCfg := range cfg.Artifacts { + trimmedArtifactName := strings.TrimSpace(artifactName) + if trimmedArtifactName == "" { + return fmt.Errorf("pipeline.scriptorium.artifacts keys must be non-empty") + } + if artifactCfg.Enabled && strings.TrimSpace(artifactCfg.PromptID) == "" { + return fmt.Errorf("pipeline.scriptorium.artifacts.%s.prompt_id is required when enabled", artifactName) + } + if artifactCfg.Enabled && strings.TrimSpace(artifactCfg.OutputPath) == "" { + return fmt.Errorf("pipeline.scriptorium.artifacts.%s.output_path is required when enabled", artifactName) + } + if err := validateDuration("pipeline.scriptorium.artifacts."+artifactName+".timeout", artifactCfg.Timeout); err != nil { + return err + } + for inputName, inputCfg := range artifactCfg.Inputs { + trimmedInputName := strings.TrimSpace(inputName) + if trimmedInputName == "" { + return fmt.Errorf("pipeline.scriptorium.artifacts.%s.inputs keys must be non-empty", artifactName) + } + if strings.TrimSpace(inputCfg.Source) == "" { + return fmt.Errorf("pipeline.scriptorium.artifacts.%s.inputs.%s.source is required", artifactName, inputName) + } + } + for varName, varValue := range artifactCfg.Vars { + if strings.TrimSpace(varName) == "" { + return fmt.Errorf("pipeline.scriptorium.artifacts.%s.vars keys must be non-empty", artifactName) + } + switch varValue.(type) { + case bool, string: + default: + return fmt.Errorf("pipeline.scriptorium.artifacts.%s.vars.%s must be a string or boolean", artifactName, varName) + } + } + } + + return nil +} + func validateSession(cfg *SessionConfig) error { if strings.TrimSpace(cfg.SessionID) == "" { return fmt.Errorf("session.session_id is required")