Files
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

239 lines
6.3 KiB
Go

// User rules (FR-4). A rule overrides the JEV classification when its condition
// matches the message.
//
// Syntax, one rule per line:
//
// <condition> => <effects>
//
// condition: `<field> <operator> "<value>"`, joined with " and ".
//
// fields: from, to, subject, source, body
// operators: contains, equals, starts_with, ends_with
//
// A single `*` matches every message (use it as the last line for a default).
// effects: comma-separated `category=<name>`, `importance=<low|medium|high>`,
// `action=<delete|unsubscribe|reply|file|keep>`.
//
// The first matching rule wins and overrides only the fields it names. The rules
// editor that writes this text lands in Phase 6; the classifier already reads it.
package classify
import (
"fmt"
"strings"
)
// Rule is one parsed line.
type Rule struct {
Line int
Raw string
MatchAll bool
Conditions []Condition
Effects map[string]string
}
// Condition is one `<field> <op> "<value>"` term.
type Condition struct {
Field string
Op string
Value string
}
// ParseError records a line that could not be parsed. The rest of the document
// still loads; the error is surfaced in logs so a typo is visible.
type ParseError struct {
Line int
Text string
Reason string
}
func (e ParseError) Error() string {
return fmt.Sprintf("rules line %d: %s (%q)", e.Line, e.Reason, strings.TrimSpace(e.Text))
}
// RuleSet is a parsed rules document.
type RuleSet struct {
Rules []Rule
Errors []ParseError
}
// effectFields are the classification fields a rule may override.
var effectFields = map[string]bool{
"category": true,
"importance": true,
"action": true,
}
var allowedImportance = map[string]bool{"low": true, "medium": true, "high": true}
var allowedAction = map[string]bool{
"delete": true, "unsubscribe": true, "reply": true, "file": true, "keep": true,
}
// ParseRules parses a rules document. Malformed lines are collected in Errors and
// skipped; parsing never fails outright, so one typo cannot disable the rules.
func ParseRules(text string) RuleSet {
var set RuleSet
for i, raw := range strings.Split(text, "\n") {
line := strings.TrimSpace(strings.TrimSuffix(raw, "\r"))
if line == "" || strings.HasPrefix(line, "#") {
continue
}
rule, err := parseRule(line)
rule.Line = i + 1
rule.Raw = line
if err != nil {
set.Errors = append(set.Errors, ParseError{Line: i + 1, Text: raw, Reason: err.Error()})
continue
}
set.Rules = append(set.Rules, rule)
}
return set
}
func parseRule(line string) (Rule, error) {
rule := Rule{Effects: map[string]string{}}
left, right, ok := strings.Cut(line, "=>")
if !ok {
return rule, fmt.Errorf("missing =>")
}
left, right = strings.TrimSpace(left), strings.TrimSpace(right)
if left == "" || right == "" {
return rule, fmt.Errorf("empty condition or effect")
}
if left == "*" {
rule.MatchAll = true
} else {
for _, part := range strings.Split(left, " and ") {
cond, err := parseCondition(strings.TrimSpace(part))
if err != nil {
return rule, err
}
rule.Conditions = append(rule.Conditions, cond)
}
}
for _, part := range strings.Split(right, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
field, value, err := parseEffect(part)
if err != nil {
return rule, err
}
rule.Effects[field] = value
}
if len(rule.Effects) == 0 {
return rule, fmt.Errorf("no effects")
}
return rule, nil
}
// parseEffect accepts `field=value` and `field: value` to be forgiving of typing.
func parseEffect(part string) (string, string, error) {
field, value, ok := strings.Cut(part, "=")
if !ok {
field, value, ok = strings.Cut(part, ":")
}
if !ok {
return "", "", fmt.Errorf("effect %q is not field=value", part)
}
field = strings.ToLower(strings.TrimSpace(field))
value = unquote(strings.TrimSpace(value))
if !effectFields[field] {
return "", "", fmt.Errorf("unknown effect field %q", field)
}
if value == "" {
return "", "", fmt.Errorf("effect %q has no value", field)
}
switch field {
case "importance":
value = strings.ToLower(value)
if !allowedImportance[value] {
return "", "", fmt.Errorf("importance %q must be low, medium, or high", value)
}
case "action":
value = strings.ToLower(value)
if !allowedAction[value] {
return "", "", fmt.Errorf("action %q must be delete, unsubscribe, reply, file, or keep", value)
}
}
return field, value, nil
}
func parseCondition(part string) (Condition, error) {
fields := strings.Fields(part)
if len(fields) < 3 {
return Condition{}, fmt.Errorf("condition %q needs a field, an operator, and a value", part)
}
field := strings.ToLower(fields[0])
switch field {
case "from", "to", "subject", "source", "body":
default:
return Condition{}, fmt.Errorf("unknown condition field %q", field)
}
op := strings.ToLower(fields[1])
switch op {
case "contains", "equals", "starts_with", "ends_with":
default:
return Condition{}, fmt.Errorf("unknown operator %q", op)
}
value := unquote(strings.TrimSpace(strings.Join(fields[2:], " ")))
if value == "" {
return Condition{}, fmt.Errorf("condition %q has no value", part)
}
return Condition{Field: field, Op: op, Value: value}, nil
}
func unquote(v string) string {
if len(v) >= 2 {
if (v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'') {
return v[1 : len(v)-1]
}
}
return v
}
// Match returns the effects of the first matching rule. It reports nil when no
// rule matches. Field lookups are case-insensitive; values compare
// case-insensitively too.
func (rs RuleSet) Match(fields map[string]string) *Rule {
normalized := make(map[string]string, len(fields))
for k, v := range fields {
normalized[strings.ToLower(k)] = strings.ToLower(v)
}
for i := range rs.Rules {
r := &rs.Rules[i]
if r.MatchAll || allMatch(r.Conditions, normalized) {
return r
}
}
return nil
}
func allMatch(conds []Condition, fields map[string]string) bool {
for _, c := range conds {
if !matches(c, fields[c.Field]) {
return false
}
}
return true
}
func matches(c Condition, fieldValue string) bool {
want := strings.ToLower(c.Value)
switch c.Op {
case "contains":
return strings.Contains(fieldValue, want)
case "equals":
return fieldValue == want
case "starts_with":
return strings.HasPrefix(fieldValue, want)
case "ends_with":
return strings.HasSuffix(fieldValue, want)
}
return false
}