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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,16 @@ const (
|
||||
DefaultIMAPPort = 993
|
||||
DefaultIMAPLookbackDays = 14
|
||||
DefaultIMAPMaxMessages = 100
|
||||
|
||||
// JEV (TypeSafe System One) defaults. The base URL is fixed by the TypeSafe
|
||||
// docs (https://docs.typesafe.ai/api.md); JEV_BASE_URL overrides it.
|
||||
DefaultJEVBaseURL = "https://api.typesafe.ai"
|
||||
DefaultJEVModel = "jev-latest"
|
||||
|
||||
// Classifier defaults (FR-3). Concurrency bounds in-flight JEV requests;
|
||||
// max messages bounds one run so a scheduled scan stays cheap.
|
||||
DefaultClassifyConcurrency = 4
|
||||
DefaultClassifyMaxMessages = 200
|
||||
)
|
||||
|
||||
// Config is the fully resolved runtime configuration.
|
||||
@@ -66,6 +76,12 @@ type Config struct {
|
||||
JEVBaseURL string
|
||||
// JEVAPIKey authenticates to JEV. Secret.
|
||||
JEVAPIKey string
|
||||
// JEVModel is the System One model alias (default jev-latest).
|
||||
JEVModel string
|
||||
// ClassifyConcurrency bounds in-flight JEV requests per run.
|
||||
ClassifyConcurrency int
|
||||
// ClassifyMaxMessages caps how many messages one classify run processes.
|
||||
ClassifyMaxMessages int
|
||||
|
||||
// OmniRouteBaseURL is the base URL of the OmniRoute gateway.
|
||||
OmniRouteBaseURL string
|
||||
@@ -99,8 +115,9 @@ func Load() (*Config, error) {
|
||||
Password: os.Getenv("EMAIL_AGENT_PASSWORD"),
|
||||
LogLevel: strings.ToLower(getenvDefault("EMAIL_AGENT_LOG_LEVEL", "info")),
|
||||
PublicBaseURL: strings.TrimRight(getenvDefault("EMAIL_AGENT_PUBLIC_BASE_URL", "http://localhost:8790"), "/"),
|
||||
JEVBaseURL: strings.TrimRight(os.Getenv("JEV_BASE_URL"), "/"),
|
||||
JEVBaseURL: strings.TrimRight(getenvDefault("JEV_BASE_URL", DefaultJEVBaseURL), "/"),
|
||||
JEVAPIKey: os.Getenv("JEV_API_KEY"),
|
||||
JEVModel: getenvDefault("JEV_MODEL", DefaultJEVModel),
|
||||
OmniRouteBaseURL: strings.TrimRight(os.Getenv("OMNIROUTE_BASE_URL"), "/"),
|
||||
OmniRouteAPIKey: os.Getenv("OMNIROUTE_API_KEY"),
|
||||
GmailUser: os.Getenv("GMAIL_USER"),
|
||||
@@ -116,6 +133,8 @@ func Load() (*Config, error) {
|
||||
c.IMAPPort = imapPort
|
||||
c.IMAPLookbackDays = getenvDefaultInt("EMAIL_AGENT_IMAP_LOOKBACK_DAYS", DefaultIMAPLookbackDays)
|
||||
c.IMAPMaxMessages = getenvDefaultInt("EMAIL_AGENT_IMAP_MAX_MESSAGES", DefaultIMAPMaxMessages)
|
||||
c.ClassifyConcurrency = getenvDefaultInt("EMAIL_AGENT_CLASSIFY_CONCURRENCY", DefaultClassifyConcurrency)
|
||||
c.ClassifyMaxMessages = getenvDefaultInt("EMAIL_AGENT_CLASSIFY_MAX_MESSAGES", DefaultClassifyMaxMessages)
|
||||
|
||||
port, err := resolvePort()
|
||||
if err != nil {
|
||||
@@ -219,6 +238,10 @@ func (c *Config) GmailConfigured() bool {
|
||||
return strings.TrimSpace(c.GmailUser) != "" && strings.TrimSpace(c.GmailAppPassword) != ""
|
||||
}
|
||||
|
||||
// JEVConfigured reports whether JEV classification can be attempted. The
|
||||
// service still starts without a key; only classification is unavailable.
|
||||
func (c *Config) JEVConfigured() bool { return strings.TrimSpace(c.JEVAPIKey) != "" }
|
||||
|
||||
// IsProduction reports whether the service runs in the production environment.
|
||||
func (c *Config) IsProduction() bool { return c.Env == "production" }
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/session"
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/version"
|
||||
)
|
||||
|
||||
@@ -226,36 +227,63 @@ type dashboardData struct {
|
||||
Folders []navItem
|
||||
}
|
||||
|
||||
// categoryCounts returns per-category message counts keyed by the canonical
|
||||
// category name, plus the unclassified bucket.
|
||||
func categoryCounts(items []store.MessageListItem, categories []store.Category) map[string]int {
|
||||
canonical := make(map[string]string, len(categories))
|
||||
for _, c := range categories {
|
||||
canonical[strings.ToLower(c.Name)] = c.Name
|
||||
}
|
||||
counts := map[string]int{}
|
||||
for _, it := range items {
|
||||
raw := strings.TrimSpace(it.Category)
|
||||
if raw == "" {
|
||||
counts[unclassifiedCategory]++
|
||||
continue
|
||||
}
|
||||
if name, ok := canonical[strings.ToLower(raw)]; ok {
|
||||
counts[name]++
|
||||
} else {
|
||||
counts[raw]++
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
// handleDashboard renders the inbox shell with real category counts.
|
||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFrom(r)
|
||||
|
||||
// FR-5: counts come from the stored messages. Phase 2 has no classifier
|
||||
// yet, so every message lands under Unclassified (AGENTS.md §13.2).
|
||||
// FR-5: counts come from the stored messages, grouped by the live category
|
||||
// list (FR-14).
|
||||
items, err := s.store.ListMessages(r.Context(), 200)
|
||||
if err != nil {
|
||||
s.logger.Error("load dashboard counts", "error", err)
|
||||
items = nil
|
||||
}
|
||||
counts := categoryCounts(items)
|
||||
categories, err := s.store.ListCategories(r.Context())
|
||||
if err != nil {
|
||||
s.logger.Error("load dashboard categories", "error", err)
|
||||
}
|
||||
counts := categoryCounts(items, categories)
|
||||
|
||||
catNav := make([]navItem, 0, len(categories)+1)
|
||||
for _, c := range categories {
|
||||
catNav = append(catNav, navItem{Name: c.Name, Color: "var(--primary-soft, var(--surface-2))", Count: counts[c.Name]})
|
||||
}
|
||||
catNav = append(catNav, navItem{
|
||||
Name: unclassifiedCategory,
|
||||
Color: "var(--ink-faint)",
|
||||
Count: counts[unclassifiedCategory],
|
||||
})
|
||||
|
||||
data := dashboardData{
|
||||
Greeting: partOfDay(time.Now()),
|
||||
User: sess.User,
|
||||
CSRFToken: s.csrfToken(w, r),
|
||||
Version: version.Version,
|
||||
Total: len(items),
|
||||
// Placeholders until FR-14 (editable categories) and FR-9 (folders)
|
||||
// land. Colours are the design system's decorative sticker palette.
|
||||
Categories: []navItem{
|
||||
{Name: "Action", Color: "var(--primary)", Count: counts["action"]},
|
||||
{Name: "Important", Color: "var(--sticker-orange)", Count: counts["important"]},
|
||||
{Name: "Info", Color: "var(--sticker-sky)", Count: counts["info"]},
|
||||
{Name: "Spam", Color: "var(--sticker-red)", Count: counts["spam"]},
|
||||
{Name: "Newsletter", Color: "var(--sticker-purple)", Count: counts["newsletter"]},
|
||||
{Name: "Auto-reply", Color: "var(--sticker-teal)", Count: counts["auto-reply"]},
|
||||
{Name: "Unclassified", Color: "var(--ink-faint)", Count: counts[unclassifiedCategory]},
|
||||
},
|
||||
Greeting: partOfDay(time.Now()),
|
||||
User: sess.User,
|
||||
CSRFToken: s.csrfToken(w, r),
|
||||
Version: version.Version,
|
||||
Total: len(items),
|
||||
Categories: catNav,
|
||||
Folders: []navItem{
|
||||
{Name: "Replied-to", Color: "var(--sticker-green)"},
|
||||
{Name: "Delete", Color: "var(--sticker-red)"},
|
||||
|
||||
151
internal/server/categories.go
Normal file
151
internal/server/categories.go
Normal file
@@ -0,0 +1,151 @@
|
||||
// Category management UI and handlers (FR-14).
|
||||
//
|
||||
// The category list is editable: add, rename, and remove. JEV uses whatever list
|
||||
// is current at classification time, and renaming rewrites existing
|
||||
// classifications so the triage list stays consistent.
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
|
||||
)
|
||||
|
||||
type categoriesData struct {
|
||||
Categories []store.Category
|
||||
CSRFToken string
|
||||
Note string
|
||||
Error string
|
||||
Total int
|
||||
Classified int
|
||||
}
|
||||
|
||||
// handleCategories renders the category editor the inbox nav links to.
|
||||
func (s *Server) handleCategories(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderCategories(w, r, http.StatusOK, r.URL.Query().Get("note"), r.URL.Query().Get("error"))
|
||||
}
|
||||
|
||||
func (s *Server) renderCategories(w http.ResponseWriter, r *http.Request, code int, note, errMsg string) {
|
||||
categories, err := s.store.ListCategories(r.Context())
|
||||
if err != nil {
|
||||
s.logger.Error("list categories", "error", err)
|
||||
http.Error(w, "could not load categories", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
coverage, err := s.store.ClassificationCoverage(r.Context())
|
||||
if err != nil {
|
||||
s.logger.Error("classification coverage", "error", err)
|
||||
}
|
||||
|
||||
data := categoriesData{
|
||||
Categories: categories,
|
||||
CSRFToken: s.csrfToken(w, r),
|
||||
Note: note,
|
||||
Error: errMsg,
|
||||
Total: coverage.Total,
|
||||
Classified: coverage.Classified,
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
if err := s.tmpl.ExecuteTemplate(w, "categories.html", data); err != nil {
|
||||
s.logger.Error("render categories", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleCategoryAdd creates a category (FR-14).
|
||||
func (s *Server) handleCategoryAdd(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.csrfOK(r) {
|
||||
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.redirectCategories(w, r, "", "Could not read the form.")
|
||||
return
|
||||
}
|
||||
name, description := r.FormValue("name"), r.FormValue("description")
|
||||
cat, err := s.store.AddCategory(r.Context(), name, description)
|
||||
switch {
|
||||
case err == nil:
|
||||
s.logger.Info("category added", "name", cat.Name)
|
||||
_ = s.store.LogAction(r.Context(), "category_add", "Sam", "name="+cat.Name)
|
||||
s.redirectCategories(w, r, fmt.Sprintf("Added category %q.", cat.Name), "")
|
||||
case errors.Is(err, store.ErrCategoryExists):
|
||||
s.redirectCategories(w, r, "", fmt.Sprintf("A category named %q already exists.", name))
|
||||
default:
|
||||
s.redirectCategories(w, r, "", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// handleCategoryRename changes a category's name and updates existing
|
||||
// classifications (FR-14).
|
||||
func (s *Server) handleCategoryRename(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.csrfOK(r) {
|
||||
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.redirectCategories(w, r, "", "Could not read the form.")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
s.redirectCategories(w, r, "", "Unknown category.")
|
||||
return
|
||||
}
|
||||
name := r.FormValue("name")
|
||||
switch err := s.store.RenameCategory(r.Context(), id, name); {
|
||||
case err == nil:
|
||||
s.logger.Info("category renamed", "id", id, "name", name)
|
||||
_ = s.store.LogAction(r.Context(), "category_rename", "Sam",
|
||||
fmt.Sprintf("id=%d name=%s", id, name))
|
||||
s.redirectCategories(w, r, fmt.Sprintf("Renamed category to %q.", name), "")
|
||||
case errors.Is(err, store.ErrCategoryExists):
|
||||
s.redirectCategories(w, r, "", fmt.Sprintf("A category named %q already exists.", name))
|
||||
default:
|
||||
s.redirectCategories(w, r, "", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// handleCategoryRemove deletes a category and moves its messages to the first
|
||||
// remaining category (FR-14).
|
||||
func (s *Server) handleCategoryRemove(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.csrfOK(r) {
|
||||
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.redirectCategories(w, r, "", "Could not read the form.")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
s.redirectCategories(w, r, "", "Unknown category.")
|
||||
return
|
||||
}
|
||||
switch err := s.store.RemoveCategory(r.Context(), id); {
|
||||
case err == nil:
|
||||
s.logger.Info("category removed", "id", id)
|
||||
_ = s.store.LogAction(r.Context(), "category_remove", "Sam", fmt.Sprintf("id=%d", id))
|
||||
s.redirectCategories(w, r, "Removed category; its messages moved to the first remaining category.", "")
|
||||
case errors.Is(err, store.ErrLastCategory):
|
||||
s.redirectCategories(w, r, "", "At least one category is required.")
|
||||
default:
|
||||
s.redirectCategories(w, r, "", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// redirectCategories is POST/redirect/GET so a refresh does not resubmit.
|
||||
func (s *Server) redirectCategories(w http.ResponseWriter, r *http.Request, note, errMsg string) {
|
||||
q := url.Values{}
|
||||
if note != "" {
|
||||
q.Set("note", note)
|
||||
}
|
||||
if errMsg != "" {
|
||||
q.Set("error", errMsg)
|
||||
}
|
||||
http.Redirect(w, r, "/categories?"+q.Encode(), http.StatusSeeOther)
|
||||
}
|
||||
213
internal/server/categories_test.go
Normal file
213
internal/server/categories_test.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/session"
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
|
||||
)
|
||||
|
||||
// login signs in and returns the session cookie plus the CSRF cookie/token.
|
||||
func login(t *testing.T, srv *Server) (*http.Cookie, *http.Cookie, string) {
|
||||
t.Helper()
|
||||
h := srv.Handler()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil))
|
||||
csrf := csrfFromBody(t, rec.Body.String())
|
||||
csrfCookie := findCookie(t, rec.Result().Cookies(), csrfCookieName)
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, loginRequest(csrf, testPassword, csrfCookie))
|
||||
sessionCookie := findCookie(t, rec.Result().Cookies(), session.CookieName)
|
||||
if sessionCookie == nil {
|
||||
t.Fatal("login did not set the session cookie")
|
||||
}
|
||||
return sessionCookie, csrfCookie, csrf
|
||||
}
|
||||
|
||||
func postForm(t *testing.T, srv *Server, path string, session, csrfCookie *http.Cookie, form url.Values) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(session)
|
||||
if csrfCookie != nil {
|
||||
req.AddCookie(csrfCookie)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func getWithSession(t *testing.T, srv *Server, path string, session *http.Cookie) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.AddCookie(session)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestCategoriesPageRenders(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
session, _, _ := login(t, srv)
|
||||
|
||||
rec := getWithSession(t, srv, "/categories", session)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"Categories", "Action", "Spam", "Add category", "Back to inbox"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("categories page missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCategoryAddRenameRemove(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
session, csrfCookie, csrf := login(t, srv)
|
||||
|
||||
// Add (FR-14).
|
||||
rec := postForm(t, srv, "/categories/add", session, csrfCookie, url.Values{
|
||||
"csrf": {csrf}, "name": {"Finance"}, "description": {"Bills and payments"},
|
||||
})
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("add status = %d, want 303 (body %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := getWithSession(t, srv, "/categories", session); !strings.Contains(rec.Body.String(), "Finance") {
|
||||
t.Fatal("added category is not rendered")
|
||||
}
|
||||
|
||||
id := categoryID(t, srv, "Finance")
|
||||
|
||||
// Rename (FR-14).
|
||||
rec = postForm(t, srv, "/categories/rename", session, csrfCookie, url.Values{
|
||||
"csrf": {csrf}, "id": {itoa(id)}, "name": {"Money"},
|
||||
})
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("rename status = %d, want 303", rec.Code)
|
||||
}
|
||||
body := getWithSession(t, srv, "/categories", session).Body.String()
|
||||
if !strings.Contains(body, "Money") || strings.Contains(body, ">Finance<") {
|
||||
t.Fatalf("rename not reflected; body contains Finance=%v Money=%v",
|
||||
strings.Contains(body, "Finance"), strings.Contains(body, "Money"))
|
||||
}
|
||||
|
||||
// Remove (FR-14).
|
||||
id = categoryID(t, srv, "Money")
|
||||
rec = postForm(t, srv, "/categories/remove", session, csrfCookie, url.Values{
|
||||
"csrf": {csrf}, "id": {itoa(id)},
|
||||
})
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("remove status = %d, want 303", rec.Code)
|
||||
}
|
||||
if rec := getWithSession(t, srv, "/categories", session); strings.Contains(rec.Body.String(), "Money") {
|
||||
t.Fatal("removed category is still rendered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCategoryMutationsRequireCSRF(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
session, _, _ := login(t, srv)
|
||||
|
||||
rec := postForm(t, srv, "/categories/add", session, nil, url.Values{"name": {"Nope"}})
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("add without CSRF status = %d, want 403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyAnonymousRedirects(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
for _, path := range []string{"/actions/classify", "/actions/reclassify"} {
|
||||
req := httptest.NewRequest(http.MethodPost, path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Errorf("%s status = %d, want 303 (redirect to login)", path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyWithoutJEVKeyReportsNotConfigured(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
session, csrfCookie, csrf := login(t, srv)
|
||||
|
||||
rec := postForm(t, srv, "/actions/classify", session, csrfCookie, url.Values{"csrf": {csrf}})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "JEV_API_KEY") {
|
||||
t.Errorf("expected a not-configured note; body: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLinksToCategoriesAndShowsClassify(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
session, _, _ := login(t, srv)
|
||||
|
||||
rec := getWithSession(t, srv, "/", session)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{`href="/categories"`, "Classify", "Re-classify all", "Action", "Auto-reply"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("dashboard missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboxShowsClassificationConfidence(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
ctx := context.Background()
|
||||
|
||||
msgID, err := srv.store.UpsertMessage(ctx, &store.Message{
|
||||
GmailID: "cls-1", FromAddr: "a@b.com", Subject: "Classified mail",
|
||||
SourceAddress: "hello@where-woof.com", DateSent: time.Now().UTC(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed message: %v", err)
|
||||
}
|
||||
if _, err := srv.store.SaveClassification(ctx, store.Classification{
|
||||
MessageID: msgID, Category: "Action", Importance: "high", Action: "reply",
|
||||
Confidence: 0.82, Model: "jev-1.13.0",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed classification: %v", err)
|
||||
}
|
||||
|
||||
session, _, _ := login(t, srv)
|
||||
rec := getWithSession(t, srv, "/partials/inbox", session)
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"Classified mail", "Action", "reply", "82%"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("inbox partial missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func categoryID(t *testing.T, srv *Server, name string) int64 {
|
||||
t.Helper()
|
||||
cats, err := srv.store.ListCategories(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListCategories() error = %v", err)
|
||||
}
|
||||
for _, c := range cats {
|
||||
if c.Name == name {
|
||||
return c.ID
|
||||
}
|
||||
}
|
||||
t.Fatalf("category %q not found", name)
|
||||
return 0
|
||||
}
|
||||
|
||||
func itoa(v int64) string {
|
||||
return strconv.FormatInt(v, 10)
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
// Inbox rendering and the manual mail scan.
|
||||
// Inbox rendering, the manual mail scan, and the classify action.
|
||||
//
|
||||
// Phase 2 lists real mail from the database. Classification is Phase 3, so
|
||||
// messages without a classification are shown under "Unclassified" with a
|
||||
// temporary "Review" recommended action; the grouping and action plumbing is
|
||||
// already category-aware.
|
||||
// Phase 3 groups messages by the live category list (FR-5) and shows the JEV
|
||||
// recommended action plus confidence on each row. Messages without a
|
||||
// classification fall under "Unclassified".
|
||||
package server
|
||||
|
||||
import (
|
||||
@@ -15,26 +14,13 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/classify"
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/mailfetch"
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
|
||||
)
|
||||
|
||||
// unclassifiedCategory is the fallback bucket before Phase 3 adds JEV
|
||||
// categories (AGENTS.md §13.2).
|
||||
const unclassifiedCategory = "unclassified"
|
||||
|
||||
// categoryOrder is the display order for the built-in categories.
|
||||
var categoryOrder = []string{"action", "important", "info", "spam", "newsletter", "auto-reply"}
|
||||
|
||||
var categoryDisplay = map[string]string{
|
||||
"action": "Action",
|
||||
"important": "Important",
|
||||
"info": "Info",
|
||||
"spam": "Spam",
|
||||
"newsletter": "Newsletter",
|
||||
"auto-reply": "Auto-reply",
|
||||
"unclassified": "Unclassified",
|
||||
}
|
||||
// unclassifiedCategory is the bucket for messages JEV has not classified yet.
|
||||
const unclassifiedCategory = "Unclassified"
|
||||
|
||||
type messageRow struct {
|
||||
ID int64
|
||||
@@ -42,6 +28,8 @@ type messageRow struct {
|
||||
Subject string
|
||||
SourceAddr string
|
||||
Action string
|
||||
Confidence int
|
||||
Classified bool
|
||||
When string
|
||||
}
|
||||
|
||||
@@ -57,21 +45,42 @@ type inboxData struct {
|
||||
Error string
|
||||
}
|
||||
|
||||
// loadInbox reads messages and groups them for rendering.
|
||||
// loadInbox reads messages and groups them by the live category list.
|
||||
func (s *Server) loadInbox(ctx context.Context) (inboxData, error) {
|
||||
items, err := s.store.ListMessages(ctx, 200)
|
||||
if err != nil {
|
||||
return inboxData{}, err
|
||||
}
|
||||
return inboxData{Groups: groupMessages(items), Total: len(items)}, nil
|
||||
categories, err := s.store.ListCategories(ctx)
|
||||
if err != nil {
|
||||
return inboxData{}, err
|
||||
}
|
||||
return inboxData{Groups: groupMessages(items, categories), Total: len(items)}, nil
|
||||
}
|
||||
|
||||
// groupMessages buckets messages by category. Unknown (custom) categories get
|
||||
// their own group; messages with no classification fall into "Unclassified".
|
||||
func groupMessages(items []store.MessageListItem) []messageGroup {
|
||||
// groupMessages buckets messages by category, ordering groups the way the
|
||||
// category list is ordered in the UI. Unknown categories (for example a
|
||||
// classification written before a rename) keep their own group; messages with
|
||||
// no classification fall into "Unclassified" last.
|
||||
func groupMessages(items []store.MessageListItem, categories []store.Category) []messageGroup {
|
||||
canonical := make(map[string]string, len(categories))
|
||||
order := make([]string, 0, len(categories))
|
||||
for _, c := range categories {
|
||||
canonical[strings.ToLower(c.Name)] = c.Name
|
||||
order = append(order, c.Name)
|
||||
}
|
||||
|
||||
buckets := map[string][]messageRow{}
|
||||
for _, it := range items {
|
||||
key := strings.ToLower(strings.TrimSpace(it.Category))
|
||||
raw := strings.TrimSpace(it.Category)
|
||||
key := ""
|
||||
if raw != "" {
|
||||
if name, ok := canonical[strings.ToLower(raw)]; ok {
|
||||
key = name
|
||||
} else {
|
||||
key = raw
|
||||
}
|
||||
}
|
||||
if key == "" {
|
||||
key = unclassifiedCategory
|
||||
}
|
||||
@@ -85,62 +94,47 @@ func groupMessages(items []store.MessageListItem) []messageGroup {
|
||||
Subject: orDefault(it.Subject, "(no subject)"),
|
||||
SourceAddr: it.SourceAddress,
|
||||
Action: action,
|
||||
Confidence: confidencePercent(it.Confidence),
|
||||
Classified: it.HasClassification,
|
||||
When: formatWhen(it.DateSent, it.ReceivedAt),
|
||||
})
|
||||
}
|
||||
|
||||
// Order: built-in categories first, then custom ones alphabetically, then
|
||||
// Unclassified last.
|
||||
var keys []string
|
||||
for _, k := range categoryOrder {
|
||||
if _, ok := buckets[k]; ok {
|
||||
keys = append(keys, k)
|
||||
for _, name := range order {
|
||||
if _, ok := buckets[name]; ok {
|
||||
keys = append(keys, name)
|
||||
}
|
||||
}
|
||||
var custom []string
|
||||
var unknown []string
|
||||
for k := range buckets {
|
||||
if k == unclassifiedCategory || contains(categoryOrder, k) {
|
||||
if k == unclassifiedCategory || contains(order, k) {
|
||||
continue
|
||||
}
|
||||
custom = append(custom, k)
|
||||
unknown = append(unknown, k)
|
||||
}
|
||||
sort.Strings(custom)
|
||||
keys = append(keys, custom...)
|
||||
sort.Strings(unknown)
|
||||
keys = append(keys, unknown...)
|
||||
if _, ok := buckets[unclassifiedCategory]; ok {
|
||||
keys = append(keys, unclassifiedCategory)
|
||||
}
|
||||
|
||||
groups := make([]messageGroup, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
groups = append(groups, messageGroup{
|
||||
Name: displayCategory(k),
|
||||
Messages: buckets[k],
|
||||
})
|
||||
groups = append(groups, messageGroup{Name: k, Messages: buckets[k]})
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
func displayCategory(key string) string {
|
||||
if name, ok := categoryDisplay[key]; ok {
|
||||
return name
|
||||
// confidencePercent turns a 0–1 confidence into a whole percentage.
|
||||
func confidencePercent(v float64) int {
|
||||
if v <= 0 {
|
||||
return 0
|
||||
}
|
||||
if key == "" {
|
||||
return "Unclassified"
|
||||
if v > 1 {
|
||||
v = 1
|
||||
}
|
||||
return strings.ToUpper(key[:1]) + key[1:]
|
||||
}
|
||||
|
||||
// categoryCounts returns per-category message counts for the left nav.
|
||||
func categoryCounts(items []store.MessageListItem) map[string]int {
|
||||
counts := map[string]int{}
|
||||
for _, it := range items {
|
||||
key := strings.ToLower(strings.TrimSpace(it.Category))
|
||||
if key == "" {
|
||||
key = unclassifiedCategory
|
||||
}
|
||||
counts[key]++
|
||||
}
|
||||
return counts
|
||||
return int(v*100 + 0.5)
|
||||
}
|
||||
|
||||
func (s *Server) renderInbox(w http.ResponseWriter, r *http.Request, note string) {
|
||||
@@ -187,6 +181,53 @@ func (s *Server) handleFetchMail(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// handleClassify classifies pending messages (FR-3) and returns the refreshed
|
||||
// list. A run can take minutes, so the write deadline is extended for this
|
||||
// handler only.
|
||||
func (s *Server) handleClassify(w http.ResponseWriter, r *http.Request) {
|
||||
s.runClassifier(w, r, false)
|
||||
}
|
||||
|
||||
// handleReclassify re-runs the classifier over every stored message. It is how
|
||||
// a category-list or rules edit is applied to mail that was already classified.
|
||||
func (s *Server) handleReclassify(w http.ResponseWriter, r *http.Request) {
|
||||
s.runClassifier(w, r, true)
|
||||
}
|
||||
|
||||
func (s *Server) runClassifier(w http.ResponseWriter, r *http.Request, all bool) {
|
||||
if !s.csrfOK(r) {
|
||||
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if rc := http.NewResponseController(w); rc != nil {
|
||||
_ = rc.SetWriteDeadline(time.Now().Add(15 * time.Minute))
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
var (
|
||||
sum classify.Summary
|
||||
err error
|
||||
)
|
||||
if all {
|
||||
sum, err = s.classifier.RunAll(ctx)
|
||||
} else {
|
||||
sum, err = s.classifier.Run(ctx)
|
||||
}
|
||||
switch {
|
||||
case err == nil:
|
||||
s.renderInbox(w, r, fmt.Sprintf(
|
||||
"Classified %d of %d message(s); %d failed; %d still unclassified.",
|
||||
sum.Classified, sum.Considered, sum.Failed, sum.Remaining))
|
||||
case errors.Is(err, classify.ErrNotConfigured):
|
||||
s.renderInbox(w, r, "Classification is not connected yet: set JEV_API_KEY.")
|
||||
default:
|
||||
s.logger.Error("classification run failed", "error", err)
|
||||
s.renderInbox(w, r, "Classification failed: "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func orDefault(v, def string) string {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return def
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/classify"
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/config"
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/mailfetch"
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/session"
|
||||
@@ -32,13 +33,14 @@ func init() {
|
||||
|
||||
// Server holds the HTTP dependencies.
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
store *store.Store
|
||||
logger *slog.Logger
|
||||
http *http.Server
|
||||
tmpl *template.Template
|
||||
sessions *session.Manager
|
||||
fetcher *mailfetch.Fetcher
|
||||
cfg *config.Config
|
||||
store *store.Store
|
||||
logger *slog.Logger
|
||||
http *http.Server
|
||||
tmpl *template.Template
|
||||
sessions *session.Manager
|
||||
fetcher *mailfetch.Fetcher
|
||||
classifier *classify.Classifier
|
||||
}
|
||||
|
||||
// New builds a Server. It does not start listening.
|
||||
@@ -64,6 +66,10 @@ func New(cfg *config.Config, st *store.Store, logger *slog.Logger) (*Server, err
|
||||
tmpl: tmpl,
|
||||
sessions: sessions,
|
||||
fetcher: mailfetch.NewFetcher(cfg, st, logger),
|
||||
classifier: classify.NewClassifier(
|
||||
classify.NewClient(cfg.JEVBaseURL, cfg.JEVAPIKey, cfg.JEVModel, logger),
|
||||
st, logger, cfg.ClassifyConcurrency, cfg.ClassifyMaxMessages,
|
||||
),
|
||||
}
|
||||
s.http = &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
@@ -113,6 +119,14 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /{$}", s.requireAuth(s.handleDashboard))
|
||||
mux.HandleFunc("GET /partials/inbox", s.requireAuth(s.handleInboxPartial))
|
||||
mux.HandleFunc("POST /actions/fetch", s.requireAuth(s.handleFetchMail))
|
||||
mux.HandleFunc("POST /actions/classify", s.requireAuth(s.handleClassify))
|
||||
mux.HandleFunc("POST /actions/reclassify", s.requireAuth(s.handleReclassify))
|
||||
|
||||
// FR-14: editable category list.
|
||||
mux.HandleFunc("GET /categories", s.requireAuth(s.handleCategories))
|
||||
mux.HandleFunc("POST /categories/add", s.requireAuth(s.handleCategoryAdd))
|
||||
mux.HandleFunc("POST /categories/rename", s.requireAuth(s.handleCategoryRename))
|
||||
mux.HandleFunc("POST /categories/remove", s.requireAuth(s.handleCategoryRemove))
|
||||
|
||||
return s.recoverPanic(s.logRequests(mux))
|
||||
}
|
||||
@@ -234,6 +248,10 @@ type statusRecorder struct {
|
||||
status int
|
||||
}
|
||||
|
||||
// Unwrap lets http.ResponseController reach the underlying writer (the classify
|
||||
// handler extends its write deadline for long runs).
|
||||
func (r *statusRecorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
|
||||
|
||||
func (r *statusRecorder) WriteHeader(code int) {
|
||||
r.status = code
|
||||
r.ResponseWriter.WriteHeader(code)
|
||||
|
||||
218
internal/store/categories.go
Normal file
218
internal/store/categories.go
Normal file
@@ -0,0 +1,218 @@
|
||||
// Category management (FR-14) and the classification read/write paths (FR-3).
|
||||
//
|
||||
// `categories.name` is the stable key: it is stored in `classifications.category`
|
||||
// and sent to JEV as the Choice option name. Renaming a category therefore
|
||||
// cascades to existing classifications so the triage list never shows a category
|
||||
// that no longer exists.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrLastCategory is returned when removing the final category would leave the
|
||||
// classifier with nothing to choose from.
|
||||
var ErrLastCategory = errors.New("at least one category is required")
|
||||
|
||||
// ErrCategoryExists is returned when adding or renaming to a name already taken.
|
||||
var ErrCategoryExists = errors.New("a category with that name already exists")
|
||||
|
||||
// Category is one editable triage category.
|
||||
type Category struct {
|
||||
ID int64
|
||||
Name string
|
||||
Description string
|
||||
Position int
|
||||
// MessageCount is the number of messages whose latest classification is
|
||||
// this category. It is filled by ListCategories.
|
||||
MessageCount int
|
||||
}
|
||||
|
||||
// ListCategories returns the categories in display order, each with the count of
|
||||
// messages currently classified into it.
|
||||
func (s *Store) ListCategories(ctx context.Context) ([]Category, error) {
|
||||
const q = `
|
||||
SELECT c.id, c.name, c.description, c.position,
|
||||
(SELECT COUNT(*) FROM messages m
|
||||
WHERE (SELECT cc.category FROM classifications cc
|
||||
WHERE cc.message_id = m.id ORDER BY cc.id DESC LIMIT 1) = c.name)
|
||||
FROM categories c
|
||||
ORDER BY c.position, c.name COLLATE NOCASE`
|
||||
rows, err := s.db.QueryContext(ctx, q)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list categories: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Category
|
||||
for rows.Next() {
|
||||
var c Category
|
||||
if err := rows.Scan(&c.ID, &c.Name, &c.Description, &c.Position, &c.MessageCount); err != nil {
|
||||
return nil, fmt.Errorf("scan category: %w", err)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list categories: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CategoryNames returns just the names, in display order.
|
||||
func (s *Store) CategoryNames(ctx context.Context) ([]string, error) {
|
||||
cats, err := s.ListCategories(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(cats))
|
||||
for _, c := range cats {
|
||||
names = append(names, c.Name)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// AddCategory appends a category. The name is trimmed, length-capped, and must
|
||||
// be unique (case-insensitively).
|
||||
func (s *Store) AddCategory(ctx context.Context, name, description string) (Category, error) {
|
||||
name, err := cleanCategoryName(name)
|
||||
if err != nil {
|
||||
return Category{}, err
|
||||
}
|
||||
description = strings.TrimSpace(description)
|
||||
|
||||
var exists int
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM categories WHERE name = ? COLLATE NOCASE`, name).Scan(&exists); err != nil {
|
||||
return Category{}, fmt.Errorf("check category name: %w", err)
|
||||
}
|
||||
if exists > 0 {
|
||||
return Category{}, ErrCategoryExists
|
||||
}
|
||||
|
||||
var position int
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(MAX(position), 0) + 1 FROM categories`).Scan(&position); err != nil {
|
||||
return Category{}, fmt.Errorf("next category position: %w", err)
|
||||
}
|
||||
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO categories (name, description, position) VALUES (?, ?, ?)`,
|
||||
name, description, position)
|
||||
if err != nil {
|
||||
return Category{}, fmt.Errorf("insert category %q: %w", name, err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return Category{}, fmt.Errorf("read new category id: %w", err)
|
||||
}
|
||||
return Category{ID: id, Name: name, Description: description, Position: position}, nil
|
||||
}
|
||||
|
||||
// RenameCategory changes the display name and rewrites existing classifications
|
||||
// so the new name is used everywhere (FR-14).
|
||||
func (s *Store) RenameCategory(ctx context.Context, id int64, newName string) error {
|
||||
old, err := s.categoryByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := cleanCategoryName(newName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.EqualFold(name, old) {
|
||||
return nil
|
||||
}
|
||||
|
||||
var exists int
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM categories WHERE name = ? COLLATE NOCASE AND id <> ?`, name, id).Scan(&exists); err != nil {
|
||||
return fmt.Errorf("check category name: %w", err)
|
||||
}
|
||||
if exists > 0 {
|
||||
return ErrCategoryExists
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin rename: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE categories SET name = ? WHERE id = ?`, name, id); err != nil {
|
||||
return fmt.Errorf("rename category: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE classifications SET category = ? WHERE category = ? COLLATE NOCASE`, name, old); err != nil {
|
||||
return fmt.Errorf("update classifications after rename: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit rename: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveCategory deletes a category and moves any messages still classified into
|
||||
// it to the first remaining category, so no classification points at a category
|
||||
// that no longer exists.
|
||||
func (s *Store) RemoveCategory(ctx context.Context, id int64) error {
|
||||
name, err := s.categoryByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM categories`).Scan(&count); err != nil {
|
||||
return fmt.Errorf("count categories: %w", err)
|
||||
}
|
||||
if count <= 1 {
|
||||
return ErrLastCategory
|
||||
}
|
||||
|
||||
var fallback string
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT name FROM categories WHERE id <> ? ORDER BY position, name COLLATE NOCASE LIMIT 1`, id).Scan(&fallback); err != nil {
|
||||
return fmt.Errorf("select fallback category: %w", err)
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin remove: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE classifications SET category = ? WHERE category = ? COLLATE NOCASE`, fallback, name); err != nil {
|
||||
return fmt.Errorf("reassign classifications: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM categories WHERE id = ?`, id); err != nil {
|
||||
return fmt.Errorf("delete category: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit remove: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) categoryByID(ctx context.Context, id int64) (string, error) {
|
||||
var name string
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT name FROM categories WHERE id = ?`, id).Scan(&name); err != nil {
|
||||
return "", fmt.Errorf("read category %d: %w", id, err)
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// cleanCategoryName trims, caps at 40 characters, and rejects an empty name.
|
||||
func cleanCategoryName(name string) (string, error) {
|
||||
name = strings.Join(strings.Fields(strings.TrimSpace(name)), " ")
|
||||
if name == "" {
|
||||
return "", errors.New("category name must not be empty")
|
||||
}
|
||||
if len([]rune(name)) > 40 {
|
||||
return "", errors.New("category name must be 40 characters or fewer")
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
245
internal/store/categories_test.go
Normal file
245
internal/store/categories_test.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newCategoryStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, err := Open(context.Background(), filepath.Join(t.TempDir(), "email_agent.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func TestDefaultCategoriesSeeded(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newCategoryStore(t)
|
||||
|
||||
cats, err := s.ListCategories(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCategories() error = %v", err)
|
||||
}
|
||||
want := []string{"Action", "Important", "Info", "Spam", "Newsletter", "Auto-reply"}
|
||||
if len(cats) != len(want) {
|
||||
t.Fatalf("categories = %d, want %d: %+v", len(cats), len(want), cats)
|
||||
}
|
||||
for i, name := range want {
|
||||
if cats[i].Name != name {
|
||||
t.Errorf("category %d = %q, want %q", i, cats[i].Name, name)
|
||||
}
|
||||
if cats[i].Description == "" {
|
||||
t.Errorf("category %q has no description (JEV needs the rubric)", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedCategoriesDoesNotReaddRemoved(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "email_agent.db")
|
||||
|
||||
s1, err := Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatalf("Open() error = %v", err)
|
||||
}
|
||||
cats, err := s1.ListCategories(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCategories() error = %v", err)
|
||||
}
|
||||
var spamID int64
|
||||
for _, c := range cats {
|
||||
if c.Name == "Spam" {
|
||||
spamID = c.ID
|
||||
}
|
||||
}
|
||||
if err := s1.RemoveCategory(ctx, spamID); err != nil {
|
||||
t.Fatalf("RemoveCategory() error = %v", err)
|
||||
}
|
||||
_ = s1.Close()
|
||||
|
||||
s2, err := Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatalf("second Open() error = %v", err)
|
||||
}
|
||||
defer s2.Close()
|
||||
cats, err = s2.ListCategories(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCategories() after restart error = %v", err)
|
||||
}
|
||||
for _, c := range cats {
|
||||
if c.Name == "Spam" {
|
||||
t.Error("removed category came back after restart")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddCategoryRejectsDuplicate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newCategoryStore(t)
|
||||
|
||||
if _, err := s.AddCategory(ctx, "Finance", "Bills and payments"); err != nil {
|
||||
t.Fatalf("AddCategory() error = %v", err)
|
||||
}
|
||||
if _, err := s.AddCategory(ctx, "finance", ""); !errors.Is(err, ErrCategoryExists) {
|
||||
t.Fatalf("duplicate AddCategory() error = %v, want ErrCategoryExists", err)
|
||||
}
|
||||
if _, err := s.AddCategory(ctx, " ", ""); err == nil {
|
||||
t.Fatal("blank AddCategory() error = nil, want an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameCategoryCascadesToClassifications(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newCategoryStore(t)
|
||||
|
||||
msgID, err := s.UpsertMessage(ctx, &Message{
|
||||
GmailID: "1", Subject: "hi", FromAddr: "a@b.com", DateSent: time.Now().UTC(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertMessage() error = %v", err)
|
||||
}
|
||||
if _, err := s.SaveClassification(ctx, Classification{
|
||||
MessageID: msgID, Category: "Info", Importance: "low", Action: "keep",
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveClassification() error = %v", err)
|
||||
}
|
||||
|
||||
cats, err := s.ListCategories(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCategories() error = %v", err)
|
||||
}
|
||||
var infoID int64
|
||||
for _, c := range cats {
|
||||
if c.Name == "Info" {
|
||||
infoID = c.ID
|
||||
}
|
||||
}
|
||||
if err := s.RenameCategory(ctx, infoID, "Updates"); err != nil {
|
||||
t.Fatalf("RenameCategory() error = %v", err)
|
||||
}
|
||||
|
||||
items, err := s.ListMessages(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListMessages() error = %v", err)
|
||||
}
|
||||
if items[0].Category != "Updates" {
|
||||
t.Errorf("classification after rename = %q, want Updates", items[0].Category)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveCategoryReassignsMessages(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newCategoryStore(t)
|
||||
|
||||
msgID, err := s.UpsertMessage(ctx, &Message{GmailID: "1", Subject: "hi"})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertMessage() error = %v", err)
|
||||
}
|
||||
if _, err := s.SaveClassification(ctx, Classification{MessageID: msgID, Category: "Spam"}); err != nil {
|
||||
t.Fatalf("SaveClassification() error = %v", err)
|
||||
}
|
||||
|
||||
cats, err := s.ListCategories(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCategories() error = %v", err)
|
||||
}
|
||||
var spamID int64
|
||||
for _, c := range cats {
|
||||
if c.Name == "Spam" {
|
||||
spamID = c.ID
|
||||
}
|
||||
}
|
||||
if err := s.RemoveCategory(ctx, spamID); err != nil {
|
||||
t.Fatalf("RemoveCategory() error = %v", err)
|
||||
}
|
||||
|
||||
items, err := s.ListMessages(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListMessages() error = %v", err)
|
||||
}
|
||||
if items[0].Category != "Action" {
|
||||
t.Errorf("reassigned category = %q, want the first remaining category Action", items[0].Category)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveLastCategoryRefused(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newCategoryStore(t)
|
||||
|
||||
for {
|
||||
cats, err := s.ListCategories(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCategories() error = %v", err)
|
||||
}
|
||||
if len(cats) == 1 {
|
||||
if err := s.RemoveCategory(ctx, cats[0].ID); !errors.Is(err, ErrLastCategory) {
|
||||
t.Fatalf("RemoveCategory(last) error = %v, want ErrLastCategory", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := s.RemoveCategory(ctx, cats[0].ID); err != nil {
|
||||
t.Fatalf("RemoveCategory() error = %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCategoryCountsReflectClassifications(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newCategoryStore(t)
|
||||
|
||||
msgID, err := s.UpsertMessage(ctx, &Message{GmailID: "1", Subject: "hi"})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertMessage() error = %v", err)
|
||||
}
|
||||
if _, err := s.SaveClassification(ctx, Classification{MessageID: msgID, Category: "Action"}); err != nil {
|
||||
t.Fatalf("SaveClassification() error = %v", err)
|
||||
}
|
||||
|
||||
cats, err := s.ListCategories(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCategories() error = %v", err)
|
||||
}
|
||||
for _, c := range cats {
|
||||
want := 0
|
||||
if c.Name == "Action" {
|
||||
want = 1
|
||||
}
|
||||
if c.MessageCount != want {
|
||||
t.Errorf("category %q count = %d, want %d", c.Name, c.MessageCount, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRulesSeedAndVersioning(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newCategoryStore(t)
|
||||
|
||||
text, version, err := s.CurrentRules(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CurrentRules() error = %v", err)
|
||||
}
|
||||
if version != 1 || text == "" {
|
||||
t.Fatalf("seeded rules = (version %d, %d bytes), want version 1 with text", version, len(text))
|
||||
}
|
||||
|
||||
next, err := s.SaveRules(ctx, `from contains "x" => category=Info`)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveRules() error = %v", err)
|
||||
}
|
||||
if next != 2 {
|
||||
t.Errorf("SaveRules() version = %d, want 2", next)
|
||||
}
|
||||
text, version, err = s.CurrentRules(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CurrentRules() error = %v", err)
|
||||
}
|
||||
if version != 2 || text != `from contains "x" => category=Info` {
|
||||
t.Errorf("CurrentRules() = (%d, %q), want version 2 with the new text", version, text)
|
||||
}
|
||||
}
|
||||
161
internal/store/classify.go
Normal file
161
internal/store/classify.go
Normal file
@@ -0,0 +1,161 @@
|
||||
// Classification persistence and the rules read/write paths (FR-3, FR-4, FR-11).
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Classification is one classifier output for a message.
|
||||
type Classification struct {
|
||||
ID int64
|
||||
MessageID int64
|
||||
Category string
|
||||
Importance string
|
||||
Action string
|
||||
Confidence float64
|
||||
Model string
|
||||
}
|
||||
|
||||
// SaveClassification appends a classification row. The triage list reads the
|
||||
// newest row per message, so re-classifying keeps a history.
|
||||
func (s *Store) SaveClassification(ctx context.Context, c Classification) (int64, error) {
|
||||
const q = `INSERT INTO classifications
|
||||
(message_id, category, importance, action, confidence, model)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
res, err := s.db.ExecContext(ctx, q,
|
||||
c.MessageID, c.Category, nullString(c.Importance), nullString(c.Action),
|
||||
c.Confidence, nullString(c.Model))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("save classification for message %d: %w", c.MessageID, err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read classification id: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// PendingClassifications returns messages whose latest classification is
|
||||
// missing or points at a category that no longer exists. Oldest first so a
|
||||
// bounded run makes steady progress.
|
||||
func (s *Store) PendingClassifications(ctx context.Context, limit int) ([]Message, error) {
|
||||
return s.messagesForClassification(ctx, limit, false)
|
||||
}
|
||||
|
||||
// AllMessagesForClassification returns every message, for a full re-classify
|
||||
// after the category list or the rules change.
|
||||
func (s *Store) AllMessagesForClassification(ctx context.Context, limit int) ([]Message, error) {
|
||||
return s.messagesForClassification(ctx, limit, true)
|
||||
}
|
||||
|
||||
func (s *Store) messagesForClassification(ctx context.Context, limit int, all bool) ([]Message, error) {
|
||||
if limit < 1 {
|
||||
limit = 100
|
||||
}
|
||||
filter := "WHERE c.id IS NULL OR c.category NOT IN (SELECT name FROM categories)"
|
||||
if all {
|
||||
filter = ""
|
||||
}
|
||||
q := `
|
||||
SELECT m.id, m.gmail_id, COALESCE(m.thread_id, ''), COALESCE(m.from_addr, ''),
|
||||
COALESCE(m.to_addr, ''), COALESCE(m.subject, ''), COALESCE(m.body_text, ''),
|
||||
COALESCE(m.source_address, ''), COALESCE(m.date_sent, ''), m.received_at,
|
||||
COALESCE(m.imap_uid, 0), COALESCE(m.imap_uidvalidity, 0)
|
||||
FROM messages m
|
||||
LEFT JOIN classifications c ON c.id = (
|
||||
SELECT id FROM classifications WHERE message_id = m.id ORDER BY id DESC LIMIT 1
|
||||
)
|
||||
` + filter + `
|
||||
ORDER BY m.id ASC
|
||||
LIMIT ?`
|
||||
rows, err := s.db.QueryContext(ctx, q, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list messages for classification: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Message
|
||||
for rows.Next() {
|
||||
var (
|
||||
m Message
|
||||
dateSent string
|
||||
received string
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&m.ID, &m.GmailID, &m.ThreadID, &m.FromAddr, &m.ToAddr, &m.Subject,
|
||||
&m.BodyText, &m.SourceAddress, &dateSent, &received,
|
||||
&m.IMAPUID, &m.IMAPUIDValidity,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan pending message: %w", err)
|
||||
}
|
||||
m.DateSent = parseSQLTime(dateSent)
|
||||
m.ReceivedAt = parseSQLTime(received)
|
||||
out = append(out, m)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list messages for classification: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ClassificationCoverage reports how many messages have a usable classification.
|
||||
type ClassificationCoverage struct {
|
||||
Total int
|
||||
Classified int
|
||||
}
|
||||
|
||||
// Missing reports how many messages still need a classification.
|
||||
func (c ClassificationCoverage) Missing() int { return c.Total - c.Classified }
|
||||
|
||||
// ClassificationCoverage returns the total message count and how many have a
|
||||
// classification whose category still exists.
|
||||
func (s *Store) ClassificationCoverage(ctx context.Context) (ClassificationCoverage, error) {
|
||||
var cov ClassificationCoverage
|
||||
const q = `
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM messages),
|
||||
(SELECT COUNT(*) FROM messages m
|
||||
WHERE (SELECT cc.category FROM classifications cc
|
||||
WHERE cc.message_id = m.id ORDER BY cc.id DESC LIMIT 1)
|
||||
IN (SELECT name FROM categories))`
|
||||
if err := s.db.QueryRowContext(ctx, q).Scan(&cov.Total, &cov.Classified); err != nil {
|
||||
return cov, fmt.Errorf("classification coverage: %w", err)
|
||||
}
|
||||
return cov, nil
|
||||
}
|
||||
|
||||
// CurrentRules returns the newest rules text and its version. An empty string
|
||||
// and version 0 mean no rules are stored yet.
|
||||
func (s *Store) CurrentRules(ctx context.Context) (string, int, error) {
|
||||
var (
|
||||
text string
|
||||
version int
|
||||
)
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT text, version FROM rules ORDER BY version DESC LIMIT 1`).Scan(&text, &version)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("read rules: %w", err)
|
||||
}
|
||||
return text, version, nil
|
||||
}
|
||||
|
||||
// SaveRules appends a new rules version and returns the new version number
|
||||
// (FR-11 versioning). The rules editor UI lands in Phase 6.
|
||||
func (s *Store) SaveRules(ctx context.Context, text string) (int, error) {
|
||||
var next int
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(MAX(version), 0) + 1 FROM rules`).Scan(&next); err != nil {
|
||||
return 0, fmt.Errorf("next rules version: %w", err)
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO rules (text, version) VALUES (?, ?)`, text, next); err != nil {
|
||||
return 0, fmt.Errorf("save rules version %d: %w", next, err)
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
@@ -159,6 +159,15 @@ func (s *Store) LogAction(ctx context.Context, action, actor, detail string) err
|
||||
return nil
|
||||
}
|
||||
|
||||
// LogMessageAction appends a per-message action to the actions_log (FR-13).
|
||||
func (s *Store) LogMessageAction(ctx context.Context, messageID int64, action, actor, detail string) error {
|
||||
const q = `INSERT INTO actions_log (message_id, action, actor, detail) VALUES (?, ?, ?, ?)`
|
||||
if _, err := s.db.ExecContext(ctx, q, messageID, action, nullString(actor), nullString(detail)); err != nil {
|
||||
return fmt.Errorf("log message action %q: %w", action, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const sqlTimeLayout = "2006-01-02 15:04:05"
|
||||
|
||||
func nullString(v string) any {
|
||||
|
||||
@@ -36,6 +36,19 @@ CREATE INDEX IF NOT EXISTS idx_messages_received_at ON messages (received_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON messages (thread_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_source ON messages (source_address);
|
||||
|
||||
-- FR-14: the editable category list. `name` is the stable key stored in
|
||||
-- classifications.category and sent to JEV as the Choice option name. Renaming a
|
||||
-- category cascades to existing classifications (see store.RenameCategory).
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL COLLATE NOCASE UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_position ON categories (position, name);
|
||||
|
||||
-- FR-3, FR-14: classifier output per message.
|
||||
CREATE TABLE IF NOT EXISTS classifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
@@ -108,6 +108,89 @@ func (s *Store) applyMigrations(ctx context.Context) error {
|
||||
return fmt.Errorf("record schema version 2: %w", err)
|
||||
}
|
||||
}
|
||||
if version < 3 {
|
||||
// FR-14: seed the default category list and a starter (comment-only)
|
||||
// rules document. Seeding happens only inside this migration so that a
|
||||
// category the user removes is not silently re-added on the next start.
|
||||
if err := s.seedCategories(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.seedRules(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`INSERT OR IGNORE INTO schema_migrations (version) VALUES (3)`); err != nil {
|
||||
return fmt.Errorf("record schema version 3: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// defaultCategories are the starter categories from AGENTS.md §13.2. The
|
||||
// descriptions are shown in the UI and sent to JEV as the Choice rubrics.
|
||||
var defaultCategories = []struct{ name, description string }{
|
||||
{"Action", "Needs Sam to do something"},
|
||||
{"Important", "Worth Sam's attention, but no specific task"},
|
||||
{"Info", "Informational, no action required"},
|
||||
{"Spam", "Unwanted, deceptive, or scam mail"},
|
||||
{"Newsletter", "Bulk mail, digests, and marketing"},
|
||||
{"Auto-reply", "Automated notifications and receipts"},
|
||||
}
|
||||
|
||||
// seedCategories inserts the built-in categories when the table is empty.
|
||||
func (s *Store) seedCategories(ctx context.Context) error {
|
||||
var n int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM categories`).Scan(&n); err != nil {
|
||||
return fmt.Errorf("count categories: %w", err)
|
||||
}
|
||||
if n > 0 {
|
||||
return nil
|
||||
}
|
||||
for i, c := range defaultCategories {
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO categories (name, description, position) VALUES (?, ?, ?)`,
|
||||
c.name, c.description, i+1); err != nil {
|
||||
return fmt.Errorf("seed category %q: %w", c.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// starterRules documents the rule syntax (FR-4/FR-11). The editor that writes
|
||||
// real rules lands in Phase 6; the classifier already reads whatever is stored.
|
||||
const starterRules = `# Email Agent rules (FR-4). One rule per line.
|
||||
#
|
||||
# <condition> => <effects>
|
||||
#
|
||||
# condition: <field> <operator> "<value>", joined with " and ".
|
||||
# fields: from, to, subject, source, body
|
||||
# operators: contains, equals, starts_with, ends_with
|
||||
# a single "*" matches every message (use it as the last line for a default).
|
||||
#
|
||||
# effects: comma-separated category=<name>, importance=<low|medium|high>,
|
||||
# action=<delete|unsubscribe|reply|file|keep>
|
||||
#
|
||||
# The first matching rule wins and overrides the JEV classification for the
|
||||
# fields it names. Category names must exist in the category list.
|
||||
#
|
||||
# Examples (remove the leading # to enable):
|
||||
# from contains "amazon.com" => category=Newsletter, action=unsubscribe
|
||||
# subject contains "invoice" => category=Action, importance=high, action=file
|
||||
`
|
||||
|
||||
// seedRules stores the starter rules document when no rules exist yet.
|
||||
func (s *Store) seedRules(ctx context.Context) error {
|
||||
var n int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM rules`).Scan(&n); err != nil {
|
||||
return fmt.Errorf("count rules: %w", err)
|
||||
}
|
||||
if n > 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO rules (text, version) VALUES (?, ?)`, starterRules, 1); err != nil {
|
||||
return fmt.Errorf("seed rules: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user