From f461922b9b760ddb3498d018eb47afcc3376e329 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 11 May 2026 13:23:59 +0000 Subject: [PATCH] Apply correction proposals to transcripts --- internal/framework/proposals/apply.go | 155 +++++++++++ internal/framework/proposals/apply_test.go | 293 +++++++++++++++++++++ 2 files changed, 448 insertions(+) create mode 100644 internal/framework/proposals/apply.go create mode 100644 internal/framework/proposals/apply_test.go diff --git a/internal/framework/proposals/apply.go b/internal/framework/proposals/apply.go new file mode 100644 index 0000000..4c66cea --- /dev/null +++ b/internal/framework/proposals/apply.go @@ -0,0 +1,155 @@ +package proposals + +import ( + "sort" + + "gitea.maximumdirect.net/eric/audita/internal/core/schema" +) + +// AppliedChange records a proposal that was successfully applied. +type AppliedChange struct { + ProposalIndex int `json:"proposal_index"` + ModuleKey string `json:"module_key"` + ModuleInstance string `json:"module_instance"` + TargetSegmentID int `json:"target_segment_id"` + OriginalText string `json:"original_text"` + CorrectedText string `json:"corrected_text"` + ReplacementCount int `json:"replacement_count"` +} + +// SkippedChange records a proposal that was not applied safely. +type SkippedChange struct { + ProposalIndex int `json:"proposal_index"` + ModuleKey string `json:"module_key"` + ModuleInstance string `json:"module_instance"` + TargetSegmentID int `json:"target_segment_id"` + OriginalText string `json:"original_text"` + CorrectedText string `json:"corrected_text"` + SkipReason ProposalSkipReason `json:"skip_reason"` + Message string `json:"message"` +} + +// ApplyResult is the deterministic output of applying proposals. +type ApplyResult struct { + Transcript *schema.Transcript `json:"transcript"` + Applied []AppliedChange `json:"applied_changes"` + Skipped []SkippedChange `json:"skipped_changes"` +} + +// ApplyProposals applies approved proposals against a working transcript copy. +// Proposals are applied in ascending proposal_index order. Later proposals run +// against segment text produced by earlier proposals. +func ApplyProposals( + transcript *schema.Transcript, + proposals []EnrichedCorrectionProposal, + policy ReplacementPolicy, +) ApplyResult { + working := cloneTranscript(transcript) + ordered := sortProposalsByIndex(proposals) + + applied := make([]AppliedChange, 0, len(ordered)) + skipped := make([]SkippedChange, 0) + + segmentIndexByID := make(map[int]int, len(working.Segments)) + for i, seg := range working.Segments { + segmentIndexByID[seg.ID] = i + } + + for _, proposal := range ordered { + segmentIdx, found := segmentIndexByID[proposal.TargetSegmentID] + if !found { + skipped = append(skipped, newSkippedChange(proposal, SkipReasonMissingSegment)) + continue + } + + preview := PreviewProposalForSegment(&working.Segments[segmentIdx], proposal.CorrectionProposal, policy) + if !preview.Applicable { + skipped = append(skipped, newSkippedChange(proposal, preview.SkipReason)) + continue + } + + working.Segments[segmentIdx].Text = preview.CorrectedSegmentText + applied = append(applied, AppliedChange{ + ProposalIndex: proposal.ProposalIndex, + ModuleKey: proposal.ModuleKey, + ModuleInstance: proposal.ModuleInstance, + TargetSegmentID: proposal.TargetSegmentID, + OriginalText: proposal.OriginalText, + CorrectedText: proposal.CorrectedText, + ReplacementCount: preview.ReplacementCount, + }) + } + + return ApplyResult{ + Transcript: working, + Applied: applied, + Skipped: skipped, + } +} + +func sortProposalsByIndex(proposals []EnrichedCorrectionProposal) []EnrichedCorrectionProposal { + ordered := make([]EnrichedCorrectionProposal, len(proposals)) + copy(ordered, proposals) + + sort.SliceStable(ordered, func(i, j int) bool { + return ordered[i].ProposalIndex < ordered[j].ProposalIndex + }) + + return ordered +} + +func cloneTranscript(t *schema.Transcript) *schema.Transcript { + if t == nil { + return &schema.Transcript{} + } + + segments := make([]schema.Segment, len(t.Segments)) + for i, s := range t.Segments { + var categories []string + if s.Categories != nil { + categories = make([]string, len(s.Categories)) + copy(categories, s.Categories) + } + + segments[i] = schema.Segment{ + ID: s.ID, + Speaker: s.Speaker, + Start: s.Start, + End: s.End, + Text: s.Text, + Categories: categories, + } + } + + return &schema.Transcript{Segments: segments} +} + +func newSkippedChange(proposal EnrichedCorrectionProposal, reason ProposalSkipReason) SkippedChange { + return SkippedChange{ + ProposalIndex: proposal.ProposalIndex, + ModuleKey: proposal.ModuleKey, + ModuleInstance: proposal.ModuleInstance, + TargetSegmentID: proposal.TargetSegmentID, + OriginalText: proposal.OriginalText, + CorrectedText: proposal.CorrectedText, + SkipReason: reason, + Message: skipReasonMessage(reason), + } +} + +func skipReasonMessage(reason ProposalSkipReason) string { + switch reason { + case SkipReasonMissingSegment: + return "target segment was not found" + case SkipReasonMissingOriginalText: + return "original_text was not found in current segment text" + case SkipReasonAmbiguousOriginal: + return "original_text matched multiple spans under require_unique policy" + case SkipReasonNoEffect: + return "original_text and corrected_text are identical" + case SkipReasonInvalidProposal: + return "proposal failed structural or policy validation" + default: + return "proposal could not be applied safely" + } +} diff --git a/internal/framework/proposals/apply_test.go b/internal/framework/proposals/apply_test.go new file mode 100644 index 0000000..fd5d3ad --- /dev/null +++ b/internal/framework/proposals/apply_test.go @@ -0,0 +1,293 @@ +package proposals + +import ( + "reflect" + "testing" + + "gitea.maximumdirect.net/eric/audita/internal/core/schema" +) + +func TestApplyProposalsApplyingOneProposal(t *testing.T) { + transcript := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "gestures"}}} + proposals := []EnrichedCorrectionProposal{ + { + CorrectionProposal: CorrectionProposal{ + TargetSegmentID: 1, + OriginalText: "gestures", + CorrectedText: "Jesters", + Confidence: 0.95, + }, + ProposalMetadata: ProposalMetadata{ProposalIndex: 0, ModuleKey: "glossary", ModuleInstance: "glossary_1"}, + }, + } + + result := ApplyProposals(transcript, proposals, ReplacementPolicyRequireUnique) + + if got := result.Transcript.Segments[0].Text; got != "Jesters" { + t.Fatalf("expected corrected text Jesters, got %q", got) + } + if len(result.Applied) != 1 { + t.Fatalf("expected 1 applied change, got %d", len(result.Applied)) + } + if len(result.Skipped) != 0 { + t.Fatalf("expected 0 skipped changes, got %d", len(result.Skipped)) + } +} + +func TestApplyProposalsApplyingMultipleProposalsInStableOrder(t *testing.T) { + transcript := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "rank rank"}}} + proposals := []EnrichedCorrectionProposal{ + { + CorrectionProposal: CorrectionProposal{ + TargetSegmentID: 1, + OriginalText: "Hrank", + CorrectedText: "X", + Confidence: 0.9, + }, + ProposalMetadata: ProposalMetadata{ProposalIndex: 2, ModuleKey: "grammar", ModuleInstance: "grammar_1"}, + }, + { + CorrectionProposal: CorrectionProposal{ + TargetSegmentID: 1, + OriginalText: "rank", + CorrectedText: "Hrank", + Confidence: 0.9, + }, + ProposalMetadata: ProposalMetadata{ProposalIndex: 1, ModuleKey: "homophones", ModuleInstance: "homophones"}, + }, + } + + result := ApplyProposals(transcript, proposals, ReplacementPolicyReplaceAll) + + if got := result.Transcript.Segments[0].Text; got != "X X" { + t.Fatalf("expected final text X X, got %q", got) + } + if len(result.Applied) != 2 { + t.Fatalf("expected 2 applied changes, got %d", len(result.Applied)) + } + if result.Applied[0].ProposalIndex != 1 || result.Applied[1].ProposalIndex != 2 { + t.Fatalf("expected applied order [1,2], got [%d,%d]", result.Applied[0].ProposalIndex, result.Applied[1].ProposalIndex) + } +} + +func TestApplyProposalsStaleProposalSkippedAfterEarlierChange(t *testing.T) { + transcript := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "foo bar"}}} + proposals := []EnrichedCorrectionProposal{ + { + CorrectionProposal: CorrectionProposal{ + TargetSegmentID: 1, + OriginalText: "foo bar", + CorrectedText: "foo-bar", + Confidence: 0.9, + }, + ProposalMetadata: ProposalMetadata{ProposalIndex: 0, ModuleKey: "grammar", ModuleInstance: "grammar_1"}, + }, + { + CorrectionProposal: CorrectionProposal{ + TargetSegmentID: 1, + OriginalText: "foo bar", + CorrectedText: "foobar", + Confidence: 0.9, + }, + ProposalMetadata: ProposalMetadata{ProposalIndex: 1, ModuleKey: "grammar", ModuleInstance: "grammar_1"}, + }, + } + + result := ApplyProposals(transcript, proposals, ReplacementPolicyRequireUnique) + + if got := result.Transcript.Segments[0].Text; got != "foo-bar" { + t.Fatalf("expected final text foo-bar, got %q", got) + } + if len(result.Applied) != 1 { + t.Fatalf("expected 1 applied change, got %d", len(result.Applied)) + } + if len(result.Skipped) != 1 { + t.Fatalf("expected 1 skipped change, got %d", len(result.Skipped)) + } + if result.Skipped[0].SkipReason != SkipReasonMissingOriginalText { + t.Fatalf("expected stale skip reason %q, got %q", SkipReasonMissingOriginalText, result.Skipped[0].SkipReason) + } +} + +func TestApplyProposalsMissingSegmentSkip(t *testing.T) { + transcript := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "hello"}}} + proposals := []EnrichedCorrectionProposal{ + { + CorrectionProposal: CorrectionProposal{ + TargetSegmentID: 999, + OriginalText: "hello", + CorrectedText: "hi", + Confidence: 0.9, + }, + ProposalMetadata: ProposalMetadata{ProposalIndex: 0, ModuleKey: "grammar", ModuleInstance: "grammar_1"}, + }, + } + + result := ApplyProposals(transcript, proposals, ReplacementPolicyRequireUnique) + + if len(result.Skipped) != 1 { + t.Fatalf("expected 1 skipped change, got %d", len(result.Skipped)) + } + if result.Skipped[0].SkipReason != SkipReasonMissingSegment { + t.Fatalf("expected skip reason %q, got %q", SkipReasonMissingSegment, result.Skipped[0].SkipReason) + } +} + +func TestApplyProposalsRequireUniqueAmbiguousSkip(t *testing.T) { + transcript := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "uh uh"}}} + proposals := []EnrichedCorrectionProposal{ + { + CorrectionProposal: CorrectionProposal{ + TargetSegmentID: 1, + OriginalText: "uh", + CorrectedText: "um", + Confidence: 0.9, + }, + ProposalMetadata: ProposalMetadata{ProposalIndex: 0, ModuleKey: "spoken_word", ModuleInstance: "spoken_word"}, + }, + } + + result := ApplyProposals(transcript, proposals, ReplacementPolicyRequireUnique) + + if len(result.Skipped) != 1 { + t.Fatalf("expected 1 skipped change, got %d", len(result.Skipped)) + } + if result.Skipped[0].SkipReason != SkipReasonAmbiguousOriginal { + t.Fatalf("expected skip reason %q, got %q", SkipReasonAmbiguousOriginal, result.Skipped[0].SkipReason) + } +} + +func TestApplyProposalsReplaceAllMultipleReplacements(t *testing.T) { + transcript := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "uh uh uh"}}} + proposals := []EnrichedCorrectionProposal{ + { + CorrectionProposal: CorrectionProposal{ + TargetSegmentID: 1, + OriginalText: "uh", + CorrectedText: "um", + Confidence: 0.9, + }, + ProposalMetadata: ProposalMetadata{ProposalIndex: 0, ModuleKey: "spoken_word", ModuleInstance: "spoken_word"}, + }, + } + + result := ApplyProposals(transcript, proposals, ReplacementPolicyReplaceAll) + + if len(result.Applied) != 1 { + t.Fatalf("expected 1 applied change, got %d", len(result.Applied)) + } + if got := result.Transcript.Segments[0].Text; got != "um um um" { + t.Fatalf("expected final text um um um, got %q", got) + } + if result.Applied[0].ReplacementCount != 3 { + t.Fatalf("expected replacement count 3, got %d", result.Applied[0].ReplacementCount) + } +} + +func TestApplyProposalsNoMutationOfInputTranscript(t *testing.T) { + transcript := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "hello"}}} + before := cloneTranscript(transcript) + + proposals := []EnrichedCorrectionProposal{ + { + CorrectionProposal: CorrectionProposal{ + TargetSegmentID: 1, + OriginalText: "hello", + CorrectedText: "hi", + Confidence: 0.9, + }, + ProposalMetadata: ProposalMetadata{ProposalIndex: 0, ModuleKey: "grammar", ModuleInstance: "grammar_1"}, + }, + } + + _ = ApplyProposals(transcript, proposals, ReplacementPolicyRequireUnique) + + if !reflect.DeepEqual(transcript, before) { + t.Fatalf("input transcript mutated: before=%+v after=%+v", before, transcript) + } +} + +func TestApplyProposalsSegmentMetadataPreservation(t *testing.T) { + transcript := &schema.Transcript{Segments: []schema.Segment{{ + ID: 1, + Speaker: "A", + Start: 12.5, + End: 15.0, + Text: "rank", + Categories: []string{"combat", "term"}, + }}} + + proposals := []EnrichedCorrectionProposal{ + { + CorrectionProposal: CorrectionProposal{ + TargetSegmentID: 1, + OriginalText: "rank", + CorrectedText: "Hrank", + Confidence: 0.9, + }, + ProposalMetadata: ProposalMetadata{ProposalIndex: 0, ModuleKey: "glossary", ModuleInstance: "glossary_1"}, + }, + } + + result := ApplyProposals(transcript, proposals, ReplacementPolicyRequireUnique) + seg := result.Transcript.Segments[0] + + if seg.Speaker != "A" || seg.Start != 12.5 || seg.End != 15.0 { + t.Fatalf("segment metadata changed: %+v", seg) + } + if !reflect.DeepEqual(seg.Categories, []string{"combat", "term"}) { + t.Fatalf("segment categories changed: %v", seg.Categories) + } +} + +func TestApplyProposalsAppliedAndSkippedRecords(t *testing.T) { + transcript := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "hello"}}} + proposals := []EnrichedCorrectionProposal{ + { + CorrectionProposal: CorrectionProposal{ + TargetSegmentID: 1, + OriginalText: "hello", + CorrectedText: "hi", + Confidence: 0.9, + }, + ProposalMetadata: ProposalMetadata{ProposalIndex: 0, ModuleKey: "grammar", ModuleInstance: "grammar_1"}, + }, + { + CorrectionProposal: CorrectionProposal{ + TargetSegmentID: 999, + OriginalText: "x", + CorrectedText: "y", + Confidence: 0.9, + }, + ProposalMetadata: ProposalMetadata{ProposalIndex: 1, ModuleKey: "grammar", ModuleInstance: "grammar_1"}, + }, + } + + result := ApplyProposals(transcript, proposals, ReplacementPolicyRequireUnique) + + if len(result.Applied) != 1 || len(result.Skipped) != 1 { + t.Fatalf("expected 1 applied and 1 skipped, got applied=%d skipped=%d", len(result.Applied), len(result.Skipped)) + } + + applied := result.Applied[0] + if applied.ProposalIndex != 0 || applied.ModuleKey != "grammar" || applied.ModuleInstance != "grammar_1" { + t.Fatalf("unexpected applied record metadata: %+v", applied) + } + if applied.TargetSegmentID != 1 || applied.OriginalText != "hello" || applied.CorrectedText != "hi" || applied.ReplacementCount != 1 { + t.Fatalf("unexpected applied record contents: %+v", applied) + } + + skipped := result.Skipped[0] + if skipped.ProposalIndex != 1 || skipped.ModuleKey != "grammar" || skipped.ModuleInstance != "grammar_1" { + t.Fatalf("unexpected skipped record metadata: %+v", skipped) + } + if skipped.TargetSegmentID != 999 || skipped.OriginalText != "x" || skipped.CorrectedText != "y" { + t.Fatalf("unexpected skipped record contents: %+v", skipped) + } + if skipped.SkipReason != SkipReasonMissingSegment { + t.Fatalf("unexpected skipped reason: %q", skipped.SkipReason) + } + if skipped.Message == "" { + t.Fatal("expected non-empty skipped message") + } +}