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>
155 lines
5.0 KiB
Go
155 lines
5.0 KiB
Go
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)
|
|
}
|
|
}
|