From c58d307ba7e08db43b1b19d3e1f17b4c8bfe9b90 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 11 May 2026 13:20:35 +0000 Subject: [PATCH] Add safe replacement preview logic --- internal/framework/proposals/preview.go | 81 +++++++++ internal/framework/proposals/preview_test.go | 171 +++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 internal/framework/proposals/preview.go create mode 100644 internal/framework/proposals/preview_test.go diff --git a/internal/framework/proposals/preview.go b/internal/framework/proposals/preview.go new file mode 100644 index 0000000..ccc5c21 --- /dev/null +++ b/internal/framework/proposals/preview.go @@ -0,0 +1,81 @@ +package proposals + +import ( + "strings" + + "gitea.maximumdirect.net/eric/audita/internal/core/schema" +) + +// ProposalSkipReason is a stable, report-friendly reason code for +// unapplied/unsafe proposals. +type ProposalSkipReason string + +const ( + SkipReasonMissingSegment ProposalSkipReason = "missing_segment" + SkipReasonMissingOriginalText ProposalSkipReason = "missing_original_text" + SkipReasonAmbiguousOriginal ProposalSkipReason = "ambiguous_original_text" + SkipReasonNoEffect ProposalSkipReason = "no_effect" + SkipReasonInvalidProposal ProposalSkipReason = "invalid_proposal" +) + +// SegmentPreviewResult is a deterministic preview of applying one proposal to +// one transcript segment. +type SegmentPreviewResult struct { + Applicable bool `json:"applicable"` + CorrectedSegmentText string `json:"corrected_segment_text,omitempty"` + ReplacementCount int `json:"replacement_count"` + SkipReason ProposalSkipReason `json:"skip_reason,omitempty"` +} + +// PreviewProposalForSegment previews applying a proposal to a single segment +// under the given replacement policy without mutating the input segment. +func PreviewProposalForSegment(segment *schema.Segment, proposal CorrectionProposal, policy ReplacementPolicy) SegmentPreviewResult { + if segment == nil { + return SegmentPreviewResult{SkipReason: SkipReasonMissingSegment} + } + + if err := proposal.Validate(); err != nil { + return SegmentPreviewResult{SkipReason: SkipReasonInvalidProposal} + } + + if !policy.IsValid() { + return SegmentPreviewResult{SkipReason: SkipReasonInvalidProposal} + } + + if proposal.TargetSegmentID != segment.ID { + return SegmentPreviewResult{SkipReason: SkipReasonMissingSegment} + } + + matchCount := strings.Count(segment.Text, proposal.OriginalText) + if matchCount == 0 { + return SegmentPreviewResult{SkipReason: SkipReasonMissingOriginalText} + } + + if proposal.OriginalText == proposal.CorrectedText { + return SegmentPreviewResult{SkipReason: SkipReasonNoEffect} + } + + switch policy { + case ReplacementPolicyRequireUnique: + if matchCount != 1 { + return SegmentPreviewResult{SkipReason: SkipReasonAmbiguousOriginal} + } + + corrected := strings.Replace(segment.Text, proposal.OriginalText, proposal.CorrectedText, 1) + return SegmentPreviewResult{ + Applicable: true, + CorrectedSegmentText: corrected, + ReplacementCount: 1, + } + + case ReplacementPolicyReplaceAll: + corrected := strings.ReplaceAll(segment.Text, proposal.OriginalText, proposal.CorrectedText) + return SegmentPreviewResult{ + Applicable: true, + CorrectedSegmentText: corrected, + ReplacementCount: matchCount, + } + } + + return SegmentPreviewResult{SkipReason: SkipReasonInvalidProposal} +} diff --git a/internal/framework/proposals/preview_test.go b/internal/framework/proposals/preview_test.go new file mode 100644 index 0000000..39564d0 --- /dev/null +++ b/internal/framework/proposals/preview_test.go @@ -0,0 +1,171 @@ +package proposals + +import ( + "reflect" + "testing" + + "gitea.maximumdirect.net/eric/audita/internal/core/schema" +) + +func TestPreviewProposalForSegmentRequireUniqueSuccess(t *testing.T) { + segment := &schema.Segment{ID: 7, Text: "The gestures are odd."} + proposal := CorrectionProposal{ + TargetSegmentID: 7, + OriginalText: "gestures", + CorrectedText: "Jesters", + Confidence: 0.9, + } + + result := PreviewProposalForSegment(segment, proposal, ReplacementPolicyRequireUnique) + if !result.Applicable { + t.Fatalf("expected applicable preview, got skip reason %q", result.SkipReason) + } + if result.CorrectedSegmentText != "The Jesters are odd." { + t.Fatalf("unexpected corrected text: %q", result.CorrectedSegmentText) + } + if result.ReplacementCount != 1 { + t.Fatalf("expected 1 replacement, got %d", result.ReplacementCount) + } +} + +func TestPreviewProposalForSegmentRequireUniqueMissingOriginalText(t *testing.T) { + segment := &schema.Segment{ID: 7, Text: "The jesters are odd."} + proposal := CorrectionProposal{ + TargetSegmentID: 7, + OriginalText: "gestures", + CorrectedText: "Jesters", + Confidence: 0.9, + } + + result := PreviewProposalForSegment(segment, proposal, ReplacementPolicyRequireUnique) + if result.Applicable { + t.Fatal("expected non-applicable preview") + } + if result.SkipReason != SkipReasonMissingOriginalText { + t.Fatalf("expected skip reason %q, got %q", SkipReasonMissingOriginalText, result.SkipReason) + } +} + +func TestPreviewProposalForSegmentRequireUniqueAmbiguousOriginalText(t *testing.T) { + segment := &schema.Segment{ID: 7, Text: "rank and rank"} + proposal := CorrectionProposal{ + TargetSegmentID: 7, + OriginalText: "rank", + CorrectedText: "Hrank", + Confidence: 0.9, + } + + result := PreviewProposalForSegment(segment, proposal, ReplacementPolicyRequireUnique) + if result.Applicable { + t.Fatal("expected non-applicable preview") + } + if result.SkipReason != SkipReasonAmbiguousOriginal { + t.Fatalf("expected skip reason %q, got %q", SkipReasonAmbiguousOriginal, result.SkipReason) + } +} + +func TestPreviewProposalForSegmentReplaceAllSuccessMultipleOccurrences(t *testing.T) { + segment := &schema.Segment{ID: 8, Text: "uh uh uh"} + proposal := CorrectionProposal{ + TargetSegmentID: 8, + OriginalText: "uh", + CorrectedText: "um", + Confidence: 0.9, + } + + result := PreviewProposalForSegment(segment, proposal, ReplacementPolicyReplaceAll) + if !result.Applicable { + t.Fatalf("expected applicable preview, got skip reason %q", result.SkipReason) + } + if result.CorrectedSegmentText != "um um um" { + t.Fatalf("unexpected corrected text: %q", result.CorrectedSegmentText) + } + if result.ReplacementCount != 3 { + t.Fatalf("expected 3 replacements, got %d", result.ReplacementCount) + } +} + +func TestPreviewProposalForSegmentReplaceAllMissingOriginalText(t *testing.T) { + segment := &schema.Segment{ID: 8, Text: "um um um"} + proposal := CorrectionProposal{ + TargetSegmentID: 8, + OriginalText: "uh", + CorrectedText: "um", + Confidence: 0.9, + } + + result := PreviewProposalForSegment(segment, proposal, ReplacementPolicyReplaceAll) + if result.Applicable { + t.Fatal("expected non-applicable preview") + } + if result.SkipReason != SkipReasonMissingOriginalText { + t.Fatalf("expected skip reason %q, got %q", SkipReasonMissingOriginalText, result.SkipReason) + } +} + +func TestPreviewProposalForSegmentNoEffectReplacement(t *testing.T) { + segment := &schema.Segment{ID: 8, Text: "hello there"} + proposal := CorrectionProposal{ + TargetSegmentID: 8, + OriginalText: "hello", + CorrectedText: "hello", + Confidence: 0.9, + } + + result := PreviewProposalForSegment(segment, proposal, ReplacementPolicyRequireUnique) + if result.Applicable { + t.Fatal("expected non-applicable preview") + } + if result.SkipReason != SkipReasonNoEffect { + t.Fatalf("expected skip reason %q, got %q", SkipReasonNoEffect, result.SkipReason) + } +} + +func TestPreviewProposalForSegmentPreservesInputSegment(t *testing.T) { + segment := &schema.Segment{ID: 9, Speaker: "A", Start: 1.0, End: 2.0, Text: "rank rank"} + original := *segment + proposal := CorrectionProposal{ + TargetSegmentID: 9, + OriginalText: "rank", + CorrectedText: "Hrank", + Confidence: 0.9, + } + + _ = PreviewProposalForSegment(segment, proposal, ReplacementPolicyReplaceAll) + + if !reflect.DeepEqual(*segment, original) { + t.Fatalf("segment mutated: before=%+v after=%+v", original, *segment) + } +} + +func TestPreviewProposalForSegmentLiteralMatchingCaseSensitiveAndPunctuation(t *testing.T) { + segment := &schema.Segment{ID: 10, Text: "Hello, world."} + + punctuationProposal := CorrectionProposal{ + TargetSegmentID: 10, + OriginalText: "world.", + CorrectedText: "earth.", + Confidence: 0.9, + } + punctuationResult := PreviewProposalForSegment(segment, punctuationProposal, ReplacementPolicyRequireUnique) + if !punctuationResult.Applicable { + t.Fatalf("expected punctuation proposal applicable, got skip reason %q", punctuationResult.SkipReason) + } + if punctuationResult.CorrectedSegmentText != "Hello, earth." { + t.Fatalf("unexpected corrected text: %q", punctuationResult.CorrectedSegmentText) + } + + caseMismatchProposal := CorrectionProposal{ + TargetSegmentID: 10, + OriginalText: "hello", + CorrectedText: "Hi", + Confidence: 0.9, + } + caseMismatchResult := PreviewProposalForSegment(segment, caseMismatchProposal, ReplacementPolicyRequireUnique) + if caseMismatchResult.Applicable { + t.Fatal("expected case-mismatch proposal to be non-applicable") + } + if caseMismatchResult.SkipReason != SkipReasonMissingOriginalText { + t.Fatalf("expected skip reason %q, got %q", SkipReasonMissingOriginalText, caseMismatchResult.SkipReason) + } +}