diff --git a/go.mod b/go.mod index be8f4bd..1726ddd 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/knadh/koanf/providers/file v1.2.0 github.com/knadh/koanf/v2 v2.2.2 github.com/robfig/cron/v3 v3.0.1 + github.com/stretchr/testify v1.10.0 github.com/tdewolff/minify/v2 v2.23.8 github.com/wneessen/go-mail v0.6.2 google.golang.org/genai v1.17.0 @@ -21,6 +22,7 @@ require ( cloud.google.com/go v0.116.0 // indirect cloud.google.com/go/auth v0.9.3 // indirect cloud.google.com/go/compute/metadata v0.5.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -38,7 +40,9 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/tdewolff/parse/v2 v2.8.1 // indirect go.opencensus.io v0.24.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect diff --git a/go.sum b/go.sum index b472d2c..174e599 100644 --- a/go.sum +++ b/go.sum @@ -98,6 +98,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= diff --git a/internal/app/services/llm.go b/internal/app/services/llm.go index 13e27fa..3ab154d 100644 --- a/internal/app/services/llm.go +++ b/internal/app/services/llm.go @@ -6,6 +6,10 @@ import ( "google.golang.org/genai" ) +// TODO: To make timeouts customizable per-call, consider adding an optional timeout +// parameter to the GenerateContent method, e.g.: +// GenerateContent(ctx context.Context, prompt string, schema *genai.Schema, timeout ...time.Duration) ([]byte, error) + type LLMService interface { GenerateContent(ctx context.Context, prompt string, schema *genai.Schema) ([]byte, error) } diff --git a/internal/digest/digest.go b/internal/digest/digest.go index 76a8fd5..12ffe9c 100644 --- a/internal/digest/digest.go +++ b/internal/digest/digest.go @@ -2,14 +2,10 @@ package digest import ( "miniflux-digest/internal/app/services" + "miniflux-digest/internal/digest/view" "miniflux-digest/internal/models" - "miniflux-digest/internal/digest/group_by" - "sort" - "time" ) - - func NewDigestService(llmService services.LLMService) services.DigestService { return &digestServiceImpl{llmService: llmService} } @@ -18,88 +14,6 @@ type digestServiceImpl struct { llmService services.LLMService } -func (s *digestServiceImpl) BuildDigestData(entries []*models.Entry, icons map[int64]*models.FeedIcon, view string, minifluxHost string) *models.OverviewTemplateData { - iconsSlice := make([]*models.FeedIcon, 0, len(icons)) - for _, icon := range icons { - iconsSlice = append(iconsSlice, icon) - } - - var allPrimaryGroups []*models.PrimaryGroupDigestData - var overallDigestSummary *string - - switch view { - case "date": - primaryGroups := group_by.GroupByDate(entries) - allPrimaryGroups, overallDigestSummary = group_by.SubGroupByFeed(primaryGroups) - case "category": - primaryGroups := group_by.GroupByCategory(entries) - allPrimaryGroups, overallDigestSummary = group_by.SubGroupByFeed(primaryGroups) - sort.Slice(allPrimaryGroups, func(i, j int) bool { - return allPrimaryGroups[i].Title < allPrimaryGroups[j].Title - }) - case "ai": - primaryGroups := group_by.GroupByCategory(entries) - allPrimaryGroups, overallDigestSummary = group_by.SubGroupByAI(primaryGroups, s.llmService) - sort.Slice(allPrimaryGroups, func(i, j int) bool { - return allPrimaryGroups[i].Title < allPrimaryGroups[j].Title - }) - default: - primaryGroups := group_by.GroupByCategory(entries) - allPrimaryGroups, overallDigestSummary = group_by.SubGroupByFeed(primaryGroups) - sort.Slice(allPrimaryGroups, func(i, j int) bool { - return allPrimaryGroups[i].Title < allPrimaryGroups[j].Title - }) - } - - 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 _, pg := range allPrimaryGroups { - pgTotalEntries := 0 - pgUniqueFeedIDs := make(map[int64]bool) - for _, subGroup := range pg.SubGroups { - pgTotalEntries += len(subGroup.Entries) - for _, entry := range subGroup.Entries { - pgUniqueFeedIDs[entry.FeedID] = true - } - } - pg.TotalEntries = pgTotalEntries - pg.TotalFeeds = len(pgUniqueFeedIDs) - } - - var allEntryGroups []*models.EntryGroup - for _, pg := range allPrimaryGroups { - allEntryGroups = append(allEntryGroups, pg.SubGroups...) - } - - uniqueFeedIDs := make(map[int64]bool) - for _, entry := range entries { - uniqueFeedIDs[entry.FeedID] = true - } - - return &models.OverviewTemplateData{ - Entries: entries, - GeneratedDate: time.Now(), - FeedIcons: iconsSlice, - PrimaryGroups: allPrimaryGroups, - EntryGroups: allEntryGroups, - OverviewSummary: safeDerefString(overallDigestSummary), - TotalEntries: len(entries), - TotalFeeds: len(uniqueFeedIDs), - MinifluxHost: minifluxHost, - } -} - -func safeDerefString(s *string) string { - if s == nil { - return "" - } - return *s -} +func (s *digestServiceImpl) BuildDigestData(entries []*models.Entry, icons map[int64]*models.FeedIcon, viewType string, minifluxHost string) *models.OverviewTemplateData { + return view.BuildDigestDataForView(entries, icons, viewType, minifluxHost, s.llmService) +} \ No newline at end of file diff --git a/internal/digest/digest_test.go b/internal/digest/digest_test.go index 9d87c4d..d5884e8 100644 --- a/internal/digest/digest_test.go +++ b/internal/digest/digest_test.go @@ -3,11 +3,11 @@ package digest import ( "context" "errors" + "miniflux-digest/internal/digest/view" "miniflux-digest/internal/models" "miniflux-digest/internal/testutil" "sort" "testing" - "miniflux-digest/internal/digest/group_by" "google.golang.org/genai" ) @@ -23,24 +23,6 @@ func (m *mockLLMService) GenerateContent(ctx context.Context, prompt string, sch return nil, errors.New("GenerateContentFunc not implemented") } -func findPrimaryGroup(groups []*models.PrimaryGroupDigestData, title string) *models.PrimaryGroupDigestData { - for _, group := range groups { - if group.Title == title { - return group - } - } - return nil -} - -func findSubGroup(primaryGroup *models.PrimaryGroupDigestData, title string) *models.EntryGroup { - for _, group := range primaryGroup.SubGroups { - if group.Title == title { - return group - } - } - return nil -} - func createLLMGrouperMockEntries() []*models.Entry { return []*models.Entry{ {ID: 1, Title: "Entry 1", FeedID: 100, FeedTitle: "Feed A", GroupID: 1, GroupTitle: "Category A"}, @@ -49,119 +31,34 @@ func createLLMGrouperMockEntries() []*models.Entry { } } -func TestDayGrouper_GroupEntries(t *testing.T) { - entries := testutil.CreateMockEntries(0) - primaryGroupsMap := group_by.GroupByCategory(entries) - - groups, summary := group_by.SubGroupByDay(primaryGroupsMap) - - if summary != nil { - t.Errorf("Expected an empty summary for DayGrouper, got %q", *summary) - } - - if len(groups) != 3 { - t.Fatalf("Expected 3 primary groups, got %d", len(groups)) - } - - catAGroup := findPrimaryGroup(groups, "Category A") - if catAGroup == nil || len(catAGroup.SubGroups) != 1 { - t.Fatalf("Incorrect sub-groups for Category A: %+v", catAGroup) - } - - subGroup := findSubGroup(catAGroup, "Jan 2, 2024") - if subGroup == nil || len(subGroup.Entries) != 4 { - t.Fatalf("Incorrect entries for sub-group: %+v", subGroup) - } -} - -func TestFeedGrouper_GroupEntries(t *testing.T) { - entries := testutil.CreateMockEntries(0) - primaryGroupsMap := group_by.GroupByCategory(entries) - - groups, summary := group_by.SubGroupByFeed(primaryGroupsMap) - - if summary != nil { - t.Errorf("Expected an empty summary for FeedGrouper, got %q", *summary) - } - - if len(groups) != 3 { - t.Fatalf("Expected 3 primary groups, got %d", len(groups)) - } - - catAGroup := findPrimaryGroup(groups, "Category A") - if catAGroup == nil || len(catAGroup.SubGroups) != 3 { - t.Fatalf("Incorrect sub-groups for Category A: %+v", catAGroup) - } -} - -func TestLLMGrouper_GroupEntries(t *testing.T) { - entries := createLLMGrouperMockEntries() - primaryGroupsMap := group_by.GroupByCategory(entries) - - mockLLM := &mockLLMService{ - GenerateContentFunc: func(ctx context.Context, prompt string, schema *genai.Schema) ([]byte, error) { - return []byte(`{ - "overview": "This is a summary of all entries.", - "primary_group_summaries": [ - { - "primary_group_id": 1, - "summary": "This is a summary of Category A." - } - ], - "sub_groups": [ - { - "title": "Sub-group 1", - "entry_ids": [1, 2] - } - ] - }`), - nil - }, - } - - groups, summary := group_by.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) - } - - if len(groups) != 1 { - t.Fatalf("Expected 1 group, got %d", len(groups)) - } - - catA := findPrimaryGroup(groups, "Category A") - if catA == nil || len(catA.SubGroups) != 2 { - t.Fatalf("Incorrect number of sub-groups for Category A: %+v", catA) - } - - if catA.Summary != "This is a summary of Category A." { - t.Errorf("Expected summary 'This is a summary of Category A.', got %q", catA.Summary) - } - - subGroup1 := findSubGroup(catA, "Sub-group 1") - if subGroup1 == nil || len(subGroup1.Entries) != 2 { - t.Fatalf("Incorrect entries for sub-group 1: %+v", subGroup1) - } - - uncategorized := findSubGroup(catA, "Uncategorized") - if uncategorized == nil || len(uncategorized.Entries) != 1 { - t.Fatalf("Incorrect entries for Uncategorized sub-group: %+v", uncategorized) - } -} - func TestDigestService_BuildDigestData(t *testing.T) { mockLLM := &mockLLMService{ GenerateContentFunc: func(ctx context.Context, prompt string, schema *genai.Schema) ([]byte, error) { - return []byte(`{ - "overview": "This is a summary of all entries.", - "primary_group_summaries": [], - "sub_groups": [ - { - "title": "Sub-group 1", - "entry_ids": [1, 2] - } - ] - }`), nil + switch schema { + case view.GroupingResponseSchema: + return []byte(`{ + "groups": [ + { + "title": "AI News", + "entry_ids": [1, 3] + }, + { + "title": "Go Lang", + "entry_ids": [2, 4] + } + ] + }`), nil + case view.SummaryResponseSchema: + return []byte(`{"summary": "This is a summary."}`), nil + case view.SubGroupingResponseSchema: + return []byte(`{ + "sub_groups": [ + {"title": "Sub-group 1", "entry_ids": [1]} + ] + }`), nil + default: + return nil, errors.New("unexpected LLM call") + } }, } digestService := NewDigestService(mockLLM) @@ -180,11 +77,11 @@ func TestDigestService_BuildDigestData(t *testing.T) { if len(overviewData.PrimaryGroups) != 3 { t.Errorf("Expected 3 primary groups, got %d", len(overviewData.PrimaryGroups)) } - dateGroup := findPrimaryGroup(overviewData.PrimaryGroups, "Jan 1, 2024") + dateGroup := testutil.FindPrimaryGroup(overviewData.PrimaryGroups, "Jan 1, 2024") if dateGroup == nil { t.Fatalf("Did not find expected primary group 'Jan 1, 2024'") } - if len(dateGroup.SubGroups) != 2 { + if len(dateGroup.SubGroups) != 1 { t.Fatalf("Incorrect sub-groups for Jan 1, 2024: %+v", dateGroup) } firstSubGroup := dateGroup.SubGroups[0] @@ -203,7 +100,7 @@ func TestDigestService_BuildDigestData(t *testing.T) { if len(overviewData.PrimaryGroups) != 3 { t.Errorf("Expected 3 primary groups, got %d", len(overviewData.PrimaryGroups)) } - catAGroup := findPrimaryGroup(overviewData.PrimaryGroups, "Category A") + catAGroup := testutil.FindPrimaryGroup(overviewData.PrimaryGroups, "Category A") if catAGroup == nil || len(catAGroup.SubGroups) != 3 { t.Fatalf("Incorrect sub-groups for Category A: %+v", catAGroup) } @@ -221,30 +118,74 @@ func TestDigestService_BuildDigestData(t *testing.T) { if overviewData == nil { t.Fatal("overviewData is nil") } - if overviewData.OverviewSummary != "This is a summary of all entries." { - t.Errorf("Expected summary, got %q", overviewData.OverviewSummary) + if len(overviewData.PrimaryGroups) != 2 { + t.Errorf("Expected 2 primary groups, got %d", len(overviewData.PrimaryGroups)) + } + }) + + t.Run("view=ai - GroupAIEntries error fallback", func(t *testing.T) { + mockLLM := &mockLLMService{ + GenerateContentFunc: func(ctx context.Context, prompt string, schema *genai.Schema) ([]byte, error) { + if schema == view.GroupingResponseSchema { + return nil, errors.New("mock LLM grouping error") + } + return nil, errors.New("unexpected LLM call") + }, + } + digestService := NewDigestService(mockLLM) + aiEntries := createLLMGrouperMockEntries() + overviewData := digestService.BuildDigestData(aiEntries, icons, "ai", "http://miniflux.test") + + if overviewData == nil { + t.Fatal("overviewData is nil") + } + if len(overviewData.PrimaryGroups) != 1 { + t.Errorf("Expected 1 primary group from date fallback, got %d", len(overviewData.PrimaryGroups)) + } + dateGroup := testutil.FindPrimaryGroup(overviewData.PrimaryGroups, "Jan 1, 0001") + if dateGroup == nil { + t.Fatalf("Did not find expected primary group 'Jan 1, 0001' after date fallback") + } + }) + + t.Run("view=ai - sub-grouping error", func(t *testing.T) { + mockLLM := &mockLLMService{ + GenerateContentFunc: func(ctx context.Context, prompt string, schema *genai.Schema) ([]byte, error) { + if schema == view.GroupingResponseSchema { + return []byte(`{ + "groups": [ + { + "title": "Test Group", + "entry_ids": [1, 2, 3] + } + ] + }`), nil + } + if schema == view.SummaryResponseSchema { + return []byte(`{"summary": "This is a summary."}`), nil + } + if schema == view.SubGroupingResponseSchema { + return nil, errors.New("mock LLM sub-grouping error") + } + return nil, errors.New("unexpected LLM call") + }, + } + digestService := NewDigestService(mockLLM) + aiEntries := createLLMGrouperMockEntries() + overviewData := digestService.BuildDigestData(aiEntries, icons, "ai", "http://miniflux.test") + + if overviewData == nil { + t.Fatal("overviewData is nil") } if len(overviewData.PrimaryGroups) != 1 { t.Errorf("Expected 1 primary group, got %d", len(overviewData.PrimaryGroups)) } - }) - - t.Run("view=ai fallback", func(t *testing.T) { - llmErrorService := NewDigestService(&mockLLMService{ - GenerateContentFunc: func(ctx context.Context, prompt string, schema *genai.Schema) ([]byte, error) { - return nil, errors.New("LLM service error") - }, - }) - overviewData := llmErrorService.BuildDigestData(entries, icons, "ai", "http://miniflux.test") - if overviewData == nil { - t.Fatal("overviewData is nil") + group := overviewData.PrimaryGroups[0] + if group.Summary != "This is a summary." { + t.Errorf("Expected group summary to be present, got %q", group.Summary) } - if overviewData.OverviewSummary != "" { - t.Errorf("Expected empty summary on fallback, got %q", overviewData.OverviewSummary) - } - catAGroup := findPrimaryGroup(overviewData.PrimaryGroups, "Category A") - if catAGroup == nil || len(catAGroup.SubGroups) != 3 { - t.Fatalf("Incorrect sub-groups for Category A on fallback: %+v", catAGroup) + if len(group.SubGroups) != 1 || group.SubGroups[0].Title != "Uncategorized" { + t.Errorf("Expected a single 'Uncategorized' sub-group on error, got %+v", group.SubGroups) } }) } diff --git a/internal/digest/group_by/date_test.go b/internal/digest/group_by/date_test.go deleted file mode 100644 index a6681dc..0000000 --- a/internal/digest/group_by/date_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package group_by - -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)) - } -} diff --git a/internal/digest/group_by/llm.go b/internal/digest/group_by/llm.go deleted file mode 100644 index 2f59ce5..0000000 --- a/internal/digest/group_by/llm.go +++ /dev/null @@ -1,238 +0,0 @@ -package group_by - -import ( - "context" - "encoding/json" - "log" - "miniflux-digest/internal/app/services" - "miniflux-digest/internal/models" - "miniflux-digest/internal/utils" - "time" - - "google.golang.org/genai" -) - -const ( - LLMTimeout = 10 * time.Minute -) - -type LLMResponse struct { - Overview string `json:"overview"` - PrimaryGroupSummaries []struct { - PrimaryGroupID int64 `json:"primary_group_id"` - Summary string `json:"summary"` - } `json:"primary_group_summaries"` - SubGroups []struct { - Title string `json:"title"` - EntryIDs []int64 `json:"entry_ids"` - } `json:"sub_groups"` -} - - - -type llmEntry struct { - ID int64 `json:"id"` - Title string `json:"title"` - URL string `json:"url"` - Content string `json:"content"` - FeedTitle string `json:"feed_title"` - PrimaryGroupID int64 `json:"primary_group_id"` -} - -const llmPrompt = `You will be given a large list of unread entry JSON Objects from a RSS feed reader. these entries help a user follow updates from blogs, social networks, and news sources. - -Your task is to generate the metadata according to the instructions below: - - - an 'overview' string field - - this is a 3-5 sentence overview for ALL entries, less than 250 words. - - the overview is really a brief overview. you are not to summarize every single entry - - the overview could highlight critical entries or summarize the main topics or both, you decide - - To improve readability, please insert two line breaks (\n\n) after every 1-2 sentences. - - - a list of 'primary_group_summaries' objects - - the 'primary_group_id' will be in every entry id - - the summary is really a brief overview using all entries associated to the primary group - - you don't need to summarize every single entry - - entries are only ever are associated with one primary group id - - this is a 2-3 sentence overview, less than 150 words. - - the overview should help the user decide whether to skim all entries in this group fast or slow and what to narrow in on. - - the overview could highlight critical entries or summarize the main topics or both whatever helps the user skip, skim or find the most important entries - - To improve readability, please insert two line breaks (\n\n) after every 1-2 sentences. - -- 'sub_groups': - - The sub_groups field should contain a list of subgroup objects. - - Each subgroup should have a 'title' that you come up with (generally 2-3 words, max 70 characters). - - Each subgroup should have an array of 'entry_ids'. An entry can only belong to one subgroup. - - The purpose of subgroups is to group related entries to help the user skim efficiently. - - For each entry, you must attempt to group it using only one of the strategies below - - Aim for sub_groups that have more than 2 entries but less than 20 entries. - - not all groups are created the same. below are a list groups from best to worst. do your best to fit entries in one of these types. - - Priority 1: Exact Duplicates. Group entries that are effectively identical or point to the same content. Goal: De-duplication. - - Priority 2: Specific Topic/Theme. Group entries about the same narrow subject (e.g., a specific product launch, a single news event). Goal: Thematic exploration. - - Priority 3: General Topic/Theme. Group entries under broader categories like 'Science', 'Technology', or 'Business'. Goal: General organization. - - Priority 4: Source. Only if an entry cannot be grouped by topic, group it with other entries from the same website or publication. Goal: Source-based review. - -Your goal is to help the user get through their unread entries as efficiently as possible. The metadata you generate will be used to create a digest that allows the user to quickly understand and navigate their unread entries. - -Below is the list of entries: ----------------------------- -` - -var llmResponseSchema = &genai.Schema{ - Type: genai.TypeObject, - Properties: map[string]*genai.Schema{ - "overview": { - Type: genai.TypeString, - }, - "primary_group_summaries": { - Type: genai.TypeArray, - Items: &genai.Schema{ - Type: genai.TypeObject, - Properties: map[string]*genai.Schema{ - "primary_group_id": { - Type: genai.TypeInteger, - }, - "summary": { - Type: genai.TypeString, - }, - }, - }, - }, - "sub_groups": { - Type: genai.TypeArray, - Items: &genai.Schema{ - Type: genai.TypeObject, - Properties: map[string]*genai.Schema{ - "title": { - Type: genai.TypeString, - }, - "entry_ids": { - Type: genai.TypeArray, - Items: &genai.Schema{ - Type: genai.TypeInteger, - }, - }, - }, - }, - }, - }, -} - -func SubGroupByAI(pgs []*models.PrimaryGroup, llmService services.LLMService) ([]*models.PrimaryGroupDigestData, *string) { - var llmEntries []llmEntry - entryMap := make(map[int64]*models.Entry) - primaryGroupMap := make(map[int64]*models.PrimaryGroup) - - for _, pg := range pgs { - primaryGroupMap[pg.ID] = pg - for _, entry := range pg.Entries { - content := entry.Content - if len(content) > 1000 { - content = content[:1000] - } - llmEntries = append(llmEntries, llmEntry{ - ID: entry.ID, - Title: entry.Title, - URL: entry.URL, - Content: content, - PrimaryGroupID: pg.ID, - }) - entryMap[entry.ID] = entry - } - } - - entriesJSON, err := json.MarshalIndent(llmEntries, "", " ") - if err != nil { - return fallbackToFeedGrouper(pgs) - } - - prompt := llmPrompt + string(entriesJSON) - - ctx, cancel := context.WithTimeout(context.Background(), LLMTimeout) - defer cancel() - - 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) - } - - var response LLMResponse - if err := json.Unmarshal(llmResponse, &response); err != nil { - log.Printf("Failed to parse LLM response, falling back to feed grouping: %v\n", err) - log.Printf("Raw LLM response:\n%s", llmResponse) - return fallbackToFeedGrouper(pgs) - } - - finalPrimaryGroupsMap := make(map[int64]*models.PrimaryGroupDigestData) - for _, pg := range pgs { - finalPrimaryGroupsMap[pg.ID] = &models.PrimaryGroupDigestData{ - ID: pg.ID, - Title: pg.Title, - Slug: utils.Slugify(pg.Title), - SubGroups: []*models.EntryGroup{}, - } - } - - for _, summary := range response.PrimaryGroupSummaries { - if pg, ok := finalPrimaryGroupsMap[summary.PrimaryGroupID]; ok { - pg.Summary = summary.Summary - } - } - - groupedEntryIDs := make(map[int64]bool) - for _, llmSubGroup := range response.SubGroups { - subGroupEntriesByPrimaryGroup := make(map[int64][]*models.Entry) - - for _, entryID := range llmSubGroup.EntryIDs { - if _, exists := groupedEntryIDs[entryID]; exists { - continue - } - if entry, ok := entryMap[entryID]; ok { - primaryGroupID := entry.GroupID - subGroupEntriesByPrimaryGroup[primaryGroupID] = append(subGroupEntriesByPrimaryGroup[primaryGroupID], entry) - groupedEntryIDs[entryID] = true - } - } - - for primaryGroupID, entriesInGroup := range subGroupEntriesByPrimaryGroup { - if pg, ok := finalPrimaryGroupsMap[primaryGroupID]; ok { - if len(entriesInGroup) > 0 { - pg.SubGroups = append(pg.SubGroups, &models.EntryGroup{ - Title: llmSubGroup.Title, - Entries: entriesInGroup, - Slug: utils.Slugify(llmSubGroup.Title), - }) - } - } - } - } - - for _, pg := range pgs { - var ungroupedInThisPrimaryGroup []*models.Entry - for _, entry := range pg.Entries { - if !groupedEntryIDs[entry.ID] { - ungroupedInThisPrimaryGroup = append(ungroupedInThisPrimaryGroup, entry) - } - } - - if len(ungroupedInThisPrimaryGroup) > 0 { - finalPrimaryGroupsMap[pg.ID].SubGroups = append(finalPrimaryGroupsMap[pg.ID].SubGroups, &models.EntryGroup{ - Title: "Uncategorized", - Entries: ungroupedInThisPrimaryGroup, - Slug: "uncategorized", - }) - } - } - - var finalPrimaryGroups []*models.PrimaryGroupDigestData - for _, pg := range finalPrimaryGroupsMap { - finalPrimaryGroups = append(finalPrimaryGroups, pg) - } - - return finalPrimaryGroups, &response.Overview -} - -func fallbackToFeedGrouper(pgs []*models.PrimaryGroup) ([]*models.PrimaryGroupDigestData, *string) { - return SubGroupByFeed(pgs) -} diff --git a/internal/digest/view/ai.go b/internal/digest/view/ai.go new file mode 100644 index 0000000..f223b51 --- /dev/null +++ b/internal/digest/view/ai.go @@ -0,0 +1,428 @@ +package view + +import ( + "context" + "encoding/json" + "fmt" + "miniflux-digest/internal/app/services" + "miniflux-digest/internal/models" + "miniflux-digest/internal/utils" + + "sort" + "log" + + "google.golang.org/genai" +) + +const ( + MaxEntryContentLengthForLLM = 1000 + MaxEntriesForSummarization = 1000 +) + +type GroupingResponse struct { + Groups []struct { + Title string `json:"title"` + EntryIDs []int64 `json:"entry_ids"` + } `json:"groups"` +} + +var GroupingResponseSchema = &genai.Schema{ + Type: genai.TypeObject, + Properties: map[string]*genai.Schema{ + "groups": { + Type: genai.TypeArray, + Items: &genai.Schema{ + Type: genai.TypeObject, + Properties: map[string]*genai.Schema{ + "title": { + Type: genai.TypeString, + }, + "entry_ids": { + Type: genai.TypeArray, + Items: &genai.Schema{ + Type: genai.TypeInteger, + }, + }, + }, + }, + }, + }, +} + +const groupingPrompt = `You will be given a list of unread JSON entry objects from an RSS feed reader. Each entry includes its ID, Title, URL, Content, and the Title of the Feed it came from. + +Your task is to group these entries based on their content. Follow these instructions: + +- Group entries that are about the same specific topic or event. +- Create group titles that are concise and descriptive (2-5 words). +- An entry can only belong to one group. +- Aim for groups with 2 to 20 entries. +- If an entry cannot be grouped, leave it out of the response. + +Your goal is to create meaningful groups that help the user quickly understand the main themes in their feed. + +Below is the list of entries: +---------------------------- +` + +type llmEntry struct { + ID int64 `json:"id"` + Title string `json:"title"` + URL string `json:"url"` + Content string `json:"content"` + FeedTitle string `json:"feed_title"` +} + +func GroupAIEntries(ctx context.Context, entries []*models.Entry, llmService services.LLMService) ([]*models.PrimaryGroupDigestData, error) { + const chunkSize = 1000 + + entryMap := make(map[int64]*models.Entry) + for _, entry := range entries { + entryMap[entry.ID] = entry + } + + groupedEntryIDs := make(map[int64]bool) + var allPrimaryGroupDigestData []*models.PrimaryGroupDigestData + primaryGroupMap := make(map[string]*models.PrimaryGroupDigestData) + currentPrimaryGroupID := int64(1) + + for i := 0; i < len(entries); i += chunkSize { + end := min(i + chunkSize, len(entries)) + chunk := entries[i:end] + + var llmEntries []llmEntry + for _, entry := range chunk { + content := entry.Content + if len(content) > MaxEntryContentLengthForLLM { + content = content[:MaxEntryContentLengthForLLM] + } + llmEntries = append(llmEntries, llmEntry{ + ID: entry.ID, + Title: entry.Title, + URL: entry.URL, + Content: content, + FeedTitle: entry.FeedTitle, + }) + } + + entriesJSON, err := json.MarshalIndent(llmEntries, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to marshal entries to JSON: %w", err) + } + + prompt := groupingPrompt + string(entriesJSON) + + llmResponse, err := llmService.GenerateContent(ctx, prompt, GroupingResponseSchema) + if err != nil { + log.Printf("LLM call for 'primary-grouping' failed on chunk %d-%d: %v", i, end, err) + return nil, fmt.Errorf("LLM service failed for chunk %d-%d: %w", i, end, err) + } + + var response GroupingResponse + if err := json.Unmarshal(llmResponse, &response); err != nil { + return nil, fmt.Errorf("failed to parse LLM response for chunk %d-%d: %w", i, end, err) + } + + for _, group := range response.Groups { + var groupEntries []*models.Entry + for _, entryID := range group.EntryIDs { + if _, exists := groupedEntryIDs[entryID]; exists { + continue + } + if entry, ok := entryMap[entryID]; ok { + groupEntries = append(groupEntries, entry) + groupedEntryIDs[entryID] = true + } + } + + if len(groupEntries) > 0 { + if existingPGDD, ok := primaryGroupMap[group.Title]; ok { + existingPGDD.SubGroups[0].Entries = append(existingPGDD.SubGroups[0].Entries, groupEntries...) + existingPGDD.TotalEntries += len(groupEntries) + existingPGDD.TotalFeeds = getUniqueFeedIDs(existingPGDD.SubGroups[0].Entries) + } else { + newPGDD := &models.PrimaryGroupDigestData{ + ID: currentPrimaryGroupID, + Title: group.Title, + Slug: utils.Slugify(group.Title), + SubGroups: []*models.EntryGroup{ + { + Title: group.Title, + Entries: groupEntries, + Slug: utils.Slugify(group.Title), + }, + }, + Summary: "", + TotalEntries: len(groupEntries), + TotalFeeds: getUniqueFeedIDs(groupEntries), + } + allPrimaryGroupDigestData = append(allPrimaryGroupDigestData, newPGDD) + primaryGroupMap[group.Title] = newPGDD + currentPrimaryGroupID++ + } + } + } + } + + var ungroupedEntries []*models.Entry + + for _, entry := range entries { + if !groupedEntryIDs[entry.ID] { + ungroupedEntries = append(ungroupedEntries, entry) + } + } + + if len(ungroupedEntries) > 0 { + allPrimaryGroupDigestData = append(allPrimaryGroupDigestData, &models.PrimaryGroupDigestData{ + ID: currentPrimaryGroupID, + Title: "Uncategorized", + Slug: "uncategorized", + SubGroups: []*models.EntryGroup{ + { + Title: "Uncategorized", + Entries: ungroupedEntries, + Slug: "uncategorized", + }, + }, + Summary: "", + TotalEntries: len(ungroupedEntries), + TotalFeeds: getUniqueFeedIDs(ungroupedEntries), + }) + } + + return allPrimaryGroupDigestData, nil +} + +type AIGroupSummaryResponse struct { + Summary string `json:"summary"` +} + +type AISubGroupingResponse struct { + SubGroups []struct { + Title string `json:"title"` + EntryIDs []int64 `json:"entry_ids"` + } `json:"sub_groups"` +} + +var SummaryResponseSchema = &genai.Schema{ + Type: genai.TypeObject, + Properties: map[string]*genai.Schema{ + "summary": { + Type: genai.TypeString, + }, + }, +} + +var SubGroupingResponseSchema = &genai.Schema{ + Type: genai.TypeObject, + Properties: map[string]*genai.Schema{ + "sub_groups": { + Type: genai.TypeArray, + Items: &genai.Schema{ + Type: genai.TypeObject, + Properties: map[string]*genai.Schema{ + "title": { + Type: genai.TypeString, + }, + "entry_ids": { + Type: genai.TypeArray, + Items: &genai.Schema{ + Type: genai.TypeInteger, + }, + }, + }, + }, + }, + }, +} + +const summaryPrompt = `You will be given a list of JSON entry objects from a primary group titled '%s'. Generate a 2-3 sentence summary (less than 150 words) of the main topics. To improve readability, insert two line breaks (\n\n) after every 1-2 sentences. + +Below is the list of entries for the primary group: +---------------------------- +` + +const subGroupingPrompt = `You will be given a list of JSON entry objects from a primary group titled '%s'. Your task is to identify relevant sub-groups within it. + +- Create sub-groups for entries about the same specific topic or event. +- Create concise, descriptive titles (2-5 words) for each sub-group. +- An entry can only belong to one sub-group. +- Aim for sub-groups with 2 to 20 entries. +- If an entry cannot be sub-grouped, leave it out of the response. + +Your goal is to create meaningful sub-groups that help the user navigate the entries. + +Below is the list of entries for the primary group: +---------------------------- +` + +func ProcessPrimaryGroupWithAI(ctx context.Context, pg *models.PrimaryGroup, llmService services.LLMService) (string, []*models.EntryGroup, error) { + var llmEntries []llmEntry + entriesToProcess := pg.Entries + if len(entriesToProcess) > MaxEntriesForSummarization { + entriesToProcess = entriesToProcess[:MaxEntriesForSummarization] + } + + entryMap := make(map[int64]*models.Entry) + for _, entry := range entriesToProcess { + entryMap[entry.ID] = entry + content := entry.Content + if len(content) > MaxEntryContentLengthForLLM { + content = content[:MaxEntryContentLengthForLLM] + } + llmEntries = append(llmEntries, llmEntry{ + ID: entry.ID, + Title: entry.Title, + URL: entry.URL, + Content: content, + FeedTitle: entry.FeedTitle, + }) + } + + entriesJSON, err := json.MarshalIndent(llmEntries, "", " ") + if err != nil { + return "", nil, fmt.Errorf("failed to marshal entries to JSON for AI processing: %w", err) + } + + // 1. Get Summary + var summary string + summaryPromptFmt := fmt.Sprintf(summaryPrompt, pg.Title) + summaryFullPrompt := summaryPromptFmt + string(entriesJSON) + + summaryResponseBytes, err := llmService.GenerateContent(ctx, summaryFullPrompt, SummaryResponseSchema) + if err != nil { + log.Printf("LLM service failed during summarization for primary group '%s': %v", pg.Title, err) + } else { + var summaryResponse AIGroupSummaryResponse + if err := json.Unmarshal(summaryResponseBytes, &summaryResponse); err != nil { + log.Printf("Failed to parse LLM summarization response for primary group '%s': %v", pg.Title, err) + } else { + summary = summaryResponse.Summary + } + } + + // 2. Get Sub-groups + var subGroups []*models.EntryGroup + subGroupingPromptFmt := fmt.Sprintf(subGroupingPrompt, pg.Title) + subGroupingFullPrompt := subGroupingPromptFmt + string(entriesJSON) + + subGroupingResponseBytes, err := llmService.GenerateContent(ctx, subGroupingFullPrompt, SubGroupingResponseSchema) + if err != nil { + log.Printf("LLM service failed during sub-grouping for primary group '%s': %v. Creating single 'Uncategorized' group.", pg.Title, err) + } else { + var subGroupingResponse AISubGroupingResponse + if err := json.Unmarshal(subGroupingResponseBytes, &subGroupingResponse); err != nil { + log.Printf("Failed to parse LLM sub-grouping response for primary group '%s': %v. Creating single 'Uncategorized' group.", pg.Title, err) + } else { + groupedEntryIDs := make(map[int64]bool) + for _, llmSubGroup := range subGroupingResponse.SubGroups { + var currentSubGroupEntries []*models.Entry + for _, entryID := range llmSubGroup.EntryIDs { + if entry, ok := entryMap[entryID]; ok { + currentSubGroupEntries = append(currentSubGroupEntries, entry) + groupedEntryIDs[entryID] = true + } + } + if len(currentSubGroupEntries) > 0 { + subGroups = append(subGroups, &models.EntryGroup{ + Title: llmSubGroup.Title, + Entries: currentSubGroupEntries, + Slug: utils.Slugify(llmSubGroup.Title), + TotalEntries: len(currentSubGroupEntries), + TotalFeeds: getUniqueFeedIDs(currentSubGroupEntries), + }) + } + } + // Handle ungrouped entries + var ungroupedEntries []*models.Entry + for _, entry := range entriesToProcess { + if !groupedEntryIDs[entry.ID] { + ungroupedEntries = append(ungroupedEntries, entry) + } + } + if len(ungroupedEntries) > 0 { + subGroups = append(subGroups, &models.EntryGroup{ + Title: "Uncategorized", + Entries: ungroupedEntries, + Slug: "uncategorized", + TotalEntries: len(ungroupedEntries), + TotalFeeds: getUniqueFeedIDs(ungroupedEntries), + }) + } + } + } + + // Fallback if sub-grouping failed or returned no groups + if len(subGroups) == 0 { + subGroups = append(subGroups, &models.EntryGroup{ + Title: "Uncategorized", + Entries: entriesToProcess, + Slug: "uncategorized", + TotalEntries: len(entriesToProcess), + TotalFeeds: getUniqueFeedIDs(entriesToProcess), + }) + } + + return summary, subGroups, nil +} + +func getUniqueFeedIDs(entries []*models.Entry) int { + uniqueFeedIDs := make(map[int64]bool) + for _, entry := range entries { + uniqueFeedIDs[entry.FeedID] = true + } + return len(uniqueFeedIDs) +} + +func BuildDigestDataByAI(entries []*models.Entry, ctx context.Context, llmService services.LLMService) []*models.PrimaryGroupDigestData { + highLevelGroups, err := GroupAIEntries(ctx, entries, llmService) + if err != nil { + categoryGroups := BuildDigestDataByCategory(entries) + if len(categoryGroups) == 1 && len(entries) > 1 { + return BuildDigestDataByDate(entries) + } + return categoryGroups + } + + var allPrimaryGroups []*models.PrimaryGroupDigestData + + for _, hlg := range highLevelGroups { + var allEntriesInHlg []*models.Entry + for _, subGroup := range hlg.SubGroups { + allEntriesInHlg = append(allEntriesInHlg, subGroup.Entries...) + } + + pg := &models.PrimaryGroup{ + ID: hlg.ID, + Title: hlg.Title, + Entries: allEntriesInHlg, + } + + summary, subGroups, err := ProcessPrimaryGroupWithAI(ctx, pg, llmService) + if err != nil { + log.Printf("Failed to process primary group '%s' with AI: %v. Using original group.", pg.Title, err) + // If processing fails, use the original primary group's entries as a single sub-group + hlg.SubGroups = []*models.EntryGroup{ + { + Title: pg.Title, + Entries: pg.Entries, + Slug: utils.Slugify(pg.Title), + TotalEntries: len(pg.Entries), + TotalFeeds: getUniqueFeedIDs(pg.Entries), + }, + } + } else { + hlg.Summary = summary + hlg.SubGroups = subGroups + } + + allPrimaryGroups = append(allPrimaryGroups, hlg) + } + + sort.Slice(allPrimaryGroups, func(i, j int) bool { + return allPrimaryGroups[i].Title < allPrimaryGroups[j].Title + }) + + + return allPrimaryGroups +} diff --git a/internal/digest/view/ai_test.go b/internal/digest/view/ai_test.go new file mode 100644 index 0000000..f10e489 --- /dev/null +++ b/internal/digest/view/ai_test.go @@ -0,0 +1,288 @@ + +package view + +import ( + "context" + "encoding/json" + "errors" + + "miniflux-digest/internal/models" + "miniflux-digest/internal/testutil" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "google.golang.org/genai" +) + +// mockLLMService is a mock implementation of the LLMService for testing. +type mockLLMService struct { + GenerateContentFunc func(ctx context.Context, prompt string, schema *genai.Schema) ([]byte, error) +} + +func (m *mockLLMService) GenerateContent(ctx context.Context, prompt string, schema *genai.Schema) ([]byte, error) { + if m.GenerateContentFunc != nil { + return m.GenerateContentFunc(ctx, prompt, schema) + } + return nil, errors.New("mockLLMService.GenerateContentFunc is not implemented") +} + +func TestGroupAIEntries(t *testing.T) { + entries := []*models.Entry{ + {ID: 1, Title: "Entry 1", Content: "Content 1", FeedTitle: "Feed 1"}, + {ID: 2, Title: "Entry 2", Content: "Content 2", FeedTitle: "Feed 2"}, + {ID: 3, Title: "Entry 3", Content: "Content 3", FeedTitle: "Feed 1"}, + {ID: 4, Title: "Entry 4", Content: "Content 4", FeedTitle: "Feed 3"}, + } + + t.Run("happy path", func(t *testing.T) { + mockResp := GroupingResponse{ + Groups: []struct { + Title string `json:"title"` + EntryIDs []int64 `json:"entry_ids"` + }{ + {Title: "Group 1", EntryIDs: []int64{1, 2}}, + {Title: "Group 2", EntryIDs: []int64{3}}, + }, + } + mockJSON, _ := json.Marshal(mockResp) + + llmService := &mockLLMService{ + GenerateContentFunc: func(ctx context.Context, prompt string, schema *genai.Schema) ([]byte, error) { + return mockJSON, nil + }, + } + + groups, err := GroupAIEntries(context.Background(), entries, llmService) + assert.NoError(t, err) + assert.NotNil(t, groups) + assert.Len(t, groups, 3, "Expected 2 grouped and 1 uncategorized group") + + group1 := testutil.FindPrimaryGroup(groups, "Group 1") + assert.NotNil(t, group1) + assert.Len(t, group1.SubGroups[0].Entries, 2) + assert.Equal(t, 2, group1.TotalEntries) + + group2 := testutil.FindPrimaryGroup(groups, "Group 2") + assert.NotNil(t, group2) + assert.Len(t, group2.SubGroups[0].Entries, 1) + assert.Equal(t, 1, group2.TotalEntries) + + uncategorized := testutil.FindPrimaryGroup(groups, "Uncategorized") + assert.NotNil(t, uncategorized) + assert.Len(t, uncategorized.SubGroups[0].Entries, 1) + assert.Equal(t, int64(4), uncategorized.SubGroups[0].Entries[0].ID) + }) + + t.Run("llm service error", func(t *testing.T) { + llmService := &mockLLMService{ + GenerateContentFunc: func(ctx context.Context, prompt string, schema *genai.Schema) ([]byte, error) { + return nil, errors.New("llm error") + }, + } + + _, err := GroupAIEntries(context.Background(), entries, llmService) + assert.Error(t, err) + }) + + t.Run("llm returns invalid json", func(t *testing.T) { + llmService := &mockLLMService{ + GenerateContentFunc: func(ctx context.Context, prompt string, schema *genai.Schema) ([]byte, error) { + return []byte("invalid json"), nil + }, + } + + _, err := GroupAIEntries(context.Background(), entries, llmService) + assert.Error(t, err) + }) +} + +func TestProcessPrimaryGroupWithAI(t *testing.T) { + pg := &models.PrimaryGroup{ + ID: 1, + Title: "Test Primary Group", + Entries: []*models.Entry{ + {ID: 1, Title: "Entry 1", Content: "Content 1"}, + {ID: 2, Title: "Entry 2", Content: "Content 2"}, + {ID: 3, Title: "Entry 3", Content: "Content 3"}, + }, + } + + t.Run("happy path", func(t *testing.T) { + summaryResp := AIGroupSummaryResponse{Summary: "This is a summary."} + summaryJSON, _ := json.Marshal(summaryResp) + + subGroupingResp := AISubGroupingResponse{ + SubGroups: []struct { + Title string `json:"title"` + EntryIDs []int64 `json:"entry_ids"` + }{ + {Title: "Sub-group 1", EntryIDs: []int64{1, 2}}, + }, + } + subGroupingJSON, _ := json.Marshal(subGroupingResp) + + llmService := &mockLLMService{ + GenerateContentFunc: func(ctx context.Context, prompt string, schema *genai.Schema) ([]byte, error) { + if schema == SummaryResponseSchema { + return summaryJSON, nil + } + if schema == SubGroupingResponseSchema { + return subGroupingJSON, nil + } + return nil, errors.New("unexpected schema in happy path test") + }, + } + + summary, subGroups, err := ProcessPrimaryGroupWithAI(context.Background(), pg, llmService) + assert.NoError(t, err) + assert.Equal(t, "This is a summary.", summary) + assert.Len(t, subGroups, 2, "Expected 1 sub-group and 1 uncategorized") + + sg1 := subGroups[0] + assert.Equal(t, "Sub-group 1", sg1.Title) + assert.Len(t, sg1.Entries, 2) + + uncategorized := subGroups[1] + assert.Equal(t, "Uncategorized", uncategorized.Title) + assert.Len(t, uncategorized.Entries, 1) + assert.Equal(t, int64(3), uncategorized.Entries[0].ID) + }) + + t.Run("summarization fails", func(t *testing.T) { + subGroupingResp := AISubGroupingResponse{ + SubGroups: []struct { + Title string `json:"title"` + EntryIDs []int64 `json:"entry_ids"` + }{ + {Title: "Sub-group 1", EntryIDs: []int64{1, 2, 3}}, + }, + } + subGroupingJSON, _ := json.Marshal(subGroupingResp) + + llmService := &mockLLMService{ + GenerateContentFunc: func(ctx context.Context, prompt string, schema *genai.Schema) ([]byte, error) { + if schema == SummaryResponseSchema { + return nil, errors.New("summarization failed") + } + if schema == SubGroupingResponseSchema { + return subGroupingJSON, nil + } + return nil, errors.New("unexpected schema") + }, + } + + summary, subGroups, err := ProcessPrimaryGroupWithAI(context.Background(), pg, llmService) + assert.NoError(t, err) + assert.Empty(t, summary, "Summary should be empty when summarization fails") + assert.Len(t, subGroups, 1, "Should still get sub-groups even if summarization fails") + assert.Equal(t, "Sub-group 1", subGroups[0].Title) + }) + + t.Run("sub-grouping fails", func(t *testing.T) { + summaryResp := AIGroupSummaryResponse{Summary: "This is a summary."} + summaryJSON, _ := json.Marshal(summaryResp) + + llmService := &mockLLMService{ + GenerateContentFunc: func(ctx context.Context, prompt string, schema *genai.Schema) ([]byte, error) { + if schema == SummaryResponseSchema { + return summaryJSON, nil + } + if schema == SubGroupingResponseSchema { + return nil, errors.New("sub-grouping failed") + } + return nil, errors.New("unexpected schema") + }, + } + + summary, subGroups, err := ProcessPrimaryGroupWithAI(context.Background(), pg, llmService) + assert.NoError(t, err) + assert.Equal(t, "This is a summary.", summary) + assert.Len(t, subGroups, 1, "Should have one fallback 'Uncategorized' group") + assert.Equal(t, "Uncategorized", subGroups[0].Title) + assert.Len(t, subGroups[0].Entries, 3, "All entries should be in the fallback group") + }) +} + +func TestBuildDigestDataByAI(t *testing.T) { + entries := []*models.Entry{ + {ID: 1, Title: "Entry 1", Content: "Content 1", FeedTitle: "Feed 1", GroupID: 101, GroupTitle: "Category 1", Date: time.Now()}, + {ID: 2, Title: "Entry 2", Content: "Content 2", FeedTitle: "Feed 2", GroupID: 101, GroupTitle: "Category 1", Date: time.Now().Add(-1 * time.Hour)}, + {ID: 3, Title: "Entry 3", Content: "Content 3", FeedTitle: "Feed 1", GroupID: 102, GroupTitle: "Category 2", Date: time.Now().Add(-2 * time.Hour)}, + } + + t.Run("happy path", func(t *testing.T) { + groupingResp := GroupingResponse{ + Groups: []struct { + Title string `json:"title"` + EntryIDs []int64 `json:"entry_ids"` + }{{Title: "AI Group 1", EntryIDs: []int64{1, 2}}}, + } + groupingJSON, _ := json.Marshal(groupingResp) + + summaryResp := AIGroupSummaryResponse{Summary: "AI summary"} + summaryJSON, _ := json.Marshal(summaryResp) + + subGroupingResp := AISubGroupingResponse{ + SubGroups: []struct { + Title string `json:"title"` + EntryIDs []int64 `json:"entry_ids"` + }{{Title: "AI Sub-group", EntryIDs: []int64{1, 2}}}, + } + subGroupingJSON, _ := json.Marshal(subGroupingResp) + + llmService := &mockLLMService{ + GenerateContentFunc: func(ctx context.Context, prompt string, schema *genai.Schema) ([]byte, error) { + if schema == GroupingResponseSchema { + return groupingJSON, nil + } + if schema == SummaryResponseSchema { + return summaryJSON, nil + } + if schema == SubGroupingResponseSchema { + return subGroupingJSON, nil + } + return nil, errors.New("unexpected schema") + }, + } + + groups := BuildDigestDataByAI(entries, context.Background(), llmService) + assert.Len(t, groups, 2) + + aiGroup := testutil.FindPrimaryGroup(groups, "AI Group 1") + assert.NotNil(t, aiGroup) + assert.Equal(t, "AI summary", aiGroup.Summary) + assert.Len(t, aiGroup.SubGroups, 1) + assert.Equal(t, "AI Sub-group", aiGroup.SubGroups[0].Title) + }) + + t.Run("fallback to category grouping", func(t *testing.T) { + llmService := &mockLLMService{ + GenerateContentFunc: func(ctx context.Context, prompt string, schema *genai.Schema) ([]byte, error) { + return nil, errors.New("llm error") + }, + } + + groups := BuildDigestDataByAI(entries, context.Background(), llmService) + assert.Len(t, groups, 2, "Should fall back to 2 categories") + cat1 := testutil.FindPrimaryGroup(groups, "Category 1") + assert.NotNil(t, cat1) + cat2 := testutil.FindPrimaryGroup(groups, "Category 2") + assert.NotNil(t, cat2) + }) + + t.Run("fallback to date grouping", func(t *testing.T) { + singleCategoryEntries := []*models.Entry{ + {ID: 1, Title: "Entry 1", GroupID: 101, GroupTitle: "Category 1", Date: time.Now()}, + {ID: 2, Title: "Entry 2", GroupID: 101, GroupTitle: "Category 1", Date: time.Now().Add(-25 * time.Hour)}, + } + llmService := &mockLLMService{ + GenerateContentFunc: func(ctx context.Context, prompt string, schema *genai.Schema) ([]byte, error) { + return nil, errors.New("llm error") + }, + } + + groups := BuildDigestDataByAI(singleCategoryEntries, context.Background(), llmService) + assert.Len(t, groups, 2, "Should fall back to 2 date groups") + }) +} diff --git a/internal/digest/group_by/category.go b/internal/digest/view/category.go similarity index 65% rename from internal/digest/group_by/category.go rename to internal/digest/view/category.go index 9c7c718..744eaa9 100644 --- a/internal/digest/group_by/category.go +++ b/internal/digest/view/category.go @@ -1,7 +1,8 @@ -package group_by +package view import ( "miniflux-digest/internal/models" + "sort" ) func GroupByCategory(entries []*models.Entry) []*models.PrimaryGroup { @@ -33,3 +34,15 @@ func GroupByCategory(entries []*models.Entry) []*models.PrimaryGroup { return result } + +func BuildDigestDataByCategory(entries []*models.Entry) []*models.PrimaryGroupDigestData { + primaryGroups := GroupByCategory(entries) + + allPrimaryGroups := SubGroupByFeed(primaryGroups) + + sort.Slice(allPrimaryGroups, func(i, j int) bool { + return allPrimaryGroups[i].Title < allPrimaryGroups[j].Title + }) + + return allPrimaryGroups +} \ No newline at end of file diff --git a/internal/digest/view/category_test.go b/internal/digest/view/category_test.go new file mode 100644 index 0000000..e6aaf19 --- /dev/null +++ b/internal/digest/view/category_test.go @@ -0,0 +1,42 @@ +package view + +import ( + "miniflux-digest/internal/models" // Added this line + "miniflux-digest/internal/testutil" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGroupByCategory(t *testing.T) { + entries := testutil.CreateMockEntries(0) // Use mock entries + + groups := GroupByCategory(entries) + + assert.Len(t, groups, 3, "Expected 3 category groups") + + // Check "Category A" group + catAGroup := findPrimaryGroup(groups, "Category A") + assert.NotNil(t, catAGroup, "Category A group not found") + assert.Len(t, catAGroup.Entries, 4, "Expected 4 entries in Category A group") + + // Check "Category B" group + catBGroup := findPrimaryGroup(groups, "Category B") + assert.NotNil(t, catBGroup, "Category B group not found") + assert.Len(t, catBGroup.Entries, 3, "Expected 3 entries in Category B group") + + // Check "Category C" group + catCGroup := findPrimaryGroup(groups, "Category C") + assert.NotNil(t, catCGroup, "Category C group not found") + assert.Len(t, catCGroup.Entries, 2, "Expected 2 entries in Category C group") +} + +// findPrimaryGroup is a helper function to find a primary group by title +func findPrimaryGroup(groups []*models.PrimaryGroup, title string) *models.PrimaryGroup { + for _, group := range groups { + if group.Title == title { + return group + } + } + return nil +} \ No newline at end of file diff --git a/internal/digest/group_by/date.go b/internal/digest/view/date.go similarity index 84% rename from internal/digest/group_by/date.go rename to internal/digest/view/date.go index c030ee2..88eb7e0 100644 --- a/internal/digest/group_by/date.go +++ b/internal/digest/view/date.go @@ -1,4 +1,4 @@ -package group_by +package view import ( "miniflux-digest/internal/models" @@ -39,7 +39,7 @@ const ( DayGroupTitleLayout = "Jan 2, 2006" ) -func SubGroupByDay(pgs []*models.PrimaryGroup) ([]*models.PrimaryGroupDigestData, *string) { +func SubGroupByDay(pgs []*models.PrimaryGroup) []*models.PrimaryGroupDigestData { var allPrimaryGroups []*models.PrimaryGroupDigestData for _, pg := range pgs { @@ -87,5 +87,17 @@ func SubGroupByDay(pgs []*models.PrimaryGroup) ([]*models.PrimaryGroupDigestData }) } - return allPrimaryGroups, nil -} \ No newline at end of file + return allPrimaryGroups +} + +func BuildDigestDataByDate(entries []*models.Entry) []*models.PrimaryGroupDigestData { + primaryGroups := GroupByDate(entries) + + allPrimaryGroups := SubGroupByDay(primaryGroups) + + sort.Slice(allPrimaryGroups, func(i, j int) bool { + return allPrimaryGroups[i].Title < allPrimaryGroups[j].Title + }) + + return allPrimaryGroups +} diff --git a/internal/digest/view/date_test.go b/internal/digest/view/date_test.go new file mode 100644 index 0000000..6f5cc75 --- /dev/null +++ b/internal/digest/view/date_test.go @@ -0,0 +1,48 @@ +package view + +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)) + } +} + +func TestDayGrouper_GroupEntries(t *testing.T) { + entries := testutil.CreateMockEntries(0) + primaryGroupsMap := GroupByCategory(entries) + + groups := SubGroupByDay(primaryGroupsMap) + + if len(groups) != 3 { + t.Fatalf("Expected 3 primary groups, got %d", len(groups)) + } + + catAGroup := testutil.FindPrimaryGroup(groups, "Category A") + if catAGroup == nil || len(catAGroup.SubGroups) != 1 { + t.Fatalf("Incorrect sub-groups for Category A: %+v", catAGroup) + } + + subGroup := testutil.FindSubGroup(catAGroup, "Jan 2, 2024") + if subGroup == nil || len(subGroup.Entries) != 4 { + t.Fatalf("Incorrect entries for sub-group: %+v", subGroup) + } +} \ No newline at end of file diff --git a/internal/digest/group_by/feed.go b/internal/digest/view/feed.go similarity index 91% rename from internal/digest/group_by/feed.go rename to internal/digest/view/feed.go index 76313bc..443d576 100644 --- a/internal/digest/group_by/feed.go +++ b/internal/digest/view/feed.go @@ -1,4 +1,4 @@ -package group_by +package view import ( "miniflux-digest/internal/models" @@ -6,7 +6,7 @@ import ( "sort" ) -func SubGroupByFeed(pgs []*models.PrimaryGroup) ([]*models.PrimaryGroupDigestData, *string) { +func SubGroupByFeed(pgs []*models.PrimaryGroup) []*models.PrimaryGroupDigestData { var allPrimaryGroups []*models.PrimaryGroupDigestData for _, pg := range pgs { @@ -53,5 +53,5 @@ func SubGroupByFeed(pgs []*models.PrimaryGroup) ([]*models.PrimaryGroupDigestDat }) } - return allPrimaryGroups, nil + return allPrimaryGroups } diff --git a/internal/digest/view/feed_test.go b/internal/digest/view/feed_test.go new file mode 100644 index 0000000..b1a567c --- /dev/null +++ b/internal/digest/view/feed_test.go @@ -0,0 +1,61 @@ +package view + +import ( + "miniflux-digest/internal/models" + "miniflux-digest/internal/testutil" + + "testing" + + "github.com/stretchr/testify/assert" +) + + + +func TestSubGroupByFeed(t *testing.T) { + entries := testutil.CreateMockEntries(0) + primaryGroups := GroupByCategory(entries) // Use GroupByCategory to get initial primary groups + + groups := SubGroupByFeed(primaryGroups) + assert.Len(t, groups, 3, "Expected 3 primary groups after SubGroupByFeed") + + // Check "Category A" group + catAGroup := findPrimaryGroupDigestData(groups, "Category A") + assert.NotNil(t, catAGroup, "Category A group not found") + assert.Len(t, catAGroup.SubGroups, 3, "Expected 3 sub-groups for Category A") // Feed A, Feed C, Feed D + + // Check "Category B" group + catBGroup := findPrimaryGroupDigestData(groups, "Category B") + assert.NotNil(t, catBGroup, "Category B group not found") + assert.Len(t, catBGroup.SubGroups, 2, "Expected 2 sub-groups for Category B") // Feed B, Feed E + + // Check "Category C" group + catCGroup := findPrimaryGroupDigestData(groups, "Category C") + assert.NotNil(t, catCGroup, "Category C group not found") + assert.Len(t, catCGroup.SubGroups, 2, "Expected 2 sub-groups for Category C") // Feed F, Feed G +} + +// findPrimaryGroupDigestData is a helper function to find a PrimaryGroupDigestData by title +func findPrimaryGroupDigestData(groups []*models.PrimaryGroupDigestData, title string) *models.PrimaryGroupDigestData { + for _, group := range groups { + if group.Title == title { + return group + } + } + return nil +} + +func TestFeedGrouper_GroupEntries(t *testing.T) { + entries := testutil.CreateMockEntries(0) + primaryGroupsMap := GroupByCategory(entries) + + groups := SubGroupByFeed(primaryGroupsMap) + + if len(groups) != 3 { + t.Fatalf("Expected 3 primary groups, got %d", len(groups)) + } + + catAGroup := testutil.FindPrimaryGroup(groups, "Category A") + if catAGroup == nil || len(catAGroup.SubGroups) != 3 { + t.Fatalf("Incorrect sub-groups for Category A: %+v", catAGroup) + } +} diff --git a/internal/digest/view/view.go b/internal/digest/view/view.go new file mode 100644 index 0000000..8d22beb --- /dev/null +++ b/internal/digest/view/view.go @@ -0,0 +1,75 @@ +package view + +import ( + "context" + "miniflux-digest/internal/app/services" + "miniflux-digest/internal/models" + "sort" + "time" +) + +func BuildDigestDataForView(entries []*models.Entry, icons map[int64]*models.FeedIcon, viewType string, minifluxHost string, llmService services.LLMService) *models.OverviewTemplateData { + iconsSlice := make([]*models.FeedIcon, 0, len(icons)) + for _, icon := range icons { + iconsSlice = append(iconsSlice, icon) + } + + var resultPrimaryGroups []*models.PrimaryGroupDigestData + + + switch viewType { + case "date": + resultPrimaryGroups = BuildDigestDataByDate(entries) + case "category", "default": + resultPrimaryGroups = BuildDigestDataByCategory(entries) + case "ai": + resultPrimaryGroups = BuildDigestDataByAI(entries, context.Background(), llmService) + } + + if viewType == "date" || viewType == "category" { + for _, pg := range resultPrimaryGroups { + 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 _, pg := range resultPrimaryGroups { + pgTotalEntries := 0 + pgUniqueFeedIDs := make(map[int64]bool) + for _, subGroup := range pg.SubGroups { + pgTotalEntries += len(subGroup.Entries) + for _, entry := range subGroup.Entries { + pgUniqueFeedIDs[entry.FeedID] = true + } + } + pg.TotalEntries = pgTotalEntries + pg.TotalFeeds = len(pgUniqueFeedIDs) + } + + var allEntryGroups []*models.EntryGroup + for _, pg := range resultPrimaryGroups { + allEntryGroups = append(allEntryGroups, pg.SubGroups...) + } + + uniqueFeedIDs := make(map[int64]bool) + for _, entry := range entries { + uniqueFeedIDs[entry.FeedID] = true + } + + return &models.OverviewTemplateData{ + Entries: entries, + GeneratedDate: time.Now(), + FeedIcons: iconsSlice, + PrimaryGroups: resultPrimaryGroups, + EntryGroups: allEntryGroups, + + TotalEntries: len(entries), + TotalFeeds: len(uniqueFeedIDs), + MinifluxHost: minifluxHost, + } +} + + diff --git a/internal/email/email.go b/internal/email/email.go index abca738..ae9aa62 100644 --- a/internal/email/email.go +++ b/internal/email/email.go @@ -46,7 +46,7 @@ func (s *EmailServiceImpl) Send(cfg *config.Config, overviewFile *os.File, group emailData := templates.EmailTemplateData{ OverviewTemplateData: *data, URL: overviewURL, - Summary: data.OverviewSummary, + } var body bytes.Buffer diff --git a/internal/email/email_test.go b/internal/email/email_test.go index b086306..b6a5061 100644 --- a/internal/email/email_test.go +++ b/internal/email/email_test.go @@ -56,7 +56,7 @@ func TestSend(t *testing.T) { data := models.OverviewTemplateData{ Entries: testutil.NewMockEntries(), FeedIcons: testutil.NewMockFeedIcons(), - OverviewSummary: "Test summary", + EntryGroups: []*models.EntryGroup{ testutil.NewMockEntryGroup(), }, diff --git a/internal/llm/gemini.go b/internal/llm/gemini.go index 8e56d73..bb8d399 100644 --- a/internal/llm/gemini.go +++ b/internal/llm/gemini.go @@ -13,6 +13,7 @@ import ( const ( Model = "gemini-2.5-flash" maxRetries = 3 + perTryTimeout = 3 * time.Minute Temperature float32 = 0.4 ) @@ -42,36 +43,38 @@ func NewGeminiService(apiKey string) (services.LLMService, error) { } func (s *GeminiService) generateContentWithRetry(ctx context.Context, prompt string, schema *genai.Schema) (*genai.GenerateContentResponse, error) { - var resp *genai.GenerateContentResponse - var err error + var lastErr error - for i := range maxRetries { - resp, err = s.client.GenerateContent(ctx, s.modelName, genai.Text(prompt), &genai.GenerateContentConfig{ + for i := 1; i <= maxRetries; i++ { + tryCtx, tryCancel := context.WithTimeout(ctx, perTryTimeout) + resp, err := s.client.GenerateContent(tryCtx, s.modelName, genai.Text(prompt), &genai.GenerateContentConfig{ ResponseMIMEType: "application/json", ResponseSchema: schema, Temperature: genai.Ptr(Temperature), }) + tryCancel() if err == nil { return resp, nil } - log.Printf("LLM call failed: %v", err) + lastErr = err + + log.Printf("LLM attempt %d/%d failed: %v", i, maxRetries, err) var apiError genai.APIError - if errors.As(err, &apiError) { - log.Printf("LLM API error: Code=%d, Status=%s, Message=%s", apiError.Code, apiError.Status, apiError.Message) - if apiError.Code == 503 || apiError.Code == 500 { - log.Printf("Retrying LLM call (%d/%d)...", i+1, maxRetries) - s.sleep(time.Second * time.Duration(i+1)) + if errors.As(err, &apiError) && (apiError.Code == 503 || apiError.Code == 500) { + if i < maxRetries { + log.Printf("Retrying after server error...") + s.sleep(time.Second * time.Duration(i)) continue } } - return nil, err + break } - return nil, err + return nil, lastErr } func (s *GeminiService) GenerateContent(ctx context.Context, prompt string, schema *genai.Schema) ([]byte, error) { @@ -100,4 +103,4 @@ func (s *GeminiService) GenerateContent(ctx context.Context, prompt string, sche } return jsonData, nil -} +} \ No newline at end of file diff --git a/internal/models/models.go b/internal/models/models.go index e4337a3..7b5018e 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -23,6 +23,18 @@ type Entry struct { Date time.Time } +type Feed struct { + ID int64 + Title string + Description string +} + +type Category struct { + ID int64 + Title string + Description string +} + type FeedIcon struct { FeedID int64 Data string @@ -34,7 +46,6 @@ type OverviewTemplateData struct { FeedIcons []*FeedIcon EntryGroups []*EntryGroup PrimaryGroups []*PrimaryGroupDigestData - OverviewSummary string TotalEntries int TotalFeeds int MinifluxHost string diff --git a/internal/processor/processor_test.go b/internal/processor/processor_test.go index 4d2d087..895fccd 100644 --- a/internal/processor/processor_test.go +++ b/internal/processor/processor_test.go @@ -61,7 +61,7 @@ func TestProcessDigest_Success(t *testing.T) { 101: {FeedID: 101, Data: "icon1"}, 102: {FeedID: 102, Data: "icon2"}, } - expectedOverviewData := &models.OverviewTemplateData{OverviewSummary: "Test Digest"} + expectedOverviewData := &models.OverviewTemplateData{} mockMinifluxClient := &mockMinifluxClientService{ getAllUnreadEntriesFunc: func() ([]*models.Entry, error) { diff --git a/internal/templates/email.gotxt b/internal/templates/email.gotxt index 9d14e89..e4ca713 100644 --- a/internal/templates/email.gotxt +++ b/internal/templates/email.gotxt @@ -1,19 +1,19 @@ -Hello, +Your digest for {{ .GeneratedDate.Format "Jan 2, 2006" }} is ready with {{.TotalEntries}} entries from {{.TotalFeeds}} feeds. -Your daily digest of {{ .Entries | len }} entries from {{ .EntryGroups | len }} feeds is ready. +---------------------------------------- -{{ if .OverviewSummary -}} -Overall Summary: -{{ .OverviewSummary }} +{{range .PrimaryGroups -}} +{{ .Title }} +{{ if .Summary -}} +{{ .Summary }} +{{- else -}} +(no summary) {{- end }} -You have {{.TotalEntries}} entries from {{.TotalFeeds}} feeds. +{{end}}---------------------------------------- {{ if .URL -}} -You can view the full digest online: -{{ .URL }} -{{- end }} - -{{ if .EntryGroups -}} -The individual digests are attached to this email for offline reading. +The individual HTML digests for each group are attached, or you can view the full digest online: {{ .URL }} +{{- else -}} +The individual HTML digests for each group are attached. {{- end }} diff --git a/internal/templates/overview.gohtml b/internal/templates/overview.gohtml index ace2d46..a980748 100644 --- a/internal/templates/overview.gohtml +++ b/internal/templates/overview.gohtml @@ -17,12 +17,7 @@
- {{if .OverviewSummary}} -