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 "—" }