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:
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user