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:
321
internal/classify/classify.go
Normal file
321
internal/classify/classify.go
Normal file
@@ -0,0 +1,321 @@
|
||||
// Classifier orchestration: read the live category list and rules, ask JEV about
|
||||
// every pending message, apply the user's rules as overrides, and store the
|
||||
// result (FR-3, FR-4, FR-5).
|
||||
package classify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
|
||||
)
|
||||
|
||||
// ActorAgent is the actor recorded in the action log for classifier writes.
|
||||
const ActorAgent = "agent"
|
||||
|
||||
// Summary describes one classification run.
|
||||
type Summary struct {
|
||||
Considered int
|
||||
Classified int
|
||||
Failed int
|
||||
Model string
|
||||
RulesVersion int
|
||||
Remaining int
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// Classifier ties the JEV client to the store.
|
||||
type Classifier struct {
|
||||
client *Client
|
||||
store *store.Store
|
||||
logger *slog.Logger
|
||||
concurrency int
|
||||
maxPerRun int
|
||||
}
|
||||
|
||||
// NewClassifier builds a Classifier. concurrency and maxPerRun fall back to safe
|
||||
// defaults when they are not positive.
|
||||
func NewClassifier(client *Client, st *store.Store, logger *slog.Logger, concurrency, maxPerRun int) *Classifier {
|
||||
if concurrency < 1 {
|
||||
concurrency = 4
|
||||
}
|
||||
if concurrency > 16 {
|
||||
concurrency = 16
|
||||
}
|
||||
if maxPerRun < 1 {
|
||||
maxPerRun = 200
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.New(slog.DiscardHandler)
|
||||
}
|
||||
return &Classifier{
|
||||
client: client,
|
||||
store: st,
|
||||
logger: logger,
|
||||
concurrency: concurrency,
|
||||
maxPerRun: maxPerRun,
|
||||
}
|
||||
}
|
||||
|
||||
// Model returns the configured JEV model alias.
|
||||
func (c *Classifier) Model() string { return c.client.Model() }
|
||||
|
||||
// Configured reports whether JEV_API_KEY is present.
|
||||
func (c *Classifier) Configured() bool { return c.client.Configured() }
|
||||
|
||||
// Run classifies the pending messages, up to maxPerRun. It returns a Summary
|
||||
// even when individual messages fail; only setup failures return an error.
|
||||
func (c *Classifier) Run(ctx context.Context) (Summary, error) {
|
||||
return c.run(ctx, false)
|
||||
}
|
||||
|
||||
// RunAll re-classifies every stored message. It is used after the category list
|
||||
// or the rules change, so the new settings take effect on existing mail.
|
||||
func (c *Classifier) RunAll(ctx context.Context) (Summary, error) {
|
||||
return c.run(ctx, true)
|
||||
}
|
||||
|
||||
func (c *Classifier) run(ctx context.Context, all bool) (Summary, error) {
|
||||
if !c.client.Configured() {
|
||||
return Summary{}, ErrNotConfigured
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
mode := "pending"
|
||||
if all {
|
||||
mode = "all"
|
||||
}
|
||||
categories, err := c.store.ListCategories(ctx)
|
||||
if err != nil {
|
||||
return Summary{}, err
|
||||
}
|
||||
if len(categories) == 0 {
|
||||
return Summary{}, fmt.Errorf("no categories configured")
|
||||
}
|
||||
options := make([]CategoryOption, 0, len(categories))
|
||||
for _, cat := range categories {
|
||||
options = append(options, CategoryOption{Name: cat.Name, Description: cat.Description})
|
||||
}
|
||||
valid := make(map[string]string, len(categories)) // lower(name) -> name
|
||||
for _, cat := range categories {
|
||||
valid[strings.ToLower(cat.Name)] = cat.Name
|
||||
}
|
||||
|
||||
rulesText, rulesVersion, err := c.store.CurrentRules(ctx)
|
||||
if err != nil {
|
||||
return Summary{}, err
|
||||
}
|
||||
rules := ParseRules(rulesText)
|
||||
for _, perr := range rules.Errors {
|
||||
c.logger.Warn("rules line skipped", "error", perr.Error())
|
||||
}
|
||||
|
||||
pending, err := c.pending(ctx, all)
|
||||
if err != nil {
|
||||
return Summary{}, err
|
||||
}
|
||||
|
||||
sum := Summary{
|
||||
Considered: len(pending),
|
||||
Model: c.client.Model(),
|
||||
RulesVersion: rulesVersion,
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
sum.Remaining = 0
|
||||
sum.Duration = time.Since(start)
|
||||
return sum, nil
|
||||
}
|
||||
|
||||
type outcome struct {
|
||||
msg store.Message
|
||||
class store.Classification
|
||||
rule *Rule
|
||||
failed bool
|
||||
}
|
||||
|
||||
work := make(chan store.Message)
|
||||
results := make(chan outcome)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < c.concurrency; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for msg := range work {
|
||||
class, rule, err := c.classifyOne(ctx, msg, options, valid, rules)
|
||||
if err != nil {
|
||||
c.logger.Warn("classify message failed",
|
||||
"message_id", msg.ID, "subject", msg.Subject, "error", err)
|
||||
results <- outcome{msg: msg, failed: true}
|
||||
continue
|
||||
}
|
||||
results <- outcome{msg: msg, class: class, rule: rule}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer close(work)
|
||||
for _, msg := range pending {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case work <- msg:
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(results)
|
||||
}()
|
||||
|
||||
for res := range results {
|
||||
if res.failed {
|
||||
sum.Failed++
|
||||
continue
|
||||
}
|
||||
if _, err := c.store.SaveClassification(ctx, res.class); err != nil {
|
||||
c.logger.Error("save classification failed", "message_id", res.msg.ID, "error", err)
|
||||
sum.Failed++
|
||||
continue
|
||||
}
|
||||
detail := fmt.Sprintf("category=%s importance=%s action=%s confidence=%.2f model=%s rules_version=%d",
|
||||
res.class.Category, res.class.Importance, res.class.Action,
|
||||
res.class.Confidence, res.class.Model, rulesVersion)
|
||||
if res.rule != nil {
|
||||
detail += fmt.Sprintf(" rule_line=%d rule=%q", res.rule.Line, res.rule.Raw)
|
||||
}
|
||||
if err := c.store.LogMessageAction(ctx, res.msg.ID, "classify", ActorAgent, detail); err != nil {
|
||||
c.logger.Warn("log classify action failed", "message_id", res.msg.ID, "error", err)
|
||||
}
|
||||
sum.Classified++
|
||||
}
|
||||
|
||||
if cov, err := c.store.ClassificationCoverage(ctx); err == nil {
|
||||
sum.Remaining = cov.Missing()
|
||||
}
|
||||
sum.Duration = time.Since(start)
|
||||
|
||||
if err := c.store.LogAction(ctx, "classify_run", ActorAgent,
|
||||
fmt.Sprintf("mode=%s considered=%d classified=%d failed=%d remaining=%d model=%s rules_version=%d",
|
||||
mode, sum.Considered, sum.Classified, sum.Failed, sum.Remaining, sum.Model, rulesVersion)); err != nil {
|
||||
c.logger.Warn("log classify run failed", "error", err)
|
||||
}
|
||||
|
||||
c.logger.Info("classification run complete",
|
||||
"considered", sum.Considered,
|
||||
"classified", sum.Classified,
|
||||
"failed", sum.Failed,
|
||||
"remaining", sum.Remaining,
|
||||
"model", sum.Model,
|
||||
"duration_ms", sum.Duration.Milliseconds(),
|
||||
)
|
||||
return sum, nil
|
||||
}
|
||||
|
||||
// classifyOne asks JEV about one message and overlays the matching user rule.
|
||||
func (c *Classifier) classifyOne(ctx context.Context, msg store.Message, options []CategoryOption, valid map[string]string, rules RuleSet) (store.Classification, *Rule, error) {
|
||||
result, err := c.client.Classify(ctx, MessageInput{
|
||||
From: msg.FromAddr,
|
||||
To: msg.ToAddr,
|
||||
Subject: msg.Subject,
|
||||
Source: msg.SourceAddress,
|
||||
Body: msg.BodyText,
|
||||
Received: receivedString(msg),
|
||||
}, options)
|
||||
if err != nil {
|
||||
return store.Classification{}, nil, err
|
||||
}
|
||||
|
||||
rule := rules.Match(map[string]string{
|
||||
"from": msg.FromAddr,
|
||||
"to": msg.ToAddr,
|
||||
"subject": msg.Subject,
|
||||
"source": msg.SourceAddress,
|
||||
"body": msg.BodyText,
|
||||
})
|
||||
|
||||
category := result.Category
|
||||
importance := result.Importance
|
||||
action := result.Action
|
||||
if rule != nil {
|
||||
if v, ok := rule.Effects["category"]; ok {
|
||||
if name, found := valid[strings.ToLower(v)]; found {
|
||||
category = name
|
||||
} else {
|
||||
c.logger.Warn("rule names an unknown category; ignoring effect",
|
||||
"message_id", msg.ID, "rule_line", rule.Line, "category", v)
|
||||
}
|
||||
}
|
||||
if v, ok := rule.Effects["importance"]; ok {
|
||||
importance = v
|
||||
}
|
||||
if v, ok := rule.Effects["action"]; ok {
|
||||
action = v
|
||||
}
|
||||
}
|
||||
|
||||
// The classifier must always store a usable category, importance, and
|
||||
// action (Phase 3 DoD). JEV can only pick from the supplied options, but a
|
||||
// broken response or a missing category list must not leave NULLs.
|
||||
if name, found := valid[strings.ToLower(category)]; found {
|
||||
category = name
|
||||
} else {
|
||||
if category != "" {
|
||||
c.logger.Warn("classifier returned an unknown category; using fallback",
|
||||
"message_id", msg.ID, "category", category)
|
||||
}
|
||||
category = options[0].Name
|
||||
}
|
||||
if !allowedImportance[strings.ToLower(importance)] {
|
||||
importance = "medium"
|
||||
} else {
|
||||
importance = strings.ToLower(importance)
|
||||
}
|
||||
if !allowedAction[strings.ToLower(action)] {
|
||||
action = "keep"
|
||||
} else {
|
||||
action = strings.ToLower(action)
|
||||
}
|
||||
|
||||
// A rule override invalidates the JEV confidence: report the confidence of
|
||||
// the answer the category actually came from.
|
||||
confidence := result.CategoryConfidence
|
||||
if rule != nil {
|
||||
if _, ok := rule.Effects["category"]; ok {
|
||||
confidence = 1
|
||||
}
|
||||
}
|
||||
|
||||
return store.Classification{
|
||||
MessageID: msg.ID,
|
||||
Category: category,
|
||||
Importance: importance,
|
||||
Action: action,
|
||||
Confidence: confidence,
|
||||
Model: result.Model,
|
||||
}, rule, nil
|
||||
}
|
||||
|
||||
// pending returns the messages to classify for this run.
|
||||
func (c *Classifier) pending(ctx context.Context, all bool) ([]store.Message, error) {
|
||||
if all {
|
||||
return c.store.AllMessagesForClassification(ctx, c.maxPerRun)
|
||||
}
|
||||
return c.store.PendingClassifications(ctx, c.maxPerRun)
|
||||
}
|
||||
|
||||
func receivedString(msg store.Message) string {
|
||||
t := msg.DateSent
|
||||
if t.IsZero() {
|
||||
t = msg.ReceivedAt
|
||||
}
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
288
internal/classify/classify_test.go
Normal file
288
internal/classify/classify_test.go
Normal file
@@ -0,0 +1,288 @@
|
||||
package classify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
|
||||
)
|
||||
|
||||
// stubJEV returns a TypeSafe-shaped response with a fixed category/action while
|
||||
// recording how many requests it handled.
|
||||
func stubJEV(t *testing.T, category, importance, action string, calls *int32) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
*calls++
|
||||
var body struct {
|
||||
Questions map[string]any `json:"questions"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if len(body.Questions) != 3 {
|
||||
t.Errorf("JEV stub saw %d questions, want 3", len(body.Questions))
|
||||
}
|
||||
resp := map[string]any{
|
||||
"model": "jev-1.13.0",
|
||||
"answers": map[string]any{
|
||||
"category": map[string]any{"type": "choice", "choice": category, "confidence": 0.8},
|
||||
"importance": map[string]any{"type": "choice", "choice": importance, "confidence": 0.7},
|
||||
"action": map[string]any{"type": "choice", "choice": action, "confidence": 0.6},
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
}
|
||||
|
||||
func newTestStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
st, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "email_agent.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
func seedMessages(t *testing.T, st *store.Store, from string, n int) {
|
||||
t.Helper()
|
||||
for i := 0; i < n; i++ {
|
||||
if _, err := st.UpsertMessage(context.Background(), &store.Message{
|
||||
GmailID: fmt.Sprintf("%s-%d@test", from, i),
|
||||
FromAddr: from,
|
||||
ToAddr: "hello@where-woof.com",
|
||||
Subject: fmt.Sprintf("message %d", i),
|
||||
BodyText: "body",
|
||||
SourceAddress: "hello@where-woof.com",
|
||||
DateSent: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed message: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newTestClassifier(t *testing.T, st *store.Store, baseURL string) *Classifier {
|
||||
t.Helper()
|
||||
client := NewClient(baseURL, "test-key", "jev-latest", slog.New(slog.DiscardHandler))
|
||||
return NewClassifier(client, st, slog.New(slog.DiscardHandler), 2, 50)
|
||||
}
|
||||
|
||||
func TestClassifierClassifiesEveryPendingMessage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
seedMessages(t, st, "someone@example.com", 3)
|
||||
|
||||
var calls int32
|
||||
srv := stubJEV(t, "Info", "low", "keep", &calls)
|
||||
defer srv.Close()
|
||||
|
||||
sum, err := newTestClassifier(t, st, srv.URL).Run(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if sum.Considered != 3 || sum.Classified != 3 || sum.Failed != 0 {
|
||||
t.Fatalf("summary = %+v, want 3 considered/classified, 0 failed", sum)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Errorf("JEV calls = %d, want 3", calls)
|
||||
}
|
||||
|
||||
coverage, err := st.ClassificationCoverage(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ClassificationCoverage() error = %v", err)
|
||||
}
|
||||
if coverage.Total != 3 || coverage.Classified != 3 || coverage.Missing() != 0 {
|
||||
t.Fatalf("coverage = %+v, want 3/3 classified", coverage)
|
||||
}
|
||||
|
||||
items, err := st.ListMessages(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListMessages() error = %v", err)
|
||||
}
|
||||
for _, it := range items {
|
||||
if it.Category == "" || it.Importance == "" || it.Action == "" {
|
||||
t.Errorf("message %d has a null classification: %+v", it.ID, it)
|
||||
}
|
||||
if !it.HasClassification {
|
||||
t.Errorf("message %d HasClassification = false", it.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifierRuleOverridesJEV(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
seedMessages(t, st, "billing@stripe.com", 1)
|
||||
if _, err := st.SaveRules(ctx, `from contains "stripe" => category=Action, importance=high, action=reply`); err != nil {
|
||||
t.Fatalf("SaveRules() error = %v", err)
|
||||
}
|
||||
|
||||
var calls int32
|
||||
srv := stubJEV(t, "Newsletter", "low", "keep", &calls)
|
||||
defer srv.Close()
|
||||
|
||||
sum, err := newTestClassifier(t, st, srv.URL).Run(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if sum.Classified != 1 {
|
||||
t.Fatalf("summary = %+v", sum)
|
||||
}
|
||||
|
||||
items, err := st.ListMessages(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListMessages() error = %v", err)
|
||||
}
|
||||
got := items[0]
|
||||
if got.Category != "Action" || got.Importance != "high" || got.Action != "reply" {
|
||||
t.Errorf("rule override not applied: %+v", got)
|
||||
}
|
||||
if got.Confidence != 1 {
|
||||
t.Errorf("Confidence = %v, want 1 for a rule override", got.Confidence)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("JEV calls = %d, want 1 (JEV still consulted)", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifierIgnoresRuleWithUnknownCategory(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
seedMessages(t, st, "billing@stripe.com", 1)
|
||||
if _, err := st.SaveRules(ctx, `from contains "stripe" => category=NoSuchCategory`); err != nil {
|
||||
t.Fatalf("SaveRules() error = %v", err)
|
||||
}
|
||||
|
||||
var calls int32
|
||||
srv := stubJEV(t, "Newsletter", "medium", "keep", &calls)
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := newTestClassifier(t, st, srv.URL).Run(ctx); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
items, err := st.ListMessages(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListMessages() error = %v", err)
|
||||
}
|
||||
if items[0].Category != "Newsletter" {
|
||||
t.Errorf("Category = %q, want the JEV answer Newsletter", items[0].Category)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifierFallsBackWhenJEVCategoryUnknown(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
seedMessages(t, st, "someone@example.com", 1)
|
||||
|
||||
var calls int32
|
||||
srv := stubJEV(t, "CategoryThatDoesNotExist", "medium", "keep", &calls)
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := newTestClassifier(t, st, srv.URL).Run(ctx); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
categories, err := st.CategoryNames(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CategoryNames() error = %v", err)
|
||||
}
|
||||
items, err := st.ListMessages(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListMessages() error = %v", err)
|
||||
}
|
||||
if items[0].Category != categories[0] {
|
||||
t.Errorf("Category = %q, want the first configured category %q", items[0].Category, categories[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifierRerunsOnlyPending(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
seedMessages(t, st, "someone@example.com", 2)
|
||||
|
||||
var calls int32
|
||||
srv := stubJEV(t, "Info", "low", "keep", &calls)
|
||||
defer srv.Close()
|
||||
|
||||
classifier := newTestClassifier(t, st, srv.URL)
|
||||
if _, err := classifier.Run(ctx); err != nil {
|
||||
t.Fatalf("first Run() error = %v", err)
|
||||
}
|
||||
sum, err := classifier.Run(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("second Run() error = %v", err)
|
||||
}
|
||||
if sum.Considered != 0 {
|
||||
t.Errorf("second run considered %d, want 0 (nothing pending)", sum.Considered)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Errorf("JEV calls = %d, want 2 (only the first run)", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifierRunAllReclassifiesEverything(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
seedMessages(t, st, "someone@example.com", 2)
|
||||
|
||||
var calls int32
|
||||
srv := stubJEV(t, "Info", "low", "keep", &calls)
|
||||
defer srv.Close()
|
||||
|
||||
classifier := newTestClassifier(t, st, srv.URL)
|
||||
if _, err := classifier.Run(ctx); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
sum, err := classifier.RunAll(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("RunAll() error = %v", err)
|
||||
}
|
||||
if sum.Considered != 2 || sum.Classified != 2 {
|
||||
t.Fatalf("RunAll() summary = %+v, want 2 considered/classified", sum)
|
||||
}
|
||||
if calls != 4 {
|
||||
t.Errorf("JEV calls = %d, want 4 (2 initial + 2 re-classified)", calls)
|
||||
}
|
||||
|
||||
var n int
|
||||
if err := st.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM classifications`).Scan(&n); err != nil {
|
||||
t.Fatalf("count classifications: %v", err)
|
||||
}
|
||||
if n != 4 {
|
||||
t.Errorf("classification rows = %d, want 4 (history is kept)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifierActionLogRecordsRule(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
seedMessages(t, st, "billing@stripe.com", 1)
|
||||
if _, err := st.SaveRules(ctx, `from contains "stripe" => category=Action`); err != nil {
|
||||
t.Fatalf("SaveRules() error = %v", err)
|
||||
}
|
||||
|
||||
var calls int32
|
||||
srv := stubJEV(t, "Newsletter", "low", "keep", &calls)
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := newTestClassifier(t, st, srv.URL).Run(ctx); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
var detail string
|
||||
if err := st.DB().QueryRowContext(ctx,
|
||||
`SELECT detail FROM actions_log WHERE action = 'classify' ORDER BY id DESC LIMIT 1`).Scan(&detail); err != nil {
|
||||
t.Fatalf("read classify log: %v", err)
|
||||
}
|
||||
for _, want := range []string{"category=Action", "rule_line=1", "from contains"} {
|
||||
if !strings.Contains(detail, want) {
|
||||
t.Errorf("classify log detail %q missing %q", detail, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
306
internal/classify/jev.go
Normal file
306
internal/classify/jev.go
Normal file
@@ -0,0 +1,306 @@
|
||||
// Package classify turns stored messages into a category, an importance, a
|
||||
// suggested action, and a confidence using the TypeSafe (JEV) HTTP API, then
|
||||
// applies the user's rules (FR-3, FR-4).
|
||||
//
|
||||
// The JEV request/response shape is confirmed against the live docs
|
||||
// (https://docs.typesafe.ai/api.md) and recorded in docs/phase3-classify.md:
|
||||
//
|
||||
// POST https://api.typesafe.ai/v1/systemone
|
||||
// Authorization: Bearer <API_KEY>
|
||||
// {"state": …, "model": "jev-latest", "questions": {"‹id›": {"type": "choice", …}}}
|
||||
// → {"model": "jev-1.13.0", "answers": {"‹id›": {"type": "choice", "confidence", "probabilities"}}}
|
||||
package classify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultBaseURL is TypeSafe's documented API host. JEV_BASE_URL overrides it.
|
||||
const DefaultBaseURL = "https://api.typesafe.ai"
|
||||
|
||||
// DefaultModel is the flagship System One alias documented at
|
||||
// https://docs.typesafe.ai/models.
|
||||
const DefaultModel = "jev-latest"
|
||||
|
||||
// systemOnePath is the documented evaluation endpoint.
|
||||
const systemOnePath = "/v1/systemone"
|
||||
|
||||
// ErrNotConfigured means JEV_API_KEY is not set.
|
||||
var ErrNotConfigured = errors.New("JEV_API_KEY is not configured")
|
||||
|
||||
// maxBodyChars bounds how much of a message body is sent to JEV.
|
||||
const maxBodyChars = 4000
|
||||
|
||||
// MessageInput is the message text handed to JEV.
|
||||
type MessageInput struct {
|
||||
From string
|
||||
To string
|
||||
Subject string
|
||||
Source string
|
||||
Body string
|
||||
Received string
|
||||
}
|
||||
|
||||
// CategoryOption is one Choice option: the live category name and its rubric.
|
||||
type CategoryOption struct {
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
// Result is one classification decision. Confidence is the Choice answer's own
|
||||
// confidence, so the UI can show how sure JEV is.
|
||||
type Result struct {
|
||||
Category string
|
||||
CategoryConfidence float64
|
||||
Importance string
|
||||
ImportanceConfidence float64
|
||||
Action string
|
||||
ActionConfidence float64
|
||||
Model string
|
||||
}
|
||||
|
||||
// Client calls the TypeSafe System One endpoint.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
model string
|
||||
http *http.Client
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewClient builds a JEV client. An empty baseURL falls back to DefaultBaseURL,
|
||||
// an empty model to DefaultModel. logger may be nil.
|
||||
func NewClient(baseURL, apiKey, model string, logger *slog.Logger) *Client {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = DefaultBaseURL
|
||||
}
|
||||
if strings.TrimSpace(model) == "" {
|
||||
model = DefaultModel
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.New(slog.DiscardHandler)
|
||||
}
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
apiKey: strings.TrimSpace(apiKey),
|
||||
model: model,
|
||||
http: &http.Client{Timeout: 60 * time.Second},
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Configured reports whether an API key is present.
|
||||
func (c *Client) Configured() bool { return c.apiKey != "" }
|
||||
|
||||
// Model returns the configured model alias.
|
||||
func (c *Client) Model() string { return c.model }
|
||||
|
||||
// Classify sends one message and returns the category, importance, action, and
|
||||
// confidences from one JEV request.
|
||||
func (c *Client) Classify(ctx context.Context, msg MessageInput, categories []CategoryOption) (Result, error) {
|
||||
if !c.Configured() {
|
||||
return Result{}, ErrNotConfigured
|
||||
}
|
||||
if len(categories) == 0 {
|
||||
return Result{}, errors.New("no categories configured")
|
||||
}
|
||||
|
||||
categoryCriteria := make(map[string]any, len(categories))
|
||||
for _, opt := range categories {
|
||||
if strings.TrimSpace(opt.Name) == "" {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(opt.Description) == "" {
|
||||
categoryCriteria[opt.Name] = nil
|
||||
} else {
|
||||
categoryCriteria[opt.Name] = opt.Description
|
||||
}
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"state": map[string]any{
|
||||
"email": map[string]any{
|
||||
"from": msg.From,
|
||||
"to": msg.To,
|
||||
"source_address": msg.Source,
|
||||
"source_domain": domainOf(msg.Source),
|
||||
"subject": msg.Subject,
|
||||
"received": msg.Received,
|
||||
"body": truncate(msg.Body, maxBodyChars),
|
||||
},
|
||||
},
|
||||
"model": c.model,
|
||||
"questions": map[string]any{
|
||||
"category": map[string]any{
|
||||
"type": "choice",
|
||||
"instructions": "Which category best fits this email for a personal family inbox? Pick the single best match.",
|
||||
"criteria": categoryCriteria,
|
||||
},
|
||||
"importance": map[string]any{
|
||||
"type": "choice",
|
||||
"instructions": "How important is this email to the recipient?",
|
||||
"criteria": map[string]any{
|
||||
"low": "Can be ignored or removed without loss",
|
||||
"medium": "Worth a quick look",
|
||||
"high": "Needs the recipient's attention soon",
|
||||
},
|
||||
},
|
||||
"action": map[string]any{
|
||||
"type": "choice",
|
||||
"instructions": "What is the best single recommended action for this email?",
|
||||
"criteria": map[string]any{
|
||||
"delete": "Junk or spam; safe to remove",
|
||||
"unsubscribe": "Stop future mail from this sender",
|
||||
"reply": "Send a reply to the sender",
|
||||
"file": "Keep for reference; move out of the inbox",
|
||||
"keep": "Leave in the inbox",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := c.post(ctx, body)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
res := Result{Model: resp.Model}
|
||||
if a, ok := resp.Answers["category"]; ok {
|
||||
res.Category = a.Choice
|
||||
res.CategoryConfidence = a.Confidence
|
||||
}
|
||||
if a, ok := resp.Answers["importance"]; ok {
|
||||
res.Importance = a.Choice
|
||||
res.ImportanceConfidence = a.Confidence
|
||||
}
|
||||
if a, ok := resp.Answers["action"]; ok {
|
||||
res.Action = a.Choice
|
||||
res.ActionConfidence = a.Confidence
|
||||
}
|
||||
if res.Model == "" {
|
||||
res.Model = c.model
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// answer is one System One Choice answer.
|
||||
type answer struct {
|
||||
Type string `json:"type"`
|
||||
Choice string `json:"choice"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Probabilities map[string]float64 `json:"probabilities"`
|
||||
Noul float64 `json:"noul"`
|
||||
}
|
||||
|
||||
type response struct {
|
||||
Model string `json:"model"`
|
||||
Answers map[string]answer `json:"answers"`
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
// post sends the request with bounded retries for rate limits and outages.
|
||||
func (c *Client) post(ctx context.Context, body map[string]any) (response, error) {
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return response{}, fmt.Errorf("encode JEV request: %w", err)
|
||||
}
|
||||
|
||||
const maxAttempts = 4
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
resp, retryable, err := c.do(ctx, payload)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
lastErr = err
|
||||
if !retryable || attempt == maxAttempts {
|
||||
break
|
||||
}
|
||||
delay := backoff(attempt)
|
||||
c.logger.Warn("jev request failed; retrying", "attempt", attempt, "delay_ms", delay.Milliseconds(), "error", err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return response{}, ctx.Err()
|
||||
case <-time.After(delay):
|
||||
}
|
||||
}
|
||||
return response{}, lastErr
|
||||
}
|
||||
|
||||
func (c *Client) do(ctx context.Context, payload []byte) (response, bool, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+systemOnePath, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return response{}, false, fmt.Errorf("build JEV request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return response{}, true, fmt.Errorf("call JEV: %w", err)
|
||||
}
|
||||
defer func() { _ = httpResp.Body.Close() }()
|
||||
|
||||
raw, err := io.ReadAll(io.LimitReader(httpResp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return response{}, true, fmt.Errorf("read JEV response: %w", err)
|
||||
}
|
||||
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
retryable := httpResp.StatusCode == http.StatusTooManyRequests ||
|
||||
httpResp.StatusCode == http.StatusServiceUnavailable ||
|
||||
httpResp.StatusCode == 529 ||
|
||||
httpResp.StatusCode >= 500
|
||||
return response{}, retryable,
|
||||
fmt.Errorf("JEV returned %d: %s", httpResp.StatusCode, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
|
||||
var out response
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return response{}, false, fmt.Errorf("decode JEV response: %w", err)
|
||||
}
|
||||
if len(out.Answers) == 0 {
|
||||
return response{}, false, fmt.Errorf("JEV response contained no answers")
|
||||
}
|
||||
return out, false, nil
|
||||
}
|
||||
|
||||
// backoff returns the delay before the next attempt, with a little jitter so
|
||||
// parallel workers do not retry in lock step.
|
||||
func backoff(attempt int) time.Duration {
|
||||
base := time.Duration(1<<(attempt-1)) * time.Second
|
||||
if base > 8*time.Second {
|
||||
base = 8 * time.Second
|
||||
}
|
||||
return base + time.Duration(rand.Int63n(int64(base/2+1)))
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "…"
|
||||
}
|
||||
|
||||
func domainOf(addr string) string {
|
||||
if i := strings.LastIndex(addr, "@"); i >= 0 && i < len(addr)-1 {
|
||||
return addr[i+1:]
|
||||
}
|
||||
return addr
|
||||
}
|
||||
154
internal/classify/jev_test.go
Normal file
154
internal/classify/jev_test.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package classify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestClientClassifyRequestAndResponse(t *testing.T) {
|
||||
var (
|
||||
gotPath string
|
||||
gotAuth string
|
||||
gotBody map[string]any
|
||||
callCount int32
|
||||
)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&callCount, 1)
|
||||
gotPath = r.URL.Path
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"model": "jev-1.13.0",
|
||||
"answers": {
|
||||
"category": {"type":"choice","choice":"Action","confidence":0.81,"probabilities":{"Action":0.81,"Info":0.19}},
|
||||
"importance": {"type":"choice","choice":"high","confidence":0.9,"probabilities":{"high":0.9}},
|
||||
"action": {"type":"choice","choice":"reply","confidence":0.7,"probabilities":{"reply":0.7}}
|
||||
},
|
||||
"usage": {"input_tokens": 100, "output_tokens": 20}
|
||||
}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "test-key", "jev-latest", nil)
|
||||
res, err := client.Classify(context.Background(), MessageInput{
|
||||
From: "favrolfe@gmail.com", To: "hello@where-woof.com",
|
||||
Subject: "Quick question", Source: "hello@where-woof.com",
|
||||
Body: "Can you send me the invoice?", Received: "2026-10-11T09:00:00Z",
|
||||
}, []CategoryOption{
|
||||
{Name: "Action", Description: "Needs Sam to do something"},
|
||||
{Name: "Info", Description: "Informational"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Classify() error = %v", err)
|
||||
}
|
||||
|
||||
if gotPath != "/v1/systemone" {
|
||||
t.Errorf("path = %q, want /v1/systemone", gotPath)
|
||||
}
|
||||
if gotAuth != "Bearer test-key" {
|
||||
t.Errorf("Authorization = %q", gotAuth)
|
||||
}
|
||||
if gotBody["model"] != "jev-latest" {
|
||||
t.Errorf("model = %v, want jev-latest", gotBody["model"])
|
||||
}
|
||||
questions, _ := gotBody["questions"].(map[string]any)
|
||||
if len(questions) != 3 {
|
||||
t.Fatalf("questions = %d, want 3", len(questions))
|
||||
}
|
||||
categoryQ, _ := questions["category"].(map[string]any)
|
||||
criteria, _ := categoryQ["criteria"].(map[string]any)
|
||||
if len(criteria) != 2 {
|
||||
t.Errorf("category criteria = %v, want the live category list", criteria)
|
||||
}
|
||||
state, _ := gotBody["state"].(map[string]any)
|
||||
if _, ok := state["email"]; !ok {
|
||||
t.Errorf("state = %v, want an email object", state)
|
||||
}
|
||||
|
||||
if res.Category != "Action" || res.Importance != "high" || res.Action != "reply" {
|
||||
t.Errorf("result = %+v", res)
|
||||
}
|
||||
if res.CategoryConfidence != 0.81 {
|
||||
t.Errorf("CategoryConfidence = %v, want 0.81", res.CategoryConfidence)
|
||||
}
|
||||
if res.Model != "jev-1.13.0" {
|
||||
t.Errorf("Model = %q, want jev-1.13.0", res.Model)
|
||||
}
|
||||
if atomic.LoadInt32(&callCount) != 1 {
|
||||
t.Errorf("call count = %d, want 1", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRetriesRateLimit(t *testing.T) {
|
||||
var attempts int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if atomic.AddInt32(&attempts, 1) == 1 {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = w.Write([]byte(`{"error":"rate limited"}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"model":"jev-1.13.0","answers":{
|
||||
"category":{"type":"choice","choice":"Info","confidence":1},
|
||||
"importance":{"type":"choice","choice":"low","confidence":1},
|
||||
"action":{"type":"choice","choice":"keep","confidence":1}}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "k", "", nil)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
res, err := client.Classify(ctx, MessageInput{Subject: "hi"}, []CategoryOption{{Name: "Info"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Classify() error = %v", err)
|
||||
}
|
||||
if res.Category != "Info" {
|
||||
t.Errorf("Category = %q, want Info", res.Category)
|
||||
}
|
||||
if got := atomic.LoadInt32(&attempts); got != 2 {
|
||||
t.Errorf("attempts = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRequiresAPIKey(t *testing.T) {
|
||||
client := NewClient("http://example.invalid", "", "", nil)
|
||||
if client.Configured() {
|
||||
t.Fatal("Configured() = true without an API key")
|
||||
}
|
||||
if _, err := client.Classify(context.Background(), MessageInput{}, []CategoryOption{{Name: "Info"}}); err != ErrNotConfigured {
|
||||
t.Fatalf("err = %v, want ErrNotConfigured", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientDefaultsBaseURL(t *testing.T) {
|
||||
client := NewClient("", "k", "", nil)
|
||||
if client.baseURL != DefaultBaseURL {
|
||||
t.Errorf("baseURL = %q, want %q", client.baseURL, DefaultBaseURL)
|
||||
}
|
||||
if client.Model() != DefaultModel {
|
||||
t.Errorf("model = %q, want %q", client.Model(), DefaultModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientHTTPErrorSurfacesBody(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"invalid api key"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "bad", "", nil)
|
||||
_, err := client.Classify(context.Background(), MessageInput{}, []CategoryOption{{Name: "Info"}})
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid api key") {
|
||||
t.Fatalf("err = %v, want the 401 body", err)
|
||||
}
|
||||
}
|
||||
238
internal/classify/rules.go
Normal file
238
internal/classify/rules.go
Normal file
@@ -0,0 +1,238 @@
|
||||
// User rules (FR-4). A rule overrides the JEV classification when its condition
|
||||
// matches the message.
|
||||
//
|
||||
// Syntax, 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 only the fields it names. The rules
|
||||
// editor that writes this text lands in Phase 6; the classifier already reads it.
|
||||
package classify
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Rule is one parsed line.
|
||||
type Rule struct {
|
||||
Line int
|
||||
Raw string
|
||||
MatchAll bool
|
||||
Conditions []Condition
|
||||
Effects map[string]string
|
||||
}
|
||||
|
||||
// Condition is one `<field> <op> "<value>"` term.
|
||||
type Condition struct {
|
||||
Field string
|
||||
Op string
|
||||
Value string
|
||||
}
|
||||
|
||||
// ParseError records a line that could not be parsed. The rest of the document
|
||||
// still loads; the error is surfaced in logs so a typo is visible.
|
||||
type ParseError struct {
|
||||
Line int
|
||||
Text string
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e ParseError) Error() string {
|
||||
return fmt.Sprintf("rules line %d: %s (%q)", e.Line, e.Reason, strings.TrimSpace(e.Text))
|
||||
}
|
||||
|
||||
// RuleSet is a parsed rules document.
|
||||
type RuleSet struct {
|
||||
Rules []Rule
|
||||
Errors []ParseError
|
||||
}
|
||||
|
||||
// effectFields are the classification fields a rule may override.
|
||||
var effectFields = map[string]bool{
|
||||
"category": true,
|
||||
"importance": true,
|
||||
"action": true,
|
||||
}
|
||||
|
||||
var allowedImportance = map[string]bool{"low": true, "medium": true, "high": true}
|
||||
|
||||
var allowedAction = map[string]bool{
|
||||
"delete": true, "unsubscribe": true, "reply": true, "file": true, "keep": true,
|
||||
}
|
||||
|
||||
// ParseRules parses a rules document. Malformed lines are collected in Errors and
|
||||
// skipped; parsing never fails outright, so one typo cannot disable the rules.
|
||||
func ParseRules(text string) RuleSet {
|
||||
var set RuleSet
|
||||
for i, raw := range strings.Split(text, "\n") {
|
||||
line := strings.TrimSpace(strings.TrimSuffix(raw, "\r"))
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
rule, err := parseRule(line)
|
||||
rule.Line = i + 1
|
||||
rule.Raw = line
|
||||
if err != nil {
|
||||
set.Errors = append(set.Errors, ParseError{Line: i + 1, Text: raw, Reason: err.Error()})
|
||||
continue
|
||||
}
|
||||
set.Rules = append(set.Rules, rule)
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func parseRule(line string) (Rule, error) {
|
||||
rule := Rule{Effects: map[string]string{}}
|
||||
left, right, ok := strings.Cut(line, "=>")
|
||||
if !ok {
|
||||
return rule, fmt.Errorf("missing =>")
|
||||
}
|
||||
left, right = strings.TrimSpace(left), strings.TrimSpace(right)
|
||||
if left == "" || right == "" {
|
||||
return rule, fmt.Errorf("empty condition or effect")
|
||||
}
|
||||
|
||||
if left == "*" {
|
||||
rule.MatchAll = true
|
||||
} else {
|
||||
for _, part := range strings.Split(left, " and ") {
|
||||
cond, err := parseCondition(strings.TrimSpace(part))
|
||||
if err != nil {
|
||||
return rule, err
|
||||
}
|
||||
rule.Conditions = append(rule.Conditions, cond)
|
||||
}
|
||||
}
|
||||
|
||||
for _, part := range strings.Split(right, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
field, value, err := parseEffect(part)
|
||||
if err != nil {
|
||||
return rule, err
|
||||
}
|
||||
rule.Effects[field] = value
|
||||
}
|
||||
if len(rule.Effects) == 0 {
|
||||
return rule, fmt.Errorf("no effects")
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
// parseEffect accepts `field=value` and `field: value` to be forgiving of typing.
|
||||
func parseEffect(part string) (string, string, error) {
|
||||
field, value, ok := strings.Cut(part, "=")
|
||||
if !ok {
|
||||
field, value, ok = strings.Cut(part, ":")
|
||||
}
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("effect %q is not field=value", part)
|
||||
}
|
||||
field = strings.ToLower(strings.TrimSpace(field))
|
||||
value = unquote(strings.TrimSpace(value))
|
||||
if !effectFields[field] {
|
||||
return "", "", fmt.Errorf("unknown effect field %q", field)
|
||||
}
|
||||
if value == "" {
|
||||
return "", "", fmt.Errorf("effect %q has no value", field)
|
||||
}
|
||||
switch field {
|
||||
case "importance":
|
||||
value = strings.ToLower(value)
|
||||
if !allowedImportance[value] {
|
||||
return "", "", fmt.Errorf("importance %q must be low, medium, or high", value)
|
||||
}
|
||||
case "action":
|
||||
value = strings.ToLower(value)
|
||||
if !allowedAction[value] {
|
||||
return "", "", fmt.Errorf("action %q must be delete, unsubscribe, reply, file, or keep", value)
|
||||
}
|
||||
}
|
||||
return field, value, nil
|
||||
}
|
||||
|
||||
func parseCondition(part string) (Condition, error) {
|
||||
fields := strings.Fields(part)
|
||||
if len(fields) < 3 {
|
||||
return Condition{}, fmt.Errorf("condition %q needs a field, an operator, and a value", part)
|
||||
}
|
||||
field := strings.ToLower(fields[0])
|
||||
switch field {
|
||||
case "from", "to", "subject", "source", "body":
|
||||
default:
|
||||
return Condition{}, fmt.Errorf("unknown condition field %q", field)
|
||||
}
|
||||
op := strings.ToLower(fields[1])
|
||||
switch op {
|
||||
case "contains", "equals", "starts_with", "ends_with":
|
||||
default:
|
||||
return Condition{}, fmt.Errorf("unknown operator %q", op)
|
||||
}
|
||||
value := unquote(strings.TrimSpace(strings.Join(fields[2:], " ")))
|
||||
if value == "" {
|
||||
return Condition{}, fmt.Errorf("condition %q has no value", part)
|
||||
}
|
||||
return Condition{Field: field, Op: op, Value: value}, nil
|
||||
}
|
||||
|
||||
func unquote(v string) string {
|
||||
if len(v) >= 2 {
|
||||
if (v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'') {
|
||||
return v[1 : len(v)-1]
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Match returns the effects of the first matching rule. It reports nil when no
|
||||
// rule matches. Field lookups are case-insensitive; values compare
|
||||
// case-insensitively too.
|
||||
func (rs RuleSet) Match(fields map[string]string) *Rule {
|
||||
normalized := make(map[string]string, len(fields))
|
||||
for k, v := range fields {
|
||||
normalized[strings.ToLower(k)] = strings.ToLower(v)
|
||||
}
|
||||
for i := range rs.Rules {
|
||||
r := &rs.Rules[i]
|
||||
if r.MatchAll || allMatch(r.Conditions, normalized) {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func allMatch(conds []Condition, fields map[string]string) bool {
|
||||
for _, c := range conds {
|
||||
if !matches(c, fields[c.Field]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func matches(c Condition, fieldValue string) bool {
|
||||
want := strings.ToLower(c.Value)
|
||||
switch c.Op {
|
||||
case "contains":
|
||||
return strings.Contains(fieldValue, want)
|
||||
case "equals":
|
||||
return fieldValue == want
|
||||
case "starts_with":
|
||||
return strings.HasPrefix(fieldValue, want)
|
||||
case "ends_with":
|
||||
return strings.HasSuffix(fieldValue, want)
|
||||
}
|
||||
return false
|
||||
}
|
||||
119
internal/classify/rules_test.go
Normal file
119
internal/classify/rules_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package classify
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseRulesSkipsCommentsAndBlanks(t *testing.T) {
|
||||
set := ParseRules(`
|
||||
# a comment
|
||||
# an indented comment
|
||||
|
||||
from contains "amazon.com" => category=Newsletter, action=unsubscribe
|
||||
`)
|
||||
if len(set.Errors) != 0 {
|
||||
t.Fatalf("Errors = %v, want none", set.Errors)
|
||||
}
|
||||
if len(set.Rules) != 1 {
|
||||
t.Fatalf("Rules len = %d, want 1", len(set.Rules))
|
||||
}
|
||||
r := set.Rules[0]
|
||||
if r.Line != 5 {
|
||||
t.Errorf("Line = %d, want 5", r.Line)
|
||||
}
|
||||
if r.Effects["category"] != "Newsletter" || r.Effects["action"] != "unsubscribe" {
|
||||
t.Errorf("Effects = %v", r.Effects)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRulesMatchAll(t *testing.T) {
|
||||
set := ParseRules(`* => category=Info`)
|
||||
if len(set.Rules) != 1 || !set.Rules[0].MatchAll {
|
||||
t.Fatalf("expected a match-all rule, got %+v (errors %v)", set.Rules, set.Errors)
|
||||
}
|
||||
if got := set.Match(map[string]string{"subject": "anything"}); got == nil {
|
||||
t.Fatal("match-all rule did not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRulesReportsBadLines(t *testing.T) {
|
||||
set := ParseRules(`
|
||||
from => category=Info
|
||||
from contains "x" => importance=urgent
|
||||
from contains "x" => bogus=1
|
||||
missing the arrow
|
||||
`)
|
||||
if len(set.Rules) != 0 {
|
||||
t.Fatalf("Rules = %+v, want none", set.Rules)
|
||||
}
|
||||
if len(set.Errors) != 4 {
|
||||
t.Fatalf("Errors len = %d, want 4: %v", len(set.Errors), set.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleMatchAndPrecedence(t *testing.T) {
|
||||
set := ParseRules(`
|
||||
from ends_with "@stripe.com" and subject contains "invoice" => category=Action, importance=high
|
||||
from contains "stripe" => category=Newsletter
|
||||
* => category=Info
|
||||
`)
|
||||
if len(set.Errors) != 0 {
|
||||
t.Fatalf("unexpected errors: %v", set.Errors)
|
||||
}
|
||||
|
||||
// First matching rule wins.
|
||||
got := set.Match(map[string]string{
|
||||
"from": "Billing@Stripe.com",
|
||||
"subject": "Your invoice is ready",
|
||||
})
|
||||
if got == nil || got.Effects["category"] != "Action" {
|
||||
t.Fatalf("first rule did not win: %+v", got)
|
||||
}
|
||||
|
||||
// The second rule matches on its own terms.
|
||||
got = set.Match(map[string]string{"from": "stripe-press@example.com"})
|
||||
if got == nil || got.Effects["category"] != "Newsletter" {
|
||||
t.Fatalf("second rule did not match: %+v", got)
|
||||
}
|
||||
|
||||
// The catch-all is the fallback.
|
||||
got = set.Match(map[string]string{"from": "someone@example.com"})
|
||||
if got == nil || got.Effects["category"] != "Info" {
|
||||
t.Fatalf("catch-all did not match: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleOperators(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
rule string
|
||||
fields map[string]string
|
||||
wantCat string
|
||||
wantNone bool
|
||||
}{
|
||||
{"equals", `from equals "a@b.com" => category=Action`, map[string]string{"from": "A@B.com"}, "Action", false},
|
||||
{"starts_with", `subject starts_with "Hello" => category=Info`, map[string]string{"subject": "hello there"}, "Info", false},
|
||||
{"ends_with", `source ends_with "where-woof.com" => category=Newsletter`, map[string]string{"source": "hello@where-woof.com"}, "Newsletter", false},
|
||||
{"body", `body contains "unsubscribe here" => category=Newsletter`, map[string]string{"body": "Click UNSUBSCRIBE HERE to stop"}, "Newsletter", false},
|
||||
{"no match", `from contains "zzz" => category=Action`, map[string]string{"from": "a@b.com"}, "", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
set := ParseRules(tc.rule)
|
||||
if len(set.Errors) != 0 {
|
||||
t.Fatalf("parse errors: %v", set.Errors)
|
||||
}
|
||||
got := set.Match(tc.fields)
|
||||
if tc.wantNone {
|
||||
if got != nil {
|
||||
t.Fatalf("expected no match, got %+v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("expected a match, got none")
|
||||
}
|
||||
if got.Effects["category"] != tc.wantCat {
|
||||
t.Errorf("category = %q, want %q", got.Effects["category"], tc.wantCat)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user