Implement classification redesign
This commit is contained in:
@@ -27,7 +27,7 @@ func fixture() (domain.Facts, domain.Dataset) {
|
||||
return f, d
|
||||
}
|
||||
|
||||
const validAnswer = `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":[]}`
|
||||
const validAnswer = `{"merchant_id":null,"new_merchant":null,"category_id":"cat_food","tag_ids":[],"confidence":"medium"}`
|
||||
|
||||
func reply(w http.ResponseWriter, content string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -76,12 +76,12 @@ func TestForceAIOverridesRuleWithoutChangingKind(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 1 || p.Enrichment.Kind != "expense" || p.Enrichment.Classification.Source != "openrouter" || p.Enrichment.Classification.Model != c.Model || p.Enrichment.CategoryID != domain.ExpenseFallback {
|
||||
if calls != 1 || p.Enrichment.Kind != "expense" || p.Enrichment.Classification.Source != "openrouter" || p.Enrichment.Classification.Model != c.Model || p.Enrichment.CategoryID != "cat_food" {
|
||||
t.Fatalf("forced proposal: %+v, calls=%d", p, calls)
|
||||
}
|
||||
f.Amount = "918.27"
|
||||
p, err = c.Classify(context.Background(), f, d, true)
|
||||
if err != nil || p.Enrichment.Kind != "income" || p.Enrichment.CategoryID != domain.IncomeFallback {
|
||||
if err == nil || p.Enrichment.Kind != "income" || p.Enrichment.CategoryID != domain.IncomeFallback {
|
||||
t.Fatalf("income sign: %+v %v", p, err)
|
||||
}
|
||||
}
|
||||
@@ -156,9 +156,9 @@ func TestMerchantSelectionAndLocalProposal(t *testing.T) {
|
||||
name, content, merchant string
|
||||
new bool
|
||||
}{
|
||||
{"existing", `{"merchant_id":"m1","new_merchant":null,"category_id":"c2","tag_ids":["t1"]}`, "mer_coffee", false},
|
||||
{"duplicate alias", `{"merchant_id":null,"new_merchant":"COFFEE-house","category_id":"c2","tag_ids":["t1"]}`, "mer_coffee", false},
|
||||
{"new", `{"merchant_id":null,"new_merchant":"Bakery Lane","category_id":"c2","tag_ids":["t1"]}`, "", true},
|
||||
{"existing", `{"merchant_id":"mer_coffee","new_merchant":null,"category_id":"cat_food","tag_ids":["tag_daily"],"confidence":"high"}`, "mer_coffee", false},
|
||||
{"duplicate alias", `{"merchant_id":null,"new_merchant":"COFFEE-house","category_id":"cat_food","tag_ids":["tag_daily"],"confidence":"high"}`, "mer_coffee", false},
|
||||
{"new", `{"merchant_id":null,"new_merchant":"Bakery Lane","category_id":"cat_food","tag_ids":["tag_daily"],"confidence":"high"}`, "", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -186,14 +186,13 @@ func TestMerchantSelectionAndLocalProposal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivatePromptAllowlistAndRouting(t *testing.T) {
|
||||
func TestIdentifierOnlyPromptRedactionAndRouting(t *testing.T) {
|
||||
f, d := fixture()
|
||||
f.Counterparty = "Alice Privateperson"
|
||||
f.Counterparty = "Coffee House"
|
||||
f.CounterpartyIBAN = "DE89370400440532013000"
|
||||
d.Accounts[0].IBAN = "DE44500105175407324931"
|
||||
d.Accounts[0].ExternalAccountID = "ext_local_secret"
|
||||
f.RawDescription = "Coffee House -918.27 EUR Alice Privateperson DE89 3704 0044 0532 0130 00 private_external private_fingerprint tx_private account_private ext_local_secret private_source Personal Checking Private Bank 550e8400-e29b-41d4-a716-446655440000 COBADEFFXXX ; reference secretpayment ; user@example.com"
|
||||
d.Merchants[0].Name = "Coffee House Alice Privateperson"
|
||||
var captured map[string]json.RawMessage
|
||||
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/chat/completions" || r.Header.Get("Authorization") != "Bearer test-secret" {
|
||||
@@ -216,21 +215,30 @@ func TestPrivatePromptAllowlistAndRouting(t *testing.T) {
|
||||
if len(messages) != 2 {
|
||||
t.Fatal("unexpected messages")
|
||||
}
|
||||
var prompt map[string]json.RawMessage
|
||||
_ = json.Unmarshal([]byte(messages[1].Content), &prompt)
|
||||
for key := range prompt {
|
||||
switch key {
|
||||
case "description", "categories", "tags", "merchants":
|
||||
default:
|
||||
t.Errorf("non-allowlisted prompt key %q", key)
|
||||
}
|
||||
var prompt struct {
|
||||
Transaction map[string]any `json:"transaction"`
|
||||
History []any `json:"history"`
|
||||
Categories []any `json:"categories"`
|
||||
Tags []any `json:"tags"`
|
||||
Merchants []any `json:"merchants"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(messages[1].Content), &prompt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(prompt.Transaction) == 0 || len(prompt.Categories) == 0 || len(prompt.Merchants) == 0 {
|
||||
t.Fatal("complete structured prompt missing")
|
||||
}
|
||||
lower := strings.ToLower(messages[1].Content)
|
||||
for _, secret := range []string{"918", "27", "alice", "privateperson", "3704", "private_external", "private_fingerprint", "tx_private", "account_private", "ext_local_secret", "private_source", "personal checking", "private bank", "550e8400", "cobadeff", "secretpayment", "example.com", "mer_coffee", "cat_food", "tag_daily"} {
|
||||
for _, secret := range []string{"private_external", "private_fingerprint", "tx_private", "account_private", "ext_local_secret", "private_source", "personal checking", "550e8400", "cobadeff", "secretpayment", "example.com", "alice privateperson", "de89370400440532013000", "de44500105175407324931"} {
|
||||
if strings.Contains(lower, secret) {
|
||||
t.Errorf("prompt leaked %q", secret)
|
||||
}
|
||||
}
|
||||
for _, public := range []string{"coffee house", "918.27", "eur", "private bank"} {
|
||||
if !strings.Contains(lower, public) {
|
||||
t.Errorf("prompt omitted allowed value %q", public)
|
||||
}
|
||||
}
|
||||
var format struct {
|
||||
Type string `json:"type"`
|
||||
Schema struct {
|
||||
@@ -247,13 +255,15 @@ func TestPrivatePromptAllowlistAndRouting(t *testing.T) {
|
||||
}
|
||||
reply(w, validAnswer)
|
||||
})
|
||||
c.PrivateNames = []string{"Alice Privateperson"}
|
||||
if _, err := c.Classify(context.Background(), f, d, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAmountRequiresExplicitOptIn(t *testing.T) {
|
||||
func TestTransactionAmountAndCounterpartyAreSent(t *testing.T) {
|
||||
f, d := fixture()
|
||||
f.Counterparty = "Coffee House"
|
||||
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Messages []struct {
|
||||
@@ -262,16 +272,20 @@ func TestAmountRequiresExplicitOptIn(t *testing.T) {
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
var prompt struct {
|
||||
Amount domain.Money `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
Transaction struct {
|
||||
Amount string `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
Counterparty string `json:"counterparty"`
|
||||
} `json:"transaction"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(req.Messages[1].Content), &prompt)
|
||||
if prompt.Amount != f.Amount || prompt.Currency != "EUR" {
|
||||
t.Errorf("explicit amount missing: %+v", prompt)
|
||||
if prompt.Transaction.Amount != string(f.Amount) ||
|
||||
prompt.Transaction.Currency != "EUR" ||
|
||||
prompt.Transaction.Counterparty != "coffee house" {
|
||||
t.Errorf("transaction context missing: %+v", prompt.Transaction)
|
||||
}
|
||||
reply(w, validAnswer)
|
||||
})
|
||||
c.IncludeAmount = true
|
||||
if _, err := c.Classify(context.Background(), f, d, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -349,7 +363,7 @@ func TestTransportFailureAndInsecureEndpointAreSafe(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedCandidatesAndGlobalDuplicateDetection(t *testing.T) {
|
||||
func TestCompleteRegistryPayloadAndGlobalDuplicateDetection(t *testing.T) {
|
||||
f, d := fixture()
|
||||
d.Merchants = nil
|
||||
for i := range 35 {
|
||||
@@ -358,33 +372,29 @@ func TestBoundedCandidatesAndGlobalDuplicateDetection(t *testing.T) {
|
||||
d.Categories = append(d.Categories, domain.Category{ID: fmt.Sprintf("cat_%02d", i), Name: fmt.Sprintf("Category %02d", i), Kind: "expense", ParentID: "cat_expenses"})
|
||||
}
|
||||
d.Merchants[34].Name = "Distant Bakery"
|
||||
set := retrieve(f.RawDescription, "expense", d, newSanitizer(f, d, false), newSanitizer(f, d, true))
|
||||
if len(set.categories) != 37 || len(set.tags) != 36 || len(set.merchants) != 20 {
|
||||
t.Fatal("merchant bound or complete leaf taxonomy violated")
|
||||
set := retrieve(f.RawDescription, "expense", d, redactor(d, f, nil), redactor(d, f, nil))
|
||||
if len(set.merchantIDs) != 35 || len(set.tags) != 36 {
|
||||
t.Fatalf("complete registry omitted entries: merchants=%d tags=%d", len(set.merchantIDs), len(set.tags))
|
||||
}
|
||||
if set.categoryIDs["c1"] != domain.ExpenseFallback {
|
||||
t.Fatal("fallback omitted from candidate set")
|
||||
}
|
||||
for _, id := range set.merchantIDs {
|
||||
if id == "mer_34" {
|
||||
t.Fatal("fixture duplicate should be outside bounded candidates")
|
||||
}
|
||||
if set.merchantIDs["mer_34"] != "mer_34" ||
|
||||
set.tagIDs["tag_34"] != "tag_34" ||
|
||||
set.categoryIDs["cat_34"] != "cat_34" {
|
||||
t.Fatal("registry omitted real ids")
|
||||
}
|
||||
before := domain.Clone(d)
|
||||
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
tagIDs := make([]string, 36)
|
||||
for i := range tagIDs {
|
||||
tagIDs[i] = fmt.Sprintf("t%d", i+1)
|
||||
}
|
||||
content, _ := json.Marshal(map[string]any{"merchant_id": nil, "new_merchant": "distant-bakery", "category_id": "c37", "tag_ids": tagIDs})
|
||||
content, _ := json.Marshal(map[string]any{
|
||||
"merchant_id": "mer_34",
|
||||
"new_merchant": nil,
|
||||
"category_id": "cat_34",
|
||||
"tag_ids": []string{"tag_34"},
|
||||
"confidence": "high",
|
||||
})
|
||||
reply(w, string(content))
|
||||
})
|
||||
p, err := c.Classify(context.Background(), f, d, true)
|
||||
if err != nil || p.NewMerchant != nil || p.Enrichment.MerchantID != "mer_34" {
|
||||
t.Fatalf("global duplicate missed: %+v %v", p, err)
|
||||
}
|
||||
if p.Enrichment.CategoryID != "cat_food" || len(p.Enrichment.TagIDs) != 36 {
|
||||
t.Fatalf("taxonomy beyond first twenty unavailable: %+v", p.Enrichment)
|
||||
if err != nil || p.Enrichment.MerchantID != "mer_34" || p.Enrichment.CategoryID != "cat_34" || !reflect.DeepEqual(p.Enrichment.TagIDs, []string{"tag_34"}) {
|
||||
t.Fatalf("complete registry selection failed: %+v %v", p, err)
|
||||
}
|
||||
if !reflect.DeepEqual(before, d) {
|
||||
t.Fatal("retrieval mutated registry order")
|
||||
@@ -418,16 +428,51 @@ func TestNearMerchantDeduplicationIsConservative(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeatedPrivateValuesAreAllRedacted(t *testing.T) {
|
||||
func TestConfiguredPrivateNamesAndIdentifiersRedactWithoutRemovingPayee(t *testing.T) {
|
||||
f, d := fixture()
|
||||
f.Counterparty = "Alice"
|
||||
clean := newSanitizer(f, d, false)
|
||||
text := clean("Alice Alice Alice Coffee House cobadeffxxx")
|
||||
if strings.Contains(text, "alice") || strings.Contains(text, "cobadeff") || !strings.Contains(text, "coffee house") {
|
||||
f.Counterparty = "Coffee House"
|
||||
clean := redactor(d, f, []string{"Alice"})
|
||||
text := clean("Alice Alice Alice Coffee House cobadeffxxx DE89370400440532013000")
|
||||
if strings.Contains(text, "alice") || strings.Contains(text, "cobadeff") || strings.Contains(text, "de893704") || !strings.Contains(text, "coffee house") {
|
||||
t.Fatalf("redaction: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLowConfidenceKeepsMerchantAndTagsButUsesFallback(t *testing.T) {
|
||||
f, d := fixture()
|
||||
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
reply(w, `{"merchant_id":"mer_coffee","new_merchant":null,"category_id":"cat_food","tag_ids":["tag_daily"],"confidence":"low"}`)
|
||||
})
|
||||
p, err := c.Classify(context.Background(), f, d, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Enrichment.CategoryID != domain.ExpenseFallback ||
|
||||
p.Enrichment.MerchantID != "mer_coffee" ||
|
||||
!reflect.DeepEqual(p.Enrichment.TagIDs, []string{"tag_daily"}) ||
|
||||
p.Enrichment.Classification.Confidence != "low" {
|
||||
t.Fatalf("low-confidence proposal was not preserved safely: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLearnAliasIsIdempotentAndRejectsAmbiguity(t *testing.T) {
|
||||
f, d := fixture()
|
||||
f.Counterparty = "Coffee Shop Berlin"
|
||||
if !LearnAlias(&d, f, "mer_coffee") || LearnAlias(&d, f, "mer_coffee") {
|
||||
t.Fatal("unambiguous alias was not learned idempotently")
|
||||
}
|
||||
if len(d.Merchants[0].Aliases) != 2 {
|
||||
t.Fatalf("alias was duplicated: %+v", d.Merchants[0].Aliases)
|
||||
}
|
||||
d.Merchants = append(d.Merchants,
|
||||
domain.Merchant{ID: "mer_other", Name: "Other", Aliases: []string{"Shared Shop"}},
|
||||
)
|
||||
f.Counterparty = "Shared Shop"
|
||||
if LearnAlias(&d, f, "mer_coffee") {
|
||||
t.Fatal("ambiguous alias was learned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayeeAliasDefaultsRemainEntirelyLocal(t *testing.T) {
|
||||
f, d := fixture()
|
||||
f.RawDescription = "Card payment reference"
|
||||
@@ -440,7 +485,7 @@ func TestPayeeAliasDefaultsRemainEntirelyLocal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayeeRanksPublicMerchantWithoutExposingRawPayee(t *testing.T) {
|
||||
func TestPayeeAndPublicMerchantAreSentToAI(t *testing.T) {
|
||||
f, d := fixture()
|
||||
f.RawDescription = "Card payment Coffee House"
|
||||
f.Counterparty = "Coffee House"
|
||||
@@ -457,25 +502,27 @@ func TestPayeeRanksPublicMerchantWithoutExposingRawPayee(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var prompt struct {
|
||||
Description string `json:"description"`
|
||||
Merchants []candidate `json:"merchants"`
|
||||
Transaction struct {
|
||||
Description string `json:"description"`
|
||||
Counterparty string `json:"counterparty"`
|
||||
} `json:"transaction"`
|
||||
Merchants []candidate `json:"merchants"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(req.Messages[1].Content), &prompt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(prompt.Description, "coffee") || strings.Contains(req.Messages[1].Content, "counterparty") {
|
||||
t.Error("raw payee exposed")
|
||||
if prompt.Transaction.Counterparty != "coffee house" {
|
||||
t.Errorf("payee was removed from transaction: %+v", prompt.Transaction)
|
||||
}
|
||||
if len(prompt.Merchants) != 20 || prompt.Merchants[0].Name != "coffee house" {
|
||||
t.Fatalf("public canonical merchant was redacted or missed: %+v", prompt.Merchants)
|
||||
if len(prompt.Merchants) != 26 || prompt.Merchants[0].Name != "coffee house" {
|
||||
t.Fatalf("complete merchant registry missing: %d", len(prompt.Merchants))
|
||||
}
|
||||
reply(w, `{"merchant_id":"m1","new_merchant":null,"category_id":"c1","tag_ids":[]}`)
|
||||
reply(w, `{"merchant_id":"mer_coffee","new_merchant":null,"category_id":"cat_food","tag_ids":[],"confidence":"high"}`)
|
||||
})
|
||||
p, err := c.Classify(context.Background(), f, d, true)
|
||||
if err != nil || p.Enrichment.MerchantID != "mer_coffee" {
|
||||
t.Fatalf("payee merchant selection: %+v %v", p, err)
|
||||
}
|
||||
// Ranking must also work when only the local payee, not description, identifies it.
|
||||
f.RawDescription = "Card payment"
|
||||
p, err = c.Classify(context.Background(), f, d, true)
|
||||
if err != nil || p.Enrichment.MerchantID != "mer_coffee" {
|
||||
|
||||
Reference in New Issue
Block a user