Added initial segment overlap resolution logic

This commit is contained in:
2026-04-27 15:52:53 -05:00
parent e42a2326e8
commit 1b9f4bd922
16 changed files with 1357 additions and 59 deletions

View File

@@ -15,7 +15,7 @@ func TestMergeWritesMergedOutputAndReport(t *testing.T) {
dir := t.TempDir()
inputA := writeJSONFile(t, dir, "a.json", `{
"segments": [
{"start": 10, "end": 11, "text": " second a ", "words": [{"word": "ignored"}]},
{"start": 10, "end": 11, "text": " second a ", "words": [{"word": "ignored", "start": 10.1, "end": 10.2}]},
{"start": 1, "end": 2, "text": "first a"}
]
}`)
@@ -217,6 +217,107 @@ func TestMergeDetectsOverlapGroups(t *testing.T) {
}
}
func TestMergeResolvesOverlapGroupsWithWordRuns(t *testing.T) {
dir := t.TempDir()
inputA := writeJSONFile(t, dir, "a.json", `{
"segments": [
{
"start": 1,
"end": 5,
"text": "alice original",
"words": [
{"word": "outside", "start": 0.5, "end": 1.0},
{"word": "hello", "start": 1.1, "end": 1.2, "score": 0.98, "speaker": "SPEAKER_00"},
{"word": "there", "start": 1.8, "end": 2.0},
{"word": "later", "start": 3.0, "end": 3.1}
]
}
]
}`)
inputB := writeJSONFile(t, dir, "b.json", `{
"segments": [
{
"start": 1.5,
"end": 4,
"text": "bob original",
"words": [
{"word": "bob", "start": 1.55, "end": 1.7},
{"word": "reply", "start": 2.0, "end": 2.2}
]
}
]
}`)
speakers := writeYAMLFile(t, dir, "speakers.yml", `match:
- speaker: Alice
match: ["a.json"]
- speaker: Bob
match: ["b.json"]
`)
output := filepath.Join(dir, "merged.json")
reportPath := filepath.Join(dir, "report.json")
err := executeMerge(
"--input-file", inputB,
"--input-file", inputA,
"--speakers", speakers,
"--output-file", output,
"--report-file", reportPath,
)
if err != nil {
t.Fatalf("merge failed: %v", err)
}
var transcript model.FinalTranscript
readJSON(t, output, &transcript)
if len(transcript.OverlapGroups) != 0 {
t.Fatalf("overlap groups = %#v, want none", transcript.OverlapGroups)
}
if got, want := len(transcript.Segments), 3; got != want {
t.Fatalf("segment count = %d, want %d", got, want)
}
wantTexts := []string{"hello there", "bob reply", "later"}
wantSpeakers := []string{"Alice", "Bob", "Alice"}
wantRefs := []string{"word-run:1:1:1", "word-run:1:2:1", "word-run:1:1:2"}
for index, segment := range transcript.Segments {
if segment.ID != index+1 {
t.Fatalf("segment %d id = %d, want %d", index, segment.ID, index+1)
}
if segment.Text != wantTexts[index] {
t.Fatalf("segment %d text = %q, want %q", index, segment.Text, wantTexts[index])
}
if segment.Speaker != wantSpeakers[index] {
t.Fatalf("segment %d speaker = %q, want %q", index, segment.Speaker, wantSpeakers[index])
}
if segment.SourceRef != wantRefs[index] {
t.Fatalf("segment %d source_ref = %q, want %q", index, segment.SourceRef, wantRefs[index])
}
if segment.SourceSegmentIndex != nil {
t.Fatalf("segment %d source_segment_index = %d, want nil", index, *segment.SourceSegmentIndex)
}
if segment.OverlapGroupID != 0 {
t.Fatalf("segment %d overlap_group_id = %d, want 0", index, segment.OverlapGroupID)
}
}
if !equalStrings(transcript.Segments[0].DerivedFrom, []string{inputA + "#0"}) {
t.Fatalf("segment 0 derived_from = %v", transcript.Segments[0].DerivedFrom)
}
outputBytes, err := os.ReadFile(output)
if err != nil {
t.Fatalf("read output bytes: %v", err)
}
if strings.Contains(string(outputBytes), "words") {
t.Fatalf("did not expect word timing in output:\n%s", outputBytes)
}
var rpt report.Report
readJSON(t, reportPath, &rpt)
if !hasReportEvent(rpt, "postprocessing", "resolve-overlaps", "processed 1 overlap group(s); changed 1; removed 2 original segment(s); created 3 replacement segment(s)") {
t.Fatal("expected resolve-overlaps summary report event")
}
}
func TestSpeakerMatchingUsesFirstMatchingRuleCaseInsensitive(t *testing.T) {
dir := t.TempDir()
input := writeJSONFile(t, dir, "2026-04-19-Adam_Rakestraw.json", `{
@@ -650,6 +751,194 @@ func TestInvalidSegmentFieldsFailWithSourceAndIndex(t *testing.T) {
}
}
func TestInvalidWordFieldsFailWithSourceAndIndex(t *testing.T) {
tests := []struct {
name string
json string
want string
}{
{
name: "words not array",
json: `{"segments":[{"start":0,"end":1,"text":"x","words":{}}]}`,
want: "segment 0 words must be an array",
},
{
name: "missing word",
json: `{"segments":[{"start":0,"end":1,"text":"x","words":[{"start":0,"end":0.1}]}]}`,
want: "segment 0 word 0 missing string word",
},
{
name: "wrong typed word",
json: `{"segments":[{"start":0,"end":1,"text":"x","words":[{"word":7,"start":0,"end":0.1}]}]}`,
want: "segment 0 word 0 word must be a string",
},
{
name: "wrong typed start",
json: `{"segments":[{"start":0,"end":1,"text":"x","words":[{"word":"x","start":"0","end":0.1}]}]}`,
want: "segment 0 word 0 start must be numeric",
},
{
name: "wrong typed end",
json: `{"segments":[{"start":0,"end":1,"text":"x","words":[{"word":"x","start":0,"end":"0.1"}]}]}`,
want: "segment 0 word 0 end must be numeric",
},
{
name: "wrong typed score",
json: `{"segments":[{"start":0,"end":1,"text":"x","words":[{"word":"x","start":0,"end":0.1,"score":"good"}]}]}`,
want: "segment 0 word 0 score must be numeric",
},
{
name: "wrong typed speaker",
json: `{"segments":[{"start":0,"end":1,"text":"x","words":[{"word":"x","start":0,"end":0.1,"speaker":7}]}]}`,
want: "segment 0 word 0 speaker must be a string",
},
{
name: "negative start",
json: `{"segments":[{"start":0,"end":1,"text":"x","words":[{"word":"x","start":-0.1,"end":0.1}]}]}`,
want: "segment 0 word 0 has negative start",
},
{
name: "end before start",
json: `{"segments":[{"start":0,"end":1,"text":"x","words":[{"word":"x","start":0.2,"end":0.1}]}]}`,
want: "segment 0 word 0 has end before start",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
dir := t.TempDir()
input := writeJSONFile(t, dir, "input.json", test.json)
output := filepath.Join(dir, "merged.json")
err := executeMerge(
"--input-file", input,
"--output-file", output,
)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), input) {
t.Fatalf("expected error to contain source path %q, got %v", input, err)
}
if !strings.Contains(err.Error(), test.want) {
t.Fatalf("expected error to contain %q, got %v", test.want, err)
}
})
}
}
func TestUntimedWordsAreAcceptedAndReported(t *testing.T) {
dir := t.TempDir()
input := writeJSONFile(t, dir, "input.json", `{
"segments": [
{
"start": 1,
"end": 2,
"text": "about 13",
"words": [
{"word": "about", "start": 1.1, "end": 1.2},
{"word": "13"}
]
}
]
}`)
output := filepath.Join(dir, "merged.json")
reportPath := filepath.Join(dir, "report.json")
err := executeMerge(
"--input-file", input,
"--output-file", output,
"--report-file", reportPath,
)
if err != nil {
t.Fatalf("merge failed: %v", err)
}
var rpt report.Report
readJSON(t, reportPath, &rpt)
if !hasReportEvent(rpt, "input", "json-files", `segment 0 word 1 "13" has no complete timing`) {
t.Fatal("expected untimed word warning report event")
}
foundWarning := false
for _, event := range rpt.Events {
if event.Stage == "input" && event.Module == "json-files" && strings.Contains(event.Message, `"13" has no complete timing`) {
foundWarning = event.Severity == report.SeverityWarning
}
}
if !foundWarning {
t.Fatal("expected untimed word event to use warning severity")
}
}
func TestMergeResolutionPreservesUntimedWordText(t *testing.T) {
dir := t.TempDir()
inputA := writeJSONFile(t, dir, "a.json", `{
"segments": [
{
"start": 1,
"end": 3,
"text": "about 13 and a half",
"words": [
{"word": "about", "start": 1.1, "end": 1.2},
{"word": "13"},
{"word": "and", "start": 1.24, "end": 1.3},
{"word": "a", "start": 1.32, "end": 1.34},
{"word": "half", "start": 1.36, "end": 1.5}
]
}
]
}`)
inputB := writeJSONFile(t, dir, "b.json", `{
"segments": [
{
"start": 1.15,
"end": 2,
"text": "bob overlap",
"words": [
{"word": "bob", "start": 1.16, "end": 1.25},
{"word": "overlap", "start": 1.3, "end": 1.5}
]
}
]
}`)
speakers := writeYAMLFile(t, dir, "speakers.yml", `match:
- speaker: Alice
match: ["a.json"]
- speaker: Bob
match: ["b.json"]
`)
output := filepath.Join(dir, "merged.json")
err := executeMerge(
"--input-file", inputA,
"--input-file", inputB,
"--speakers", speakers,
"--output-file", output,
)
if err != nil {
t.Fatalf("merge failed: %v", err)
}
var transcript model.FinalTranscript
readJSON(t, output, &transcript)
if len(transcript.OverlapGroups) != 0 {
t.Fatalf("expected overlap group to be resolved, got %#v", transcript.OverlapGroups)
}
found := false
for _, segment := range transcript.Segments {
if segment.Speaker == "Alice" && segment.Text == "about 13 and a half" {
found = true
if segment.Start != 1.1 || segment.End != 1.5 {
t.Fatalf("Alice replacement bounds = %f-%f, want 1.1-1.5", segment.Start, segment.End)
}
}
}
if !found {
t.Fatalf("expected Alice replacement to preserve untimed word text, got %#v", transcript.Segments)
}
}
func TestInvalidTimingFails(t *testing.T) {
tests := []struct {
name string
@@ -761,8 +1050,11 @@ func assertSegment(t *testing.T, segment model.Segment, id int, source string, s
if segment.Source != source {
t.Fatalf("segment source = %q, want %q", segment.Source, source)
}
if segment.SourceSegmentIndex != sourceIndex {
t.Fatalf("segment source index = %d, want %d", segment.SourceSegmentIndex, sourceIndex)
if segment.SourceSegmentIndex == nil {
t.Fatalf("segment source index = nil, want %d", sourceIndex)
}
if *segment.SourceSegmentIndex != sourceIndex {
t.Fatalf("segment source index = %d, want %d", *segment.SourceSegmentIndex, sourceIndex)
}
if segment.Speaker != speaker {
t.Fatalf("segment speaker = %q, want %q", segment.Speaker, speaker)