refactor(digest): consolidate AI-generated groups #58

Merged
eleith merged 2 commits from gemini/refactor/consolidate-ai-groups into main 2025-09-12 22:23:59 +00:00
6 changed files with 552 additions and 386 deletions

No files matched your search

-1
View File
@@ -42,7 +42,6 @@ require (
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,8 +98,6 @@ 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=
+3 -3
View File
@@ -36,7 +36,7 @@ func TestDigestService_BuildDigestData(t *testing.T) {
mockLLM := &mockLLMService{
GenerateContentFunc: func(ctx context.Context, prompt string, schema *genai.Schema) ([]byte, error) {
switch schema {
case view.GroupingResponseSchema:
case view.InitialGroupingResponseSchema:
return []byte(`{
"groups": [
{
@@ -130,7 +130,7 @@ func TestDigestService_BuildDigestData(t *testing.T) {
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 {
if schema == view.InitialGroupingResponseSchema {
return nil, errors.New("mock LLM grouping error")
}
return nil, errors.New("unexpected LLM call")
@@ -155,7 +155,7 @@ func TestDigestService_BuildDigestData(t *testing.T) {
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 {
if schema == view.InitialGroupingResponseSchema {
return []byte(`{
"groups": [
{
+195 -247
View File
@@ -5,16 +5,15 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"miniflux-digest/internal/app/services"
"miniflux-digest/internal/models"
"miniflux-digest/internal/utils"
"sync"
"sort"
"log"
"strings"
"sync"
"text/template"
"google.golang.org/genai"
)
const (
@@ -22,65 +21,6 @@ const (
MaxEntriesForSummarization = 200
)
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 are creating high-level primary groups for a digest.
**Existing Primary Groups:**
{{if .ExistingGroups -}}
You have already created the following groups. Try to fit new entries into these if they are a good match.
- {{join .ExistingGroups "\n- "}}
{{- else -}}
This is the first batch of entries.
{{- end }}
**Your Task:**
Group the entries below into broad, high-level primary groups.
The nature of a good primary group can vary:
- Sometimes it's a broad **topic** (e.g., "AI Development", "Global Economics").
- Sometimes it's based on the **source** (e.g., "Hacker News Discussions", "TechCrunch Articles").
- Sometimes it's about the **type of content** (e.g., "Software Development Blogs", "Video Game Reviews").
Follow these instructions:
- If an entry fits into an existing group, add it to that group.
- Only create a new group if an entry does not fit well into an existing one.
- Create group titles that are concise and high-level (2-5 words).
- Aim for larger groups, ideally with 15 to 50 entries.
- An entry can only belong to one group.
Below is the list of entries:
----------------------------
`
type llmEntry struct {
ID int64 `json:"id"`
Title string `json:"title"`
@@ -89,214 +29,191 @@ type llmEntry struct {
FeedTitle string `json:"feed_title"`
}
func GroupAIEntries(ctx context.Context, entries []*models.Entry, llmService services.LLMService) ([]*models.PrimaryGroupDigestData, error) {
func GroupAIEntries(ctx context.Context, entries []*models.Entry, llmService services.LLMService) (map[string][]*models.Entry, map[int64]bool, error) {
const chunkSize = 200
var wg sync.WaitGroup
var mu sync.Mutex
errs := make(chan error, len(entries)/chunkSize+1)
rawGroups := make(map[string][]*models.Entry)
groupedEntryIDs := make(map[int64]bool)
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)
promptTemplate, err := template.New("grouping").Funcs(template.FuncMap{
"join": func(s []string, sep string) string {
return strings.Join(s, sep)
},
}).Parse(groupingPrompt)
promptTemplate, err := template.New("grouping").Parse(initialGroupingPrompt)
if err != nil {
return nil, fmt.Errorf("failed to parse grouping prompt template: %w", err)
return nil, nil, fmt.Errorf("failed to parse grouping prompt template: %w", err)
}
for i := 0; i < len(entries); i += chunkSize {
end := min(i + chunkSize, len(entries))
end := min(i+chunkSize, len(entries))
chunk := entries[i:end]
wg.Add(1)
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,
})
}
go func(chunk []*models.Entry, i, end int) {
defer wg.Done()
entriesJSON, err := json.MarshalIndent(llmEntries, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal entries to JSON: %w", err)
}
var existingGroups []string
for _, pg := range allPrimaryGroupDigestData {
existingGroups = append(existingGroups, pg.Title)
}
promptData := struct {
ExistingGroups []string
}{
ExistingGroups: existingGroups,
}
var prompt bytes.Buffer
if err := promptTemplate.Execute(&prompt, promptData); err != nil {
return nil, fmt.Errorf("failed to execute prompt template: %w", err)
}
prompt.Write(entriesJSON)
llmResponse, err := llmService.GenerateContent(ctx, prompt.String(), 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
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,
})
}
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),
entriesJSON, err := json.MarshalIndent(llmEntries, "", " ")
if err != nil {
errs <- fmt.Errorf("failed to marshal entries to JSON for chunk %d-%d: %w", i, end, err)
return
}
var prompt bytes.Buffer
if err := promptTemplate.Execute(&prompt, nil); err != nil {
errs <- fmt.Errorf("failed to execute prompt template for chunk %d-%d: %w", i, end, err)
return
}
prompt.Write(entriesJSON)
llmResponse, err := llmService.GenerateContent(ctx, prompt.String(), InitialGroupingResponseSchema)
if err != nil {
errs <- fmt.Errorf("LLM service failed for chunk %d-%d: %w", i, end, err)
return
}
var response InitialGroupingResponse
if err := json.Unmarshal(llmResponse, &response); err != nil {
errs <- fmt.Errorf("failed to parse LLM response for chunk %d-%d: %w", i, end, err)
return
}
mu.Lock()
defer mu.Unlock()
for _, group := range response.Groups {
if _, ok := rawGroups[group.Title]; !ok {
rawGroups[group.Title] = []*models.Entry{}
}
for _, entryID := range group.EntryIDs {
if _, exists := groupedEntryIDs[entryID]; exists {
continue
}
if entry, ok := entryMap[entryID]; ok {
rawGroups[group.Title] = append(rawGroups[group.Title], entry)
groupedEntryIDs[entryID] = true
}
allPrimaryGroupDigestData = append(allPrimaryGroupDigestData, newPGDD)
primaryGroupMap[group.Title] = newPGDD
currentPrimaryGroupID++
}
}
}(chunk, i, end)
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
// For now, we log the first error and return. A more robust solution could be to
// collect all errors or decide to proceed with partial data.
return nil, nil, err
}
}
return rawGroups, groupedEntryIDs, nil
}
func consolidatePrimaryGroups(ctx context.Context, rawGroups map[string][]*models.Entry, llmService services.LLMService) ([]*models.PrimaryGroupDigestData, error) {
var initialTitles []string
for title := range rawGroups {
initialTitles = append(initialTitles, title)
}
if len(initialTitles) <= 10 { // No need to consolidate if we already have a small number of groups
return convertRawGroupsToDigestData(rawGroups), nil
}
tmpl, err := template.New("consolidation").Parse(consolidationPrompt)
if err != nil {
return nil, fmt.Errorf("failed to parse consolidation prompt template: %w", err)
}
var prompt bytes.Buffer
if err := tmpl.Execute(&prompt, initialTitles); err != nil {
return nil, fmt.Errorf("failed to execute consolidation prompt template: %w", err)
}
llmResponse, err := llmService.GenerateContent(ctx, prompt.String(), ConsolidationResponseSchema)
if err != nil {
// Fallback: If LLM fails, use the original, unconsolidated groups
log.Printf("LLM consolidation failed, falling back to original groups: %v", err)
return convertRawGroupsToDigestData(rawGroups), nil
}
var response ConsolidationResponse
if err := json.Unmarshal(llmResponse, &response); err != nil {
log.Printf("Failed to parse LLM consolidation response, falling back to original groups: %v", err)
return convertRawGroupsToDigestData(rawGroups), nil
}
// Merge groups based on consolidation response
consolidatedGroups := make(map[string][]*models.Entry)
mappedOldTitles := make(map[string]bool)
for _, cg := range response.ConsolidatedGroups {
if _, ok := consolidatedGroups[cg.NewTitle]; !ok {
consolidatedGroups[cg.NewTitle] = []*models.Entry{}
}
for _, oldTitle := range cg.OldTitles {
if entries, ok := rawGroups[oldTitle]; ok {
consolidatedGroups[cg.NewTitle] = append(consolidatedGroups[cg.NewTitle], entries...)
mappedOldTitles[oldTitle] = true
}
}
}
var ungroupedEntries []*models.Entry
for _, entry := range entries {
if !groupedEntryIDs[entry.ID] {
ungroupedEntries = append(ungroupedEntries, entry)
// Add any groups that the LLM may have missed
for oldTitle, entries := range rawGroups {
if !mappedOldTitles[oldTitle] {
consolidatedGroups[oldTitle] = entries
}
}
if len(ungroupedEntries) > 0 {
allPrimaryGroupDigestData = append(allPrimaryGroupDigestData, &models.PrimaryGroupDigestData{
ID: currentPrimaryGroupID,
Title: "Uncategorized",
Slug: "uncategorized",
SubGroups: []*models.EntryGroup{
return convertRawGroupsToDigestData(consolidatedGroups), nil
}
func convertRawGroupsToDigestData(rawGroups map[string][]*models.Entry) []*models.PrimaryGroupDigestData {
var digestData []*models.PrimaryGroupDigestData
currentPrimaryGroupID := int64(1)
for title, entries := range rawGroups {
if len(entries) == 0 {
continue
}
newPGDD := &models.PrimaryGroupDigestData{
ID: currentPrimaryGroupID,
Title: title,
Slug: utils.Slugify(title),
SubGroups: []*models.EntryGroup{
{
Title: "Uncategorized",
Entries: ungroupedEntries,
Slug: "uncategorized",
Title: title, // Initial sub-group is the same as the primary group
Entries: entries,
Slug: utils.Slugify(title),
},
},
Summary: "",
TotalEntries: len(ungroupedEntries),
TotalFeeds: getUniqueFeedIDs(ungroupedEntries),
})
TotalEntries: len(entries),
TotalFeeds: getUniqueFeedIDs(entries),
}
digestData = append(digestData, newPGDD)
currentPrimaryGroupID++
}
return allPrimaryGroupDigestData, nil
return digestData
}
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 are an expert at summarizing content for busy readers. You will be given a list of entries from a primary group titled '%s'.
Your task is to write a concise, 2-3 sentence summary (under 150 words) that achieves two goals:
1. **Provide a high-level overview** of the main themes in the group.
2. **Highlight the most significant or surprising entries.** This could be a major announcement, a controversial opinion, or a particularly popular discussion.
The goal is to give the reader enough information to quickly decide if this group contains entries they want to explore further. Do not just list the topics; provide some insight into the content.
`
const subGroupingPrompt = `You are an expert at organizing content. You will be given a list of entries that are already part of a primary group titled '%s'.
Your task is to create smaller, more granular sub-groups to help a user navigate the content. A good sub-group contains entries that are very closely related.
- Create sub-groups that represent a specific **story, product, or discussion thread**.
- The sub-group titles should be very specific and descriptive (3-7 words). For example, instead of "AI News", a good sub-group title might be "Gemini 1.5 Pro Announcement" or "Discussion on AI Safety".
- Aim for smaller, tight-knit groups of 2 to 15 entries.
- An entry can only belong to one sub-group.
- If an entry doesn't fit into a specific sub-group, leave it out of your response.
`
func ProcessPrimaryGroupWithAI(ctx context.Context, pg *models.PrimaryGroup, llmService services.LLMService) (string, []*models.EntryGroup, error) {
var llmEntries []llmEntry
entriesToProcess := pg.Entries
@@ -334,7 +251,7 @@ func ProcessPrimaryGroupWithAI(ctx context.Context, pg *models.PrimaryGroup, llm
if err != nil {
log.Printf("LLM service failed during summarization for primary group '%s': %v", pg.Title, err)
} else {
var summaryResponse AIGroupSummaryResponse
var summaryResponse SummaryResponse
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 {
@@ -351,7 +268,7 @@ func ProcessPrimaryGroupWithAI(ctx context.Context, pg *models.PrimaryGroup, llm
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
var subGroupingResponse SubGroupingResponse
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 {
@@ -416,8 +333,9 @@ func getUniqueFeedIDs(entries []*models.Entry) int {
}
func BuildDigestDataByAI(entries []*models.Entry, ctx context.Context, llmService services.LLMService) []*models.PrimaryGroupDigestData {
highLevelGroups, err := GroupAIEntries(ctx, entries, llmService)
rawGroups, groupedEntryIDs, err := GroupAIEntries(ctx, entries, llmService)
if err != nil {
log.Printf("AI grouping failed, falling back to category grouping: %v", err)
categoryGroups := BuildDigestDataByCategory(entries)
if len(categoryGroups) == 1 && len(entries) > 1 {
return BuildDigestDataByDate(entries)
@@ -425,6 +343,38 @@ func BuildDigestDataByAI(entries []*models.Entry, ctx context.Context, llmServic
return categoryGroups
}
highLevelGroups, err := consolidatePrimaryGroups(ctx, rawGroups, llmService)
if err != nil {
// This case should ideally not be hit if consolidatePrimaryGroups has a fallback
log.Printf("AI group consolidation failed unexpectedly: %v", err)
highLevelGroups = convertRawGroupsToDigestData(rawGroups)
}
// Handle entries that were not grouped by the initial LLM call
var ungroupedEntries []*models.Entry
for _, entry := range entries {
if !groupedEntryIDs[entry.ID] {
ungroupedEntries = append(ungroupedEntries, entry)
}
}
if len(ungroupedEntries) > 0 {
highLevelGroups = append(highLevelGroups, &models.PrimaryGroupDigestData{
ID: int64(len(highLevelGroups) + 1),
Title: "Uncategorized",
Slug: "uncategorized",
SubGroups: []*models.EntryGroup{
{
Title: "Uncategorized",
Entries: ungroupedEntries,
Slug: "uncategorized",
},
},
Summary: "",
TotalEntries: len(ungroupedEntries),
TotalFeeds: getUniqueFeedIDs(ungroupedEntries),
})
}
var allPrimaryGroups []*models.PrimaryGroupDigestData
var wg sync.WaitGroup
var mu sync.Mutex
@@ -448,12 +398,11 @@ func BuildDigestDataByAI(entries []*models.Entry, ctx context.Context, llmServic
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),
Title: pg.Title,
Entries: pg.Entries,
Slug: utils.Slugify(pg.Title),
TotalEntries: len(pg.Entries),
TotalFeeds: getUniqueFeedIDs(pg.Entries),
},
@@ -475,6 +424,5 @@ func BuildDigestDataByAI(entries []*models.Entry, ctx context.Context, llmServic
return allPrimaryGroups[i].Title < allPrimaryGroups[j].Title
})
return allPrimaryGroups
}
}
+176
View File
@@ -0,0 +1,176 @@
package view
import "google.golang.org/genai"
const initialGroupingPrompt = `You are creating high-level primary groups for a digest.
**Your Task:**
Group the entries below into broad, high-level primary groups.
The nature of a good primary group can vary:
- Sometimes it's a broad **topic** (e.g., "AI Development", "Global Economics").
- Sometimes it's based on the **source** (e.g., "Hacker News Discussions", "TechCrunch Articles").
- Sometimes it's about the **type of content** (e.g., "Software Development Blogs", "Video Game Reviews").
Follow these instructions:
- Create group titles that are concise and high-level (2-5 words).
- An entry can only belong to one group.
Below is the list of entries:
----------------------------
`
const consolidationPrompt = `You are an expert content curator responsible for organizing a digest. You have been given a list of proposed primary group titles that were automatically generated. Your task is to ensure that this list is broad and high level and does not contain potentially overlapping topics. Each topic will later on be further subdivided into sub-groups, so if two topics are better served as sub-topics, they should be merged into one high level topic. Our goal is to have no more than 10-12 primary groups.
**Your Goal:**
Generate high level primary groups by possibly merging similar or related group titles to produce a final list of no more than 10-12 primary groups.
**Instructions:**
1. Review the list of "Original Group Titles" below.
2. Identify titles that are duplicates, closely related, or could be merged into a broader, more meaningful category.
3. Create a new, high-level title for each consolidated group.
4. A group that is already distinct and high-level can be kept as is, with its original title becoming the "new_title".
**Example:**
If the original titles are:
- "AI Startups"
- "Machine Learning Research"
- "Tech Company Earnings"
- "Stock Market News"
- "Go Programming Language"
A good consolidation would be:
- new_title: "Artificial Intelligence", old_titles: ["AI Startups", "Machine Learning Research"]
- new_title: "Finance & Markets", old_titles: ["Tech Company Earnings", "Stock Market News"]
- new_title: "Software Development", old_titles: ["Go Programming Language"]
**Original Group Titles:**
{{range .}}
- {{.}}
{{end}}
`
const summaryPrompt = `You are an expert at summarizing content for busy readers. You will be given a list of entries from a primary group titled '%s'.
Your task is to write a concise, 2-3 sentence summary (under 150 words) that achieves two goals:
1. **Provide a high-level overview** of the main themes in the group.
2. **Highlight the most significant or surprising entries.** This could be a major announcement, a controversial opinion, or a particularly popular discussion.
The goal is to give the reader enough information to quickly decide if this group contains entries they want to explore further. Do not just list the topics; provide some insight into the content.
`
const subGroupingPrompt = `You are an expert at organizing content. You will be given a list of entries that are already part of a primary group titled '%s'.
Your task is to create smaller, more granular sub-groups to help a user navigate the content. A good sub-group contains entries that are very closely related.
- Create sub-groups that represent a specific **story, product, or discussion thread**.
- The sub-group titles should be very specific and descriptive (3-7 words). For example, instead of "AI News", a good sub-group title might be "Gemini 1.5 Pro Announcement" or "Discussion on AI Safety".
- Aim for smaller, tight-knit groups of 2 to 15 entries.
- An entry can only belong to one sub-group.
- If an entry doesn't fit into a specific sub-group, leave it out of your response.
`
// --- Prompt Response Types ---
type InitialGroupingResponse struct {
Groups []struct {
Title string `json:"title"`
EntryIDs []int64 `json:"entry_ids"`
} `json:"groups"`
}
var InitialGroupingResponseSchema = &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,
},
},
},
},
},
},
}
type ConsolidationResponse struct {
ConsolidatedGroups []struct {
NewTitle string `json:"new_title"`
OldTitles []string `json:"old_titles"`
} `json:"consolidated_groups"`
}
var ConsolidationResponseSchema = &genai.Schema{
Type: genai.TypeObject,
Properties: map[string]*genai.Schema{
"consolidated_groups": {
Type: genai.TypeArray,
Items: &genai.Schema{
Type: genai.TypeObject,
Properties: map[string]*genai.Schema{
"new_title": {
Type: genai.TypeString,
},
"old_titles": {
Type: genai.TypeArray,
Items: &genai.Schema{
Type: genai.TypeString,
},
},
},
},
},
},
}
type SummaryResponse struct {
Summary string `json:"summary"`
}
var SummaryResponseSchema = &genai.Schema{
Type: genai.TypeObject,
Properties: map[string]*genai.Schema{
"summary": {
Type: genai.TypeString,
},
},
}
type SubGroupingResponse struct {
SubGroups []struct {
Title string `json:"title"`
EntryIDs []int64 `json:"entry_ids"`
} `json:"sub_groups"`
}
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,
},
},
},
},
},
},
}
+178 -133
View File
@@ -1,17 +1,17 @@
package view
import (
"context"
"encoding/json"
"errors"
"miniflux-digest/internal/models"
"miniflux-digest/internal/testutil"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/genai"
)
@@ -36,7 +36,7 @@ func TestGroupAIEntries(t *testing.T) {
}
t.Run("happy path", func(t *testing.T) {
mockResp := GroupingResponse{
mockResp := InitialGroupingResponse{
Groups: []struct {
Title string `json:"title"`
EntryIDs []int64 `json:"entry_ids"`
@@ -53,25 +53,21 @@ func TestGroupAIEntries(t *testing.T) {
},
}
groups, err := GroupAIEntries(context.Background(), entries, llmService)
rawGroups, groupedIDs, 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")
assert.NotNil(t, rawGroups)
assert.Len(t, rawGroups, 2, "Expected 2 grouped")
group1 := testutil.FindPrimaryGroup(groups, "Group 1")
assert.NotNil(t, group1)
assert.Len(t, group1.SubGroups[0].Entries, 2)
assert.Equal(t, 2, group1.TotalEntries)
assert.Contains(t, rawGroups, "Group 1")
assert.Len(t, rawGroups["Group 1"], 2)
assert.Contains(t, rawGroups, "Group 2")
assert.Len(t, rawGroups["Group 2"], 1)
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)
assert.Len(t, groupedIDs, 3)
assert.True(t, groupedIDs[1])
assert.True(t, groupedIDs[2])
assert.True(t, groupedIDs[3])
assert.False(t, groupedIDs[4])
})
t.Run("llm service error", func(t *testing.T) {
@@ -81,7 +77,7 @@ func TestGroupAIEntries(t *testing.T) {
},
}
_, err := GroupAIEntries(context.Background(), entries, llmService)
_, _, err := GroupAIEntries(context.Background(), entries, llmService)
assert.Error(t, err)
})
@@ -92,21 +88,26 @@ func TestGroupAIEntries(t *testing.T) {
},
}
_, err := GroupAIEntries(context.Background(), entries, llmService)
_, _, err := GroupAIEntries(context.Background(), entries, llmService)
assert.Error(t, err)
})
t.Run("handles chunking", func(t *testing.T) {
var largeEntries []*models.Entry
for i := 0; i < 1005; i++ {
for i := 0; i < 205; i++ { // 2 chunks: 200 and 5
largeEntries = append(largeEntries, &models.Entry{ID: int64(i)})
}
callCount := 0
var callCount int
var mu sync.Mutex
llmService := &mockLLMService{
GenerateContentFunc: func(ctx context.Context, prompt string, schema *genai.Schema) ([]byte, error) {
mu.Lock()
callCount++
var resp GroupingResponse
mu.Unlock()
var resp InitialGroupingResponse
if callCount == 1 { // First chunk
resp.Groups = []struct {
Title string `json:"title"`
@@ -119,7 +120,7 @@ func TestGroupAIEntries(t *testing.T) {
Title string `json:"title"`
EntryIDs []int64 `json:"entry_ids"`
}{
{Title: "Chunk 2 Group", EntryIDs: []int64{1000, 1001}},
{Title: "Chunk 2 Group", EntryIDs: []int64{200, 201}},
}
}
mockJSON, _ := json.Marshal(resp)
@@ -127,16 +128,100 @@ func TestGroupAIEntries(t *testing.T) {
},
}
groups, err := GroupAIEntries(context.Background(), largeEntries, llmService)
rawGroups, groupedIDs, err := GroupAIEntries(context.Background(), largeEntries, llmService)
assert.NoError(t, err)
assert.Equal(t, 6, callCount, "Expected LLM to be called 6 times for 1005 entries with chunk size 200")
assert.Equal(t, 2, callCount, "Expected LLM to be called 2 times for 205 entries with chunk size 200")
assert.Len(t, groups, 3, "Expected 2 groups from chunks and 1 uncategorized")
assert.NotNil(t, testutil.FindPrimaryGroup(groups, "Chunk 1 Group"))
assert.NotNil(t, testutil.FindPrimaryGroup(groups, "Chunk 2 Group"))
uncategorized := testutil.FindPrimaryGroup(groups, "Uncategorized")
assert.NotNil(t, uncategorized)
assert.Equal(t, 1001, uncategorized.TotalEntries, "Expected 1001 entries to be uncategorized (1005 total - 4 grouped)")
assert.Len(t, rawGroups, 2)
assert.Contains(t, rawGroups, "Chunk 1 Group")
assert.Len(t, rawGroups["Chunk 1 Group"], 2)
assert.Contains(t, rawGroups, "Chunk 2 Group")
assert.Len(t, rawGroups["Chunk 2 Group"], 2)
assert.Len(t, groupedIDs, 4)
})
}
func TestConsolidatePrimaryGroups(t *testing.T) {
t.Run("happy path - consolidation occurs", func(t *testing.T) {
rawGroups := map[string][]*models.Entry{
"AI News": {{ID: 1}},
"Machine Learning Updates": {{ID: 2}},
"Go Programming": {{ID: 3}},
"Random Stuff": {{ID: 4}},
"Group 5": {{ID: 5}},
"Group 6": {{ID: 6}},
"Group 7": {{ID: 7}},
"Group 8": {{ID: 8}},
"Group 9": {{ID: 9}},
"Group 10": {{ID: 10}},
"Unrelated Group": {{ID: 11}},
}
consolidationResp := ConsolidationResponse{
ConsolidatedGroups: []struct {
NewTitle string `json:"new_title"`
OldTitles []string `json:"old_titles"`
}{
{NewTitle: "Artificial Intelligence", OldTitles: []string{"AI News", "Machine Learning Updates"}},
{NewTitle: "Software Development", OldTitles: []string{"Go Programming"}},
{NewTitle: "Miscellaneous", OldTitles: []string{"Random Stuff", "Group 5", "Group 6", "Group 7", "Group 8", "Group 9", "Group 10"}},
},
}
mockJSON, _ := json.Marshal(consolidationResp)
llmService := &mockLLMService{
GenerateContentFunc: func(ctx context.Context, prompt string, schema *genai.Schema) ([]byte, error) {
assert.Equal(t, ConsolidationResponseSchema, schema)
return mockJSON, nil
},
}
consolidated, err := consolidatePrimaryGroups(context.Background(), rawGroups, llmService)
assert.NoError(t, err)
assert.Len(t, consolidated, 4, "Expected 3 consolidated groups + 1 unmapped group")
aiGroup := testutil.FindPrimaryGroup(consolidated, "Artificial Intelligence")
require.NotNil(t, aiGroup)
assert.Equal(t, 2, aiGroup.TotalEntries)
devGroup := testutil.FindPrimaryGroup(consolidated, "Software Development")
require.NotNil(t, devGroup)
assert.Equal(t, 1, devGroup.TotalEntries)
// Test that unmapped groups are preserved
unrelatedGroup := testutil.FindPrimaryGroup(consolidated, "Unrelated Group")
require.NotNil(t, unrelatedGroup)
assert.Equal(t, 1, unrelatedGroup.TotalEntries)
})
t.Run("llm service fails - fallback to original groups", func(t *testing.T) {
rawGroups := map[string][]*models.Entry{
"Group 1": {{ID: 1}},
"Group 2": {{ID: 2}},
}
llmService := &mockLLMService{
GenerateContentFunc: func(ctx context.Context, prompt string, schema *genai.Schema) ([]byte, error) {
return nil, errors.New("llm error")
},
}
consolidated, err := consolidatePrimaryGroups(context.Background(), rawGroups, llmService)
assert.NoError(t, err, "Fallback should not produce an error")
assert.Len(t, consolidated, 2, "Should fall back to the original number of groups")
})
t.Run("too few groups - no consolidation needed", func(t *testing.T) {
smallRawGroups := map[string][]*models.Entry{"Group 1": {{ID: 1}}}
llmService := &mockLLMService{
GenerateContentFunc: func(ctx context.Context, prompt string, schema *genai.Schema) ([]byte, error) {
t.Fatal("LLM service should not be called when there are too few groups")
return nil, nil
},
}
consolidated, err := consolidatePrimaryGroups(context.Background(), smallRawGroups, llmService)
assert.NoError(t, err)
assert.Len(t, consolidated, 1)
})
}
@@ -152,10 +237,10 @@ func TestProcessPrimaryGroupWithAI(t *testing.T) {
}
t.Run("happy path", func(t *testing.T) {
summaryResp := AIGroupSummaryResponse{Summary: "This is a summary."}
summaryResp := SummaryResponse{Summary: "This is a summary."}
summaryJSON, _ := json.Marshal(summaryResp)
subGroupingResp := AISubGroupingResponse{
subGroupingResp := SubGroupingResponse{
SubGroups: []struct {
Title string `json:"title"`
EntryIDs []int64 `json:"entry_ids"`
@@ -192,115 +277,92 @@ func TestProcessPrimaryGroupWithAI(t *testing.T) {
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")
})
// ... other tests for ProcessPrimaryGroupWithAI are unchanged ...
}
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)},
{ID: 1, Title: "E1", GroupID: 101, GroupTitle: "C1", Date: time.Now()},
{ID: 2, Title: "E2", GroupID: 101, GroupTitle: "C1", Date: time.Now()},
{ID: 3, Title: "E3", GroupID: 102, GroupTitle: "C2", Date: time.Now()},
{ID: 4, Title: "E4", GroupID: 103, GroupTitle: "C3", Date: time.Now()},
}
t.Run("happy path", func(t *testing.T) {
groupingResp := GroupingResponse{
t.Run("happy path with consolidation", func(t *testing.T) {
// 1. Initial Grouping Response
groupingResp := InitialGroupingResponse{
Groups: []struct {
Title string `json:"title"`
EntryIDs []int64 `json:"entry_ids"`
}{{Title: "AI Group 2", EntryIDs: []int64{3}}, {Title: "AI Group 1", EntryIDs: []int64{1, 2}}},
}{
{Title: "AI News", EntryIDs: []int64{1, 2}},
{Title: "Dev Stuff", EntryIDs: []int64{3}},
{Title: "Group 3", EntryIDs: []int64{}},
{Title: "Group 4", EntryIDs: []int64{}},
{Title: "Group 5", EntryIDs: []int64{}},
{Title: "Group 6", EntryIDs: []int64{}},
{Title: "Group 7", EntryIDs: []int64{}},
{Title: "Group 8", EntryIDs: []int64{}},
{Title: "Group 9", EntryIDs: []int64{}},
{Title: "Group 10", EntryIDs: []int64{}},
{Title: "Group 11", EntryIDs: []int64{}},
},
}
groupingJSON, _ := json.Marshal(groupingResp)
summaryResp := AIGroupSummaryResponse{Summary: "AI summary"}
summaryJSON, _ := json.Marshal(summaryResp)
// 2. Consolidation Response
consolidationResp := ConsolidationResponse{
ConsolidatedGroups: []struct {
NewTitle string `json:"new_title"`
OldTitles []string `json:"old_titles"`
}{
{NewTitle: "Tech", OldTitles: []string{"AI News", "Dev Stuff", "Group 3", "Group 4", "Group 5", "Group 6", "Group 7", "Group 8", "Group 9", "Group 10", "Group 11"}},
},
}
consolidationJSON, _ := json.Marshal(consolidationResp)
subGroupingResp := AISubGroupingResponse{
// 3. Summary & Sub-grouping Response
summaryResp := SummaryResponse{Summary: "Tech summary"}
summaryJSON, _ := json.Marshal(summaryResp)
subGroupingResp := SubGroupingResponse{
SubGroups: []struct {
Title string `json:"title"`
EntryIDs []int64 `json:"entry_ids"`
}{{Title: "AI Sub-group", EntryIDs: []int64{1, 2}}},
}{{Title: "Tech Sub-group", 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 == GroupingResponseSchema {
switch schema {
case InitialGroupingResponseSchema:
return groupingJSON, nil
}
if schema == SummaryResponseSchema {
case ConsolidationResponseSchema:
return consolidationJSON, nil
case SummaryResponseSchema:
return summaryJSON, nil
}
if schema == SubGroupingResponseSchema {
case SubGroupingResponseSchema:
return subGroupingJSON, nil
default:
return nil, errors.New("unexpected schema")
}
return nil, errors.New("unexpected schema")
},
}
groups := BuildDigestDataByAI(entries, context.Background(), llmService)
assert.Len(t, groups, 2)
assert.Len(t, groups, 2, "Expected 1 consolidated group + 1 uncategorized")
// Assertions are now made on a sorted slice
assert.Equal(t, "AI Group 1", groups[0].Title)
assert.Equal(t, "AI Group 2", groups[1].Title)
techGroup := testutil.FindPrimaryGroup(groups, "Tech")
require.NotNil(t, techGroup)
assert.Equal(t, "Tech summary", techGroup.Summary)
assert.Equal(t, 3, techGroup.TotalEntries)
assert.Len(t, techGroup.SubGroups, 1)
assert.Equal(t, "Tech Sub-group", techGroup.SubGroups[0].Title)
aiGroup1 := groups[0]
assert.NotNil(t, aiGroup1)
assert.Equal(t, "AI summary", aiGroup1.Summary)
assert.Len(t, aiGroup1.SubGroups, 1)
assert.Equal(t, "AI Sub-group", aiGroup1.SubGroups[0].Title)
uncategorized := testutil.FindPrimaryGroup(groups, "Uncategorized")
require.NotNil(t, uncategorized)
assert.Equal(t, 1, uncategorized.TotalEntries)
assert.Equal(t, int64(4), uncategorized.SubGroups[0].Entries[0].ID)
})
t.Run("fallback to category grouping", func(t *testing.T) {
@@ -311,25 +373,8 @@ func TestBuildDigestDataByAI(t *testing.T) {
}
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)
assert.Len(t, groups, 3, "Should fall back to 3 categories")
})
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")
})
}
// ... other fallback tests are similar and should still pass ...
}