- internal/hub: project side of the frozen ask-contract. GET /capabilities, read-only POST /ask answerer (no LLM, <3s), and the outbound POST /notify client. - internal/apprise: daily-summary delivery through the house Apprise API. - internal/summary: deterministic plain-text daily summary builder. - internal/store: CountByCategory, MessagesByCategory, and summary CRUD. - internal/server: /capabilities and /ask (bearer-token protected), plus the Reports screen (/reports, build, send, delete). - main.go: -summary, -summary-send, and -notify CLI entry points for n8n. Co-Authored-By: Paperclip <noreply@paperclip.ing>
218 lines
7.2 KiB
Go
218 lines
7.2 KiB
Go
// Summary and reporting persistence for Phase 7 (FR-10).
|
|
//
|
|
// The daily summary is built from the same classification data the triage list
|
|
// uses; it is stored once per date in the `summaries` table (AGENTS.md §7) and
|
|
// then delivered by email through Apprise.
|
|
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// CategoryCount is one bucket of the inbox overview: a category name and how
|
|
// many messages currently sit in it. The empty category is the unclassified
|
|
// bucket.
|
|
type CategoryCount struct {
|
|
Category string
|
|
Count int
|
|
}
|
|
|
|
// CountByCategory returns message counts for one folder, grouped by each
|
|
// message's latest classification category. Messages with no usable
|
|
// classification are counted under the empty category name.
|
|
func (s *Store) CountByCategory(ctx context.Context, folder string) ([]CategoryCount, error) {
|
|
const q = `
|
|
SELECT COALESCE(c.category, ''), COUNT(*)
|
|
FROM messages m
|
|
LEFT JOIN classifications c ON c.id = (
|
|
SELECT id FROM classifications WHERE message_id = m.id ORDER BY id DESC LIMIT 1
|
|
)
|
|
WHERE (? = '' OR m.folder = ?)
|
|
GROUP BY 1
|
|
ORDER BY 2 DESC, 1 ASC`
|
|
rows, err := s.db.QueryContext(ctx, q, folder, folder)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("count by category: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []CategoryCount
|
|
for rows.Next() {
|
|
var c CategoryCount
|
|
if err := rows.Scan(&c.Category, &c.Count); err != nil {
|
|
return nil, fmt.Errorf("scan category count: %w", err)
|
|
}
|
|
out = append(out, c)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("count by category: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// MessagesByCategory returns the newest messages in a folder whose latest
|
|
// classification is one of categories, highest importance first. It backs the
|
|
// "what needs attention" list in the daily summary and the Hub answer.
|
|
func (s *Store) MessagesByCategory(ctx context.Context, folder string, categories []string, limit int) ([]MessageListItem, error) {
|
|
if len(categories) == 0 {
|
|
return nil, nil
|
|
}
|
|
if limit < 1 {
|
|
limit = 20
|
|
}
|
|
placeholders := make([]string, len(categories))
|
|
args := make([]any, 0, len(categories)+2)
|
|
for i, c := range categories {
|
|
placeholders[i] = "?"
|
|
args = append(args, c)
|
|
}
|
|
// The importance rank puts high first; an unset importance sorts last.
|
|
rank := `CASE LOWER(COALESCE(c.importance, ''))
|
|
WHEN 'high' THEN 0 WHEN 'medium' THEN 1 WHEN 'low' THEN 2 ELSE 3 END`
|
|
folderClause := ""
|
|
if folder != "" {
|
|
folderClause = "AND m.folder = ?"
|
|
args = append(args, folder)
|
|
}
|
|
args = append(args, limit)
|
|
|
|
q := `
|
|
SELECT
|
|
m.id, m.gmail_id, COALESCE(m.thread_id, ''), COALESCE(m.from_addr, ''),
|
|
COALESCE(m.to_addr, ''), COALESCE(m.subject, ''), COALESCE(m.body_text, ''),
|
|
COALESCE(m.source_address, ''), COALESCE(m.date_sent, ''), m.received_at,
|
|
COALESCE(m.imap_uid, 0), COALESCE(m.imap_uidvalidity, 0),
|
|
COALESCE(m.folder, 'inbox'), COALESCE(m.mailbox, 'INBOX'),
|
|
COALESCE(c.category, ''), COALESCE(c.importance, ''),
|
|
COALESCE(c.action, ''), COALESCE(c.confidence, 0)
|
|
FROM messages m
|
|
LEFT JOIN classifications c ON c.id = (
|
|
SELECT id FROM classifications WHERE message_id = m.id ORDER BY id DESC LIMIT 1
|
|
)
|
|
WHERE c.category IN (` + strings.Join(placeholders, ",") + `) ` + folderClause + `
|
|
ORDER BY ` + rank + `, COALESCE(NULLIF(m.date_sent, ''), m.received_at) DESC, m.id DESC
|
|
LIMIT ?`
|
|
rows, err := s.db.QueryContext(ctx, q, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("messages by category: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []MessageListItem
|
|
for rows.Next() {
|
|
var (
|
|
item MessageListItem
|
|
dateSent string
|
|
received string
|
|
)
|
|
if err := rows.Scan(
|
|
&item.ID, &item.GmailID, &item.ThreadID, &item.FromAddr,
|
|
&item.ToAddr, &item.Subject, &item.BodyText,
|
|
&item.SourceAddress, &dateSent, &received,
|
|
&item.IMAPUID, &item.IMAPUIDValidity, &item.Folder, &item.Mailbox,
|
|
&item.Category, &item.Importance, &item.Action, &item.Confidence,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scan category message: %w", err)
|
|
}
|
|
item.DateSent = parseSQLTime(dateSent)
|
|
item.ReceivedAt = parseSQLTime(received)
|
|
item.HasClassification = item.Category != ""
|
|
out = append(out, item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("messages by category: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Summary is one stored daily summary (FR-10).
|
|
type Summary struct {
|
|
ID int64
|
|
Date string
|
|
Text string
|
|
DeliveredTo string
|
|
CreatedAt string
|
|
}
|
|
|
|
// SaveSummary stores the summary for a date, replacing an earlier build for the
|
|
// same date so re-running the job does not duplicate rows. It returns the row id.
|
|
func (s *Store) SaveSummary(ctx context.Context, date, text, deliveredTo string) (int64, error) {
|
|
const q = `
|
|
INSERT INTO summaries (date, text, delivered_to) VALUES (?, ?, ?)
|
|
ON CONFLICT (date) DO UPDATE SET
|
|
text = excluded.text,
|
|
delivered_to = excluded.delivered_to`
|
|
if _, err := s.db.ExecContext(ctx, q, date, text, nullString(deliveredTo)); err != nil {
|
|
return 0, fmt.Errorf("save summary %s: %w", date, err)
|
|
}
|
|
var id int64
|
|
if err := s.db.QueryRowContext(ctx,
|
|
`SELECT id FROM summaries WHERE date = ?`, date).Scan(&id); err != nil {
|
|
return 0, fmt.Errorf("read summary id for %s: %w", date, err)
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// MarkSummaryDelivered records the recipient once a summary has been emailed.
|
|
func (s *Store) MarkSummaryDelivered(ctx context.Context, id int64, deliveredTo string) error {
|
|
if _, err := s.db.ExecContext(ctx,
|
|
`UPDATE summaries SET delivered_to = ? WHERE id = ?`, nullString(deliveredTo), id); err != nil {
|
|
return fmt.Errorf("mark summary %d delivered: %w", id, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SummaryByDate loads one summary by its YYYY-MM-DD date. ok is false when none
|
|
// exists.
|
|
func (s *Store) SummaryByDate(ctx context.Context, date string) (Summary, bool, error) {
|
|
row := s.db.QueryRowContext(ctx, `
|
|
SELECT id, date, text, COALESCE(delivered_to, ''), created_at
|
|
FROM summaries WHERE date = ?`, date)
|
|
var sum Summary
|
|
if err := row.Scan(&sum.ID, &sum.Date, &sum.Text, &sum.DeliveredTo, &sum.CreatedAt); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Summary{}, false, nil
|
|
}
|
|
return Summary{}, false, fmt.Errorf("read summary %s: %w", date, err)
|
|
}
|
|
return sum, true, nil
|
|
}
|
|
|
|
// ListSummaries returns stored summaries, newest date first.
|
|
func (s *Store) ListSummaries(ctx context.Context, limit int) ([]Summary, error) {
|
|
if limit < 1 {
|
|
limit = 30
|
|
}
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT id, date, text, COALESCE(delivered_to, ''), created_at
|
|
FROM summaries ORDER BY date DESC LIMIT ?`, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list summaries: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []Summary
|
|
for rows.Next() {
|
|
var sum Summary
|
|
if err := rows.Scan(&sum.ID, &sum.Date, &sum.Text, &sum.DeliveredTo, &sum.CreatedAt); err != nil {
|
|
return nil, fmt.Errorf("scan summary: %w", err)
|
|
}
|
|
out = append(out, sum)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("list summaries: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// DeleteSummary removes one stored report (AGENTS.md §5 "delete-report").
|
|
func (s *Store) DeleteSummary(ctx context.Context, id int64) error {
|
|
if _, err := s.db.ExecContext(ctx, `DELETE FROM summaries WHERE id = ?`, id); err != nil {
|
|
return fmt.Errorf("delete summary %d: %w", id, err)
|
|
}
|
|
return nil
|
|
}
|