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:
2026-10-11 21:37:38 +11:00
parent bf43b6bbef
commit af3b5e5f0e
27 changed files with 3235 additions and 102 deletions

View 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
}

View 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
View 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
}

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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
}