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 = ¢s } 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 }