Files
seriatim/internal/config/config.go
2026-05-08 14:53:59 +00:00

403 lines
11 KiB
Go

package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)
const (
DefaultInputReader = "json-files"
DefaultOutputModules = "json"
DefaultOutputSchema = OutputSchemaIntermediate
DefaultPreprocessingModules = "validate-raw,normalize-speakers,trim-text"
DefaultPostprocessingModules = "detect-overlaps,resolve-overlaps,backchannel,filler,resolve-danglers,coalesce,detect-overlaps,autocorrect,assign-ids,validate-output"
DefaultOverlapWordRunGap = 1.0
DefaultWordRunReorderWindow = 1.0
DefaultCoalesceGap = 3.0
DefaultCoalesceGapValue = "3.0"
DefaultBackchannelMaxDuration = 2.0
DefaultFillerMaxDuration = 1.25
OutputSchemaEnv = "SERIATIM_OUTPUT_SCHEMA"
OverlapWordRunGapEnv = "SERIATIM_OVERLAP_WORD_RUN_GAP"
WordRunReorderWindowEnv = "SERIATIM_OVERLAP_WORD_RUN_REORDER_WINDOW"
BackchannelMaxDurationEnv = "SERIATIM_BACKCHANNEL_MAX_DURATION"
FillerMaxDurationEnv = "SERIATIM_FILLER_MAX_DURATION"
OutputSchemaMinimal = "seriatim-minimal"
OutputSchemaIntermediate = "seriatim-intermediate"
OutputSchemaFull = "seriatim-full"
)
// MergeOptions captures raw CLI option values before validation.
type MergeOptions struct {
InputFiles []string
OutputFile string
ReportFile string
SpeakersFile string
AutocorrectFile string
InputReader string
OutputModules string
OutputSchema string
PreprocessingModules string
PostprocessingModules string
CoalesceGap string
}
// TrimOptions captures raw CLI option values before validation.
type TrimOptions struct {
InputFile string
OutputFile string
ReportFile string
Keep string
Remove string
OutputSchema string
AllowEmpty bool
}
// Config is the validated runtime configuration for a merge invocation.
type Config struct {
InputFiles []string
OutputFile string
ReportFile string
SpeakersFile string
AutocorrectFile string
InputReader string
OutputModules []string
OutputSchema string
PreprocessingModules []string
PostprocessingModules []string
OverlapWordRunGap float64
WordRunReorderWindow float64
CoalesceGap float64
BackchannelMaxDuration float64
FillerMaxDuration float64
}
// TrimConfig is the validated runtime configuration for a trim invocation.
type TrimConfig struct {
InputFile string
OutputFile string
ReportFile string
Mode string
Selector string
OutputSchema string
AllowEmpty bool
}
// NewMergeConfig validates raw merge options and returns normalized config.
func NewMergeConfig(opts MergeOptions) (Config, error) {
cfg := Config{
InputReader: strings.TrimSpace(opts.InputReader),
OutputModules: nil,
OutputSchema: "",
PreprocessingModules: nil,
PostprocessingModules: nil,
OverlapWordRunGap: DefaultOverlapWordRunGap,
WordRunReorderWindow: DefaultWordRunReorderWindow,
CoalesceGap: DefaultCoalesceGap,
BackchannelMaxDuration: DefaultBackchannelMaxDuration,
FillerMaxDuration: DefaultFillerMaxDuration,
}
if cfg.InputReader == "" {
return Config{}, errors.New("--input-reader is required")
}
var err error
cfg.OutputSchema, err = resolveOutputSchema(opts.OutputSchema)
if err != nil {
return Config{}, err
}
cfg.OutputModules, err = parseModuleList(opts.OutputModules)
if err != nil {
return Config{}, fmt.Errorf("--output-modules: %w", err)
}
cfg.PreprocessingModules, err = parseModuleList(opts.PreprocessingModules)
if err != nil {
return Config{}, fmt.Errorf("--preprocessing-modules: %w", err)
}
cfg.PostprocessingModules, err = parseModuleList(opts.PostprocessingModules)
if err != nil {
return Config{}, fmt.Errorf("--postprocessing-modules: %w", err)
}
if len(cfg.OutputModules) == 0 {
return Config{}, errors.New("--output-modules must include at least one module")
}
cfg.InputFiles, err = normalizeInputFiles(opts.InputFiles)
if err != nil {
return Config{}, err
}
cfg.OutputFile, err = normalizeOutputPath(opts.OutputFile, "--output-file")
if err != nil {
return Config{}, err
}
if opts.ReportFile != "" {
cfg.ReportFile, err = normalizeOutputPath(opts.ReportFile, "--report-file")
if err != nil {
return Config{}, err
}
}
cfg.SpeakersFile = filepath.Clean(strings.TrimSpace(opts.SpeakersFile))
if opts.SpeakersFile == "" {
cfg.SpeakersFile = ""
}
cfg.AutocorrectFile = filepath.Clean(strings.TrimSpace(opts.AutocorrectFile))
if opts.AutocorrectFile == "" {
cfg.AutocorrectFile = ""
}
if cfg.SpeakersFile != "" {
if err := requireFile(cfg.SpeakersFile, "--speakers"); err != nil {
return Config{}, err
}
}
if cfg.AutocorrectFile != "" {
if err := requireFile(cfg.AutocorrectFile, "--autocorrect"); err != nil {
return Config{}, err
}
}
cfg.OverlapWordRunGap, err = parseOverlapWordRunGap()
if err != nil {
return Config{}, err
}
cfg.WordRunReorderWindow, err = parseWordRunReorderWindow()
if err != nil {
return Config{}, err
}
cfg.CoalesceGap, err = parseCoalesceGap(opts.CoalesceGap)
if err != nil {
return Config{}, err
}
cfg.BackchannelMaxDuration, err = parseBackchannelMaxDuration()
if err != nil {
return Config{}, err
}
cfg.FillerMaxDuration, err = parseFillerMaxDuration()
if err != nil {
return Config{}, err
}
return cfg, nil
}
// NewTrimConfig validates raw trim options and returns normalized config.
func NewTrimConfig(opts TrimOptions) (TrimConfig, error) {
inputFile := filepath.Clean(strings.TrimSpace(opts.InputFile))
if strings.TrimSpace(opts.InputFile) == "" {
return TrimConfig{}, errors.New("--input-file is required")
}
if err := requireFile(inputFile, "--input-file"); err != nil {
return TrimConfig{}, err
}
outputFile, err := normalizeOutputPath(opts.OutputFile, "--output-file")
if err != nil {
return TrimConfig{}, err
}
reportFile := ""
if strings.TrimSpace(opts.ReportFile) != "" {
reportFile, err = normalizeOutputPath(opts.ReportFile, "--report-file")
if err != nil {
return TrimConfig{}, err
}
}
keep := strings.TrimSpace(opts.Keep)
remove := strings.TrimSpace(opts.Remove)
if keep == "" && remove == "" {
return TrimConfig{}, errors.New("exactly one of --keep or --remove is required")
}
if keep != "" && remove != "" {
return TrimConfig{}, errors.New("--keep and --remove are mutually exclusive")
}
mode := "keep"
selector := keep
if remove != "" {
mode = "remove"
selector = remove
}
outputSchema := strings.TrimSpace(opts.OutputSchema)
if outputSchema != "" {
if err := validateOutputSchema(outputSchema); err != nil {
return TrimConfig{}, err
}
}
return TrimConfig{
InputFile: inputFile,
OutputFile: outputFile,
ReportFile: reportFile,
Mode: mode,
Selector: selector,
OutputSchema: outputSchema,
AllowEmpty: opts.AllowEmpty,
}, nil
}
func parseModuleList(value string) ([]string, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, nil
}
parts := strings.Split(value, ",")
names := make([]string, 0, len(parts))
for _, part := range parts {
name := strings.TrimSpace(part)
if name == "" {
return nil, errors.New("module names cannot be empty")
}
names = append(names, name)
}
return names, nil
}
func validateOutputSchema(value string) error {
switch value {
case OutputSchemaMinimal, OutputSchemaIntermediate, OutputSchemaFull:
return nil
default:
return fmt.Errorf("--output-schema must be one of %q, %q, or %q", OutputSchemaMinimal, OutputSchemaIntermediate, OutputSchemaFull)
}
}
func resolveOutputSchema(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
value = strings.TrimSpace(os.Getenv(OutputSchemaEnv))
}
if value == "" {
value = DefaultOutputSchema
}
if err := validateOutputSchema(value); err != nil {
return "", err
}
return value, nil
}
func normalizeInputFiles(paths []string) ([]string, error) {
if len(paths) == 0 {
return nil, errors.New("at least one --input-file is required")
}
normalized := make([]string, 0, len(paths))
seen := make(map[string]struct{}, len(paths))
for _, path := range paths {
path = strings.TrimSpace(path)
if path == "" {
return nil, errors.New("--input-file cannot be empty")
}
clean := filepath.Clean(path)
if err := requireFile(clean, "--input-file"); err != nil {
return nil, err
}
if _, exists := seen[clean]; exists {
return nil, fmt.Errorf("duplicate --input-file %q", clean)
}
seen[clean] = struct{}{}
normalized = append(normalized, clean)
}
sort.Strings(normalized)
return normalized, nil
}
func normalizeOutputPath(path string, flag string) (string, error) {
path = strings.TrimSpace(path)
if path == "" {
return "", fmt.Errorf("%s is required", flag)
}
clean := filepath.Clean(path)
parent := filepath.Dir(clean)
stat, err := os.Stat(parent)
if err != nil {
return "", fmt.Errorf("%s parent directory %q: %w", flag, parent, err)
}
if !stat.IsDir() {
return "", fmt.Errorf("%s parent path %q is not a directory", flag, parent)
}
return clean, nil
}
func requireFile(path string, flag string) error {
stat, err := os.Stat(path)
if err != nil {
return fmt.Errorf("%s %q: %w", flag, path, err)
}
if stat.IsDir() {
return fmt.Errorf("%s %q is a directory, not a file", flag, path)
}
return nil
}
func parseOverlapWordRunGap() (float64, error) {
return parsePositiveFloatEnv(OverlapWordRunGapEnv, DefaultOverlapWordRunGap)
}
func parseWordRunReorderWindow() (float64, error) {
return parsePositiveFloatEnv(WordRunReorderWindowEnv, DefaultWordRunReorderWindow)
}
func parseBackchannelMaxDuration() (float64, error) {
return parsePositiveFloatEnv(BackchannelMaxDurationEnv, DefaultBackchannelMaxDuration)
}
func parseFillerMaxDuration() (float64, error) {
return parsePositiveFloatEnv(FillerMaxDurationEnv, DefaultFillerMaxDuration)
}
func parseCoalesceGap(value string) (float64, error) {
value = strings.TrimSpace(value)
if value == "" {
return DefaultCoalesceGap, nil
}
gap, err := strconv.ParseFloat(value, 64)
if err != nil {
return 0, fmt.Errorf("--coalesce-gap must be a non-negative number of seconds: %w", err)
}
if gap < 0 {
return 0, fmt.Errorf("--coalesce-gap must be non-negative")
}
return gap, nil
}
func parsePositiveFloatEnv(name string, defaultValue float64) (float64, error) {
value := strings.TrimSpace(os.Getenv(name))
if value == "" {
return defaultValue, nil
}
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return 0, fmt.Errorf("%s must be a positive number of seconds: %w", name, err)
}
if parsed <= 0 {
return 0, fmt.Errorf("%s must be positive", name)
}
return parsed, nil
}
func contains(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}