feat(ai): Implement multi-call AI grouping and summarization #56

Merged
eleith merged 1 commits from gemini/feat/llm-dynamic-grouping-additive into main 2025-09-05 19:11:22 +00:00
28 changed files with 1164 additions and 561 deletions

No files matched your search

+4
View File
@@ -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
+2
View File
@@ -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=
+4
View File
@@ -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)
}
+4 -90
View File
@@ -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)
}
+92 -151
View File
@@ -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)
}
})
}
-23
View File
@@ -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))
}
}
-238
View File
@@ -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)
}
+428
View File
@@ -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
}
+288
View File
@@ -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")
})
}
@@ -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
}
+42
View File
@@ -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
}
@@ -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
}
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
}
+48
View File
@@ -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)
}
}
@@ -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
}
+61
View File
@@ -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)
}
}
+75
View File
@@ -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,
}
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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(),
},
+16 -13
View File
@@ -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
}
}
+12 -1
View File
@@ -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
+1 -1
View File
@@ -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) {
+12 -12
View File
@@ -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 }}
+1 -6
View File
@@ -17,12 +17,7 @@
</p>
</section>
{{if .OverviewSummary}}
<section class="summary">
<h2 class="group-entries-title">Summary</h2>
<div>{{textToHTML .OverviewSummary}}</div>
</section>
{{end}}
<section class="grouped-digests">
<h2 class="group-entries-title">Grouped Digests</h2>
+1 -1
View File
@@ -11,7 +11,7 @@ import (
type EmailTemplateData struct {
models.OverviewTemplateData
URL string
Summary string
}
//go:embed *.gohtml *.gotxt
+1 -1
View File
@@ -73,7 +73,7 @@ func TestOverviewTemplateExecution(t *testing.T) {
Entries: testutil.NewMockEntries(),
GeneratedDate: time.Now(),
FeedIcons: testutil.NewMockFeedIcons(),
OverviewSummary: "This is a test summary.",
TotalEntries: 5,
TotalFeeds: 2,
MinifluxHost: "http://localhost:8080",
+22 -13
View File
@@ -12,24 +12,16 @@ import (
type MockMinifluxClient struct {
services.MinifluxClientService
FeedIconFunc func(feedID int64) (*miniflux.FeedIcon, error)
GetAllUnreadEntriesFunc func() (*miniflux.Entries, error)
UpdateEntriesFunc func(entryIDs []int64, status string) error
}
func (m *MockMinifluxClient) FeedIcon(feedID int64) (*miniflux.FeedIcon, error) {
if m.FeedIconFunc != nil {
return m.FeedIconFunc(feedID)
@@ -39,8 +31,6 @@ func (m *MockMinifluxClient) FeedIcon(feedID int64) (*miniflux.FeedIcon, error)
func (m *MockMinifluxClient) GetAllUnreadEntries() (*miniflux.Entries, error) {
if m.GetAllUnreadEntriesFunc != nil {
return m.GetAllUnreadEntriesFunc()
@@ -86,3 +76,22 @@ func (m *MockDigestService) BuildDigestData(category *miniflux.Category, entries
}
return nil
}
// Helper functions for tests
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
}
+14
View File
@@ -17,3 +17,17 @@ func Slugify(input string) string {
return slug
}
// UniqueWords extracts unique words from a string, converts them to lowercase.
func UniqueWords(input string) []string {
words := strings.Fields(strings.ToLower(input))
uniqueMap := make(map[string]bool)
for _, word := range words {
uniqueMap[word] = true
}
uniqueSlice := make([]string, 0, len(uniqueMap))
for word := range uniqueMap {
uniqueSlice = append(uniqueSlice, word)
}
return uniqueSlice
}
+1 -1
View File
@@ -93,7 +93,7 @@ func generateMockDigest(cfg *config.Config) *models.OverviewTemplateData {
data.PrimaryGroups[2].Summary = "This is another mock summary for a different group, showing that not all groups have summaries."
}
data.OverviewSummary = "This is a mock overview summary to see how it looks on the page. It should provide a high-level overview of all the digests included below."
return data
}