Add trim CLI command

This commit is contained in:
2026-05-08 14:53:59 +00:00
parent 1c0e4438ae
commit ac3dcf2557
7 changed files with 996 additions and 1 deletions

View File

@@ -17,5 +17,6 @@ func NewRootCommand() *cobra.Command {
}
cmd.AddCommand(newMergeCommand())
cmd.AddCommand(newTrimCommand())
return cmd
}

125
internal/cli/trim.go Normal file
View File

@@ -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)
}

301
internal/cli/trim_test.go Normal file
View File

@@ -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)
}
}
}