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

View File

@@ -9,6 +9,7 @@ import (
"time"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/session"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/version"
)
@@ -226,36 +227,63 @@ type dashboardData struct {
Folders []navItem
}
// categoryCounts returns per-category message counts keyed by the canonical
// category name, plus the unclassified bucket.
func categoryCounts(items []store.MessageListItem, categories []store.Category) map[string]int {
canonical := make(map[string]string, len(categories))
for _, c := range categories {
canonical[strings.ToLower(c.Name)] = c.Name
}
counts := map[string]int{}
for _, it := range items {
raw := strings.TrimSpace(it.Category)
if raw == "" {
counts[unclassifiedCategory]++
continue
}
if name, ok := canonical[strings.ToLower(raw)]; ok {
counts[name]++
} else {
counts[raw]++
}
}
return counts
}
// handleDashboard renders the inbox shell with real category counts.
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
sess := sessionFrom(r)
// FR-5: counts come from the stored messages. Phase 2 has no classifier
// yet, so every message lands under Unclassified (AGENTS.md §13.2).
// FR-5: counts come from the stored messages, grouped by the live category
// list (FR-14).
items, err := s.store.ListMessages(r.Context(), 200)
if err != nil {
s.logger.Error("load dashboard counts", "error", err)
items = nil
}
counts := categoryCounts(items)
categories, err := s.store.ListCategories(r.Context())
if err != nil {
s.logger.Error("load dashboard categories", "error", err)
}
counts := categoryCounts(items, categories)
catNav := make([]navItem, 0, len(categories)+1)
for _, c := range categories {
catNav = append(catNav, navItem{Name: c.Name, Color: "var(--primary-soft, var(--surface-2))", Count: counts[c.Name]})
}
catNav = append(catNav, navItem{
Name: unclassifiedCategory,
Color: "var(--ink-faint)",
Count: counts[unclassifiedCategory],
})
data := dashboardData{
Greeting: partOfDay(time.Now()),
User: sess.User,
CSRFToken: s.csrfToken(w, r),
Version: version.Version,
Total: len(items),
// Placeholders until FR-14 (editable categories) and FR-9 (folders)
// land. Colours are the design system's decorative sticker palette.
Categories: []navItem{
{Name: "Action", Color: "var(--primary)", Count: counts["action"]},
{Name: "Important", Color: "var(--sticker-orange)", Count: counts["important"]},
{Name: "Info", Color: "var(--sticker-sky)", Count: counts["info"]},
{Name: "Spam", Color: "var(--sticker-red)", Count: counts["spam"]},
{Name: "Newsletter", Color: "var(--sticker-purple)", Count: counts["newsletter"]},
{Name: "Auto-reply", Color: "var(--sticker-teal)", Count: counts["auto-reply"]},
{Name: "Unclassified", Color: "var(--ink-faint)", Count: counts[unclassifiedCategory]},
},
Greeting: partOfDay(time.Now()),
User: sess.User,
CSRFToken: s.csrfToken(w, r),
Version: version.Version,
Total: len(items),
Categories: catNav,
Folders: []navItem{
{Name: "Replied-to", Color: "var(--sticker-green)"},
{Name: "Delete", Color: "var(--sticker-red)"},