diff --git a/README.md b/README.md index dd9d75a..c2c0bdd 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,22 @@ YAML decoding is strict (`KnownFields(true)`), so unknown fields fail fast. - `transcripts/normalized.json`: placeholder normalized transcript (Seriatim schema pass-through for now) - `transcripts/trimmed.json`: gameplay-only polished transcript from trim stage +## Normalize Configuration + +`pipeline.normalize` is optional. When omitted, Narratio defaults to: + +- `output_path: transcripts/normalized.json` +- `output_schema: seriatim-intermediate` +- `report: true` + +Allowed `normalize.output_schema` values: + +- `seriatim-minimal` +- `seriatim-intermediate` +- `seriatim-full` + +`normalize.output_path` is treated as session-workdir-relative when not absolute. + ## Trim Configuration `pipeline.trim` is optional. If omitted, no trim config is loaded. If `trim.enabled` is omitted, it defaults to `false`. diff --git a/architecture.md b/architecture.md index ed2594e..1ee8fc6 100644 --- a/architecture.md +++ b/architecture.md @@ -98,6 +98,21 @@ Adapter behavior: `pipeline.trim` is optional. Existing pipelines without trim config continue to work. +`pipeline.normalize` is optional. Existing pipelines without normalize config continue to work. + +When `pipeline.normalize` is omitted, defaults are applied: + +- `output_path: transcripts/normalized.json` +- `output_schema: seriatim-intermediate` +- `report: true` + +When `pipeline.normalize` is present: + +- `output_path` must be non-empty +- `output_schema` must be one of `seriatim-minimal`, `seriatim-intermediate`, or `seriatim-full` +- relative `output_path` values are session-workdir-relative paths +- Seriatim binary settings still come from `pipeline.seriatim` + When `pipeline.trim` is present: - `enabled` is optional and defaults to `false` when omitted diff --git a/examples/pipeline.minimal.yml b/examples/pipeline.minimal.yml index cc279ef..6a05867 100644 --- a/examples/pipeline.minimal.yml +++ b/examples/pipeline.minimal.yml @@ -43,6 +43,12 @@ audita: 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. diff --git a/internal/config/config.go b/internal/config/config.go index 66db116..e0d685d 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"` + Normalize *NormalizeConfig `yaml:"normalize"` Trim *TrimConfig `yaml:"trim"` Scriptorium *ScriptoriumConfig `yaml:"scriptorium"` Analyzer AnalyzerConfig `yaml:"analyzer"` @@ -84,6 +85,15 @@ type AuditaConfig struct { Report *bool `yaml:"report"` } +// NormalizeConfig configures normalize-stage transcript schema/output behavior. +type NormalizeConfig struct { + OutputPath string `yaml:"output_path"` + OutputSchema string `yaml:"output_schema"` + Report *bool `yaml:"report"` + + outputPathSet bool `yaml:"-"` +} + // TrimConfig configures trim-stage transcript boundary behavior. type TrimConfig struct { Enabled bool `yaml:"enabled"` diff --git a/internal/config/load.go b/internal/config/load.go index 50dc9e7..00ffb34 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -84,6 +84,10 @@ func applyPipelineDefaults(cfg *PipelineConfig) { applyWhisperXDefaults(&cfg.WhisperX) applySeriatimDefaults(&cfg.Seriatim) applyAuditaDefaults(&cfg.Audita) + if cfg.Normalize == nil { + cfg.Normalize = &NormalizeConfig{} + } + applyNormalizeDefaults(cfg.Normalize) applyTrimDefaults(cfg.Trim) applyScriptoriumDefaults(cfg.Scriptorium) } @@ -188,6 +192,21 @@ func applyTrimDefaults(cfg *TrimConfig) { } } +func applyNormalizeDefaults(cfg *NormalizeConfig) { + if cfg == nil { + return + } + if cfg.OutputSchema == "" { + cfg.OutputSchema = defaultNormalizeOutputSchema + } + if cfg.OutputPath == "" && !cfg.outputPathWasSet() { + cfg.OutputPath = defaultNormalizeOutputPath + } + if cfg.Report == nil { + cfg.Report = boolPtr(true) + } +} + func float64Ptr(v float64) *float64 { p := v return &p diff --git a/internal/config/load_validate_test.go b/internal/config/load_validate_test.go index 576b224..cc5ba86 100644 --- a/internal/config/load_validate_test.go +++ b/internal/config/load_validate_test.go @@ -626,6 +626,18 @@ inputs: if cfg.Pipeline.Audita.Report == nil || *cfg.Pipeline.Audita.Report != true { t.Fatalf("audita.report = %v, want true", cfg.Pipeline.Audita.Report) } + if cfg.Pipeline.Normalize == nil { + t.Fatal("normalize config should be present via defaults") + } + if cfg.Pipeline.Normalize.OutputPath != "transcripts/normalized.json" { + t.Fatalf("normalize.output_path = %q, want %q", cfg.Pipeline.Normalize.OutputPath, "transcripts/normalized.json") + } + if cfg.Pipeline.Normalize.OutputSchema != "seriatim-intermediate" { + t.Fatalf("normalize.output_schema = %q, want %q", cfg.Pipeline.Normalize.OutputSchema, "seriatim-intermediate") + } + if cfg.Pipeline.Normalize.Report == nil || *cfg.Pipeline.Normalize.Report != true { + t.Fatalf("normalize.report = %v, want true", cfg.Pipeline.Normalize.Report) + } } err = Validate(cfg) diff --git a/internal/config/normalize.go b/internal/config/normalize.go new file mode 100644 index 0000000..7ce33e7 --- /dev/null +++ b/internal/config/normalize.go @@ -0,0 +1,54 @@ +package config + +import ( + "fmt" + + "gopkg.in/yaml.v3" +) + +const ( + defaultNormalizeOutputPath = "transcripts/normalized.json" + defaultNormalizeOutputSchema = "seriatim-intermediate" +) + +// UnmarshalYAML tracks explicit normalize.output_path presence so validation can +// distinguish omitted vs explicitly empty values. +func (cfg *NormalizeConfig) UnmarshalYAML(node *yaml.Node) error { + if node.Kind != yaml.MappingNode { + return fmt.Errorf("normalize must be a mapping") + } + allowedKeys := map[string]struct{}{ + "output_path": {}, + "output_schema": {}, + "report": {}, + } + for i := 0; i+1 < len(node.Content); i += 2 { + key := node.Content[i].Value + if _, ok := allowedKeys[key]; !ok { + return fmt.Errorf("field %q not found in type config.NormalizeConfig", key) + } + } + + type rawNormalize NormalizeConfig + var raw rawNormalize + if err := node.Decode(&raw); err != nil { + return err + } + *cfg = NormalizeConfig(raw) + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value == "output_path" { + cfg.outputPathSet = true + break + } + } + return nil +} + +// outputPathSet records whether normalize.output_path appeared in YAML. +// This is intentionally unexported and not serialized. +func (cfg *NormalizeConfig) outputPathWasSet() bool { + if cfg == nil { + return false + } + return cfg.outputPathSet +} diff --git a/internal/config/normalize_test.go b/internal/config/normalize_test.go new file mode 100644 index 0000000..40833fb --- /dev/null +++ b/internal/config/normalize_test.go @@ -0,0 +1,129 @@ +package config + +import ( + "strings" + "testing" +) + +func TestNormalizeLoadAndValidate(t *testing.T) { + tests := []struct { + name string + normalizeYAML string + wantLoadErr string + wantValidateErr string + assert func(t *testing.T, cfg *Config) + }{ + { + name: "normalize defaults when omitted", + normalizeYAML: "", + assert: func(t *testing.T, cfg *Config) { + t.Helper() + if cfg.Pipeline.Normalize == nil { + t.Fatal("normalize config should be present via defaults") + } + if cfg.Pipeline.Normalize.OutputPath != "transcripts/normalized.json" { + t.Fatalf("normalize.output_path = %q, want %q", cfg.Pipeline.Normalize.OutputPath, "transcripts/normalized.json") + } + if cfg.Pipeline.Normalize.OutputSchema != "seriatim-intermediate" { + t.Fatalf("normalize.output_schema = %q, want %q", cfg.Pipeline.Normalize.OutputSchema, "seriatim-intermediate") + } + if cfg.Pipeline.Normalize.Report == nil || *cfg.Pipeline.Normalize.Report != true { + t.Fatalf("normalize.report = %v, want true", cfg.Pipeline.Normalize.Report) + } + }, + }, + { + name: "valid explicit normalize config", + normalizeYAML: `normalize: + output_path: transcripts/custom-normalized.json + output_schema: seriatim-full + report: false +`, + assert: func(t *testing.T, cfg *Config) { + t.Helper() + if cfg.Pipeline.Normalize == nil { + t.Fatal("normalize config should be present") + } + if cfg.Pipeline.Normalize.OutputPath != "transcripts/custom-normalized.json" { + t.Fatalf("normalize.output_path = %q, want %q", cfg.Pipeline.Normalize.OutputPath, "transcripts/custom-normalized.json") + } + if cfg.Pipeline.Normalize.OutputSchema != "seriatim-full" { + t.Fatalf("normalize.output_schema = %q, want %q", cfg.Pipeline.Normalize.OutputSchema, "seriatim-full") + } + if cfg.Pipeline.Normalize.Report == nil || *cfg.Pipeline.Normalize.Report != false { + t.Fatalf("normalize.report = %v, want false", cfg.Pipeline.Normalize.Report) + } + }, + }, + { + name: "invalid normalize output schema fails", + normalizeYAML: `normalize: + output_path: transcripts/normalized.json + output_schema: not-a-schema + report: true +`, + wantValidateErr: "pipeline.normalize.output_schema must be one of: seriatim-minimal, seriatim-intermediate, seriatim-full", + }, + { + name: "empty normalize output path fails", + normalizeYAML: `normalize: + output_path: "" + output_schema: seriatim-intermediate + report: true +`, + wantValidateErr: "pipeline.normalize.output_path must be non-empty", + }, + { + name: "unknown normalize field fails strict decoding", + normalizeYAML: `normalize: + output_path: transcripts/normalized.json + output_schema: seriatim-intermediate + report: true + bogus: true +`, + wantLoadErr: "strict decode failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pipelineYAML := testPipelineBaseYAML + if tt.normalizeYAML != "" { + pipelineYAML += "\n" + tt.normalizeYAML + } + 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 947fc4d..08fb099 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 := validateNormalize(cfg.Normalize); err != nil { + return err + } if err := validateTrim(cfg.Trim); err != nil { return err } @@ -58,6 +61,19 @@ func validatePipeline(cfg *PipelineConfig) error { return nil } +func validateNormalize(cfg *NormalizeConfig) error { + if cfg == nil { + return nil + } + if strings.TrimSpace(cfg.OutputPath) == "" { + return fmt.Errorf("pipeline.normalize.output_path must be non-empty") + } + if err := validateSeriatimOutputSchema("pipeline.normalize.output_schema", cfg.OutputSchema); err != nil { + return err + } + return nil +} + func validateTrim(cfg *TrimConfig) error { if cfg == nil { return nil @@ -127,11 +143,8 @@ func validateSeriatim(cfg SeriatimConfig) error { if err := validateDuration("pipeline.seriatim.timeout", cfg.Timeout); err != nil { return err } - - switch cfg.OutputSchema { - case "seriatim-minimal", "seriatim-intermediate", "seriatim-full": - default: - return fmt.Errorf("pipeline.seriatim.output_schema must be one of: seriatim-minimal, seriatim-intermediate, seriatim-full") + if err := validateSeriatimOutputSchema("pipeline.seriatim.output_schema", cfg.OutputSchema); err != nil { + return err } if cfg.CoalesceGap == nil { @@ -158,6 +171,15 @@ func validateSeriatim(cfg SeriatimConfig) error { return nil } +func validateSeriatimOutputSchema(field, value string) error { + switch strings.TrimSpace(value) { + case "seriatim-minimal", "seriatim-intermediate", "seriatim-full": + return nil + default: + return fmt.Errorf("%s must be one of: seriatim-minimal, seriatim-intermediate, seriatim-full", field) + } +} + func validateAudita(cfg AuditaConfig) error { if strings.TrimSpace(cfg.Binary) == "" { return fmt.Errorf("pipeline.audita.binary is required")