From ea39594b33add3d4262dad018ecb527f467cee8a Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 8 May 2026 16:54:17 +0000 Subject: [PATCH] Add trim configuration contract --- README.md | 34 +++++++ architecture.md | 37 +++++++ examples/pipeline.minimal.yml | 16 +++ internal/config/config.go | 25 +++++ internal/config/load.go | 13 +++ internal/config/trim_test.go | 187 ++++++++++++++++++++++++++++++++++ internal/config/validate.go | 33 ++++++ 7 files changed, 345 insertions(+) create mode 100644 internal/config/trim_test.go diff --git a/README.md b/README.md index d39a9c2..d326832 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,40 @@ Narratio expects two YAML files: YAML decoding is strict (`KnownFields(true)`), so unknown fields fail fast. +## Trim Configuration (Scaffold) + +`pipeline.trim` is optional. If omitted, no trim config is loaded. If `trim.enabled` is omitted, it defaults to `false`. + +When `trim.enabled: true`: + +- `trim.output_path` is required +- `trim.bounds.prompt_id` is required +- `trim.bounds.transcript_input_name` is required +- `trim.bounds.output_path` is required +- `trim.bounds.timeout` must be a valid Go duration when provided +- `trim.bounds.render_debug: true` requires `trim.bounds.render_output_path` +- `trim.bounds.profile_id` may be empty to use the prompt default profile + +Trim paths are treated as session-workdir-relative when not absolute. + +Example trim scaffold config: + +```yaml +trim: + enabled: true + output_path: "transcripts/trimmed.json" + bounds: + prompt_id: "dnd_session.bounds" + 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 +``` + ## Scriptorium Configuration `pipeline.scriptorium` is optional. When present, Narratio validates and uses it for analyze-stage artifact generation. diff --git a/architecture.md b/architecture.md index 04a4bfb..083f725 100644 --- a/architecture.md +++ b/architecture.md @@ -94,6 +94,25 @@ Adapter behavior: `pipeline.scriptorium` is optional. Existing pipelines without Scriptorium continue to work. +`pipeline.trim` is optional. Existing pipelines without trim config continue to work. + +When `pipeline.trim` is present: + +- `enabled` is optional and defaults to `false` when omitted +- relative `output_path`, `bounds.output_path`, and `bounds.render_output_path` values are session-workdir-relative paths +- do not store secrets in trim config values + +When `pipeline.trim.enabled: true`: + +- `output_path` is required and non-empty +- `bounds.prompt_id` is required and non-empty +- `bounds.transcript_input_name` is required and non-empty +- `bounds.output_path` is required and non-empty +- `bounds.timeout` must parse as a Go duration when provided +- `bounds.render_debug: true` requires non-empty `bounds.render_output_path` +- `bounds.profile_id` may be empty to use the prompt default profile +- prompt IDs are config values, not hardcoded stage logic + When `pipeline.scriptorium` is present: - `binary` is required and non-empty @@ -116,6 +135,24 @@ For each artifact definition: Prompt IDs and profile IDs are configuration values, not hardcoded stage logic. +Trim scaffold config shape: + +```yaml +trim: + enabled: true + output_path: "transcripts/trimmed.json" + bounds: + prompt_id: "dnd_session.bounds" + 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 +``` + ## 6. Analyze Stage (Current Implementation) The current real analyze implementation supports only `scriptorium.artifacts.session_recap`. diff --git a/examples/pipeline.minimal.yml b/examples/pipeline.minimal.yml index 769cbcd..d25b9fb 100644 --- a/examples/pipeline.minimal.yml +++ b/examples/pipeline.minimal.yml @@ -43,6 +43,22 @@ audita: validation_llm_concurrency: 1 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 + scriptorium: binary: "scriptorium" config_path: "/etc/scriptorium/config.yml" diff --git a/internal/config/config.go b/internal/config/config.go index 384eb3f..e65382d 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"` + Trim *TrimConfig `yaml:"trim"` Scriptorium *ScriptoriumConfig `yaml:"scriptorium"` Analyzer AnalyzerConfig `yaml:"analyzer"` Notification NotificationConfig `yaml:"notification"` @@ -83,6 +84,30 @@ type AuditaConfig struct { Report *bool `yaml:"report"` } +// TrimConfig configures the future trim stage boundary. +type TrimConfig struct { + Enabled bool `yaml:"enabled"` + OutputPath string `yaml:"output_path"` + Bounds TrimBoundsConfig `yaml:"bounds"` + Seriatim TrimSeriatimConfig `yaml:"seriatim"` +} + +// TrimBoundsConfig configures Scriptorium bounds prompt execution for trim. +type TrimBoundsConfig struct { + PromptID string `yaml:"prompt_id"` + ProfileID string `yaml:"profile_id"` + TranscriptInputName string `yaml:"transcript_input_name"` + OutputPath string `yaml:"output_path"` + Timeout string `yaml:"timeout"` + RenderDebug bool `yaml:"render_debug"` + RenderOutputPath string `yaml:"render_output_path"` +} + +// TrimSeriatimConfig configures Seriatim options for trim output construction. +type TrimSeriatimConfig struct { + Report *bool `yaml:"report"` +} + // ScriptoriumConfig configures Scriptorium-backed artifact generation. type ScriptoriumConfig struct { Binary string `yaml:"binary"` diff --git a/internal/config/load.go b/internal/config/load.go index 53e94ed..50dc9e7 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) + applyTrimDefaults(cfg.Trim) applyScriptoriumDefaults(cfg.Scriptorium) } @@ -175,6 +176,18 @@ func applyScriptoriumDefaults(cfg *ScriptoriumConfig) { } } +func applyTrimDefaults(cfg *TrimConfig) { + if cfg == nil { + return + } + if cfg.Bounds.Timeout == "" { + cfg.Bounds.Timeout = "10m" + } + if cfg.Seriatim.Report == nil { + cfg.Seriatim.Report = boolPtr(false) + } +} + func float64Ptr(v float64) *float64 { p := v return &p diff --git a/internal/config/trim_test.go b/internal/config/trim_test.go new file mode 100644 index 0000000..0a71f9f --- /dev/null +++ b/internal/config/trim_test.go @@ -0,0 +1,187 @@ +package config + +import ( + "strings" + "testing" +) + +func TestTrimLoadAndValidate(t *testing.T) { + tests := []struct { + name string + trimYAML string + wantLoadErr string + wantValidateErr string + assert func(t *testing.T, cfg *Config) + }{ + { + name: "valid trim config", + trimYAML: `trim: + enabled: true + output_path: transcripts/trimmed.json + bounds: + prompt_id: dnd_session.bounds + 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 +`, + assert: func(t *testing.T, cfg *Config) { + t.Helper() + if cfg.Pipeline.Trim == nil { + t.Fatal("trim config should be present") + } + if cfg.Pipeline.Trim.Enabled != true { + t.Fatalf("trim.enabled = %t, want true", cfg.Pipeline.Trim.Enabled) + } + if cfg.Pipeline.Trim.Bounds.ProfileID != "" { + t.Fatalf("trim.bounds.profile_id = %q, want empty", cfg.Pipeline.Trim.Bounds.ProfileID) + } + }, + }, + { + name: "enabled omitted defaults disabled", + trimYAML: `trim: + output_path: transcripts/trimmed.json + bounds: + prompt_id: dnd_session.bounds + transcript_input_name: transcript + output_path: artifacts/session_bounds.json +`, + assert: func(t *testing.T, cfg *Config) { + t.Helper() + if cfg.Pipeline.Trim == nil { + t.Fatal("trim config should be present") + } + if cfg.Pipeline.Trim.Enabled { + t.Fatal("trim.enabled should default to false when omitted") + } + }, + }, + { + name: "missing prompt id fails when enabled", + trimYAML: `trim: + enabled: true + output_path: transcripts/trimmed.json + bounds: + transcript_input_name: transcript + output_path: artifacts/session_bounds.json +`, + wantValidateErr: "pipeline.trim.bounds.prompt_id is required when pipeline.trim.enabled is true", + }, + { + name: "missing transcript input name fails when enabled", + trimYAML: `trim: + enabled: true + output_path: transcripts/trimmed.json + bounds: + prompt_id: dnd_session.bounds + output_path: artifacts/session_bounds.json +`, + wantValidateErr: "pipeline.trim.bounds.transcript_input_name is required when pipeline.trim.enabled is true", + }, + { + name: "missing bounds output path fails when enabled", + trimYAML: `trim: + enabled: true + output_path: transcripts/trimmed.json + bounds: + prompt_id: dnd_session.bounds + transcript_input_name: transcript +`, + wantValidateErr: "pipeline.trim.bounds.output_path is required when pipeline.trim.enabled is true", + }, + { + name: "missing trimmed output path fails when enabled", + trimYAML: `trim: + enabled: true + bounds: + prompt_id: dnd_session.bounds + transcript_input_name: transcript + output_path: artifacts/session_bounds.json +`, + wantValidateErr: "pipeline.trim.output_path is required when pipeline.trim.enabled is true", + }, + { + name: "invalid timeout fails", + trimYAML: `trim: + enabled: true + output_path: transcripts/trimmed.json + bounds: + prompt_id: dnd_session.bounds + transcript_input_name: transcript + output_path: artifacts/session_bounds.json + timeout: definitely-not-a-duration +`, + wantValidateErr: "pipeline.trim.bounds.timeout must be a valid duration", + }, + { + name: "render debug true requires render output path", + trimYAML: `trim: + enabled: true + output_path: transcripts/trimmed.json + bounds: + prompt_id: dnd_session.bounds + transcript_input_name: transcript + output_path: artifacts/session_bounds.json + render_debug: true +`, + wantValidateErr: "pipeline.trim.bounds.render_output_path is required when pipeline.trim.bounds.render_debug is true", + }, + { + name: "unknown trim field fails strict decoding", + trimYAML: `trim: + enabled: true + output_path: transcripts/trimmed.json + bounds: + prompt_id: dnd_session.bounds + transcript_input_name: transcript + output_path: artifacts/session_bounds.json + bogus: true +`, + wantLoadErr: "strict decode failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pipelineYAML := testPipelineBaseYAML + "\n" + tt.trimYAML + 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) + } + }) + } +} diff --git a/internal/config/validate.go b/internal/config/validate.go index ec9ae36..947fc4d 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 := validateTrim(cfg.Trim); err != nil { + return err + } if err := validateScriptorium(cfg.Scriptorium); err != nil { return err } @@ -55,6 +58,36 @@ func validatePipeline(cfg *PipelineConfig) error { return nil } +func validateTrim(cfg *TrimConfig) error { + if cfg == nil { + return nil + } + if !cfg.Enabled { + return nil + } + + if strings.TrimSpace(cfg.OutputPath) == "" { + return fmt.Errorf("pipeline.trim.output_path is required when pipeline.trim.enabled is true") + } + if strings.TrimSpace(cfg.Bounds.PromptID) == "" { + return fmt.Errorf("pipeline.trim.bounds.prompt_id is required when pipeline.trim.enabled is true") + } + if strings.TrimSpace(cfg.Bounds.TranscriptInputName) == "" { + return fmt.Errorf("pipeline.trim.bounds.transcript_input_name is required when pipeline.trim.enabled is true") + } + if strings.TrimSpace(cfg.Bounds.OutputPath) == "" { + return fmt.Errorf("pipeline.trim.bounds.output_path is required when pipeline.trim.enabled is true") + } + if err := validateDuration("pipeline.trim.bounds.timeout", cfg.Bounds.Timeout); err != nil { + return err + } + if cfg.Bounds.RenderDebug && strings.TrimSpace(cfg.Bounds.RenderOutputPath) == "" { + return fmt.Errorf("pipeline.trim.bounds.render_output_path is required when pipeline.trim.bounds.render_debug is true") + } + + return nil +} + func validateWhisperX(cfg WhisperXConfig) error { if strings.TrimSpace(cfg.TranscribeURL) == "" { return fmt.Errorf("pipeline.whisperx.transcribe_url is required")