Files
email_agent/internal/server/categories.go
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

152 lines
4.9 KiB
Go

// 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)
}