package summary
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"log/slog"
"strings"
"sync"
"time"
"github.com/firebase/genkit/go/ai"
"github.com/firebase/genkit/go/genkit"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"mimi/internal/bot/llm/agent"
"mimi/internal/persist"
"mimi/internal/provider/git"
"mimi/internal/provider/github/db"
"mimi/internal/provider/github/projects"
)
const (
evalPrompt = "summary"
periodPrompt = "period-extractor"
)
type SummaryAgent struct {
evalPrompt ai.Prompt
periodExtractor ai.Prompt
ghOrg string
pgPool *pgxpool.Pool
logseqRepoPath string
projectService *projects.Service
}
func New(ctx context.Context, g *genkit.Genkit, pgPool *pgxpool.Pool, ghOrg, logseqRepoPath string) SummaryAgent {
eval := genkit.LookupPrompt(g, evalPrompt)
if eval == nil {
log.Fatalf("no prompt named '%s' found", evalPrompt)
}
periodExtractor := genkit.LookupPrompt(g, periodPrompt)
if periodExtractor == nil {
log.Fatalf("no prompt named '%s' found", periodPrompt)
}
projectService := projects.New(ctx, pgPool)
return SummaryAgent{
pgPool: pgPool,
ghOrg: ghOrg,
evalPrompt: eval,
periodExtractor: periodExtractor,
logseqRepoPath: logseqRepoPath,
projectService: projectService,
}
}
func (a SummaryAgent) GetInfo() agent.Info {
return agent.Info{
Name: "summary",
Description: `Provides overall summary across all available resources`,
}
}
func (a SummaryAgent) Run(ctx context.Context, query string, msgs ...*ai.Message) (agent.Response, error) {
var result agent.Response
resp, err := a.periodExtractor.Execute(ctx, ai.WithInput(map[string]any{"query": query}))
if err != nil {
return result, fmt.Errorf("failed to extract period from query '%s' with %w", query, err)
}
period := strings.TrimSuffix(resp.Text(), "\n")
slog.Info("generating summary", "period", period)
since := time.Now()
switch period {
default:
return result, fmt.Errorf("unexpected period '%s'", period)
case "month":
since = since.AddDate(0, 0, -30)
case "week":
since = since.AddDate(0, 0, -7)
case "day":
since = since.AddDate(0, 0, -1)
}
docChan := make(chan *ai.Document, 3)
errChan := make(chan error, 3)
var wg sync.WaitGroup
wg.Add(3)
startT := time.Now()
go func() {
defer wg.Done()
projectsWithIssues, err := a.projectService.FetchActiveProjects(ctx, a.ghOrg, since)
if err != nil {
errChan <- fmt.Errorf("failed to fetch GitHub projects: %w", err)
return
}
if len(projectsWithIssues) == 0 {
slog.Warn("no active projects found")
docChan <- ai.DocumentFromText("{}", map[string]any{"info": "GitHub projects issues (none found)"})
return
}
slog.Info("fetched GitHub projects with issues", "count", len(projectsWithIssues))
type projectIssues struct {
Project string `json:"project"`
Category string `json:"category"`
Issues []db.Issue `json:"issues"`
}
allIssues := make([]projectIssues, 0, len(projectsWithIssues))
for _, pwi := range projectsWithIssues {
allIssues = append(allIssues, projectIssues{
Project: pwi.Title,
Category: pwi.Category,
Issues: pwi.Issues,
})
}
blob, err := json.Marshal(allIssues)
if err != nil {
errChan <- fmt.Errorf("failed to marshal GitHub projects info: %w", err)
return
}
docChan <- ai.DocumentFromText(string(blob), map[string]any{"info": "GitHub projects issues"})
}()
go func() {
defer wg.Done()
q := persist.New(a.pgPool)
messages, err := q.FindTelegramMessages(ctx, pgtype.Timestamptz{Time: since, Valid: true})
if err != nil {
errChan <- fmt.Errorf("failed to retrieve Telegram message from DB: %w", err)
return
}
slog.Info("retrieved Telegram messages", "length", len(messages))
blob, err := json.Marshal(messages)
if err != nil {
errChan <- fmt.Errorf("failed to marshal Telegram messages: %w", err)
return
}
docChan <- ai.DocumentFromText(string(blob), map[string]any{"info": "Related telegram messages"})
}()
go func() {
defer wg.Done()
diff, err := git.DiffInterval(a.logseqRepoPath, since)
if err != nil {
errChan <- err
return
}
slog.Info("retrieved LogSeq diff", "length", len(diff))
docChan <- ai.DocumentFromText(diff, map[string]any{"info": "LogSeq git diff"})
}()
wg.Wait()
slog.Info("summary data retrieved", "elapsed", time.Since(startT))
close(errChan)
close(docChan)
var errs []error
for err := range errChan {
errs = append(errs, err)
}
if len(errs) > 0 {
slog.Warn("some data retrieval errors occurred", "errors", errors.Join(errs...))
}
var docs []*ai.Document
for doc := range docChan {
docs = append(docs, doc)
}
if len(docs) == 0 {
return result, fmt.Errorf("failed to retrieve any data for summary: %w", errors.Join(errs...))
}
resp, err = a.evalPrompt.Execute(ctx, ai.WithDocs(docs...), ai.WithInput(map[string]any{"period": period}))
if err != nil {
return result, err
}
slog.Info("generated summary", "text", resp.Text())
result = agent.NewResponse(agent.DataText{Text: resp.Text()}, resp)
return result, nil
}