Add strict pipeline and session config loading

This commit is contained in:
2026-05-02 10:43:45 -05:00
parent 902e6bc994
commit 4172849f20
9 changed files with 569 additions and 25 deletions

2
go.mod
View File

@@ -1,3 +1,5 @@
module gitea.maximumdirect.net/eric/narratio
go 1.25.0
require gopkg.in/yaml.v3 v3.0.1

4
go.sum Normal file
View File

@@ -0,0 +1,4 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -2,18 +2,22 @@ package app
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestExecuteValidCommands(t *testing.T) {
pipelinePath, sessionPath := writeValidConfigFiles(t)
cases := []struct {
name string
args []string
wantOut string
}{
{name: "run", args: []string{"run"}, wantOut: "narratio run: not yet implemented"},
{name: "plan", args: []string{"plan"}, wantOut: "narratio plan: not yet implemented"},
{name: "run", args: []string{"run", "--config", pipelinePath, "--session", sessionPath}, wantOut: "narratio run: configuration loaded and valid"},
{name: "plan", args: []string{"plan", "--config", pipelinePath, "--session", sessionPath}, wantOut: "narratio plan: configuration loaded and valid"},
{name: "status", args: []string{"status"}, wantOut: "narratio status: not yet implemented"},
{name: "resume", args: []string{"resume"}, wantOut: "narratio resume: not yet implemented"},
{name: "run-stage", args: []string{"run-stage", "polish"}, wantOut: "narratio run-stage: not yet implemented"},
@@ -38,6 +42,35 @@ func TestExecuteValidCommands(t *testing.T) {
}
}
func TestExecuteMissingConfigFlags(t *testing.T) {
cases := []struct {
name string
args []string
want string
}{
{name: "run missing flags", args: []string{"run"}, want: "run: --config and --session are required"},
{name: "plan missing flags", args: []string{"plan"}, want: "plan: --config and --session are required"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute(tc.args, &stdout, &stderr)
if code == 0 {
t.Fatalf("exit code = 0, want non-zero")
}
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty", stdout.String())
}
if !strings.Contains(stderr.String(), tc.want) {
t.Fatalf("stderr = %q, want to contain %q", stderr.String(), tc.want)
}
})
}
}
func TestExecuteInvalidCommand(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -73,3 +106,46 @@ func TestExecuteMissingCommand(t *testing.T) {
t.Fatalf("stderr = %q, want usage message", stderr.String())
}
}
func writeValidConfigFiles(t *testing.T) (string, string) {
t.Helper()
dir := t.TempDir()
pipelinePath := filepath.Join(dir, "pipeline.yml")
sessionPath := filepath.Join(dir, "session.yml")
pipelineYAML := `workspace:
root: /tmp/narratio
storage:
backend: s3
whisperx:
timeout: 15m
seriatim:
timeout: 30s
audita:
timeout: 1h
analyzer:
timeout: 20m
artifacts:
output_dir: artifacts
notification:
timeout: 10s
`
sessionYAML := `session_id: 2026-05-03
inputs:
audio_dir: ./audio
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
`
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
t.Fatalf("write pipeline config: %v", err)
}
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session config: %v", err)
}
return pipelinePath, sessionPath
}

View File

