Phase 3: JEV classification, rules table, editable categories (AUD-69)

Implements FR-3, FR-4, FR-5 and FR-14.

- internal/classify: TypeSafe System One (JEV) HTTP client with the confirmed
  request/response shape, retry with backoff on 429/529/5xx, a plain-text rule
  engine (first match wins, overrides only the named fields), and bounded
  orchestration with a per-run cap.
- internal/store: migration v3 adds an editable `categories` table (name =
  JEV Choice option and classifications key; rename cascades, remove reassigns)
  and seeds a starter rules version once. Classification persistence, coverage,
  and pending/all message queries.
- internal/server: POST /actions/classify and /actions/reclassify, plus the
  FR-14 category editor (GET /categories and add/rename/remove). The triage list
  now groups by the live category list and shows action + confidence.
- CLI: -classify and -classify-all for n8n.
- Docs: docs/phase3-classify.md with the confirmed JEV shape, a live read-only
  classification, and the DoD evidence.

DoD: all 100 stored messages have a non-null category, importance, and suggested
action (0 nulls); JEV picked a newly added/renamed category for 6 real messages
over HTTP. One live JEV call recorded in the phase doc.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-11 21:37:38 +11:00
parent bf43b6bbef
commit af3b5e5f0e
27 changed files with 3235 additions and 102 deletions

306
internal/classify/jev.go Normal file
View File

