Apply correction proposals to transcripts
This commit is contained in:
155
internal/framework/proposals/apply.go
Normal file
155
internal/framework/proposals/apply.go
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
293
internal/framework/proposals/apply_test.go
Normal file
293
internal/framework/proposals/apply_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user