274 lines
7.6 KiB
Go
274 lines
7.6 KiB
Go
|
|
package web
|
||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/json"
|
||
|
|
"net/http"
|
||
|
|
"net/url"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"maisym.com/hsa/internal/receipt"
|
||
|
|
"maisym.com/hsa/internal/storage"
|
||
|
|
)
|
||
|
|
|
||
|
|
// --- AI tab: classifier correction notes + failure review ---
|
||
|
|
|
||
|
|
type aiView struct {
|
||
|
|
Notes []storage.Note
|
||
|
|
Misses []missSummary // unreviewed failures
|
||
|
|
Prompt string // live assembled system prompt, or "" when unavailable
|
||
|
|
PromptNote string // why the prompt is unavailable (classification off)
|
||
|
|
Error string
|
||
|
|
}
|
||
|
|
|
||
|
|
// missSummary is one unreviewed failure in the review queue.
|
||
|
|
type missSummary struct {
|
||
|
|
ReceiptID string
|
||
|
|
When string
|
||
|
|
Amount string
|
||
|
|
Model string
|
||
|
|
Fields string // comma-joined names of the overridden fields
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) handleAI(w http.ResponseWriter, r *http.Request) {
|
||
|
|
notes, err := s.store.ListActiveNotes()
|
||
|
|
if err != nil {
|
||
|
|
s.serverError(w, "list notes", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
rows, err := s.store.ListUnreviewedClassifications()
|
||
|
|
if err != nil {
|
||
|
|
s.serverError(w, "list classifications", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
cats, people, err := s.lookups()
|
||
|
|
if err != nil {
|
||
|
|
s.serverError(w, "load lookups", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
var misses []missSummary
|
||
|
|
for _, c := range rows {
|
||
|
|
ovs := deriveOverrides(c, cats, people)
|
||
|
|
var names []string
|
||
|
|
for _, o := range ovs {
|
||
|
|
if o.Overridden {
|
||
|
|
names = append(names, o.Field)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(names) == 0 {
|
||
|
|
continue // not a miss — the AI got every field right
|
||
|
|
}
|
||
|
|
misses = append(misses, missSummary{
|
||
|
|
ReceiptID: c.ReceiptID,
|
||
|
|
When: c.CreatedAt.Local().Format(dateLayout),
|
||
|
|
Amount: dollars(c.FinalAmountCents),
|
||
|
|
Model: c.Model,
|
||
|
|
Fields: strings.Join(names, ", "),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
view := aiView{Notes: notes, Misses: misses, Error: r.URL.Query().Get("error")}
|
||
|
|
if s.classifier != nil {
|
||
|
|
var texts []string
|
||
|
|
for _, n := range notes {
|
||
|
|
texts = append(texts, n.Text)
|
||
|
|
}
|
||
|
|
view.Prompt = s.classifier.PromptPreview(time.Now(), texts)
|
||
|
|
} else {
|
||
|
|
view.PromptNote = "Classification is disabled (no API key configured), so the live prompt is unavailable."
|
||
|
|
}
|
||
|
|
|
||
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
|
|
if err := aiPage.ExecuteTemplate(w, "base", view); err != nil {
|
||
|
|
s.serverError(w, "render ai", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) handleAddNote(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if _, err := s.store.AddNote(strings.TrimSpace(r.FormValue("text"))); err != nil {
|
||
|
|
redirectAI(w, r, "Could not add the note (it may be empty).")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
redirectAI(w, r, "")
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) handleEditNote(w http.ResponseWriter, r *http.Request) {
|
||
|
|
id, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("id")), 10, 64)
|
||
|
|
if err != nil {
|
||
|
|
redirectAI(w, r, "Invalid note id.")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if _, err := s.store.EditNote(id, strings.TrimSpace(r.FormValue("text"))); err != nil {
|
||
|
|
redirectAI(w, r, "Could not save the note (it may be empty).")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
redirectAI(w, r, "")
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
|
||
|
|
id, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("id")), 10, 64)
|
||
|
|
if err != nil {
|
||
|
|
redirectAI(w, r, "Invalid note id.")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if err := s.store.DeleteNote(id); err != nil {
|
||
|
|
redirectAI(w, r, "Could not delete the note.")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
redirectAI(w, r, "")
|
||
|
|
}
|
||
|
|
|
||
|
|
// reviewView is the single-failure review page.
|
||
|
|
type reviewView struct {
|
||
|
|
ReceiptID string
|
||
|
|
When string
|
||
|
|
Model string
|
||
|
|
Fields []fieldOverride
|
||
|
|
SinceNotes []storage.Note // notes added after this failure (candidate fixes)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) handleReview(w http.ResponseWriter, r *http.Request) {
|
||
|
|
id := r.PathValue("id")
|
||
|
|
c, err := s.store.GetClassification(id)
|
||
|
|
if err != nil {
|
||
|
|
http.Error(w, "not found", http.StatusNotFound)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
cats, people, err := s.lookups()
|
||
|
|
if err != nil {
|
||
|
|
s.serverError(w, "load lookups", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
since, err := s.store.NotesCreatedAfter(c.CreatedAt)
|
||
|
|
if err != nil {
|
||
|
|
s.serverError(w, "notes since", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
|
|
if err := reviewPage.ExecuteTemplate(w, "base", reviewView{
|
||
|
|
ReceiptID: c.ReceiptID,
|
||
|
|
When: c.CreatedAt.Local().Format("2006-01-02 15:04"),
|
||
|
|
Model: c.Model,
|
||
|
|
Fields: deriveOverrides(c, cats, people),
|
||
|
|
SinceNotes: since,
|
||
|
|
}); err != nil {
|
||
|
|
s.serverError(w, "render review", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) handleReviewSubmit(w http.ResponseWriter, r *http.Request) {
|
||
|
|
id := r.PathValue("id")
|
||
|
|
if err := r.ParseForm(); err != nil {
|
||
|
|
http.Error(w, "bad form", http.StatusBadRequest)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
var fixIDs []int64
|
||
|
|
for _, v := range r.Form["fix"] {
|
||
|
|
if nid, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||
|
|
fixIDs = append(fixIDs, nid)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
resolution := "unresolved"
|
||
|
|
if len(fixIDs) > 0 {
|
||
|
|
resolution = "fixed"
|
||
|
|
}
|
||
|
|
if err := s.store.MarkClassificationReviewed(id, resolution, fixIDs); err != nil {
|
||
|
|
s.serverError(w, "mark reviewed", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
http.Redirect(w, r, "/ai", http.StatusSeeOther)
|
||
|
|
}
|
||
|
|
|
||
|
|
func redirectAI(w http.ResponseWriter, r *http.Request, errMsg string) {
|
||
|
|
target := "/ai"
|
||
|
|
if errMsg != "" {
|
||
|
|
target += "?error=" + url.QueryEscape(errMsg)
|
||
|
|
}
|
||
|
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
||
|
|
}
|
||
|
|
|
||
|
|
// fieldOverride is one of the four AI-suggested fields, with the suggestion, the
|
||
|
|
// final value, and whether the user overrode it.
|
||
|
|
type fieldOverride struct {
|
||
|
|
Field string
|
||
|
|
Suggested string
|
||
|
|
Final string
|
||
|
|
Overridden bool
|
||
|
|
}
|
||
|
|
|
||
|
|
// deriveOverrides compares the stored AI suggestion blob against the receipt's final
|
||
|
|
// values to determine which of the four fields the user changed. A field the AI left
|
||
|
|
// null/empty that the user then filled counts as an override (the AI missed it).
|
||
|
|
func deriveOverrides(c storage.ClassificationRow, cats, people []storage.Lookup) []fieldOverride {
|
||
|
|
var cr classifyResponse
|
||
|
|
_ = json.Unmarshal([]byte(c.ResponseJSON), &cr)
|
||
|
|
|
||
|
|
finalAmount := dollars(c.FinalAmountCents)
|
||
|
|
finalDate := c.FinalReceiptDate.Format(dateLayout)
|
||
|
|
finalCat := labelFor(c.FinalCategoryID, cats)
|
||
|
|
finalWho := whoLabel(c.FinalPersonID, people)
|
||
|
|
|
||
|
|
return []fieldOverride{
|
||
|
|
{Field: "amount", Suggested: strPtr(cr.Amount), Final: finalAmount,
|
||
|
|
Overridden: amountOverridden(cr.Amount, c.FinalAmountCents)},
|
||
|
|
{Field: "date", Suggested: strPtr(cr.Date), Final: finalDate,
|
||
|
|
Overridden: dateOverridden(cr.Date, c.FinalReceiptDate)},
|
||
|
|
{Field: "category", Suggested: lookupOr(cr.CategoryID, cats, cr.Category), Final: finalCat,
|
||
|
|
Overridden: int64PtrOverridden(cr.CategoryID, &c.FinalCategoryID)},
|
||
|
|
{Field: "who", Suggested: lookupOr(cr.PersonID, people, cr.Person), Final: finalWho,
|
||
|
|
Overridden: int64PtrOverridden(cr.PersonID, c.FinalPersonID)},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func amountOverridden(suggested *string, finalCents int64) bool {
|
||
|
|
if suggested == nil {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
cents, err := receipt.ParseAmountCents(*suggested)
|
||
|
|
if err != nil {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
return cents != finalCents
|
||
|
|
}
|
||
|
|
|
||
|
|
func dateOverridden(suggested *string, final time.Time) bool {
|
||
|
|
if suggested == nil {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
d, err := time.Parse(dateLayout, strings.TrimSpace(*suggested))
|
||
|
|
if err != nil {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
return d.Year() != final.Year() || d.Month() != final.Month() || d.Day() != final.Day()
|
||
|
|
}
|
||
|
|
|
||
|
|
func int64PtrOverridden(suggested, final *int64) bool {
|
||
|
|
if suggested == nil || final == nil {
|
||
|
|
return suggested != final // both nil → not overridden; one nil → overridden
|
||
|
|
}
|
||
|
|
return *suggested != *final
|
||
|
|
}
|
||
|
|
|
||
|
|
func strPtr(p *string) string {
|
||
|
|
if p == nil || strings.TrimSpace(*p) == "" {
|
||
|
|
return "—"
|
||
|
|
}
|
||
|
|
return *p
|
||
|
|
}
|
||
|
|
|
||
|
|
// lookupOr renders a suggested lookup id as its label, falling back to a label the
|
||
|
|
// blob already carried, then to "—".
|
||
|
|
func lookupOr(id *int64, set []storage.Lookup, fallback string) string {
|
||
|
|
if id != nil {
|
||
|
|
if l := labelFor(*id, set); l != "" {
|
||
|
|
return l
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if strings.TrimSpace(fallback) != "" {
|
||
|
|
return fallback
|
||
|
|
}
|
||
|
|
return "—"
|
||
|
|
}
|