Add safe replacement preview logic

This commit is contained in:
2026-05-11 13:20:35 +00:00
parent 1eb93481e0
commit c58d307ba7
2 changed files with 252 additions and 0 deletions

View File

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

View File

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