@@ -0,0 +1,306 @@
// Package classify turns stored messages into a category, an importance, a
// suggested action, and a confidence using the TypeSafe (JEV) HTTP API, then
// applies the user's rules (FR-3, FR-4).
//
// The JEV request/response shape is confirmed against the live docs
// (https://docs.typesafe.ai/api.md) and recorded in docs/phase3-classify.md:
//
// POST https://api.typesafe.ai/v1/systemone
// Authorization: Bearer <API_KEY>
// {"state": …, "model": "jev-latest", "questions": {"‹id›": {"type": "choice", …}}}
// → {"model": "jev-1.13.0", "answers": {"‹id›": {"type": "choice", "confidence", "probabilities"}}}
package classify
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"math/rand"
"net/http"
"strings"
"time"
)
// DefaultBaseURL is TypeSafe's documented API host. JEV_BASE_URL overrides it.
const DefaultBaseURL = "https://api.typesafe.ai"
// DefaultModel is the flagship System One alias documented at
// https://docs.typesafe.ai/models.
const DefaultModel = "jev-latest"
// systemOnePath is the documented evaluation endpoint.
const systemOnePath = "/v1/systemone"
// ErrNotConfigured means JEV_API_KEY is not set.
var ErrNotConfigured = errors.New("JEV_API_KEY is not configured")
// maxBodyChars bounds how much of a message body is sent to JEV.
const maxBodyChars = 4000
// MessageInput is the message text handed to JEV.
type MessageInput struct {
From string
To string
Subject string
Source string
Body string
Received string
}
// CategoryOption is one Choice option: the live category name and its rubric.
type CategoryOption struct {
Name string
Description string
}
// Result is one classification decision. Confidence is the Choice answer's own
// confidence, so the UI can show how sure JEV is.
type Result struct {
Category string
CategoryConfidence float64
Importance string
ImportanceConfidence float64
Action string
ActionConfidence float64
Model string
}
// Client calls the TypeSafe System One endpoint.
type Client struct {
baseURL string
apiKey string
model string
http *http.Client
logger *slog.Logger
}
// NewClient builds a JEV client. An empty baseURL falls back to DefaultBaseURL,
// an empty model to DefaultModel. logger may be nil.
func NewClient(baseURL, apiKey, model string, logger *slog.Logger) *Client {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" {
baseURL = DefaultBaseURL
}
if strings.TrimSpace(model) == "" {
model = DefaultModel
}
if logger == nil {
logger = slog.New(slog.DiscardHandler)
}
return &Client{
baseURL: baseURL,
apiKey: strings.TrimSpace(apiKey),
model: model,
http: &http.Client{Timeout: 60 * time.Second},
logger: logger,
}
}
// Configured reports whether an API key is present.
func (c *Client) Configured() bool { return c.apiKey != "" }
// Model returns the configured model alias.
func (c *Client) Model() string { return c.model }
// Classify sends one message and returns the category, importance, action, and
// confidences from one JEV request.
func (c *Client) Classify(ctx context.Context, msg MessageInput, categories []CategoryOption) (Result, error) {
if !c.Configured() {
return Result{}, ErrNotConfigured
}
if len(categories) == 0 {
return Result{}, errors.New("no categories configured")
}
categoryCriteria := make(map[string]any, len(categories))
for _, opt := range categories {
if strings.TrimSpace(opt.Name) == "" {
continue
}
if strings.TrimSpace(opt.Description) == "" {
categoryCriteria[opt.Name] = nil
} else {
categoryCriteria[opt.Name] = opt.Description
}
}
body := map[string]any{
"state": map[string]any{
"email": map[string]any{
"from": msg.From,
"to": msg.To,
"source_address": msg.Source,
"source_domain": domainOf(msg.Source),
"subject": msg.Subject,
"received": msg.Received,
"body": truncate(msg.Body, maxBodyChars),
},
},
"model": c.model,
"questions": map[string]any{
"category": map[string]any{
"type": "choice",
"instructions": "Which category best fits this email for a personal family inbox? Pick the single best match.",
"criteria": categoryCriteria,
},
"importance": map[string]any{
"type": "choice",
"instructions": "How important is this email to the recipient?",
"criteria": map[string]any{
"low": "Can be ignored or removed without loss",
"medium": "Worth a quick look",
"high": "Needs the recipient's attention soon",
},
},
"action": map[string]any{
"type": "choice",
"instructions": "What is the best single recommended action for this email?",
"criteria": map[string]any{
"delete": "Junk or spam; safe to remove",
"unsubscribe": "Stop future mail from this sender",
"reply": "Send a reply to the sender",
"file": "Keep for reference; move out of the inbox",
"keep": "Leave in the inbox",
},
},
},
}
resp, err := c.post(ctx, body)
if err != nil {
return Result{}, err
}
res := Result{Model: resp.Model}
if a, ok := resp.Answers["category"]; ok {
res.Category = a.Choice
res.CategoryConfidence = a.Confidence
}
if a, ok := resp.Answers["importance"]; ok {
res.Importance = a.Choice
res.ImportanceConfidence = a.Confidence
}
if a, ok := resp.Answers["action"]; ok {
res.Action = a.Choice
res.ActionConfidence = a.Confidence
}
if res.Model == "" {
res.Model = c.model
}
return res, nil
}
// answer is one System One Choice answer.
type answer struct {
Type string `json:"type"`
Choice string `json:"choice"`
Confidence float64 `json:"confidence"`
Probabilities map[string]float64 `json:"probabilities"`
Noul float64 `json:"noul"`
}
type response struct {
Model string `json:"model"`
Answers map[string]answer `json:"answers"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
} `json:"usage"`
}
// post sends the request with bounded retries for rate limits and outages.
func (c *Client) post(ctx context.Context, body map[string]any) (response, error) {
payload, err := json.Marshal(body)
if err != nil {
return response{}, fmt.Errorf("encode JEV request: %w", err)
}
const maxAttempts = 4
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
resp, retryable, err := c.do(ctx, payload)
if err == nil {
return resp, nil
}
lastErr = err
if !retryable || attempt == maxAttempts {
break
}
delay := backoff(attempt)
c.logger.Warn("jev request failed; retrying", "attempt", attempt, "delay_ms", delay.Milliseconds(), "error", err)
select {
case <-ctx.Done():
return response{}, ctx.Err()
case <-time.After(delay):
}
}
return response{}, lastErr
}
func (c *Client) do(ctx context.Context, payload []byte) (response, bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+systemOnePath, bytes.NewReader(payload))
if err != nil {
return response{}, false, fmt.Errorf("build JEV request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
httpResp, err := c.http.Do(req)
if err != nil {
return response{}, true, fmt.Errorf("call JEV: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()
raw, err := io.ReadAll(io.LimitReader(httpResp.Body, 1<<20))
if err != nil {
return response{}, true, fmt.Errorf("read JEV response: %w", err)
}
if httpResp.StatusCode != http.StatusOK {
retryable := httpResp.StatusCode == http.StatusTooManyRequests ||
httpResp.StatusCode == http.StatusServiceUnavailable ||
httpResp.StatusCode == 529 ||
httpResp.StatusCode >= 500
return response{}, retryable,
fmt.Errorf("JEV returned %d: %s", httpResp.StatusCode, strings.TrimSpace(string(raw)))
}
var out response
if err := json.Unmarshal(raw, &out); err != nil {
return response{}, false, fmt.Errorf("decode JEV response: %w", err)
}
if len(out.Answers) == 0 {
return response{}, false, fmt.Errorf("JEV response contained no answers")
}
return out, false, nil
}
// backoff returns the delay before the next attempt, with a little jitter so
// parallel workers do not retry in lock step.
func backoff(attempt int) time.Duration {
base := time.Duration(1<<(attempt-1)) * time.Second
if base > 8*time.Second {
base = 8 * time.Second
}
return base + time.Duration(rand.Int63n(int64(base/2+1)))
}
func truncate(s string, max int) string {
s = strings.TrimSpace(s)
if len(s) <= max {
return s
}
return s[:max] + "…"
}
func domainOf(addr string) string {
if i := strings.LastIndex(addr, "@"); i >= 0 && i < len(addr)-1 {
return addr[i+1:]
}
return addr
}