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/normalized.json`: placeholder normalized transcript (Seriatim schema pass-through for now)
|
||||||
- `transcripts/trimmed.json`: gameplay-only polished transcript from trim stage
|
- `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
|
## Trim Configuration
|
||||||
|
|
||||||
`pipeline.trim` is optional. If omitted, no trim config is loaded. If `trim.enabled` is omitted, it defaults to `false`.
|
`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.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:
|
When `pipeline.trim` is present:
|
||||||
|
|
||||||
- `enabled` is optional and defaults to `false` when omitted
|
- `enabled` is optional and defaults to `false` when omitted
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ audita:
|
|||||||
validation_llm_concurrency: 1
|
validation_llm_concurrency: 1
|
||||||
report: true
|
report: true
|
||||||
|
|
||||||
|
normalize:
|
||||||
|
# Session-workdir-relative when not absolute.
|
||||||
|
output_path: "transcripts/normalized.json"
|
||||||
|
output_schema: "seriatim-intermediate"
|
||||||
|
report: true
|
||||||
|
|
||||||
trim:
|
trim:
|
||||||
enabled: true
|
enabled: true
|
||||||
# Session-workdir-relative when not absolute.
|
# Session-workdir-relative when not absolute.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type PipelineConfig struct {
|
|||||||
WhisperX WhisperXConfig `yaml:"whisperx"`
|
WhisperX WhisperXConfig `yaml:"whisperx"`
|
||||||
Seriatim SeriatimConfig `yaml:"seriatim"`
|
Seriatim SeriatimConfig `yaml:"seriatim"`
|
||||||
Audita AuditaConfig `yaml:"audita"`
|
Audita AuditaConfig `yaml:"audita"`
|
||||||
|
Normalize *NormalizeConfig `yaml:"normalize"`
|
||||||
Trim *TrimConfig `yaml:"trim"`
|
Trim *TrimConfig `yaml:"trim"`
|
||||||
Scriptorium *ScriptoriumConfig `yaml:"scriptorium"`
|
Scriptorium *ScriptoriumConfig `yaml:"scriptorium"`
|
||||||
Analyzer AnalyzerConfig `yaml:"analyzer"`
|
Analyzer AnalyzerConfig `yaml:"analyzer"`
|
||||||
@@ -84,6 +85,15 @@ type AuditaConfig struct {
|
|||||||
Report *bool `yaml:"report"`
|
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.
|
// TrimConfig configures trim-stage transcript boundary behavior.
|
||||||
type TrimConfig struct {
|
type TrimConfig struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ func applyPipelineDefaults(cfg *PipelineConfig) {
|
|||||||
applyWhisperXDefaults(&cfg.WhisperX)
|
applyWhisperXDefaults(&cfg.WhisperX)
|
||||||
applySeriatimDefaults(&cfg.Seriatim)
|
applySeriatimDefaults(&cfg.Seriatim)
|
||||||
applyAuditaDefaults(&cfg.Audita)
|
applyAuditaDefaults(&cfg.Audita)
|
||||||
|
if cfg.Normalize == nil {
|
||||||
|
cfg.Normalize = &NormalizeConfig{}
|
||||||
|
}
|
||||||
|
applyNormalizeDefaults(cfg.Normalize)
|
||||||
applyTrimDefaults(cfg.Trim)
|
applyTrimDefaults(cfg.Trim)
|
||||||
applyScriptoriumDefaults(cfg.Scriptorium)
|
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 {
|
func float64Ptr(v float64) *float64 {
|
||||||
p := v
|
p := v
|
||||||
return &p
|
return &p
|
||||||
|
|||||||
@@ -626,6 +626,18 @@ inputs:
|
|||||||
if cfg.Pipeline.Audita.Report == nil || *cfg.Pipeline.Audita.Report != true {
|
if cfg.Pipeline.Audita.Report == nil || *cfg.Pipeline.Audita.Report != true {
|
||||||
t.Fatalf("audita.report = %v, want true", cfg.Pipeline.Audita.Report)
|
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)
|
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 {
|
if err := validateAudita(cfg.Audita); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := validateNormalize(cfg.Normalize); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := validateTrim(cfg.Trim); err != nil {
|
if err := validateTrim(cfg.Trim); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -58,6 +61,19 @@ func validatePipeline(cfg *PipelineConfig) error {
|
|||||||
return nil
|
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 {
|
func validateTrim(cfg *TrimConfig) error {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -127,11 +143,8 @@ func validateSeriatim(cfg SeriatimConfig) error {
|
|||||||
if err := validateDuration("pipeline.seriatim.timeout", cfg.Timeout); err != nil {
|
if err := validateDuration("pipeline.seriatim.timeout", cfg.Timeout); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := validateSeriatimOutputSchema("pipeline.seriatim.output_schema", cfg.OutputSchema); err != nil {
|
||||||
switch cfg.OutputSchema {
|
return err
|
||||||
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 cfg.CoalesceGap == nil {
|
if cfg.CoalesceGap == nil {
|
||||||
@@ -158,6 +171,15 @@ func validateSeriatim(cfg SeriatimConfig) error {
|
|||||||
return nil
|
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 {
|
func validateAudita(cfg AuditaConfig) error {
|
||||||
if strings.TrimSpace(cfg.Binary) == "" {
|
if strings.TrimSpace(cfg.Binary) == "" {
|
||||||
return fmt.Errorf("pipeline.audita.binary is required")
|
return fmt.Errorf("pipeline.audita.binary is required")
|
||||||
|
|||||||
Reference in New Issue
Block a user