Add normalize configuration contract
This commit is contained in:
16
README.md
16
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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
54
internal/config/normalize.go
Normal file
54
internal/config/normalize.go
Normal 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
|
||||
}
|
||||
129
internal/config/normalize_test.go
Normal file
129
internal/config/normalize_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user