hsa-app/internal/config/catalog.go
Jean-Michel Tremblay 8b7252dad4 Initial commit: HSA receipt tracker
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>
2026-06-17 21:40:12 -04:00

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
}