93 lines
3 KiB
Go
93 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
|
||
|
|
}
|