Files
email_agent/internal/classify/jev.go
Sam Rolfe af3b5e5f0e 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>
2026-10-11 21:37:38 +11:00

307 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}