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>
239 lines
6.3 KiB
Go
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
|
|
}
|