@@ -2,10 +2,41 @@ package app
import (
"context"
"flag"
"fmt"
"io"
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
// Plan is a placeholder for future stage planning behavior.
func Plan(_ context.Context, _ []string, out io.Writer) error {
return placeholder(out, "plan")
// Plan validates configuration inputs and reports readiness for future planning.
func Plan(_ context.Context, args []string, out io.Writer) error {
fs := flag.NewFlagSet("plan", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var pipelinePath string
var sessionPath string
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
if err := fs.Parse(args); err != nil {
return fmt.Errorf("plan: invalid flags: %w", err)
}
if fs.NArg() != 0 {
return fmt.Errorf("plan: unexpected positional arguments")
}
if pipelinePath == "" || sessionPath == "" {
return fmt.Errorf("plan: --config and --session are required")
}
cfg, err := config.Load(pipelinePath, sessionPath)
if err != nil {
return fmt.Errorf("plan: %w", err)
}
if err := config.Validate(cfg); err != nil {
return fmt.Errorf("plan: %w", err)
}
_, err = fmt.Fprintln(out, "narratio plan: configuration loaded and valid")
return err
}

View File

@@ -2,10 +2,41 @@ package app
import (
"context"
"flag"
"fmt"
"io"
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
// Run is a placeholder for the future end-to-end pipeline execution command.
func Run(_ context.Context, _ []string, out io.Writer) error {
return placeholder(out, "run")
// Run validates configuration inputs and reports readiness for future execution.
func Run(_ context.Context, args []string, out io.Writer) error {
fs := flag.NewFlagSet("run", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var pipelinePath string
var sessionPath string
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
if err := fs.Parse(args); err != nil {
return fmt.Errorf("run: invalid flags: %w", err)
}
if fs.NArg() != 0 {
return fmt.Errorf("run: unexpected positional arguments")
}
if pipelinePath == "" || sessionPath == "" {
return fmt.Errorf("run: --config and --session are required")
}
cfg, err := config.Load(pipelinePath, sessionPath)
if err != nil {
return fmt.Errorf("run: %w", err)
}
if err := config.Validate(cfg); err != nil {
return fmt.Errorf("run: %w", err)
}
_, err = fmt.Fprintln(out, "narratio run: configuration loaded and valid")
return err
}

View File

@@ -1,15 +1,91 @@
package config
// Config contains loaded pipeline and session configuration.
// Config is the resolved combined configuration from pipeline.yml and session.yml.
type Config struct {
Pipeline *Pipeline
Session *Session
Pipeline *PipelineConfig
Session *SessionConfig
PipelinePath string
SessionPath string
}
// Pipeline represents durable pipeline-level settings.
type Pipeline struct{}
// Session represents per-session inputs and metadata.
type Session struct {
SessionID string
// PipelineConfig contains durable pipeline-level settings.
type PipelineConfig struct {
Workspace WorkspaceConfig `yaml:"workspace"`
Storage StorageConfig `yaml:"storage"`
WhisperX WhisperXConfig `yaml:"whisperx"`
Seriatim SeriatimConfig `yaml:"seriatim"`
Audita AuditaConfig `yaml:"audita"`
Analyzer AnalyzerConfig `yaml:"analyzer"`
Notification NotificationConfig `yaml:"notification"`
}
// SessionConfig contains per-session inputs and metadata.
type SessionConfig struct {
SessionID string `yaml:"session_id"`
Campaign string `yaml:"campaign"`
Date string `yaml:"date"`
Title string `yaml:"title"`
Inputs SessionInputsConfig `yaml:"inputs"`
}
// WorkspaceConfig configures local workspace behavior.
type WorkspaceConfig struct {
Root string `yaml:"root"`
}
// StorageConfig configures storage backends and related parameters.
type StorageConfig struct {
Backend string `yaml:"backend"`
Bucket string `yaml:"bucket"`
Prefix string `yaml:"prefix"`
}
// WhisperXConfig configures WhisperX adapter settings.
type WhisperXConfig struct {
BaseURL string `yaml:"base_url"`
Timeout string `yaml:"timeout"`
Concurrency int `yaml:"concurrency"`
}
// SeriatimConfig configures seriatim adapter settings.
type SeriatimConfig struct {
BinaryPath string `yaml:"binary_path"`
Timeout string `yaml:"timeout"`
Args []string `yaml:"args"`
}
// AuditaConfig configures audita adapter settings.
type AuditaConfig struct {
BinaryPath string `yaml:"binary_path"`
Timeout string `yaml:"timeout"`
Args []string `yaml:"args"`
}
// AnalyzerConfig configures analyzer adapter settings.
type AnalyzerConfig struct {
BinaryPath string `yaml:"binary_path"`
Timeout string `yaml:"timeout"`
Artifacts ArtifactSettings `yaml:"artifacts"`
}
// NotificationConfig configures notification backend settings.
type NotificationConfig struct {
Backend string `yaml:"backend"`
Recipient string `yaml:"recipient"`
Timeout string `yaml:"timeout"`
}
// ArtifactSettings configures generated artifact selection and paths.
type ArtifactSettings struct {
OutputDir string `yaml:"output_dir"`
Types []string `yaml:"types"`
}
// SessionInputsConfig contains per-session input references.
type SessionInputsConfig struct {
AudioDir string `yaml:"audio_dir"`
AudioFiles []string `yaml:"audio_files"`
SpeakersFile string `yaml:"speakers_file"`
AutocorrectFile string `yaml:"autocorrect_file"`
GlossaryFile string `yaml:"glossary_file"`
}

View File

@@ -1,8 +1,68 @@
package config
import "fmt"
import (
"fmt"
"io"
"os"
// Load is a placeholder for strict pipeline/session config decoding.
func Load(_ string, _ string) (*Config, error) {
return nil, fmt.Errorf("config load: not yet implemented")
"gopkg.in/yaml.v3"
)
// LoadPipeline loads pipeline configuration from a YAML file with strict field checking.
func LoadPipeline(path string) (*PipelineConfig, error) {
var cfg PipelineConfig
if err := decodeStrictYAML(path, &cfg); err != nil {
return nil, fmt.Errorf("load pipeline config: %w", err)
}
return &cfg, nil
}
// LoadSession loads session configuration from a YAML file with strict field checking.
func LoadSession(path string) (*SessionConfig, error) {
var cfg SessionConfig
if err := decodeStrictYAML(path, &cfg); err != nil {
return nil, fmt.Errorf("load session config: %w", err)
}
return &cfg, nil
}
// Load loads and resolves combined pipeline and session configuration.
func Load(pipelinePath, sessionPath string) (*Config, error) {
pipelineCfg, err := LoadPipeline(pipelinePath)
if err != nil {
return nil, err
}
sessionCfg, err := LoadSession(sessionPath)
if err != nil {
return nil, err
}
return &Config{
Pipeline: pipelineCfg,
Session: sessionCfg,
PipelinePath: pipelinePath,
SessionPath: sessionPath,
}, nil
}
func decodeStrictYAML(path string, out any) error {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("open %q: %w", path, err)
}
defer f.Close()
dec := yaml.NewDecoder(f)
dec.KnownFields(true)
if err := dec.Decode(out); err != nil {
return fmt.Errorf("decode %q: %w", path, err)
}
var extra any
if err := dec.Decode(&extra); err != nil && err != io.EOF {
return fmt.Errorf("decode trailing content in %q: %w", path, err)
}
return nil
}

View File

@@ -0,0 +1,182 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestLoadAndValidate(t *testing.T) {
tests := []struct {
name string
pipelineYAML string
sessionYAML string
wantLoadErr string
wantValidate string
}{
{
name: "valid minimal config",
pipelineYAML: `workspace:
root: /tmp/narratio
whisperx:
timeout: 10m
seriatim:
timeout: 30s
audita:
timeout: 1h
analyzer:
timeout: 20m
notification:
timeout: 15s
`,
sessionYAML: `session_id: 2026-05-03
inputs:
audio_dir: ./audio
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
`,
},
{
name: "unknown pipeline field fails",
pipelineYAML: `workspace:
root: /tmp/narratio
bogus: true
`,
sessionYAML: `session_id: 2026-05-03
inputs:
audio_dir: ./audio
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
`,
wantLoadErr: "field bogus not found",
},
{
name: "unknown session field fails",
pipelineYAML: `workspace:
root: /tmp/narratio
`,
sessionYAML: `session_id: 2026-05-03
inputs:
audio_dir: ./audio
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
unknown_field: true
`,
wantLoadErr: "field unknown_field not found",
},
{
name: "missing required field fails",
pipelineYAML: `workspace:
root: /tmp/narratio
`,
sessionYAML: `session_id: ""
inputs:
audio_dir: ./audio
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
`,
wantValidate: "session.session_id is required",
},
{
name: "invalid timeout fails",
pipelineYAML: `workspace:
root: /tmp/narratio
whisperx:
timeout: definitely-not-a-duration
`,
sessionYAML: `session_id: 2026-05-03
inputs:
audio_dir: ./audio
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
`,
wantValidate: "pipeline.whisperx.timeout must be a valid duration",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pipelinePath, sessionPath := writeConfigFiles(t, tt.pipelineYAML, tt.sessionYAML)
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 cfg.PipelinePath != pipelinePath {
t.Fatalf("PipelinePath = %q, want %q", cfg.PipelinePath, pipelinePath)
}
if cfg.SessionPath != sessionPath {
t.Fatalf("SessionPath = %q, want %q", cfg.SessionPath, sessionPath)
}
err = Validate(cfg)
if tt.wantValidate != "" {
if err == nil {
t.Fatalf("expected validation error containing %q, got nil", tt.wantValidate)
}
if !strings.Contains(err.Error(), tt.wantValidate) {
t.Fatalf("validation error = %q, want to contain %q", err.Error(), tt.wantValidate)
}
return
}
if err != nil {
t.Fatalf("Validate() error = %v", err)
}
})
}
}
func TestValidateMissingAudioSource(t *testing.T) {
cfg := &Config{
Pipeline: &PipelineConfig{Workspace: WorkspaceConfig{Root: "/tmp/narratio"}},
Session: &SessionConfig{
SessionID: "2026-05-03",
Inputs: SessionInputsConfig{
SpeakersFile: "speakers.yml",
AutocorrectFile: "autocorrect.yml",
GlossaryFile: "glossary.yml",
},
},
}
err := Validate(cfg)
if err == nil {
t.Fatal("expected validation error, got nil")
}
if !strings.Contains(err.Error(), "audio_dir or at least one audio_files") {
t.Fatalf("error = %q, want audio source guidance", err.Error())
}
}
func writeConfigFiles(t *testing.T, pipelineYAML, sessionYAML string) (string, string) {
t.Helper()
dir := t.TempDir()
pipelinePath := filepath.Join(dir, "pipeline.yml")
sessionPath := filepath.Join(dir, "session.yml")
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
t.Fatalf("write pipeline.yml: %v", err)
}
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err)
}
return pipelinePath, sessionPath
}

View File

@@ -1,8 +1,90 @@
package config
import "fmt"
import (
"fmt"
"strings"
"time"
)
// Validate is a placeholder for configuration validation logic.
func Validate(_ *Config) error {
return fmt.Errorf("config validation: not yet implemented")
// Validate checks resolved configuration for required fields and parseable durations.
func Validate(cfg *Config) error {
if cfg == nil {
return fmt.Errorf("config is nil")
}
if cfg.Pipeline == nil {
return fmt.Errorf("pipeline config is required")
}
if cfg.Session == nil {
return fmt.Errorf("session config is required")
}
if err := validatePipeline(cfg.Pipeline); err != nil {
return err
}
if err := validateSession(cfg.Session); err != nil {
return err
}
return nil
}
func validatePipeline(cfg *PipelineConfig) error {
if strings.TrimSpace(cfg.Workspace.Root) == "" {
return fmt.Errorf("pipeline.workspace.root is required")
}
if err := validateDuration("pipeline.whisperx.timeout", cfg.WhisperX.Timeout); err != nil {
return err
}
if err := validateDuration("pipeline.seriatim.timeout", cfg.Seriatim.Timeout); err != nil {
return err
}
if err := validateDuration("pipeline.audita.timeout", cfg.Audita.Timeout); err != nil {
return err
}
if err := validateDuration("pipeline.analyzer.timeout", cfg.Analyzer.Timeout); err != nil {
return err
}
if err := validateDuration("pipeline.notification.timeout", cfg.Notification.Timeout); err != nil {
return err
}
return nil
}
func validateSession(cfg *SessionConfig) error {
if strings.TrimSpace(cfg.SessionID) == "" {
return fmt.Errorf("session.session_id is required")
}
if strings.TrimSpace(cfg.Inputs.SpeakersFile) == "" {
return fmt.Errorf("session.inputs.speakers_file is required")
}
if strings.TrimSpace(cfg.Inputs.AutocorrectFile) == "" {
return fmt.Errorf("session.inputs.autocorrect_file is required")
}
if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" {
return fmt.Errorf("session.inputs.glossary_file is required")
}
hasAudioDir := strings.TrimSpace(cfg.Inputs.AudioDir) != ""
hasAudioFiles := len(cfg.Inputs.AudioFiles) > 0
if !hasAudioDir && !hasAudioFiles {
return fmt.Errorf("session.inputs requires audio_dir or at least one audio_files entry")
}
return nil
}
func validateDuration(fieldName, value string) error {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil
}
if _, err := time.ParseDuration(trimmed); err != nil {
return fmt.Errorf("%s must be a valid duration: %w", fieldName, err)
}
return nil
}