diff --git a/internal/gdocs/grouping.go b/internal/gdocs/grouping.go index 7c6baaf..d02a641 100644 --- a/internal/gdocs/grouping.go +++ b/internal/gdocs/grouping.go @@ -55,6 +55,51 @@ func GroupActionableSuggestions(suggestions []ActionableSuggestion, structure *D return result } +func ResolveGroupedConflicts(groups []LocationGroupedSuggestions) []LocationGroupedSuggestions { + for i := range groups { + // Clean up the suggestions within each location group + groups[i].Suggestions = resolveSuggestionsInGroup(groups[i].Suggestions) + } + return groups +} + +func resolveSuggestionsInGroup(suggestions []GroupedActionableSuggestion) []GroupedActionableSuggestion { + if len(suggestions) <= 1 { + return suggestions + } + + // 1. Sort by Range Size (Largest to Smallest) + sort.Slice(suggestions, func(i, j int) bool { + sizeI := suggestions[i].Position.EndIndex - suggestions[i].Position.StartIndex + sizeJ := suggestions[j].Position.EndIndex - suggestions[j].Position.StartIndex + return sizeI > sizeJ + }) + + var resolved []GroupedActionableSuggestion + for _, current := range suggestions { + isConflict := false + for _, accepted := range resolved { + // Overlap check + if current.Position.StartIndex < accepted.Position.EndIndex && + current.Position.EndIndex > accepted.Position.StartIndex { + isConflict = true + break + } + } + + if !isConflict { + resolved = append(resolved, current) + } + } + + // 2. Sort back to Document Order + sort.Slice(resolved, func(i, j int) bool { + return resolved[i].Position.StartIndex < resolved[j].Position.StartIndex + }) + + return resolved +} + // groupSuggestionsByID groups suggestions by their ID and merges contiguous atomic operations. // Suggestions with the same ID that are contiguous in position are merged into a single // GroupedActionableSuggestion. Non-contiguous suggestions with the same ID are kept separate. diff --git a/internal/gdocs/grouping_test.go b/internal/gdocs/grouping_test.go index 91aef70..c654f3e 100644 --- a/internal/gdocs/grouping_test.go +++ b/internal/gdocs/grouping_test.go @@ -1172,6 +1172,90 @@ func TestGroupSuggestionsByID_SortedOutput(t *testing.T) { } } +// TestResolveGroupedConflicts_NestedConflict tests that large deletions swallow small nested edits +func TestResolveGroupedConflicts_NestedConflict(t *testing.T) { + // Setup: A large deletion and a tiny replacement inside it + groups := []LocationGroupedSuggestions{ + { + Location: SuggestionLocation{Section: "Body"}, + Suggestions: []GroupedActionableSuggestion{ + { + ID: "suggest.big_delete", + Change: SuggestionChange{ + Type: "delete", + OriginalText: "Ubuntu Core 24 is amazing and should be deleted.", + }, + Position: struct { + StartIndex int64 `json:"start_index"` + EndIndex int64 `json:"end_index"` + }{StartIndex: 1600, EndIndex: 1650}, + }, + { + ID: "suggest.small_fix", + Change: SuggestionChange{ + Type: "replace", + OriginalText: "24", + NewText: "2024", + }, + Position: struct { + StartIndex int64 `json:"start_index"` + EndIndex int64 `json:"end_index"` + }{StartIndex: 1612, EndIndex: 1614}, // This is INSIDE the big delete + }, + }, + }, + } + + resolved := ResolveGroupedConflicts(groups) + + if len(resolved[0].Suggestions) != 1 { + t.Fatalf("Expected 1 suggestion after resolution, got %d", len(resolved[0].Suggestions)) + } + + winner := resolved[0].Suggestions[0] + if winner.ID != "suggest.big_delete" { + t.Errorf("The large deletion should have won, but got %s", winner.ID) + } +} + +// TestResolveGroupedConflicts_PartialOverlap tests resolution when ranges partially touch +func TestResolveGroupedConflicts_PartialOverlap(t *testing.T) { + groups := []LocationGroupedSuggestions{ + { + Location: SuggestionLocation{Section: "Body"}, + Suggestions: []GroupedActionableSuggestion{ + { + ID: "suggest.left", // Indices 10 to 30 + Change: SuggestionChange{Type: "delete"}, + Position: struct { + StartIndex int64 `json:"start_index"` + EndIndex int64 `json:"end_index"` + }{StartIndex: 10, EndIndex: 30}, + }, + { + ID: "suggest.right", // Indices 25 to 40 (Overlaps 25-30) + Change: SuggestionChange{Type: "delete"}, + Position: struct { + StartIndex int64 `json:"start_index"` + EndIndex int64 `json:"end_index"` + }{StartIndex: 25, EndIndex: 40}, + }, + }, + }, + } + + resolved := ResolveGroupedConflicts(groups) + + // In size-based logic, 10-30 (Size 20) beats 25-40 (Size 15) + if len(resolved[0].Suggestions) != 1 { + t.Errorf("Expected 1 suggestion, got %d", len(resolved[0].Suggestions)) + } + + if resolved[0].Suggestions[0].ID != "suggest.left" { + t.Errorf("Larger range (suggest.left) should have won") + } +} + // TestAreContiguous tests the contiguity checking function func TestAreContiguous(t *testing.T) { tests := []struct { diff --git a/internal/gdocs/process.go b/internal/gdocs/process.go index ce70034..54d08a6 100644 --- a/internal/gdocs/process.go +++ b/internal/gdocs/process.go @@ -59,6 +59,10 @@ func (c *Client) ProcessDocument(ctx context.Context, docID string) (*Processing groupedSuggestions := GroupActionableSuggestions(actionableSuggestions, docStructure) slog.Info("Grouped actionable suggestions", slog.Int("location_groups", len(groupedSuggestions))) + // Resolve Conflits in Grouped Suggestions + groupedSuggestions = ResolveGroupedConflicts(groupedSuggestions) + slog.Info("Conflicts resolved", slog.Int("location_groups_remaining", len(groupedSuggestions))) + return &ProcessingResult{ DocumentTitle: doc.Title, DocumentID: doc.DocumentId,