hsa-app/internal/config/catalog.go

93 lines
3 KiB
Go
Raw Normal View History

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
}