hsa-app/internal/web/ai.go

274 lines
7.6 KiB
Go
Raw Permalink Normal View History

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