hsa-app/internal/web/scan.go
Jean-Michel Tremblay c715c8e0c0
All checks were successful
Build and Test / build-and-test (push) Successful in 37s
AI classifier correction notes + misread review (AI tab)
Add a global, temporal ai_notes list appended to the classifier prompt
(seeded once from no-PII defaults, documented in README), managed inline
on a new AI tab with a read-only view of the assembled prompt. Every
AI-run upload records the browser-round-tripped suggestion blob + model;
misreads are derived (final field != AI guess) and reviewed one by one
(image + per-field guess-vs-entered + notes-since), attributing which
note fixed each or closing unresolved. Update SPEC (new section 10),
DESIGN item 15, README, and changelog (0.0.3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 16:04:30 -04:00

122 lines
3.4 KiB
Go

package web
import (
"encoding/json"
"io"
"net/http"
"strings"
"time"
"maisym.com/hsa/internal/classify"
"maisym.com/hsa/internal/storage"
)
// classifyResponse is the JSON returned to the upload page to pre-fill the form.
// IDs map the classifier's label suggestions onto the live lookup rows; they are
// null when the classifier returned nothing or the label has no matching row.
type classifyResponse struct {
Amount *string `json:"amount"`
Date *string `json:"date"`
CategoryID *int64 `json:"category_id"`
PersonID *int64 `json:"person_id"`
Category string `json:"category"` // label, for display
Person string `json:"person"` // label, for display
RawName string `json:"raw_name"`
RawDate string `json:"raw_date"`
RawAmount string `json:"raw_amount"`
Model string `json:"model"` // model that ran, for display
CostCents *float64 `json:"cost_cents,omitempty"` // nil when the model's price is unknown
}
// handleClassify accepts an uploaded receipt image and returns suggested form
// values from the AI classifier. It mutates no state — the user still reviews and
// submits via POST /upload.
func (s *Server) handleClassify(w http.ResponseWriter, r *http.Request) {
if s.classifier == nil {
http.Error(w, "classification not configured", http.StatusServiceUnavailable)
return
}
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUploadBytes)
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, "upload too large or malformed", http.StatusBadRequest)
return
}
file, _, err := r.FormFile("receipt")
if err != nil {
http.Error(w, "attach a receipt image or PDF", http.StatusBadRequest)
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
http.Error(w, "could not read the uploaded file", http.StatusBadRequest)
return
}
mimeType := detectMime(data)
if !allowedMime(mimeType) {
http.Error(w, "only images and PDFs are allowed", http.StatusUnsupportedMediaType)
return
}
data = normalizeOrientation(data, mimeType) // upright image reads better for the AI
// Append the user's correction notes to the prompt (best-effort).
var notes []string
if ns, err := s.store.ListActiveNotes(); err == nil {
for _, n := range ns {
notes = append(notes, n.Text)
}
}
sug, err := s.classifier.Classify(r.Context(), time.Now(), data, mimeType, notes)
if err != nil {
s.serverError(w, "classify receipt", err)
return
}
cats, people, err := s.lookups()
if err != nil {
s.serverError(w, "load lookups", err)
return
}
out := classifyResponse{
Amount: sug.Amount,
Date: sug.Date,
Category: sug.Category,
RawName: sug.RawName,
RawDate: sug.RawDate,
RawAmount: sug.RawAmount,
Model: s.classifier.Model,
}
if cents, ok := classify.CostCents(s.classifier.Model, sug.Usage); ok {
out.CostCents = &cents
}
if id, ok := idForLabel(sug.Category, cats); ok {
out.CategoryID = &id
}
if sug.Person != nil {
if id, ok := idForLabel(*sug.Person, people); ok {
out.PersonID = &id
out.Person = *sug.Person
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(out)
}
// idForLabel finds a lookup row by case-insensitive label match.
func idForLabel(label string, set []storage.Lookup) (int64, bool) {
label = strings.TrimSpace(label)
for _, l := range set {
if strings.EqualFold(l.Label, label) {
return l.ID, true
}
}
return 0, false
}