Use compact classification IDs and extend preview lifetime
This commit is contained in:
@@ -2,9 +2,11 @@ package classification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -60,6 +62,17 @@ func ledgerFixture() (domain.Dataset, domain.Facts) {
|
||||
return d, facts
|
||||
}
|
||||
|
||||
func categoryRefForPath(t *testing.T, categories []categoryPrompt, path string) string {
|
||||
t.Helper()
|
||||
for _, category := range categories {
|
||||
if category.Path == path {
|
||||
return category.ID
|
||||
}
|
||||
}
|
||||
t.Errorf("category path %q missing from prompt: %+v", path, categories)
|
||||
return ""
|
||||
}
|
||||
|
||||
// strictKeywords is what every targeted provider accepts in strict
|
||||
// structured-output mode. uniqueItems is rejected outright by OpenAI-family
|
||||
// endpoints ("'uniqueItems' is not permitted"); minItems/maxItems make Gemini
|
||||
@@ -115,7 +128,9 @@ func TestLedgerRowClassifiesThroughStrictSchema(t *testing.T) {
|
||||
}
|
||||
}
|
||||
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
reply(w, `{"merchant_id":null,"new_merchant":"Finanzamt Bruehl","category_id":"`+taxes+`","tag_ids":[],"confidence":"high"}`)
|
||||
prompt := decodeClassificationPrompt(t, r)
|
||||
category := categoryRefForPath(t, prompt.Categories, normalize(domain.CategoryPath(d, taxes)))
|
||||
reply(w, `{"merchant_id":null,"new_merchant":"Finanzamt Bruehl","category_id":"`+category+`","tag_ids":[],"confidence":"high"}`)
|
||||
})
|
||||
p, err := c.Classify(context.Background(), facts, d, true)
|
||||
if err != nil {
|
||||
@@ -188,18 +203,123 @@ func TestManualCorrectionsOutrankAIPrecedent(t *testing.T) {
|
||||
add("tx_corrected", "2026-08-01", events, "manual")
|
||||
target := domain.Facts{ID: "tx_new", AccountID: "acct_kontist", BookingDate: "2026-08-30",
|
||||
Amount: "-13.00", Currency: "EUR", Counterparty: "LVR Landesmuseum Bonn"}
|
||||
rows := history(target, d, func(s string) string { return normalize(s) }, 20)
|
||||
if len(rows) == 0 || rows[0].Source != "user" || rows[0].CategoryID != events {
|
||||
set := retrieve("", "expense", d, nil, nil)
|
||||
rows := set.history(target, d, normalize, 20)
|
||||
eventsRef := categoryRefForPath(t, set.categories, domain.CategoryPath(d, events))
|
||||
if len(rows) == 0 {
|
||||
t.Fatal("manual correction missing from precedent")
|
||||
}
|
||||
if rows[0].Source != "user" || rows[0].CategoryID != eventsRef {
|
||||
t.Fatalf("manual correction did not lead precedent: %+v", rows[0])
|
||||
}
|
||||
// The correction keeps its slot even in a window the AI rows could fill.
|
||||
users := 0
|
||||
for _, row := range rows {
|
||||
if row.Source == "user" {
|
||||
users++
|
||||
}
|
||||
|
||||
func TestHistoryReferencesResolveThroughCurrentRequestCandidates(t *testing.T) {
|
||||
facts, d := fixture()
|
||||
d.Categories = append(d.Categories, domain.Category{ID: "cat_salary", Name: "Salary", ParentID: "cat_income", Kind: "income"})
|
||||
d.Merchants = append(d.Merchants, domain.Merchant{ID: "mer_payroll", Name: "Payroll", DefaultCategoryID: "cat_salary"})
|
||||
manual := facts
|
||||
manual.ID, manual.Fingerprint, manual.BookingDate = "tx_manual", "fp_manual", "2026-08-01"
|
||||
d.Transactions = append(d.Transactions, domain.Transaction{Facts: manual, Enrichment: domain.Enrichment{
|
||||
Kind: "expense", CategoryID: "cat_food", MerchantID: "mer_coffee", TagIDs: []string{"tag_daily"},
|
||||
Classification: domain.Provenance{Source: "manual"},
|
||||
}})
|
||||
income := manual
|
||||
income.ID, income.Fingerprint, income.BookingDate, income.Amount = "tx_income", "fp_income", "2026-08-31", "100.00"
|
||||
d.Transactions = append(d.Transactions, domain.Transaction{Facts: income, Enrichment: domain.Enrichment{
|
||||
Kind: "income", CategoryID: "cat_salary", MerchantID: "mer_payroll", TagIDs: []string{"tag_daily"},
|
||||
Classification: domain.Provenance{Source: "manual"},
|
||||
}})
|
||||
categoryPattern := regexp.MustCompile(`^c[1-9][0-9]*$`)
|
||||
merchantPattern := regexp.MustCompile(`^m[1-9][0-9]*$`)
|
||||
tagPattern := regexp.MustCompile(`^t[1-9][0-9]*$`)
|
||||
expectedCategories := 2
|
||||
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
prompt := decodeClassificationPrompt(t, r)
|
||||
if len(prompt.Categories) != expectedCategories || len(prompt.Merchants) != len(d.Merchants) || len(prompt.Tags) != len(d.Tags) {
|
||||
t.Errorf("request lost eligible registry candidates: categories=%d merchants=%d tags=%d",
|
||||
len(prompt.Categories), len(prompt.Merchants), len(prompt.Tags))
|
||||
}
|
||||
}
|
||||
if users == 0 {
|
||||
t.Fatal("correction crowded out of the history window")
|
||||
categories := make(map[string]bool)
|
||||
for _, candidate := range prompt.Categories {
|
||||
if !categoryPattern.MatchString(candidate.ID) || candidate.Kind != "expense" || categories[candidate.ID] {
|
||||
t.Errorf("invalid expense category reference: %+v", candidate)
|
||||
}
|
||||
categories[candidate.ID] = true
|
||||
}
|
||||
food := categoryRefForPath(t, prompt.Categories, normalize(domain.CategoryPath(d, "cat_food")))
|
||||
merchants := make(map[string]bool)
|
||||
coffee := ""
|
||||
for _, candidate := range prompt.Merchants {
|
||||
if !merchantPattern.MatchString(candidate.ID) || merchants[candidate.ID] {
|
||||
t.Errorf("invalid merchant reference: %+v", candidate)
|
||||
}
|
||||
merchants[candidate.ID] = true
|
||||
if candidate.UsualCategory != "" && !categories[candidate.UsualCategory] {
|
||||
t.Errorf("merchant has dangling usual category: %+v", candidate)
|
||||
}
|
||||
if candidate.Name == "coffee house" {
|
||||
coffee = candidate.ID
|
||||
if candidate.UsualCategory != food {
|
||||
t.Errorf("merchant usual category does not identify Food: %+v", candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
tags := make(map[string]bool)
|
||||
daily := ""
|
||||
for _, candidate := range prompt.Tags {
|
||||
if !tagPattern.MatchString(candidate.ID) || tags[candidate.ID] {
|
||||
t.Errorf("invalid tag reference: %+v", candidate)
|
||||
}
|
||||
tags[candidate.ID] = true
|
||||
if candidate.Name == "daily" {
|
||||
daily = candidate.ID
|
||||
}
|
||||
}
|
||||
if coffee == "" || daily == "" {
|
||||
t.Error("request lost Coffee House or Daily")
|
||||
}
|
||||
if len(prompt.History) != 1 {
|
||||
t.Errorf("expected only applicable manual expense history, got %+v", prompt.History)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
history := prompt.History[0]
|
||||
if history.Source != "user" || history.CategoryID != food || history.MerchantID != coffee ||
|
||||
!reflect.DeepEqual(history.TagIDs, []string{daily}) {
|
||||
t.Errorf("manual history references do not match offered records: %+v", history)
|
||||
}
|
||||
// Copying the correction must select the original registry records, not
|
||||
// whatever records occupied these request-local references previously.
|
||||
answer, err := json.Marshal(map[string]any{
|
||||
"merchant_id": history.MerchantID, "new_merchant": nil,
|
||||
"category_id": history.CategoryID, "tag_ids": history.TagIDs, "confidence": "high",
|
||||
})
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
reply(w, string(answer))
|
||||
})
|
||||
for _, name := range []string{"original registry", "shifted registry"} {
|
||||
if name == "shifted registry" {
|
||||
// New names sort before every selected record and change all three
|
||||
// references without changing the canonical correction.
|
||||
d.Categories = append(d.Categories, domain.Category{ID: "cat_early", Name: "Aardvark", ParentID: "cat_expenses", Kind: "expense"})
|
||||
d.Merchants = append(d.Merchants, domain.Merchant{ID: "mer_early", Name: "Aardvark"})
|
||||
d.Tags = append(d.Tags, domain.Tag{ID: "tag_early", Name: "Aardvark"})
|
||||
expectedCategories++
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
p, err := c.Classify(context.Background(), facts, d, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.NewMerchant != nil || p.Enrichment.CategoryID != "cat_food" || p.Enrichment.MerchantID != "mer_coffee" ||
|
||||
!reflect.DeepEqual(p.Enrichment.TagIDs, []string{"tag_daily"}) {
|
||||
t.Fatalf("manual precedent resolved to wrong canonical records: %+v", p)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user