Go app for capturing and archiving HSA-eligible receipts: OIDC/PKCE auth against Authelia, SQLite storage with dual-write (filesystem + DB blob), mobile-first upload, and DB export. Adds AI receipt classification: a config.json catalog of people and categories (seeded into the DB on startup), a prompt builder that derives name-order/initial variants from the data (with same-surname ambiguity handling), and an Anthropic tool-use client behind POST /classify. Tests run against a mock endpoint; a live integration test is env-gated to the cheapest model. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
92 lines
3 KiB
Go
92 lines
3 KiB
Go
package config
|
|
|
|
// This file loads the editable, non-secret lists from config.json (path set by
|
|
// CONFIG_PATH) into Go structs: the people a receipt can belong to, and the
|
|
// categories it can fall into. It is separate from config.go, which only reads
|
|
// environment variables (secrets, ports, paths). On startup these lists are
|
|
// seeded into the database; the per-category "examples" are used to build the
|
|
// receipt-classification prompt and deliberately do NOT live in the DB, so they
|
|
// can be tuned without a schema change.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
)
|
|
|
|
// Catalog is the parsed content of config.json.
|
|
type Catalog struct {
|
|
Persons []Person `json:"persons"`
|
|
Categories []Category `json:"categories"`
|
|
}
|
|
|
|
// Person is one possible "Who" for a receipt, stored structurally (last/first/
|
|
// middle) so the classifier can derive the many ways a name may appear on a
|
|
// receipt — different order, a comma, or initials.
|
|
type Person struct {
|
|
Last string `json:"last"`
|
|
First string `json:"first"`
|
|
Middle string `json:"middle"`
|
|
}
|
|
|
|
// Category is one classification bucket plus authored examples (store names,
|
|
// typical line items) that help the classifier decide. Examples are semantic and
|
|
// cannot be derived from the name, so they are authored here by hand.
|
|
type Category struct {
|
|
Name string `json:"name"`
|
|
Examples []string `json:"examples"`
|
|
}
|
|
|
|
// Label is the canonical display name ("First Last"), used both as the DB label
|
|
// and as the exact string the classifier must emit. The middle name is carried
|
|
// only to disambiguate; it is not part of the canonical label.
|
|
func (p Person) Label() string {
|
|
return strings.TrimSpace(strings.TrimSpace(p.First) + " " + strings.TrimSpace(p.Last))
|
|
}
|
|
|
|
// LoadCatalog reads and validates config.json at path. A missing file is an error:
|
|
// the app needs people and categories to function.
|
|
func LoadCatalog(path string) (Catalog, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return Catalog{}, fmt.Errorf("read config file %q: %w", path, err)
|
|
}
|
|
defer f.Close()
|
|
|
|
var c Catalog
|
|
dec := json.NewDecoder(f)
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(&c); err != nil {
|
|
return Catalog{}, fmt.Errorf("parse config file %q: %w", path, err)
|
|
}
|
|
for i, p := range c.Persons {
|
|
if strings.TrimSpace(p.First) == "" || strings.TrimSpace(p.Last) == "" {
|
|
return Catalog{}, fmt.Errorf("person %d: first and last are required", i)
|
|
}
|
|
}
|
|
for i, cat := range c.Categories {
|
|
if strings.TrimSpace(cat.Name) == "" {
|
|
return Catalog{}, fmt.Errorf("category %d: name is required", i)
|
|
}
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// CategoryNames returns the category labels in config order.
|
|
func (c Catalog) CategoryNames() []string {
|
|
out := make([]string, 0, len(c.Categories))
|
|
for _, cat := range c.Categories {
|
|
out = append(out, cat.Name)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// PersonLabels returns the canonical person labels in config order.
|
|
func (c Catalog) PersonLabels() []string {
|
|
out := make([]string, 0, len(c.Persons))
|
|
for _, p := range c.Persons {
|
|
out = append(out, p.Label())
|
|
}
|
|
return out
|
|
}
|