feat(ai): Implement multi-call AI grouping and summarization #56
No files matched your search
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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>
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
type EmailTemplateData struct {
|
||||
models.OverviewTemplateData
|
||||
URL string
|
||||
Summary string
|
||||
|
||||
}
|
||||
|
||||
//go:embed *.gohtml *.gotxt
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user