refactor(digest): Remove grouper abstraction and add date primary group #55

Merged
eleith merged 1 commits from gemini/refactor/grouper-abstraction-and-date-primary-group into main 2025-09-04 05:41:59 +00:00
9 changed files with 138 additions and 81 deletions

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
package digest
import (
"miniflux-digest/internal/models"
"sort"
"time"
)
func GroupByDate(entries []*models.Entry) []*primaryGroup {
groups := make(map[string]*primaryGroup)
for _, entry := range entries {
dateKey := entry.Date.Format("2006-01-02")
if _, ok := groups[dateKey]; !ok {
groups[dateKey] = &primaryGroup{
Title: entry.Date.Format("Jan 2, 2006"),
}
}
groups[dateKey].Entries = append(groups[dateKey].Entries, entry)
}
var result []*primaryGroup
for _, pg := range groups {
result = append(result, pg)
}
sort.Slice(result, func(i, j int) bool {
iDate, _ := time.Parse("Jan 2, 2006", result[i].Title)
jDate, _ := time.Parse("Jan 2, 2006", result[j].Title)
return iDate.Before(jDate) // Oldest dates first
})
return result
}
+23
View File
@@ -0,0 +1,23 @@
package digest
import (
"miniflux-digest/internal/testutil"
"testing"
)
func TestGroupByDate(t *testing.T) {
entries := testutil.CreateMockEntries(0)
groups := GroupByDate(entries)
if len(groups) != 3 {
t.Fatalf("Expected 3 date groups, got %d", len(groups))
}
if groups[0].Title != "Jan 1, 2024" {
t.Errorf("Expected first group to be Jan 1, 2024, got %s", groups[0].Title)
}
if len(groups[0].Entries) != 3 {
t.Errorf("Expected 3 entries in first group, got %d", len(groups[0].Entries))
}
}
+7 -6
View File
@@ -1,16 +1,19 @@
package digest
import (
"fmt"
"miniflux-digest/internal/models"
"miniflux-digest/internal/utils"
"sort"
"time"
)
type DayGrouper struct{}
const (
DayGroupLayout = "2006-01-02"
DayGroupTitleLayout = "Jan 2, 2006"
)
func (g *DayGrouper) GroupEntries(pgs []*primaryGroup) ([]*models.PrimaryGroupDigestData, *string) {
func SubGroupByDay(pgs []*primaryGroup) ([]*models.PrimaryGroupDigestData, *string) {
var allPrimaryGroups []*models.PrimaryGroupDigestData
for _, pg := range pgs {
@@ -50,9 +53,7 @@ func (g *DayGrouper) GroupEntries(pgs []*primaryGroup) ([]*models.PrimaryGroupDi
})
}
for _, seg := range sortedEntryGroups {
seg.Title = fmt.Sprintf("%s - %s", pg.Title, seg.Title)
}
allPrimaryGroups = append(allPrimaryGroups, &models.PrimaryGroupDigestData{
ID: pg.ID,
+32 -22
View File
@@ -7,11 +7,7 @@ import (
"time"
)
type primaryGroup struct {
ID int64
Title string
Entries []*models.Entry
}
func GroupEntries(entries []*models.Entry, groupBy string) []*primaryGroup {
groups := make(map[int64]*primaryGroup)
@@ -62,29 +58,43 @@ func (s *digestServiceImpl) BuildDigestData(entries []*models.Entry, icons map[i
iconsSlice = append(iconsSlice, icon)
}
var groupBy, subGroupBy string
var allPrimaryGroups []*models.PrimaryGroupDigestData
var overallDigestSummary *string
switch view {
case "date":
groupBy = "category"
subGroupBy = "date"
primaryGroups := GroupByDate(entries)
allPrimaryGroups, overallDigestSummary = SubGroupByFeed(primaryGroups)
case "category":
groupBy = "category"
subGroupBy = "feed"
primaryGroups := GroupEntries(entries, "category")
allPrimaryGroups, overallDigestSummary = SubGroupByFeed(primaryGroups)
sort.Slice(allPrimaryGroups, func(i, j int) bool {
return allPrimaryGroups[i].Title < allPrimaryGroups[j].Title
})
case "ai":
groupBy = "category"
subGroupBy = "ai"
default: // also "category"
groupBy = "category"
subGroupBy = "feed"
primaryGroups := GroupEntries(entries, "category")
allPrimaryGroups, overallDigestSummary = SubGroupByAI(primaryGroups, s.llmService)
sort.Slice(allPrimaryGroups, func(i, j int) bool {
return allPrimaryGroups[i].Title < allPrimaryGroups[j].Title
})
default: // category
primaryGroups := GroupEntries(entries, "category")
allPrimaryGroups, overallDigestSummary = SubGroupByFeed(primaryGroups)
sort.Slice(allPrimaryGroups, func(i, j int) bool {
return allPrimaryGroups[i].Title < allPrimaryGroups[j].Title
})
}
primaryGroups := GroupEntries(entries, groupBy)
grouper := NewSubGrouper(subGroupBy, s.llmService)
allPrimaryGroups, overallDigestSummary := grouper.GroupEntries(primaryGroups)
sort.Slice(allPrimaryGroups, func(i, j int) bool {
return allPrimaryGroups[i].Title < allPrimaryGroups[j].Title
})
// Sort entries by date for date and category views
if view == "date" || view == "category" {
for _, pg := range allPrimaryGroups {
for _, sg := range pg.SubGroups {
sort.Slice(sg.Entries, func(i, j int) bool {
return sg.Entries[i].Date.Before(sg.Entries[j].Date)
})
}
}
}
for _, primaryGroup := range allPrimaryGroups {
primaryGroupTotalEntries := 0
+26 -14
View File
@@ -5,6 +5,7 @@ import (
"errors"
"miniflux-digest/internal/models"
"miniflux-digest/internal/testutil"
"sort"
"testing"
"google.golang.org/genai"
@@ -51,8 +52,7 @@ func TestDayGrouper_GroupEntries(t *testing.T) {
entries := testutil.CreateMockEntries(0)
primaryGroupsMap := GroupEntries(entries, "category")
grouper := &DayGrouper{}
groups, summary := grouper.GroupEntries(primaryGroupsMap)
groups, summary := SubGroupByDay(primaryGroupsMap)
if summary != nil {
t.Errorf("Expected an empty summary for DayGrouper, got %q", *summary)
@@ -67,7 +67,7 @@ func TestDayGrouper_GroupEntries(t *testing.T) {
t.Fatalf("Incorrect sub-groups for Category A: %+v", catAGroup)
}
subGroup := findSubGroup(catAGroup, "Category A - Jan 2, 2024")
subGroup := findSubGroup(catAGroup, "Jan 2, 2024")
if subGroup == nil || len(subGroup.Entries) != 4 {
t.Fatalf("Incorrect entries for sub-group: %+v", subGroup)
}
@@ -77,8 +77,7 @@ func TestFeedGrouper_GroupEntries(t *testing.T) {
entries := testutil.CreateMockEntries(0)
primaryGroupsMap := GroupEntries(entries, "category")
grouper := &FeedGrouper{}
groups, summary := grouper.GroupEntries(primaryGroupsMap)
groups, summary := SubGroupByFeed(primaryGroupsMap)
if summary != nil {
t.Errorf("Expected an empty summary for FeedGrouper, got %q", *summary)
@@ -119,8 +118,7 @@ func TestLLMGrouper_GroupEntries(t *testing.T) {
},
}
grouper := &LLMGrouper{LLMService: mockLLM}
groups, summary := grouper.GroupEntries(primaryGroupsMap)
groups, summary := SubGroupByAI(primaryGroupsMap, mockLLM)
if summary == nil || *summary != "This is a summary of all entries." {
t.Errorf("Expected summary 'This is a summary of all entries.', got %q", *summary)
@@ -181,14 +179,21 @@ func TestDigestService_BuildDigestData(t *testing.T) {
if len(overviewData.PrimaryGroups) != 3 {
t.Errorf("Expected 3 primary groups, got %d", len(overviewData.PrimaryGroups))
}
// Check for date-based sub-grouping
catAGroup := findPrimaryGroup(overviewData.PrimaryGroups, "Category A")
if catAGroup == nil || len(catAGroup.SubGroups) != 1 {
t.Fatalf("Incorrect sub-groups for Category A: %+v", catAGroup)
// Check for date-based primary grouping
dateGroup := findPrimaryGroup(overviewData.PrimaryGroups, "Jan 1, 2024")
if dateGroup == nil {
t.Fatalf("Did not find expected primary group 'Jan 1, 2024'")
}
subGroup := findSubGroup(catAGroup, "Category A - Jan 2, 2024")
if subGroup == nil {
t.Fatalf("Did not find expected sub-group 'Category A - Jan 2, 2024'")
// Check for feed-based sub-grouping
if len(dateGroup.SubGroups) != 2 {
t.Fatalf("Incorrect sub-groups for Jan 1, 2024: %+v", dateGroup)
}
// Check that entries are sorted by date
firstSubGroup := dateGroup.SubGroups[0]
if !sort.SliceIsSorted(firstSubGroup.Entries, func(i, j int) bool {
return firstSubGroup.Entries[i].Date.Before(firstSubGroup.Entries[j].Date)
}) {
t.Errorf("Entries in sub-group are not sorted by date")
}
})
@@ -205,6 +210,13 @@ func TestDigestService_BuildDigestData(t *testing.T) {
if catAGroup == nil || len(catAGroup.SubGroups) != 3 {
t.Fatalf("Incorrect sub-groups for Category A: %+v", catAGroup)
}
// Check that entries are sorted by date
firstSubGroup := catAGroup.SubGroups[0]
if !sort.SliceIsSorted(firstSubGroup.Entries, func(i, j int) bool {
return firstSubGroup.Entries[i].Date.Before(firstSubGroup.Entries[j].Date)
}) {
t.Errorf("Entries in sub-group are not sorted by date")
}
})
t.Run("view=ai", func(t *testing.T) {
+3 -7
View File
@@ -1,15 +1,13 @@
package digest
import (
"fmt"
"miniflux-digest/internal/models"
"miniflux-digest/internal/utils"
"sort"
)
type FeedGrouper struct{}
func (g *FeedGrouper) GroupEntries(pgs []*primaryGroup) ([]*models.PrimaryGroupDigestData, *string) {
func SubGroupByFeed(pgs []*primaryGroup) ([]*models.PrimaryGroupDigestData, *string) {
var allPrimaryGroups []*models.PrimaryGroupDigestData
for _, pg := range pgs {
@@ -46,9 +44,7 @@ func (g *FeedGrouper) GroupEntries(pgs []*primaryGroup) ([]*models.PrimaryGroupD
})
}
for _, seg := range entryGroups {
seg.Title = fmt.Sprintf("%s - %s", pg.Title, seg.Title)
}
allPrimaryGroups = append(allPrimaryGroups, &models.PrimaryGroupDigestData{
ID: pg.ID,
-26
View File
@@ -1,26 +0,0 @@
package digest
import (
"miniflux-digest/internal/app/services"
"miniflux-digest/internal/models"
)
const (
DayGroupLayout = "2006-01-02"
DayGroupTitleLayout = "Jan 2, 2006"
)
type SubGrouper interface {
GroupEntries(pgs []*primaryGroup) ([]*models.PrimaryGroupDigestData, *string)
}
func NewSubGrouper(subGroupBy string, llmService services.LLMService) SubGrouper {
switch subGroupBy {
case "ai":
return &LLMGrouper{LLMService: llmService}
case "feed":
return &FeedGrouper{}
default: // day
return &DayGrouper{}
}
}
+9
View File
@@ -0,0 +1,9 @@
package digest
import "miniflux-digest/internal/models"
type primaryGroup struct {
ID int64
Title string
Entries []*models.Entry
}
+4 -6
View File
@@ -28,9 +28,7 @@ type LLMResponse struct {
} `json:"sub_groups"`
}
type LLMGrouper struct {
LLMService services.LLMService
}
type llmEntry struct {
ID int64 `json:"id"`
@@ -120,7 +118,7 @@ var llmResponseSchema = &genai.Schema{
},
}
func (g *LLMGrouper) GroupEntries(pgs []*primaryGroup) ([]*models.PrimaryGroupDigestData, *string) {
func SubGroupByAI(pgs []*primaryGroup, llmService services.LLMService) ([]*models.PrimaryGroupDigestData, *string) {
var llmEntries []llmEntry
entryMap := make(map[int64]*models.Entry)
primaryGroupMap := make(map[int64]*primaryGroup)
@@ -153,7 +151,7 @@ func (g *LLMGrouper) GroupEntries(pgs []*primaryGroup) ([]*models.PrimaryGroupDi
ctx, cancel := context.WithTimeout(context.Background(), LLMTimeout)
defer cancel()
llmResponse, err := g.LLMService.GenerateContent(ctx, prompt, llmResponseSchema)
llmResponse, err := llmService.GenerateContent(ctx, prompt, llmResponseSchema)
if err != nil {
log.Printf("LLM service failed, falling back to feed grouping: %v\n", err)
return fallbackToFeedGrouper(pgs)
@@ -236,5 +234,5 @@ func (g *LLMGrouper) GroupEntries(pgs []*primaryGroup) ([]*models.PrimaryGroupDi
}
func fallbackToFeedGrouper(pgs []*primaryGroup) ([]*models.PrimaryGroupDigestData, *string) {
return (&FeedGrouper{}).GroupEntries(pgs)
return SubGroupByFeed(pgs)
}