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,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)
}

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

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

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