Add normalize configuration contract

This commit is contained in:
2026-05-10 21:30:35 +00:00
parent 86d2dfa6c5
commit d1eb6d77ea
9 changed files with 288 additions and 5 deletions

View File

@@ -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`.

View File

@@ -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

View File

@@ -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.

View File

@@ -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"`

View File

@@ -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

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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)
}
})
}
}

View File

@@ -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")