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