Phase 3: JEV classification, rules table, editable categories (AUD-69)
Implements FR-3, FR-4, FR-5 and FR-14. - internal/classify: TypeSafe System One (JEV) HTTP client with the confirmed request/response shape, retry with backoff on 429/529/5xx, a plain-text rule engine (first match wins, overrides only the named fields), and bounded orchestration with a per-run cap. - internal/store: migration v3 adds an editable `categories` table (name = JEV Choice option and classifications key; rename cascades, remove reassigns) and seeds a starter rules version once. Classification persistence, coverage, and pending/all message queries. - internal/server: POST /actions/classify and /actions/reclassify, plus the FR-14 category editor (GET /categories and add/rename/remove). The triage list now groups by the live category list and shows action + confidence. - CLI: -classify and -classify-all for n8n. - Docs: docs/phase3-classify.md with the confirmed JEV shape, a live read-only classification, and the DoD evidence. DoD: all 100 stored messages have a non-null category, importance, and suggested action (0 nulls); JEV picked a newly added/renamed category for 6 real messages over HTTP. One live JEV call recorded in the phase doc. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
218
internal/store/categories.go
Normal file
218
internal/store/categories.go
Normal file
@@ -0,0 +1,218 @@
|
||||
// Category management (FR-14) and the classification read/write paths (FR-3).
|
||||
//
|
||||
// `categories.name` is the stable key: it is stored in `classifications.category`
|
||||
// and sent to JEV as the Choice option name. Renaming a category therefore
|
||||
// cascades to existing classifications so the triage list never shows a category
|
||||
// that no longer exists.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrLastCategory is returned when removing the final category would leave the
|
||||
// classifier with nothing to choose from.
|
||||
var ErrLastCategory = errors.New("at least one category is required")
|
||||
|
||||
// ErrCategoryExists is returned when adding or renaming to a name already taken.
|
||||
var ErrCategoryExists = errors.New("a category with that name already exists")
|
||||
|
||||
// Category is one editable triage category.
|
||||
type Category struct {
|
||||
ID int64
|
||||
Name string
|
||||
Description string
|
||||
Position int
|
||||
// MessageCount is the number of messages whose latest classification is
|
||||
// this category. It is filled by ListCategories.
|
||||
MessageCount int
|
||||
}
|
||||
|
||||
// ListCategories returns the categories in display order, each with the count of
|
||||
// messages currently classified into it.
|
||||
func (s *Store) ListCategories(ctx context.Context) ([]Category, error) {
|
||||
const q = `
|
||||
SELECT c.id, c.name, c.description, c.position,
|
||||
(SELECT COUNT(*) FROM messages m
|
||||
WHERE (SELECT cc.category FROM classifications cc
|
||||
WHERE cc.message_id = m.id ORDER BY cc.id DESC LIMIT 1) = c.name)
|
||||
FROM categories c
|
||||
ORDER BY c.position, c.name COLLATE NOCASE`
|
||||
rows, err := s.db.QueryContext(ctx, q)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list categories: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Category
|
||||
for rows.Next() {
|
||||
var c Category
|
||||
if err := rows.Scan(&c.ID, &c.Name, &c.Description, &c.Position, &c.MessageCount); err != nil {
|
||||
return nil, fmt.Errorf("scan category: %w", err)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list categories: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CategoryNames returns just the names, in display order.
|
||||
func (s *Store) CategoryNames(ctx context.Context) ([]string, error) {
|
||||
cats, err := s.ListCategories(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(cats))
|
||||
for _, c := range cats {
|
||||
names = append(names, c.Name)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// AddCategory appends a category. The name is trimmed, length-capped, and must
|
||||
// be unique (case-insensitively).
|
||||
func (s *Store) AddCategory(ctx context.Context, name, description string) (Category, error) {
|
||||
name, err := cleanCategoryName(name)
|
||||
if err != nil {
|
||||
return Category{}, err
|
||||
}
|
||||
description = strings.TrimSpace(description)
|
||||
|
||||
var exists int
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM categories WHERE name = ? COLLATE NOCASE`, name).Scan(&exists); err != nil {
|
||||
return Category{}, fmt.Errorf("check category name: %w", err)
|
||||
}
|
||||
if exists > 0 {
|
||||
return Category{}, ErrCategoryExists
|
||||
}
|
||||
|
||||
var position int
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(MAX(position), 0) + 1 FROM categories`).Scan(&position); err != nil {
|
||||
return Category{}, fmt.Errorf("next category position: %w", err)
|
||||
}
|
||||
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO categories (name, description, position) VALUES (?, ?, ?)`,
|
||||
name, description, position)
|
||||
if err != nil {
|
||||
return Category{}, fmt.Errorf("insert category %q: %w", name, err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return Category{}, fmt.Errorf("read new category id: %w", err)
|
||||
}
|
||||
return Category{ID: id, Name: name, Description: description, Position: position}, nil
|
||||
}
|
||||
|
||||
// RenameCategory changes the display name and rewrites existing classifications
|
||||
// so the new name is used everywhere (FR-14).
|
||||
func (s *Store) RenameCategory(ctx context.Context, id int64, newName string) error {
|
||||
old, err := s.categoryByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := cleanCategoryName(newName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.EqualFold(name, old) {
|
||||
return nil
|
||||
}
|
||||
|
||||
var exists int
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM categories WHERE name = ? COLLATE NOCASE AND id <> ?`, name, id).Scan(&exists); err != nil {
|
||||
return fmt.Errorf("check category name: %w", err)
|
||||
}
|
||||
if exists > 0 {
|
||||
return ErrCategoryExists
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin rename: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE categories SET name = ? WHERE id = ?`, name, id); err != nil {
|
||||
return fmt.Errorf("rename category: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE classifications SET category = ? WHERE category = ? COLLATE NOCASE`, name, old); err != nil {
|
||||
return fmt.Errorf("update classifications after rename: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit rename: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveCategory deletes a category and moves any messages still classified into
|
||||
// it to the first remaining category, so no classification points at a category
|
||||
// that no longer exists.
|
||||
func (s *Store) RemoveCategory(ctx context.Context, id int64) error {
|
||||
name, err := s.categoryByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM categories`).Scan(&count); err != nil {
|
||||
return fmt.Errorf("count categories: %w", err)
|
||||
}
|
||||
if count <= 1 {
|
||||
return ErrLastCategory
|
||||
}
|
||||
|
||||
var fallback string
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT name FROM categories WHERE id <> ? ORDER BY position, name COLLATE NOCASE LIMIT 1`, id).Scan(&fallback); err != nil {
|
||||
return fmt.Errorf("select fallback category: %w", err)
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin remove: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE classifications SET category = ? WHERE category = ? COLLATE NOCASE`, fallback, name); err != nil {
|
||||
return fmt.Errorf("reassign classifications: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM categories WHERE id = ?`, id); err != nil {
|
||||
return fmt.Errorf("delete category: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit remove: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) categoryByID(ctx context.Context, id int64) (string, error) {
|
||||
var name string
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT name FROM categories WHERE id = ?`, id).Scan(&name); err != nil {
|
||||
return "", fmt.Errorf("read category %d: %w", id, err)
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// cleanCategoryName trims, caps at 40 characters, and rejects an empty name.
|
||||
func cleanCategoryName(name string) (string, error) {
|
||||
name = strings.Join(strings.Fields(strings.TrimSpace(name)), " ")
|
||||
if name == "" {
|
||||
return "", errors.New("category name must not be empty")
|
||||
}
|
||||
if len([]rune(name)) > 40 {
|
||||
return "", errors.New("category name must be 40 characters or fewer")
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
245
internal/store/categories_test.go
Normal file
245
internal/store/categories_test.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newCategoryStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, err := Open(context.Background(), filepath.Join(t.TempDir(), "email_agent.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func TestDefaultCategoriesSeeded(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newCategoryStore(t)
|
||||
|
||||
cats, err := s.ListCategories(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCategories() error = %v", err)
|
||||
}
|
||||
want := []string{"Action", "Important", "Info", "Spam", "Newsletter", "Auto-reply"}
|
||||
if len(cats) != len(want) {
|
||||
t.Fatalf("categories = %d, want %d: %+v", len(cats), len(want), cats)
|
||||
}
|
||||
for i, name := range want {
|
||||
if cats[i].Name != name {
|
||||
t.Errorf("category %d = %q, want %q", i, cats[i].Name, name)
|
||||
}
|
||||
if cats[i].Description == "" {
|
||||
t.Errorf("category %q has no description (JEV needs the rubric)", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedCategoriesDoesNotReaddRemoved(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "email_agent.db")
|
||||
|
||||
s1, err := Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatalf("Open() error = %v", err)
|
||||
}
|
||||
cats, err := s1.ListCategories(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCategories() error = %v", err)
|
||||
}
|
||||
var spamID int64
|
||||
for _, c := range cats {
|
||||
if c.Name == "Spam" {
|
||||
spamID = c.ID
|
||||
}
|
||||
}
|
||||
if err := s1.RemoveCategory(ctx, spamID); err != nil {
|
||||
t.Fatalf("RemoveCategory() error = %v", err)
|
||||
}
|
||||
_ = s1.Close()
|
||||
|
||||
s2, err := Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatalf("second Open() error = %v", err)
|
||||
}
|
||||
defer s2.Close()
|
||||
cats, err = s2.ListCategories(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCategories() after restart error = %v", err)
|
||||
}
|
||||
for _, c := range cats {
|
||||
if c.Name == "Spam" {
|
||||
t.Error("removed category came back after restart")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddCategoryRejectsDuplicate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newCategoryStore(t)
|
||||
|
||||
if _, err := s.AddCategory(ctx, "Finance", "Bills and payments"); err != nil {
|
||||
t.Fatalf("AddCategory() error = %v", err)
|
||||
}
|
||||
if _, err := s.AddCategory(ctx, "finance", ""); !errors.Is(err, ErrCategoryExists) {
|
||||
t.Fatalf("duplicate AddCategory() error = %v, want ErrCategoryExists", err)
|
||||
}
|
||||
if _, err := s.AddCategory(ctx, " ", ""); err == nil {
|
||||
t.Fatal("blank AddCategory() error = nil, want an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameCategoryCascadesToClassifications(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newCategoryStore(t)
|
||||
|
||||
msgID, err := s.UpsertMessage(ctx, &Message{
|
||||
GmailID: "1", Subject: "hi", FromAddr: "a@b.com", DateSent: time.Now().UTC(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertMessage() error = %v", err)
|
||||
}
|
||||
if _, err := s.SaveClassification(ctx, Classification{
|
||||
MessageID: msgID, Category: "Info", Importance: "low", Action: "keep",
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveClassification() error = %v", err)
|
||||
}
|
||||
|
||||
cats, err := s.ListCategories(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCategories() error = %v", err)
|
||||
}
|
||||
var infoID int64
|
||||
for _, c := range cats {
|
||||
if c.Name == "Info" {
|
||||
infoID = c.ID
|
||||
}
|
||||
}
|
||||
if err := s.RenameCategory(ctx, infoID, "Updates"); err != nil {
|
||||
t.Fatalf("RenameCategory() error = %v", err)
|
||||
}
|
||||
|
||||
items, err := s.ListMessages(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListMessages() error = %v", err)
|
||||
}
|
||||
if items[0].Category != "Updates" {
|
||||
t.Errorf("classification after rename = %q, want Updates", items[0].Category)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveCategoryReassignsMessages(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newCategoryStore(t)
|
||||
|
||||
msgID, err := s.UpsertMessage(ctx, &Message{GmailID: "1", Subject: "hi"})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertMessage() error = %v", err)
|
||||
}
|
||||
if _, err := s.SaveClassification(ctx, Classification{MessageID: msgID, Category: "Spam"}); err != nil {
|
||||
t.Fatalf("SaveClassification() error = %v", err)
|
||||
}
|
||||
|
||||
cats, err := s.ListCategories(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCategories() error = %v", err)
|
||||
}
|
||||
var spamID int64
|
||||
for _, c := range cats {
|
||||
if c.Name == "Spam" {
|
||||
spamID = c.ID
|
||||
}
|
||||
}
|
||||
if err := s.RemoveCategory(ctx, spamID); err != nil {
|
||||
t.Fatalf("RemoveCategory() error = %v", err)
|
||||
}
|
||||
|
||||
items, err := s.ListMessages(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListMessages() error = %v", err)
|
||||
}
|
||||
if items[0].Category != "Action" {
|
||||
t.Errorf("reassigned category = %q, want the first remaining category Action", items[0].Category)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveLastCategoryRefused(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newCategoryStore(t)
|
||||
|
||||
for {
|
||||
cats, err := s.ListCategories(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCategories() error = %v", err)
|
||||
}
|
||||
if len(cats) == 1 {
|
||||
if err := s.RemoveCategory(ctx, cats[0].ID); !errors.Is(err, ErrLastCategory) {
|
||||
t.Fatalf("RemoveCategory(last) error = %v, want ErrLastCategory", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := s.RemoveCategory(ctx, cats[0].ID); err != nil {
|
||||
t.Fatalf("RemoveCategory() error = %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCategoryCountsReflectClassifications(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newCategoryStore(t)
|
||||
|
||||
msgID, err := s.UpsertMessage(ctx, &Message{GmailID: "1", Subject: "hi"})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertMessage() error = %v", err)
|
||||
}
|
||||
if _, err := s.SaveClassification(ctx, Classification{MessageID: msgID, Category: "Action"}); err != nil {
|
||||
t.Fatalf("SaveClassification() error = %v", err)
|
||||
}
|
||||
|
||||
cats, err := s.ListCategories(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCategories() error = %v", err)
|
||||
}
|
||||
for _, c := range cats {
|
||||
want := 0
|
||||
if c.Name == "Action" {
|
||||
want = 1
|
||||
}
|
||||
if c.MessageCount != want {
|
||||
t.Errorf("category %q count = %d, want %d", c.Name, c.MessageCount, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRulesSeedAndVersioning(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newCategoryStore(t)
|
||||
|
||||
text, version, err := s.CurrentRules(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CurrentRules() error = %v", err)
|
||||
}
|
||||
if version != 1 || text == "" {
|
||||
t.Fatalf("seeded rules = (version %d, %d bytes), want version 1 with text", version, len(text))
|
||||
}
|
||||
|
||||
next, err := s.SaveRules(ctx, `from contains "x" => category=Info`)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveRules() error = %v", err)
|
||||
}
|
||||
if next != 2 {
|
||||
t.Errorf("SaveRules() version = %d, want 2", next)
|
||||
}
|
||||
text, version, err = s.CurrentRules(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CurrentRules() error = %v", err)
|
||||
}
|
||||
if version != 2 || text != `from contains "x" => category=Info` {
|
||||
t.Errorf("CurrentRules() = (%d, %q), want version 2 with the new text", version, text)
|
||||
}
|
||||
}
|
||||
161
internal/store/classify.go
Normal file
161
internal/store/classify.go
Normal file
@@ -0,0 +1,161 @@
|
||||
// Classification persistence and the rules read/write paths (FR-3, FR-4, FR-11).
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Classification is one classifier output for a message.
|
||||
type Classification struct {
|
||||
ID int64
|
||||
MessageID int64
|
||||
Category string
|
||||
Importance string
|
||||
Action string
|
||||
Confidence float64
|
||||
Model string
|
||||
}
|
||||
|
||||
// SaveClassification appends a classification row. The triage list reads the
|
||||
// newest row per message, so re-classifying keeps a history.
|
||||
func (s *Store) SaveClassification(ctx context.Context, c Classification) (int64, error) {
|
||||
const q = `INSERT INTO classifications
|
||||
(message_id, category, importance, action, confidence, model)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
res, err := s.db.ExecContext(ctx, q,
|
||||
c.MessageID, c.Category, nullString(c.Importance), nullString(c.Action),
|
||||
c.Confidence, nullString(c.Model))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("save classification for message %d: %w", c.MessageID, err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read classification id: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// PendingClassifications returns messages whose latest classification is
|
||||
// missing or points at a category that no longer exists. Oldest first so a
|
||||
// bounded run makes steady progress.
|
||||
func (s *Store) PendingClassifications(ctx context.Context, limit int) ([]Message, error) {
|
||||
return s.messagesForClassification(ctx, limit, false)
|
||||
}
|
||||
|
||||
// AllMessagesForClassification returns every message, for a full re-classify
|
||||
// after the category list or the rules change.
|
||||
func (s *Store) AllMessagesForClassification(ctx context.Context, limit int) ([]Message, error) {
|
||||
return s.messagesForClassification(ctx, limit, true)
|
||||
}
|
||||
|
||||
func (s *Store) messagesForClassification(ctx context.Context, limit int, all bool) ([]Message, error) {
|
||||
if limit < 1 {
|
||||
limit = 100
|
||||
}
|
||||
filter := "WHERE c.id IS NULL OR c.category NOT IN (SELECT name FROM categories)"
|
||||
if all {
|
||||
filter = ""
|
||||
}
|
||||
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)
|
||||
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
|
||||
)
|
||||
` + filter + `
|
||||
ORDER BY m.id ASC
|
||||
LIMIT ?`
|
||||
rows, err := s.db.QueryContext(ctx, q, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list messages for classification: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Message
|
||||
for rows.Next() {
|
||||
var (
|
||||
m Message
|
||||
dateSent string
|
||||
received string
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&m.ID, &m.GmailID, &m.ThreadID, &m.FromAddr, &m.ToAddr, &m.Subject,
|
||||
&m.BodyText, &m.SourceAddress, &dateSent, &received,
|
||||
&m.IMAPUID, &m.IMAPUIDValidity,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan pending message: %w", err)
|
||||
}
|
||||
m.DateSent = parseSQLTime(dateSent)
|
||||
m.ReceivedAt = parseSQLTime(received)
|
||||
out = append(out, m)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list messages for classification: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ClassificationCoverage reports how many messages have a usable classification.
|
||||
type ClassificationCoverage struct {
|
||||
Total int
|
||||
Classified int
|
||||
}
|
||||
|
||||
// Missing reports how many messages still need a classification.
|
||||
func (c ClassificationCoverage) Missing() int { return c.Total - c.Classified }
|
||||
|
||||
// ClassificationCoverage returns the total message count and how many have a
|
||||
// classification whose category still exists.
|
||||
func (s *Store) ClassificationCoverage(ctx context.Context) (ClassificationCoverage, error) {
|
||||
var cov ClassificationCoverage
|
||||
const q = `
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM messages),
|
||||
(SELECT COUNT(*) FROM messages m
|
||||
WHERE (SELECT cc.category FROM classifications cc
|
||||
WHERE cc.message_id = m.id ORDER BY cc.id DESC LIMIT 1)
|
||||
IN (SELECT name FROM categories))`
|
||||
if err := s.db.QueryRowContext(ctx, q).Scan(&cov.Total, &cov.Classified); err != nil {
|
||||
return cov, fmt.Errorf("classification coverage: %w", err)
|
||||
}
|
||||
return cov, nil
|
||||
}
|
||||
|
||||
// CurrentRules returns the newest rules text and its version. An empty string
|
||||
// and version 0 mean no rules are stored yet.
|
||||
func (s *Store) CurrentRules(ctx context.Context) (string, int, error) {
|
||||
var (
|
||||
text string
|
||||
version int
|
||||
)
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT text, version FROM rules ORDER BY version DESC LIMIT 1`).Scan(&text, &version)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("read rules: %w", err)
|
||||
}
|
||||
return text, version, nil
|
||||
}
|
||||
|
||||
// SaveRules appends a new rules version and returns the new version number
|
||||
// (FR-11 versioning). The rules editor UI lands in Phase 6.
|
||||
func (s *Store) SaveRules(ctx context.Context, text string) (int, error) {
|
||||
var next int
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(MAX(version), 0) + 1 FROM rules`).Scan(&next); err != nil {
|
||||
return 0, fmt.Errorf("next rules version: %w", err)
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO rules (text, version) VALUES (?, ?)`, text, next); err != nil {
|
||||
return 0, fmt.Errorf("save rules version %d: %w", next, err)
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
@@ -159,6 +159,15 @@ func (s *Store) LogAction(ctx context.Context, action, actor, detail string) err
|
||||
return nil
|
||||
}
|
||||
|
||||
// LogMessageAction appends a per-message action to the actions_log (FR-13).
|
||||
func (s *Store) LogMessageAction(ctx context.Context, messageID int64, action, actor, detail string) error {
|
||||
const q = `INSERT INTO actions_log (message_id, action, actor, detail) VALUES (?, ?, ?, ?)`
|
||||
if _, err := s.db.ExecContext(ctx, q, messageID, action, nullString(actor), nullString(detail)); err != nil {
|
||||
return fmt.Errorf("log message action %q: %w", action, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const sqlTimeLayout = "2006-01-02 15:04:05"
|
||||
|
||||
func nullString(v string) any {
|
||||
|
||||
@@ -36,6 +36,19 @@ CREATE INDEX IF NOT EXISTS idx_messages_received_at ON messages (received_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON messages (thread_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_source ON messages (source_address);
|
||||
|
||||
-- FR-14: the editable category list. `name` is the stable key stored in
|
||||
-- classifications.category and sent to JEV as the Choice option name. Renaming a
|
||||
-- category cascades to existing classifications (see store.RenameCategory).
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL COLLATE NOCASE UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_position ON categories (position, name);
|
||||
|
||||
-- FR-3, FR-14: classifier output per message.
|
||||
CREATE TABLE IF NOT EXISTS classifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
@@ -108,6 +108,89 @@ func (s *Store) applyMigrations(ctx context.Context) error {
|
||||
return fmt.Errorf("record schema version 2: %w", err)
|
||||
}
|
||||
}
|
||||
if version < 3 {
|
||||
// FR-14: seed the default category list and a starter (comment-only)
|
||||
// rules document. Seeding happens only inside this migration so that a
|
||||
// category the user removes is not silently re-added on the next start.
|
||||
if err := s.seedCategories(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.seedRules(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`INSERT OR IGNORE INTO schema_migrations (version) VALUES (3)`); err != nil {
|
||||
return fmt.Errorf("record schema version 3: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// defaultCategories are the starter categories from AGENTS.md §13.2. The
|
||||
// descriptions are shown in the UI and sent to JEV as the Choice rubrics.
|
||||
var defaultCategories = []struct{ name, description string }{
|
||||
{"Action", "Needs Sam to do something"},
|
||||
{"Important", "Worth Sam's attention, but no specific task"},
|
||||
{"Info", "Informational, no action required"},
|
||||
{"Spam", "Unwanted, deceptive, or scam mail"},
|
||||
{"Newsletter", "Bulk mail, digests, and marketing"},
|
||||
{"Auto-reply", "Automated notifications and receipts"},
|
||||
}
|
||||
|
||||
// seedCategories inserts the built-in categories when the table is empty.
|
||||
func (s *Store) seedCategories(ctx context.Context) error {
|
||||
var n int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM categories`).Scan(&n); err != nil {
|
||||
return fmt.Errorf("count categories: %w", err)
|
||||
}
|
||||
if n > 0 {
|
||||
return nil
|
||||
}
|
||||
for i, c := range defaultCategories {
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO categories (name, description, position) VALUES (?, ?, ?)`,
|
||||
c.name, c.description, i+1); err != nil {
|
||||
return fmt.Errorf("seed category %q: %w", c.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// starterRules documents the rule syntax (FR-4/FR-11). The editor that writes
|
||||
// real rules lands in Phase 6; the classifier already reads whatever is stored.
|
||||
const starterRules = `# Email Agent rules (FR-4). One rule per line.
|
||||
#
|
||||
# <condition> => <effects>
|
||||
#
|
||||
# condition: <field> <operator> "<value>", joined with " and ".
|
||||
# fields: from, to, subject, source, body
|
||||
# operators: contains, equals, starts_with, ends_with
|
||||
# a single "*" matches every message (use it as the last line for a default).
|
||||
#
|
||||
# effects: comma-separated category=<name>, importance=<low|medium|high>,
|
||||
# action=<delete|unsubscribe|reply|file|keep>
|
||||
#
|
||||
# The first matching rule wins and overrides the JEV classification for the
|
||||
# fields it names. Category names must exist in the category list.
|
||||
#
|
||||
# Examples (remove the leading # to enable):
|
||||
# from contains "amazon.com" => category=Newsletter, action=unsubscribe
|
||||
# subject contains "invoice" => category=Action, importance=high, action=file
|
||||
`
|
||||
|
||||
// seedRules stores the starter rules document when no rules exist yet.
|
||||
func (s *Store) seedRules(ctx context.Context) error {
|
||||
var n int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM rules`).Scan(&n); err != nil {
|
||||
return fmt.Errorf("count rules: %w", err)
|
||||
}
|
||||
if n > 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO rules (text, version) VALUES (?, ?)`, starterRules, 1); err != nil {
|
||||
return fmt.Errorf("seed rules: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user