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