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