158 lines
4.9 KiB
Go
158 lines
4.9 KiB
Go
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 SkipReasonEmptyResultingText:
|
|
return "proposal would leave the segment empty"
|
|
case SkipReasonInvalidProposal:
|
|
return "proposal failed structural or policy validation"
|
|
default:
|
|
return "proposal could not be applied safely"
|
|
}
|
|
}
|