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>
152 lines
4.9 KiB
Go
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)
|
|
}
|