Files
audita/internal/framework/proposals/apply.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"
}
}