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)"},
|
||||
|
||||
151
internal/server/categories.go
Normal file
151
internal/server/categories.go
Normal file
@@ -0,0 +1,151 @@
|
||||
// Category management UI and handlers (FR-14).
|
||||
//
|
||||
// The category list is editable: add, rename, and remove. JEV uses whatever list
|
||||
// is current at classification time, and renaming rewrites existing
|
||||
// classifications so the triage list stays consistent.
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
|
||||
)
|
||||
|
||||
type categoriesData struct {
|
||||
Categories []store.Category
|
||||
CSRFToken string
|
||||
Note string
|
||||
Error string
|
||||
Total int
|
||||
Classified int
|
||||
}
|
||||
|
||||
// handleCategories renders the category editor the inbox nav links to.
|
||||
func (s *Server) handleCategories(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderCategories(w, r, http.StatusOK, r.URL.Query().Get("note"), r.URL.Query().Get("error"))
|
||||
}
|
||||
|
||||
func (s *Server) renderCategories(w http.ResponseWriter, r *http.Request, code int, note, errMsg string) {
|
||||
categories, err := s.store.ListCategories(r.Context())
|
||||
if err != nil {
|
||||
s.logger.Error("list categories", "error", err)
|
||||
http.Error(w, "could not load categories", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
coverage, err := s.store.ClassificationCoverage(r.Context())
|
||||
if err != nil {
|
||||
s.logger.Error("classification coverage", "error", err)
|
||||
}
|
||||
|
||||
data := categoriesData{
|
||||
Categories: categories,
|
||||
CSRFToken: s.csrfToken(w, r),
|
||||
Note: note,
|
||||
Error: errMsg,
|
||||
Total: coverage.Total,
|
||||
Classified: coverage.Classified,
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
if err := s.tmpl.ExecuteTemplate(w, "categories.html", data); err != nil {
|
||||
s.logger.Error("render categories", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleCategoryAdd creates a category (FR-14).
|
||||
func (s *Server) handleCategoryAdd(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.csrfOK(r) {
|
||||
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.redirectCategories(w, r, "", "Could not read the form.")
|
||||
return
|
||||
}
|
||||
name, description := r.FormValue("name"), r.FormValue("description")
|
||||
cat, err := s.store.AddCategory(r.Context(), name, description)
|
||||
switch {
|
||||
case err == nil:
|
||||
s.logger.Info("category added", "name", cat.Name)
|
||||
_ = s.store.LogAction(r.Context(), "category_add", "Sam", "name="+cat.Name)
|
||||
s.redirectCategories(w, r, fmt.Sprintf("Added category %q.", cat.Name), "")
|
||||
case errors.Is(err, store.ErrCategoryExists):
|
||||
s.redirectCategories(w, r, "", fmt.Sprintf("A category named %q already exists.", name))
|
||||
default:
|
||||
s.redirectCategories(w, r, "", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// handleCategoryRename changes a category's name and updates existing
|
||||
// classifications (FR-14).
|
||||
func (s *Server) handleCategoryRename(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.csrfOK(r) {
|
||||
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.redirectCategories(w, r, "", "Could not read the form.")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
s.redirectCategories(w, r, "", "Unknown category.")
|
||||
return
|
||||
}
|
||||
name := r.FormValue("name")
|
||||
switch err := s.store.RenameCategory(r.Context(), id, name); {
|
||||
case err == nil:
|
||||
s.logger.Info("category renamed", "id", id, "name", name)
|
||||
_ = s.store.LogAction(r.Context(), "category_rename", "Sam",
|
||||
fmt.Sprintf("id=%d name=%s", id, name))
|
||||
s.redirectCategories(w, r, fmt.Sprintf("Renamed category to %q.", name), "")
|
||||
case errors.Is(err, store.ErrCategoryExists):
|
||||
s.redirectCategories(w, r, "", fmt.Sprintf("A category named %q already exists.", name))
|
||||
default:
|
||||
s.redirectCategories(w, r, "", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// handleCategoryRemove deletes a category and moves its messages to the first
|
||||
// remaining category (FR-14).
|
||||
func (s *Server) handleCategoryRemove(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.csrfOK(r) {
|
||||
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.redirectCategories(w, r, "", "Could not read the form.")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
s.redirectCategories(w, r, "", "Unknown category.")
|
||||
return
|
||||
}
|
||||
switch err := s.store.RemoveCategory(r.Context(), id); {
|
||||
case err == nil:
|
||||
s.logger.Info("category removed", "id", id)
|
||||
_ = s.store.LogAction(r.Context(), "category_remove", "Sam", fmt.Sprintf("id=%d", id))
|
||||
s.redirectCategories(w, r, "Removed category; its messages moved to the first remaining category.", "")
|
||||
case errors.Is(err, store.ErrLastCategory):
|
||||
s.redirectCategories(w, r, "", "At least one category is required.")
|
||||
default:
|
||||
s.redirectCategories(w, r, "", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// redirectCategories is POST/redirect/GET so a refresh does not resubmit.
|
||||
func (s *Server) redirectCategories(w http.ResponseWriter, r *http.Request, note, errMsg string) {
|
||||
q := url.Values{}
|
||||
if note != "" {
|
||||
q.Set("note", note)
|
||||
}
|
||||
if errMsg != "" {
|
||||
q.Set("error", errMsg)
|
||||
}
|
||||
http.Redirect(w, r, "/categories?"+q.Encode(), http.StatusSeeOther)
|
||||
}
|
||||
213
internal/server/categories_test.go
Normal file
213
internal/server/categories_test.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/session"
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
|
||||
)
|
||||
|
||||
// login signs in and returns the session cookie plus the CSRF cookie/token.
|
||||
func login(t *testing.T, srv *Server) (*http.Cookie, *http.Cookie, string) {
|
||||
t.Helper()
|
||||
h := srv.Handler()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil))
|
||||
csrf := csrfFromBody(t, rec.Body.String())
|
||||
csrfCookie := findCookie(t, rec.Result().Cookies(), csrfCookieName)
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, loginRequest(csrf, testPassword, csrfCookie))
|
||||
sessionCookie := findCookie(t, rec.Result().Cookies(), session.CookieName)
|
||||
if sessionCookie == nil {
|
||||
t.Fatal("login did not set the session cookie")
|
||||
}
|
||||
return sessionCookie, csrfCookie, csrf
|
||||
}
|
||||
|
||||
func postForm(t *testing.T, srv *Server, path string, session, csrfCookie *http.Cookie, form url.Values) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(session)
|
||||
if csrfCookie != nil {
|
||||
req.AddCookie(csrfCookie)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func getWithSession(t *testing.T, srv *Server, path string, session *http.Cookie) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.AddCookie(session)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestCategoriesPageRenders(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
session, _, _ := login(t, srv)
|
||||
|
||||
rec := getWithSession(t, srv, "/categories", session)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"Categories", "Action", "Spam", "Add category", "Back to inbox"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("categories page missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCategoryAddRenameRemove(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
session, csrfCookie, csrf := login(t, srv)
|
||||
|
||||
// Add (FR-14).
|
||||
rec := postForm(t, srv, "/categories/add", session, csrfCookie, url.Values{
|
||||
"csrf": {csrf}, "name": {"Finance"}, "description": {"Bills and payments"},
|
||||
})
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("add status = %d, want 303 (body %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := getWithSession(t, srv, "/categories", session); !strings.Contains(rec.Body.String(), "Finance") {
|
||||
t.Fatal("added category is not rendered")
|
||||
}
|
||||
|
||||
id := categoryID(t, srv, "Finance")
|
||||
|
||||
// Rename (FR-14).
|
||||
rec = postForm(t, srv, "/categories/rename", session, csrfCookie, url.Values{
|
||||
"csrf": {csrf}, "id": {itoa(id)}, "name": {"Money"},
|
||||
})
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("rename status = %d, want 303", rec.Code)
|
||||
}
|
||||
body := getWithSession(t, srv, "/categories", session).Body.String()
|
||||
if !strings.Contains(body, "Money") || strings.Contains(body, ">Finance<") {
|
||||
t.Fatalf("rename not reflected; body contains Finance=%v Money=%v",
|
||||
strings.Contains(body, "Finance"), strings.Contains(body, "Money"))
|
||||
}
|
||||
|
||||
// Remove (FR-14).
|
||||
id = categoryID(t, srv, "Money")
|
||||
rec = postForm(t, srv, "/categories/remove", session, csrfCookie, url.Values{
|
||||
"csrf": {csrf}, "id": {itoa(id)},
|
||||
})
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("remove status = %d, want 303", rec.Code)
|
||||
}
|
||||
if rec := getWithSession(t, srv, "/categories", session); strings.Contains(rec.Body.String(), "Money") {
|
||||
t.Fatal("removed category is still rendered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCategoryMutationsRequireCSRF(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
session, _, _ := login(t, srv)
|
||||
|
||||
rec := postForm(t, srv, "/categories/add", session, nil, url.Values{"name": {"Nope"}})
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("add without CSRF status = %d, want 403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyAnonymousRedirects(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
for _, path := range []string{"/actions/classify", "/actions/reclassify"} {
|
||||
req := httptest.NewRequest(http.MethodPost, path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Errorf("%s status = %d, want 303 (redirect to login)", path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyWithoutJEVKeyReportsNotConfigured(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
session, csrfCookie, csrf := login(t, srv)
|
||||
|
||||
rec := postForm(t, srv, "/actions/classify", session, csrfCookie, url.Values{"csrf": {csrf}})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "JEV_API_KEY") {
|
||||
t.Errorf("expected a not-configured note; body: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLinksToCategoriesAndShowsClassify(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
session, _, _ := login(t, srv)
|
||||
|
||||
rec := getWithSession(t, srv, "/", session)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{`href="/categories"`, "Classify", "Re-classify all", "Action", "Auto-reply"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("dashboard missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboxShowsClassificationConfidence(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
ctx := context.Background()
|
||||
|
||||
msgID, err := srv.store.UpsertMessage(ctx, &store.Message{
|
||||
GmailID: "cls-1", FromAddr: "a@b.com", Subject: "Classified mail",
|
||||
SourceAddress: "hello@where-woof.com", DateSent: time.Now().UTC(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed message: %v", err)
|
||||
}
|
||||
if _, err := srv.store.SaveClassification(ctx, store.Classification{
|
||||
MessageID: msgID, Category: "Action", Importance: "high", Action: "reply",
|
||||
Confidence: 0.82, Model: "jev-1.13.0",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed classification: %v", err)
|
||||
}
|
||||
|
||||
session, _, _ := login(t, srv)
|
||||
rec := getWithSession(t, srv, "/partials/inbox", session)
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"Classified mail", "Action", "reply", "82%"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("inbox partial missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func categoryID(t *testing.T, srv *Server, name string) int64 {
|
||||
t.Helper()
|
||||
cats, err := srv.store.ListCategories(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListCategories() error = %v", err)
|
||||
}
|
||||
for _, c := range cats {
|
||||
if c.Name == name {
|
||||
return c.ID
|
||||
}
|
||||
}
|
||||
t.Fatalf("category %q not found", name)
|
||||
return 0
|
||||
}
|
||||
|
||||
func itoa(v int64) string {
|
||||
return strconv.FormatInt(v, 10)
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
// Inbox rendering and the manual mail scan.
|
||||
// Inbox rendering, the manual mail scan, and the classify action.
|
||||
//
|
||||
// Phase 2 lists real mail from the database. Classification is Phase 3, so
|
||||
// messages without a classification are shown under "Unclassified" with a
|
||||
// temporary "Review" recommended action; the grouping and action plumbing is
|
||||
// already category-aware.
|
||||
// Phase 3 groups messages by the live category list (FR-5) and shows the JEV
|
||||
// recommended action plus confidence on each row. Messages without a
|
||||
// classification fall under "Unclassified".
|
||||
package server
|
||||
|
||||
import (
|
||||
@@ -15,26 +14,13 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/classify"
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/mailfetch"
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
|
||||
)
|
||||
|
||||
// unclassifiedCategory is the fallback bucket before Phase 3 adds JEV
|
||||
// categories (AGENTS.md §13.2).
|
||||
const unclassifiedCategory = "unclassified"
|
||||
|
||||
// categoryOrder is the display order for the built-in categories.
|
||||
var categoryOrder = []string{"action", "important", "info", "spam", "newsletter", "auto-reply"}
|
||||
|
||||
var categoryDisplay = map[string]string{
|
||||
"action": "Action",
|
||||
"important": "Important",
|
||||
"info": "Info",
|
||||
"spam": "Spam",
|
||||
"newsletter": "Newsletter",
|
||||
"auto-reply": "Auto-reply",
|
||||
"unclassified": "Unclassified",
|
||||
}
|
||||
// unclassifiedCategory is the bucket for messages JEV has not classified yet.
|
||||
const unclassifiedCategory = "Unclassified"
|
||||
|
||||
type messageRow struct {
|
||||
ID int64
|
||||
@@ -42,6 +28,8 @@ type messageRow struct {
|
||||
Subject string
|
||||
SourceAddr string
|
||||
Action string
|
||||
Confidence int
|
||||
Classified bool
|
||||
When string
|
||||
}
|
||||
|
||||
@@ -57,21 +45,42 @@ type inboxData struct {
|
||||
Error string
|
||||
}
|
||||
|
||||
// loadInbox reads messages and groups them for rendering.
|
||||
// loadInbox reads messages and groups them by the live category list.
|
||||
func (s *Server) loadInbox(ctx context.Context) (inboxData, error) {
|
||||
items, err := s.store.ListMessages(ctx, 200)
|
||||
if err != nil {
|
||||
return inboxData{}, err
|
||||
}
|
||||
return inboxData{Groups: groupMessages(items), Total: len(items)}, nil
|
||||
categories, err := s.store.ListCategories(ctx)
|
||||
if err != nil {
|
||||
return inboxData{}, err
|
||||
}
|
||||
return inboxData{Groups: groupMessages(items, categories), Total: len(items)}, nil
|
||||
}
|
||||
|
||||
// groupMessages buckets messages by category. Unknown (custom) categories get
|
||||
// their own group; messages with no classification fall into "Unclassified".
|
||||
func groupMessages(items []store.MessageListItem) []messageGroup {
|
||||
// groupMessages buckets messages by category, ordering groups the way the
|
||||
// category list is ordered in the UI. Unknown categories (for example a
|
||||
// classification written before a rename) keep their own group; messages with
|
||||
// no classification fall into "Unclassified" last.
|
||||
func groupMessages(items []store.MessageListItem, categories []store.Category) []messageGroup {
|
||||
canonical := make(map[string]string, len(categories))
|
||||
order := make([]string, 0, len(categories))
|
||||
for _, c := range categories {
|
||||
canonical[strings.ToLower(c.Name)] = c.Name
|
||||
order = append(order, c.Name)
|
||||
}
|
||||
|
||||
buckets := map[string][]messageRow{}
|
||||
for _, it := range items {
|
||||
key := strings.ToLower(strings.TrimSpace(it.Category))
|
||||
raw := strings.TrimSpace(it.Category)
|
||||
key := ""
|
||||
if raw != "" {
|
||||
if name, ok := canonical[strings.ToLower(raw)]; ok {
|
||||
key = name
|
||||
} else {
|
||||
key = raw
|
||||
}
|
||||
}
|
||||
if key == "" {
|
||||
key = unclassifiedCategory
|
||||
}
|
||||
@@ -85,62 +94,47 @@ func groupMessages(items []store.MessageListItem) []messageGroup {
|
||||
Subject: orDefault(it.Subject, "(no subject)"),
|
||||
SourceAddr: it.SourceAddress,
|
||||
Action: action,
|
||||
Confidence: confidencePercent(it.Confidence),
|
||||
Classified: it.HasClassification,
|
||||
When: formatWhen(it.DateSent, it.ReceivedAt),
|
||||
})
|
||||
}
|
||||
|
||||
// Order: built-in categories first, then custom ones alphabetically, then
|
||||
// Unclassified last.
|
||||
var keys []string
|
||||
for _, k := range categoryOrder {
|
||||
if _, ok := buckets[k]; ok {
|
||||
keys = append(keys, k)
|
||||
for _, name := range order {
|
||||
if _, ok := buckets[name]; ok {
|
||||
keys = append(keys, name)
|
||||
}
|
||||
}
|
||||
var custom []string
|
||||
var unknown []string
|
||||
for k := range buckets {
|
||||
if k == unclassifiedCategory || contains(categoryOrder, k) {
|
||||
if k == unclassifiedCategory || contains(order, k) {
|
||||
continue
|
||||
}
|
||||
custom = append(custom, k)
|
||||
unknown = append(unknown, k)
|
||||
}
|
||||
sort.Strings(custom)
|
||||
keys = append(keys, custom...)
|
||||
sort.Strings(unknown)
|
||||
keys = append(keys, unknown...)
|
||||
if _, ok := buckets[unclassifiedCategory]; ok {
|
||||
keys = append(keys, unclassifiedCategory)
|
||||
}
|
||||
|
||||
groups := make([]messageGroup, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
groups = append(groups, messageGroup{
|
||||
Name: displayCategory(k),
|
||||
Messages: buckets[k],
|
||||
})
|
||||
groups = append(groups, messageGroup{Name: k, Messages: buckets[k]})
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
func displayCategory(key string) string {
|
||||
if name, ok := categoryDisplay[key]; ok {
|
||||
return name
|
||||
// confidencePercent turns a 0–1 confidence into a whole percentage.
|
||||
func confidencePercent(v float64) int {
|
||||
if v <= 0 {
|
||||
return 0
|
||||
}
|
||||
if key == "" {
|
||||
return "Unclassified"
|
||||
if v > 1 {
|
||||
v = 1
|
||||
}
|
||||
return strings.ToUpper(key[:1]) + key[1:]
|
||||
}
|
||||
|
||||
// categoryCounts returns per-category message counts for the left nav.
|
||||
func categoryCounts(items []store.MessageListItem) map[string]int {
|
||||
counts := map[string]int{}
|
||||
for _, it := range items {
|
||||
key := strings.ToLower(strings.TrimSpace(it.Category))
|
||||
if key == "" {
|
||||
key = unclassifiedCategory
|
||||
}
|
||||
counts[key]++
|
||||
}
|
||||
return counts
|
||||
return int(v*100 + 0.5)
|
||||
}
|
||||
|
||||
func (s *Server) renderInbox(w http.ResponseWriter, r *http.Request, note string) {
|
||||
@@ -187,6 +181,53 @@ func (s *Server) handleFetchMail(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// handleClassify classifies pending messages (FR-3) and returns the refreshed
|
||||
// list. A run can take minutes, so the write deadline is extended for this
|
||||
// handler only.
|
||||
func (s *Server) handleClassify(w http.ResponseWriter, r *http.Request) {
|
||||
s.runClassifier(w, r, false)
|
||||
}
|
||||
|
||||
// handleReclassify re-runs the classifier over every stored message. It is how
|
||||
// a category-list or rules edit is applied to mail that was already classified.
|
||||
func (s *Server) handleReclassify(w http.ResponseWriter, r *http.Request) {
|
||||
s.runClassifier(w, r, true)
|
||||
}
|
||||
|
||||
func (s *Server) runClassifier(w http.ResponseWriter, r *http.Request, all bool) {
|
||||
if !s.csrfOK(r) {
|
||||
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if rc := http.NewResponseController(w); rc != nil {
|
||||
_ = rc.SetWriteDeadline(time.Now().Add(15 * time.Minute))
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
var (
|
||||
sum classify.Summary
|
||||
err error
|
||||
)
|
||||
if all {
|
||||
sum, err = s.classifier.RunAll(ctx)
|
||||
} else {
|
||||
sum, err = s.classifier.Run(ctx)
|
||||
}
|
||||
switch {
|
||||
case err == nil:
|
||||
s.renderInbox(w, r, fmt.Sprintf(
|
||||
"Classified %d of %d message(s); %d failed; %d still unclassified.",
|
||||
sum.Classified, sum.Considered, sum.Failed, sum.Remaining))
|
||||
case errors.Is(err, classify.ErrNotConfigured):
|
||||
s.renderInbox(w, r, "Classification is not connected yet: set JEV_API_KEY.")
|
||||
default:
|
||||
s.logger.Error("classification run failed", "error", err)
|
||||
s.renderInbox(w, r, "Classification failed: "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func orDefault(v, def string) string {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return def
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/classify"
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/config"
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/mailfetch"
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/session"
|
||||
@@ -32,13 +33,14 @@ func init() {
|
||||
|
||||
// Server holds the HTTP dependencies.
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
store *store.Store
|
||||
logger *slog.Logger
|
||||
http *http.Server
|
||||
tmpl *template.Template
|
||||
sessions *session.Manager
|
||||
fetcher *mailfetch.Fetcher
|
||||
cfg *config.Config
|
||||
store *store.Store
|
||||
logger *slog.Logger
|
||||
http *http.Server
|
||||
tmpl *template.Template
|
||||
sessions *session.Manager
|
||||
fetcher *mailfetch.Fetcher
|
||||
classifier *classify.Classifier
|
||||
}
|
||||
|
||||
// New builds a Server. It does not start listening.
|
||||
@@ -64,6 +66,10 @@ func New(cfg *config.Config, st *store.Store, logger *slog.Logger) (*Server, err
|
||||
tmpl: tmpl,
|
||||
sessions: sessions,
|
||||
fetcher: mailfetch.NewFetcher(cfg, st, logger),
|
||||
classifier: classify.NewClassifier(
|
||||
classify.NewClient(cfg.JEVBaseURL, cfg.JEVAPIKey, cfg.JEVModel, logger),
|
||||
st, logger, cfg.ClassifyConcurrency, cfg.ClassifyMaxMessages,
|
||||
),
|
||||
}
|
||||
s.http = &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
@@ -113,6 +119,14 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /{$}", s.requireAuth(s.handleDashboard))
|
||||
mux.HandleFunc("GET /partials/inbox", s.requireAuth(s.handleInboxPartial))
|
||||
mux.HandleFunc("POST /actions/fetch", s.requireAuth(s.handleFetchMail))
|
||||
mux.HandleFunc("POST /actions/classify", s.requireAuth(s.handleClassify))
|
||||
mux.HandleFunc("POST /actions/reclassify", s.requireAuth(s.handleReclassify))
|
||||
|
||||
// FR-14: editable category list.
|
||||
mux.HandleFunc("GET /categories", s.requireAuth(s.handleCategories))
|
||||
mux.HandleFunc("POST /categories/add", s.requireAuth(s.handleCategoryAdd))
|
||||
mux.HandleFunc("POST /categories/rename", s.requireAuth(s.handleCategoryRename))
|
||||
mux.HandleFunc("POST /categories/remove", s.requireAuth(s.handleCategoryRemove))
|
||||
|
||||
return s.recoverPanic(s.logRequests(mux))
|
||||
}
|
||||
@@ -234,6 +248,10 @@ type statusRecorder struct {
|
||||
status int
|
||||
}
|
||||
|
||||
// Unwrap lets http.ResponseController reach the underlying writer (the classify
|
||||
// handler extends its write deadline for long runs).
|
||||
func (r *statusRecorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
|
||||
|
||||
func (r *statusRecorder) WriteHeader(code int) {
|
||||
r.status = code
|
||||
r.ResponseWriter.WriteHeader(code)
|
||||
|
||||
Reference in New Issue
Block a user