From ac3dcf25571b093a08230bea2845513af8963538 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 8 May 2026 14:53:59 +0000 Subject: [PATCH] Add trim CLI command --- internal/cli/root.go | 1 + internal/cli/trim.go | 125 +++++++++++ internal/cli/trim_test.go | 301 +++++++++++++++++++++++++ internal/config/config.go | 79 +++++++ internal/config/config_test.go | 99 +++++++++ internal/trim/apply.go | 5 +- internal/trim/artifact.go | 387 +++++++++++++++++++++++++++++++++ 7 files changed, 996 insertions(+), 1 deletion(-) create mode 100644 internal/cli/trim.go create mode 100644 internal/cli/trim_test.go create mode 100644 internal/trim/artifact.go diff --git a/internal/cli/root.go b/internal/cli/root.go index 811d6ca..c2305cc 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -17,5 +17,6 @@ func NewRootCommand() *cobra.Command { } cmd.AddCommand(newMergeCommand()) + cmd.AddCommand(newTrimCommand()) return cmd } diff --git a/internal/cli/trim.go b/internal/cli/trim.go new file mode 100644 index 0000000..7828016 --- /dev/null +++ b/internal/cli/trim.go @@ -0,0 +1,125 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" + + "gitea.maximumdirect.net/eric/seriatim/internal/config" + "gitea.maximumdirect.net/eric/seriatim/internal/report" + triminternal "gitea.maximumdirect.net/eric/seriatim/internal/trim" +) + +func newTrimCommand() *cobra.Command { + var opts config.TrimOptions + + cmd := &cobra.Command{ + Use: "trim", + Short: "Trim an existing seriatim transcript artifact by segment ID", + RunE: func(cmd *cobra.Command, args []string) error { + trimOpts := opts + if !cmd.Flags().Changed("output-schema") { + trimOpts.OutputSchema = "" + } + + cfg, err := config.NewTrimConfig(trimOpts) + if err != nil { + return err + } + + selector, err := triminternal.ParseSelector(cfg.Selector) + if err != nil { + return fmt.Errorf("invalid selector %q: %w", cfg.Selector, err) + } + + data, err := os.ReadFile(cfg.InputFile) + if err != nil { + return fmt.Errorf("read --input-file %q: %w", cfg.InputFile, err) + } + + artifact, err := triminternal.ParseArtifactJSON(data) + if err != nil { + return fmt.Errorf("--input-file %q: %w", cfg.InputFile, err) + } + + mode := triminternal.ModeKeep + if cfg.Mode == "remove" { + mode = triminternal.ModeRemove + } + + trimmed, err := triminternal.ApplyArtifact(artifact, triminternal.Options{ + Mode: mode, + Selector: selector, + AllowEmpty: cfg.AllowEmpty, + }) + if err != nil { + return err + } + + outputSchema := artifact.Schema + if cfg.OutputSchema != "" { + outputSchema = cfg.OutputSchema + } + + outputArtifact, err := triminternal.ConvertArtifact(trimmed.Artifact, outputSchema) + if err != nil { + return err + } + + if err := triminternal.ValidateArtifact(outputArtifact); err != nil { + return fmt.Errorf("validate trimmed output: %w", err) + } + + if err := writeOutputJSON(cfg.OutputFile, outputArtifact.Value()); err != nil { + return err + } + + if cfg.ReportFile != "" { + rpt := report.Report{ + Metadata: report.Metadata{ + Application: outputArtifact.Application(), + Version: outputArtifact.Version(), + InputReader: "trim-artifact", + InputFiles: []string{cfg.InputFile}, + OutputModules: []string{"json"}, + }, + Events: []report.Event{ + report.Info("trim", "trim", fmt.Sprintf("mode=%s retained %d segment(s), removed %d segment(s)", cfg.Mode, len(trimmed.OldToNewID), len(trimmed.RemovedIDs))), + report.Info("trim", "validate-output", fmt.Sprintf("validated %d output segment(s)", outputArtifact.SegmentCount())), + report.Info("output", "json", "wrote transcript JSON"), + }, + } + if err := report.WriteJSON(cfg.ReportFile, rpt); err != nil { + return err + } + } + + return nil + }, + } + + flags := cmd.Flags() + flags.StringVar(&opts.InputFile, "input-file", "", "input seriatim transcript artifact JSON file") + flags.StringVar(&opts.OutputFile, "output-file", "", "output transcript JSON file") + flags.StringVar(&opts.ReportFile, "report-file", "", "optional report JSON file") + flags.StringVar(&opts.Keep, "keep", "", "segment ID selector to keep (for example: 1-10,15)") + flags.StringVar(&opts.Remove, "remove", "", "segment ID selector to remove (for example: 1-10,15)") + flags.StringVar(&opts.OutputSchema, "output-schema", "", "optional output JSON schema override: seriatim-minimal, seriatim-intermediate, or seriatim-full") + flags.BoolVar(&opts.AllowEmpty, "allow-empty", false, "allow trimming to an empty transcript") + + return cmd +} + +func writeOutputJSON(path string, value any) error { + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + + enc := json.NewEncoder(file) + enc.SetIndent("", " ") + return enc.Encode(value) +} diff --git a/internal/cli/trim_test.go b/internal/cli/trim_test.go new file mode 100644 index 0000000..175cbe6 --- /dev/null +++ b/internal/cli/trim_test.go @@ -0,0 +1,301 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/seriatim/internal/config" + "gitea.maximumdirect.net/eric/seriatim/schema" +) + +func TestTrimKeepModeEndToEnd(t *testing.T) { + dir := t.TempDir() + input := writeTrimFullFixture(t, dir, "input.json") + output := filepath.Join(dir, "trimmed.json") + + err := executeTrim( + "--input-file", input, + "--output-file", output, + "--keep", "2,4", + ) + if err != nil { + t.Fatalf("trim failed: %v", err) + } + + var transcript schema.Transcript + readJSON(t, output, &transcript) + if len(transcript.Segments) != 2 { + t.Fatalf("segment count = %d, want 2", len(transcript.Segments)) + } + if transcript.Segments[0].Text != "two" || transcript.Segments[1].Text != "four" { + t.Fatalf("unexpected kept text order: %#v", transcript.Segments) + } + assertSequentialIDs(t, []int{transcript.Segments[0].ID, transcript.Segments[1].ID}) +} + +func TestTrimRemoveModeEndToEnd(t *testing.T) { + dir := t.TempDir() + input := writeTrimFullFixture(t, dir, "input.json") + output := filepath.Join(dir, "trimmed.json") + + err := executeTrim( + "--input-file", input, + "--output-file", output, + "--remove", "2,4", + ) + if err != nil { + t.Fatalf("trim failed: %v", err) + } + + var transcript schema.Transcript + readJSON(t, output, &transcript) + if len(transcript.Segments) != 2 { + t.Fatalf("segment count = %d, want 2", len(transcript.Segments)) + } + if transcript.Segments[0].Text != "one" || transcript.Segments[1].Text != "three" { + t.Fatalf("unexpected remaining text order: %#v", transcript.Segments) + } + assertSequentialIDs(t, []int{transcript.Segments[0].ID, transcript.Segments[1].ID}) +} + +func TestTrimMutualExclusionFailure(t *testing.T) { + dir := t.TempDir() + input := writeTrimFullFixture(t, dir, "input.json") + output := filepath.Join(dir, "trimmed.json") + + err := executeTrim( + "--input-file", input, + "--output-file", output, + "--keep", "1", + "--remove", "2", + ) + if err == nil { + t.Fatal("expected mutual exclusion error") + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestTrimMissingSelectionFailure(t *testing.T) { + dir := t.TempDir() + input := writeTrimFullFixture(t, dir, "input.json") + output := filepath.Join(dir, "trimmed.json") + + err := executeTrim( + "--input-file", input, + "--output-file", output, + ) + if err == nil { + t.Fatal("expected selection flag error") + } + if !strings.Contains(err.Error(), "exactly one of --keep or --remove is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestTrimInvalidSelectedIDFailure(t *testing.T) { + dir := t.TempDir() + input := writeTrimFullFixture(t, dir, "input.json") + output := filepath.Join(dir, "trimmed.json") + + err := executeTrim( + "--input-file", input, + "--output-file", output, + "--keep", "99", + ) + if err == nil { + t.Fatal("expected missing selected ID error") + } + if !strings.Contains(err.Error(), "does not exist") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestTrimOmittedOutputSchemaPreservesInputSchema(t *testing.T) { + dir := t.TempDir() + input := writeTrimMinimalFixture(t, dir, "input-minimal.json") + output := filepath.Join(dir, "trimmed.json") + + err := executeTrim( + "--input-file", input, + "--output-file", output, + "--keep", "1", + ) + if err != nil { + t.Fatalf("trim failed: %v", err) + } + + var transcript schema.MinimalTranscript + readJSON(t, output, &transcript) + if transcript.Metadata.OutputSchema != config.OutputSchemaMinimal { + t.Fatalf("output_schema = %q, want %q", transcript.Metadata.OutputSchema, config.OutputSchemaMinimal) + } + if len(transcript.Segments) != 1 || transcript.Segments[0].ID != 1 { + t.Fatalf("unexpected minimal trim output: %#v", transcript.Segments) + } +} + +func TestTrimExplicitOutputSchemaChangesOutputSchema(t *testing.T) { + dir := t.TempDir() + input := writeTrimFullFixture(t, dir, "input.json") + output := filepath.Join(dir, "trimmed.json") + + err := executeTrim( + "--input-file", input, + "--output-file", output, + "--keep", "1,3", + "--output-schema", config.OutputSchemaMinimal, + ) + if err != nil { + t.Fatalf("trim failed: %v", err) + } + + var transcript schema.MinimalTranscript + readJSON(t, output, &transcript) + if transcript.Metadata.OutputSchema != config.OutputSchemaMinimal { + t.Fatalf("output_schema = %q, want %q", transcript.Metadata.OutputSchema, config.OutputSchemaMinimal) + } + if len(transcript.Segments) != 2 { + t.Fatalf("segment count = %d, want 2", len(transcript.Segments)) + } + assertSequentialIDs(t, []int{transcript.Segments[0].ID, transcript.Segments[1].ID}) +} + +func TestTrimAllowEmptyBehavior(t *testing.T) { + dir := t.TempDir() + input := writeTrimFullFixture(t, dir, "input.json") + output := filepath.Join(dir, "trimmed.json") + + err := executeTrim( + "--input-file", input, + "--output-file", output, + "--remove", "1-4", + ) + if err == nil { + t.Fatal("expected empty-output error") + } + if !strings.Contains(err.Error(), "empty transcript") { + t.Fatalf("unexpected error: %v", err) + } + + err = executeTrim( + "--input-file", input, + "--output-file", output, + "--remove", "1-4", + "--allow-empty", + ) + if err != nil { + t.Fatalf("trim with --allow-empty failed: %v", err) + } + + var transcript schema.Transcript + readJSON(t, output, &transcript) + if len(transcript.Segments) != 0 { + t.Fatalf("segment count = %d, want 0", len(transcript.Segments)) + } +} + +func TestTrimRejectsNonSeriatimInputArtifacts(t *testing.T) { + dir := t.TempDir() + input := writeJSONFile(t, dir, "raw-whisperx.json", `{ + "segments": [ + {"start": 1, "end": 2, "text": "hello"} + ] + }`) + output := filepath.Join(dir, "trimmed.json") + + err := executeTrim( + "--input-file", input, + "--output-file", output, + "--keep", "1", + ) + if err == nil { + t.Fatal("expected invalid artifact error") + } + if !strings.Contains(err.Error(), "not a valid seriatim output artifact") { + t.Fatalf("unexpected error: %v", err) + } +} + +func executeTrim(args ...string) error { + cmd := NewRootCommand() + cmd.SetArgs(append([]string{"trim"}, args...)) + return cmd.Execute() +} + +func writeTrimFullFixture(t *testing.T, dir string, name string) string { + t.Helper() + + first := 10 + second := 20 + third := 30 + fourth := 40 + value := schema.Transcript{ + Metadata: schema.Metadata{ + Application: "seriatim", + Version: "v-test", + InputReader: "json-files", + InputFiles: []string{"a.json"}, + PreprocessingModules: []string{"validate-raw"}, + PostprocessingModules: []string{"assign-ids"}, + OutputModules: []string{"json"}, + }, + Segments: []schema.Segment{ + {ID: 1, Source: "a.json", SourceSegmentIndex: &first, SourceRef: "a.json#10", Speaker: "A", Start: 1, End: 2, Text: "one", OverlapGroupID: 9}, + {ID: 2, Source: "a.json", SourceSegmentIndex: &second, SourceRef: "a.json#20", Speaker: "B", Start: 2, End: 3, Text: "two", OverlapGroupID: 9}, + {ID: 3, Source: "a.json", SourceSegmentIndex: &third, SourceRef: "a.json#30", Speaker: "C", Start: 4, End: 5, Text: "three", OverlapGroupID: 10}, + {ID: 4, Source: "a.json", SourceSegmentIndex: &fourth, SourceRef: "a.json#40", Speaker: "D", Start: 5, End: 6, Text: "four", OverlapGroupID: 10}, + }, + OverlapGroups: []schema.OverlapGroup{ + {ID: 9, Start: 1, End: 3, Segments: []string{"a.json#10", "a.json#20"}, Speakers: []string{"A", "B"}, Class: "unknown", Resolution: "unresolved"}, + }, + } + + return writeTrimArtifactFile(t, dir, name, value) +} + +func writeTrimMinimalFixture(t *testing.T, dir string, name string) string { + t.Helper() + + value := schema.MinimalTranscript{ + Metadata: schema.MinimalMetadata{ + Application: "seriatim", + Version: "v-test", + OutputSchema: config.OutputSchemaMinimal, + }, + Segments: []schema.MinimalSegment{ + {ID: 1, Start: 1, End: 2, Speaker: "A", Text: "one"}, + {ID: 2, Start: 2, End: 3, Speaker: "B", Text: "two"}, + }, + } + + return writeTrimArtifactFile(t, dir, name, value) +} + +func writeTrimArtifactFile(t *testing.T, dir string, name string, value any) string { + t.Helper() + + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + t.Fatalf("marshal fixture: %v", err) + } + path := filepath.Join(dir, name) + if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { + t.Fatalf("write fixture: %v", err) + } + return path +} + +func assertSequentialIDs(t *testing.T, ids []int) { + t.Helper() + for index, id := range ids { + want := index + 1 + if id != want { + t.Fatalf("id at index %d = %d, want %d", index, id, want) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 3fcb362..b8f47b6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -47,6 +47,17 @@ type MergeOptions struct { 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 @@ -66,6 +77,17 @@ type Config struct { 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{ @@ -168,6 +190,63 @@ func NewMergeConfig(opts MergeOptions) (Config, error) { 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 == "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0e2b3a7..d4570cd 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -612,6 +612,105 @@ func TestCoalesceGapRejectsInvalidOverride(t *testing.T) { } } +func TestNewTrimConfigRequiresInputAndOutput(t *testing.T) { + dir := t.TempDir() + input := writeTempFile(t, dir, "input.json") + output := filepath.Join(dir, "trimmed.json") + + _, err := NewTrimConfig(TrimOptions{ + OutputFile: output, + Keep: "1", + }) + if err == nil || !strings.Contains(err.Error(), "--input-file is required") { + t.Fatalf("expected input-file required error, got %v", err) + } + + _, err = NewTrimConfig(TrimOptions{ + InputFile: input, + Keep: "1", + }) + if err == nil || !strings.Contains(err.Error(), "--output-file is required") { + t.Fatalf("expected output-file required error, got %v", err) + } +} + +func TestNewTrimConfigRequiresExactlyOneSelectorFlag(t *testing.T) { + dir := t.TempDir() + input := writeTempFile(t, dir, "input.json") + output := filepath.Join(dir, "trimmed.json") + + _, err := NewTrimConfig(TrimOptions{ + InputFile: input, + OutputFile: output, + }) + if err == nil || !strings.Contains(err.Error(), "exactly one of --keep or --remove is required") { + t.Fatalf("expected missing selector error, got %v", err) + } + + _, err = NewTrimConfig(TrimOptions{ + InputFile: input, + OutputFile: output, + Keep: "1", + Remove: "2", + }) + if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("expected mutually exclusive selector error, got %v", err) + } +} + +func TestNewTrimConfigAcceptsOutputSchemaOverride(t *testing.T) { + dir := t.TempDir() + input := writeTempFile(t, dir, "input.json") + output := filepath.Join(dir, "trimmed.json") + reportPath := filepath.Join(dir, "report.json") + + cfg, err := NewTrimConfig(TrimOptions{ + InputFile: input, + OutputFile: output, + ReportFile: reportPath, + Remove: "3-5", + OutputSchema: OutputSchemaMinimal, + AllowEmpty: true, + }) + if err != nil { + t.Fatalf("config failed: %v", err) + } + if cfg.Mode != "remove" { + t.Fatalf("mode = %q, want remove", cfg.Mode) + } + if cfg.Selector != "3-5" { + t.Fatalf("selector = %q, want 3-5", cfg.Selector) + } + if cfg.OutputSchema != OutputSchemaMinimal { + t.Fatalf("output schema = %q, want %q", cfg.OutputSchema, OutputSchemaMinimal) + } + if !cfg.AllowEmpty { + t.Fatal("allow empty should be true") + } + if cfg.ReportFile != reportPath { + t.Fatalf("report file = %q, want %q", cfg.ReportFile, reportPath) + } +} + +func TestNewTrimConfigRejectsInvalidOutputSchemaOverride(t *testing.T) { + dir := t.TempDir() + input := writeTempFile(t, dir, "input.json") + output := filepath.Join(dir, "trimmed.json") + + _, err := NewTrimConfig(TrimOptions{ + InputFile: input, + OutputFile: output, + Keep: "1", + OutputSchema: "compact", + }) + if err == nil { + t.Fatal("expected output schema validation error") + } + if !strings.Contains(err.Error(), "--output-schema must be one of") { + t.Fatalf("unexpected error: %v", err) + } +} + func assertPositiveFloatEnvValidation(t *testing.T, envName string) { t.Helper() diff --git a/internal/trim/apply.go b/internal/trim/apply.go index 07bd6ef..7c71c97 100644 --- a/internal/trim/apply.go +++ b/internal/trim/apply.go @@ -95,6 +95,9 @@ func Apply(input schema.Transcript, opts Options) (Result, error) { } kept, groups := recomputeOverlapGroups(kept) + if groups == nil { + groups = make([]schema.OverlapGroup, 0) + } out := copyTranscript(input) out.Segments = kept @@ -278,7 +281,7 @@ func validateSelectedIDsExist(selected []int, idIndex map[int]int) error { func recomputeOverlapGroups(segments []schema.Segment) ([]schema.Segment, []schema.OverlapGroup) { if len(segments) == 0 { - return segments, nil + return segments, make([]schema.OverlapGroup, 0) } modelSegments := make([]model.Segment, len(segments)) diff --git a/internal/trim/artifact.go b/internal/trim/artifact.go new file mode 100644 index 0000000..8ed4504 --- /dev/null +++ b/internal/trim/artifact.go @@ -0,0 +1,387 @@ +package trim + +import ( + "encoding/json" + "fmt" + + "gitea.maximumdirect.net/eric/seriatim/schema" +) + +const ( + SchemaMinimal = "seriatim-minimal" + SchemaIntermediate = "seriatim-intermediate" + SchemaFull = "seriatim-full" +) + +// Artifact stores a parsed seriatim output artifact of one supported schema. +type Artifact struct { + Schema string + Full *schema.Transcript + Intermediate *schema.IntermediateTranscript + Minimal *schema.MinimalTranscript +} + +// ApplyArtifactResult contains trimmed artifact output and ID mapping metadata. +type ApplyArtifactResult struct { + Artifact Artifact + OldToNewID map[int]int + RemovedIDs []int +} + +// ParseArtifactJSON parses and validates a serialized seriatim output artifact. +func ParseArtifactJSON(data []byte) (Artifact, error) { + var full schema.Transcript + if err := json.Unmarshal(data, &full); err == nil { + if err := schema.ValidateTranscript(full); err == nil { + return Artifact{ + Schema: SchemaFull, + Full: &full, + }, nil + } + } + + var intermediate schema.IntermediateTranscript + if err := json.Unmarshal(data, &intermediate); err == nil { + if err := schema.ValidateIntermediateTranscript(intermediate); err == nil { + return Artifact{ + Schema: SchemaIntermediate, + Intermediate: &intermediate, + }, nil + } + } + + var minimal schema.MinimalTranscript + if err := json.Unmarshal(data, &minimal); err == nil { + if err := schema.ValidateMinimalTranscript(minimal); err == nil { + return Artifact{ + Schema: SchemaMinimal, + Minimal: &minimal, + }, nil + } + } + + return Artifact{}, fmt.Errorf("input JSON is not a valid seriatim output artifact") +} + +// ValidateArtifact validates an artifact against its declared schema. +func ValidateArtifact(artifact Artifact) error { + switch artifact.Schema { + case SchemaFull: + if artifact.Full == nil { + return fmt.Errorf("full artifact payload is missing") + } + return schema.ValidateTranscript(*artifact.Full) + case SchemaIntermediate: + if artifact.Intermediate == nil { + return fmt.Errorf("intermediate artifact payload is missing") + } + return schema.ValidateIntermediateTranscript(*artifact.Intermediate) + case SchemaMinimal: + if artifact.Minimal == nil { + return fmt.Errorf("minimal artifact payload is missing") + } + return schema.ValidateMinimalTranscript(*artifact.Minimal) + default: + return fmt.Errorf("unsupported artifact schema %q", artifact.Schema) + } +} + +// Value returns the artifact value for JSON serialization. +func (artifact Artifact) Value() any { + switch artifact.Schema { + case SchemaFull: + if artifact.Full == nil { + return schema.Transcript{} + } + return *artifact.Full + case SchemaIntermediate: + if artifact.Intermediate == nil { + return schema.IntermediateTranscript{} + } + return *artifact.Intermediate + case SchemaMinimal: + if artifact.Minimal == nil { + return schema.MinimalTranscript{} + } + return *artifact.Minimal + default: + return nil + } +} + +// SegmentCount returns the number of segments in the artifact. +func (artifact Artifact) SegmentCount() int { + switch artifact.Schema { + case SchemaFull: + if artifact.Full == nil { + return 0 + } + return len(artifact.Full.Segments) + case SchemaIntermediate: + if artifact.Intermediate == nil { + return 0 + } + return len(artifact.Intermediate.Segments) + case SchemaMinimal: + if artifact.Minimal == nil { + return 0 + } + return len(artifact.Minimal.Segments) + default: + return 0 + } +} + +// Application returns artifact metadata application name. +func (artifact Artifact) Application() string { + switch artifact.Schema { + case SchemaFull: + if artifact.Full == nil { + return "" + } + return artifact.Full.Metadata.Application + case SchemaIntermediate: + if artifact.Intermediate == nil { + return "" + } + return artifact.Intermediate.Metadata.Application + case SchemaMinimal: + if artifact.Minimal == nil { + return "" + } + return artifact.Minimal.Metadata.Application + default: + return "" + } +} + +// Version returns artifact metadata version. +func (artifact Artifact) Version() string { + switch artifact.Schema { + case SchemaFull: + if artifact.Full == nil { + return "" + } + return artifact.Full.Metadata.Version + case SchemaIntermediate: + if artifact.Intermediate == nil { + return "" + } + return artifact.Intermediate.Metadata.Version + case SchemaMinimal: + if artifact.Minimal == nil { + return "" + } + return artifact.Minimal.Metadata.Version + default: + return "" + } +} + +// ApplyArtifact trims a parsed artifact while preserving its input schema. +func ApplyArtifact(input Artifact, opts Options) (ApplyArtifactResult, error) { + switch input.Schema { + case SchemaFull: + if input.Full == nil { + return ApplyArtifactResult{}, fmt.Errorf("full artifact payload is missing") + } + result, err := Apply(*input.Full, opts) + if err != nil { + return ApplyArtifactResult{}, err + } + out := result.Transcript + return ApplyArtifactResult{ + Artifact: Artifact{ + Schema: SchemaFull, + Full: &out, + }, + OldToNewID: result.OldToNewID, + RemovedIDs: result.RemovedIDs, + }, nil + case SchemaIntermediate: + if input.Intermediate == nil { + return ApplyArtifactResult{}, fmt.Errorf("intermediate artifact payload is missing") + } + result, err := ApplyIntermediate(*input.Intermediate, opts) + if err != nil { + return ApplyArtifactResult{}, err + } + out := result.Transcript + return ApplyArtifactResult{ + Artifact: Artifact{ + Schema: SchemaIntermediate, + Intermediate: &out, + }, + OldToNewID: result.OldToNewID, + RemovedIDs: result.RemovedIDs, + }, nil + case SchemaMinimal: + if input.Minimal == nil { + return ApplyArtifactResult{}, fmt.Errorf("minimal artifact payload is missing") + } + result, err := ApplyMinimal(*input.Minimal, opts) + if err != nil { + return ApplyArtifactResult{}, err + } + out := result.Transcript + return ApplyArtifactResult{ + Artifact: Artifact{ + Schema: SchemaMinimal, + Minimal: &out, + }, + OldToNewID: result.OldToNewID, + RemovedIDs: result.RemovedIDs, + }, nil + default: + return ApplyArtifactResult{}, fmt.Errorf("unsupported artifact schema %q", input.Schema) + } +} + +// ConvertArtifact converts a parsed artifact to another supported output schema. +func ConvertArtifact(input Artifact, outputSchema string) (Artifact, error) { + if outputSchema == "" || outputSchema == input.Schema { + return input, nil + } + + switch input.Schema { + case SchemaFull: + if input.Full == nil { + return Artifact{}, fmt.Errorf("full artifact payload is missing") + } + switch outputSchema { + case SchemaIntermediate: + out := intermediateFromFull(*input.Full) + return Artifact{ + Schema: SchemaIntermediate, + Intermediate: &out, + }, nil + case SchemaMinimal: + out := minimalFromFull(*input.Full) + return Artifact{ + Schema: SchemaMinimal, + Minimal: &out, + }, nil + default: + return Artifact{}, fmt.Errorf("unsupported output schema %q", outputSchema) + } + case SchemaIntermediate: + if input.Intermediate == nil { + return Artifact{}, fmt.Errorf("intermediate artifact payload is missing") + } + switch outputSchema { + case SchemaMinimal: + out := minimalFromIntermediate(*input.Intermediate) + return Artifact{ + Schema: SchemaMinimal, + Minimal: &out, + }, nil + case SchemaFull: + return Artifact{}, fmt.Errorf("cannot emit %q from %q input artifact", SchemaFull, SchemaIntermediate) + default: + return Artifact{}, fmt.Errorf("unsupported output schema %q", outputSchema) + } + case SchemaMinimal: + if input.Minimal == nil { + return Artifact{}, fmt.Errorf("minimal artifact payload is missing") + } + switch outputSchema { + case SchemaIntermediate: + out := intermediateFromMinimal(*input.Minimal) + return Artifact{ + Schema: SchemaIntermediate, + Intermediate: &out, + }, nil + case SchemaFull: + return Artifact{}, fmt.Errorf("cannot emit %q from %q input artifact", SchemaFull, SchemaMinimal) + default: + return Artifact{}, fmt.Errorf("unsupported output schema %q", outputSchema) + } + default: + return Artifact{}, fmt.Errorf("unsupported input schema %q", input.Schema) + } +} + +func intermediateFromFull(input schema.Transcript) schema.IntermediateTranscript { + segments := make([]schema.IntermediateSegment, len(input.Segments)) + for index, segment := range input.Segments { + segments[index] = schema.IntermediateSegment{ + ID: segment.ID, + Start: segment.Start, + End: segment.End, + Speaker: segment.Speaker, + Text: segment.Text, + Categories: append([]string(nil), segment.Categories...), + } + } + return schema.IntermediateTranscript{ + Metadata: schema.IntermediateMetadata{ + Application: input.Metadata.Application, + Version: input.Metadata.Version, + OutputSchema: SchemaIntermediate, + }, + Segments: segments, + } +} + +func minimalFromFull(input schema.Transcript) schema.MinimalTranscript { + segments := make([]schema.MinimalSegment, len(input.Segments)) + for index, segment := range input.Segments { + segments[index] = schema.MinimalSegment{ + ID: segment.ID, + Start: segment.Start, + End: segment.End, + Speaker: segment.Speaker, + Text: segment.Text, + } + } + return schema.MinimalTranscript{ + Metadata: schema.MinimalMetadata{ + Application: input.Metadata.Application, + Version: input.Metadata.Version, + OutputSchema: SchemaMinimal, + }, + Segments: segments, + } +} + +func minimalFromIntermediate(input schema.IntermediateTranscript) schema.MinimalTranscript { + segments := make([]schema.MinimalSegment, len(input.Segments)) + for index, segment := range input.Segments { + segments[index] = schema.MinimalSegment{ + ID: segment.ID, + Start: segment.Start, + End: segment.End, + Speaker: segment.Speaker, + Text: segment.Text, + } + } + return schema.MinimalTranscript{ + Metadata: schema.MinimalMetadata{ + Application: input.Metadata.Application, + Version: input.Metadata.Version, + OutputSchema: SchemaMinimal, + }, + Segments: segments, + } +} + +func intermediateFromMinimal(input schema.MinimalTranscript) schema.IntermediateTranscript { + segments := make([]schema.IntermediateSegment, len(input.Segments)) + for index, segment := range input.Segments { + segments[index] = schema.IntermediateSegment{ + ID: segment.ID, + Start: segment.Start, + End: segment.End, + Speaker: segment.Speaker, + Text: segment.Text, + } + } + return schema.IntermediateTranscript{ + Metadata: schema.IntermediateMetadata{ + Application: input.Metadata.Application, + Version: input.Metadata.Version, + OutputSchema: SchemaIntermediate, + }, + Segments: segments, + } +}