Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions internal/gdocs/grouping.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
84 changes: 84 additions & 0 deletions internal/gdocs/grouping_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions internal/gdocs/process.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down