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>
90 lines
2.6 KiB
Go
90 lines
2.6 KiB
Go
package classify
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"maisym.com/hsa/internal/config"
|
|
)
|
|
|
|
func contains(list []string, want string) bool {
|
|
for _, s := range list {
|
|
if s == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// The Tremblay family: Jean-Michel and Jude share the initial "J", so neither may
|
|
// get a bare "J. Tremblay" variant; Alex ("A") and Evan ("E") are unique and may.
|
|
func TestNameVariantsAmbiguousInitial(t *testing.T) {
|
|
all := []config.Person{
|
|
{First: "Jean-Michel", Last: "Tremblay"},
|
|
{First: "Jude", Last: "Tremblay"},
|
|
{First: "Alex", Last: "Tremblay"},
|
|
{First: "Evan", Last: "Tremblay"},
|
|
{First: "Lynna", Last: "Nguyen"},
|
|
}
|
|
|
|
jm := NameVariants(all[0], all)
|
|
if contains(jm, "J. Tremblay") || contains(jm, "Tremblay, J.") {
|
|
t.Errorf("Jean-Michel must not get an ambiguous single-initial variant: %v", jm)
|
|
}
|
|
// Compound initials are unique to Jean-Michel and should appear.
|
|
if !contains(jm, "J-M Tremblay") {
|
|
t.Errorf("Jean-Michel should get compound initial variant J-M Tremblay: %v", jm)
|
|
}
|
|
|
|
jude := NameVariants(all[1], all)
|
|
if contains(jude, "J. Tremblay") {
|
|
t.Errorf("Jude must not get an ambiguous single-initial variant: %v", jude)
|
|
}
|
|
|
|
alex := NameVariants(all[2], all)
|
|
if !contains(alex, "A. Tremblay") {
|
|
t.Errorf("Alex (unique initial) should get A. Tremblay: %v", alex)
|
|
}
|
|
|
|
// Lynna's surname is unique, so a single initial is fine.
|
|
lynna := NameVariants(all[4], all)
|
|
if !contains(lynna, "L. Nguyen") {
|
|
t.Errorf("Lynna (unique surname) should get L. Nguyen: %v", lynna)
|
|
}
|
|
|
|
// Order/comma variants are always present and unambiguous.
|
|
for _, want := range []string{"Jean-Michel Tremblay", "Tremblay Jean-Michel", "Tremblay, Jean-Michel"} {
|
|
if !contains(jm, want) {
|
|
t.Errorf("expected variant %q in %v", want, jm)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildSystemPrompt(t *testing.T) {
|
|
persons := []config.Person{
|
|
{First: "Jean-Michel", Last: "Tremblay"},
|
|
{First: "Lynna", Last: "Nguyen"},
|
|
}
|
|
categories := []config.Category{
|
|
{Name: "Pharmacy", Examples: []string{"CVS", "Rx"}},
|
|
{Name: "Other"},
|
|
}
|
|
p := BuildSystemPrompt(persons, categories, "2026-06-17")
|
|
|
|
for _, want := range []string{
|
|
"Jean-Michel Tremblay", // canonical label
|
|
"Lynna Nguyen",
|
|
"Pharmacy",
|
|
"CVS", // example injected
|
|
"2026-06-17", // today
|
|
"AMBIGUOUS", // the ambiguity rule
|
|
"IGNORE everyone", // ignore-other-people rule
|
|
"null", // unknown handling
|
|
"raw_name", // raw echo
|
|
"CLOSEST to today", // date tie-breaker
|
|
} {
|
|
if !strings.Contains(p, want) {
|
|
t.Errorf("system prompt missing %q", want)
|
|
}
|
|
}
|
|
}
|