Use compact classification IDs and extend preview lifetime

This commit is contained in:
Lars Nolden
2026-09-14 09:31:44 +02:00
parent 77f4ea5655
commit 46e02d95cb
8 changed files with 424 additions and 142 deletions
+141 -60
View File
@@ -28,7 +28,7 @@ func fixture() (domain.Facts, domain.Dataset) {
return f, d
}
const validAnswer = `{"merchant_id":null,"new_merchant":null,"category_id":"cat_food","tag_ids":[],"confidence":"medium"}`
const validAnswer = `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":[],"confidence":"medium"}`
func reply(w http.ResponseWriter, content string) {
w.Header().Set("Content-Type", "application/json")
@@ -44,6 +44,39 @@ func mockClient(t *testing.T, handler http.HandlerFunc) *Client {
return client
}
type classificationPrompt struct {
Categories []categoryPrompt `json:"categories"`
Tags []tagPrompt `json:"tags"`
Merchants []merchantPrompt `json:"merchants"`
History []promptHistory `json:"history"`
Transactions []struct {
Ref string `json:"ref"`
Counterparty string `json:"counterparty"`
Amount string `json:"amount"`
Currency string `json:"currency"`
} `json:"transactions"`
}
func decodeClassificationPrompt(t *testing.T, r *http.Request) classificationPrompt {
t.Helper()
var req struct {
Messages []struct {
Content string `json:"content"`
} `json:"messages"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatal(err)
}
if len(req.Messages) != 2 {
t.Fatalf("expected system and user messages, got %d", len(req.Messages))
}
var prompt classificationPrompt
if err := json.Unmarshal([]byte(req.Messages[1].Content), &prompt); err != nil {
t.Fatal(err)
}
return prompt
}
func TestExplicitDefaultsAreOptInAndBypassAI(t *testing.T) {
f, d := fixture()
d.Merchants[0].UseDefaults = true
@@ -72,7 +105,22 @@ func TestForceAIOverridesRuleWithoutChangingKind(t *testing.T) {
f, d := fixture()
d.Merchants[0].UseDefaults = true
calls := 0
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) { calls++; reply(w, validAnswer) })
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
calls++
prompt := decodeClassificationPrompt(t, r)
// Food is an expense-only choice; do not reuse c1 after the request
// switches to income, where that reference names a different category.
categoryID := "c999"
for _, category := range prompt.Categories {
if category.Path == normalize(domain.CategoryPath(d, "cat_food")) {
categoryID = category.ID
}
if calls == 2 && category.Kind != "income" {
t.Errorf("income request offered an expense category: %+v", category)
}
}
reply(w, fmt.Sprintf(`{"merchant_id":null,"new_merchant":null,"category_id":%q,"tag_ids":[],"confidence":"medium"}`, categoryID))
})
p, err := c.Classify(context.Background(), f, d, true)
if err != nil {
t.Fatal(err)
@@ -82,7 +130,7 @@ func TestForceAIOverridesRuleWithoutChangingKind(t *testing.T) {
}
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 || calls != 2 || p.Enrichment.Kind != "income" || p.Enrichment.CategoryID != domain.IncomeFallback {
t.Fatalf("income sign: %+v %v", p, err)
}
}
@@ -117,21 +165,24 @@ func TestTransferNeverCallsAIOrAliases(t *testing.T) {
func TestInvalidModelOutputsFailClosed(t *testing.T) {
cases := map[string]string{
"unknown key": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":[],"confidence":0.9}`,
"change kind": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":[],"kind":"transfer"}`,
"missing field": `{"merchant_id":null,"category_id":"c1","tag_ids":[]}`,
"duplicate key": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","category_id":"c2","tag_ids":[]}`,
"case folded key": `{"Merchant_ID":null,"new_merchant":null,"category_id":"c1","tag_ids":[]}`,
"unknown category": `{"merchant_id":null,"new_merchant":null,"category_id":"cat_invented","tag_ids":[]}`,
"real ID not offered": `{"merchant_id":null,"new_merchant":null,"category_id":"cat_food","tag_ids":[]}`,
"unknown tag": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":["t999"]}`,
"duplicate tags": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":["t1","t1"]}`,
"null tags": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":null}`,
"null tag member": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":[null]}`,
"unknown merchant": `{"merchant_id":"m999","new_merchant":null,"category_id":"c1","tag_ids":[]}`,
"both merchant modes": `{"merchant_id":"m1","new_merchant":"Coffee","category_id":"c1","tag_ids":[]}`,
"blank proposal": `{"merchant_id":null,"new_merchant":" ","category_id":"c1","tag_ids":[]}`,
"wrong scalar": `{"merchant_id":23,"new_merchant":null,"category_id":"c1","tag_ids":[]}`,
"unknown key": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":[],"confidence":"high","unexpected":true}`,
"change kind": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":[],"confidence":"high","kind":"transfer"}`,
"missing field": `{"merchant_id":null,"category_id":"c1","tag_ids":[],"confidence":"high"}`,
"duplicate key": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","category_id":"c2","tag_ids":[],"confidence":"high"}`,
"case folded key": `{"Merchant_ID":null,"new_merchant":null,"category_id":"c1","tag_ids":[],"confidence":"high"}`,
"unknown category": `{"merchant_id":null,"new_merchant":null,"category_id":"c999","tag_ids":[],"confidence":"high"}`,
"canonical category": `{"merchant_id":null,"new_merchant":null,"category_id":"cat_food","tag_ids":[],"confidence":"high"}`,
"unknown tag": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":["t999"],"confidence":"high"}`,
"canonical tag": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":["tag_daily"],"confidence":"high"}`,
"duplicate tags": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":["t1","t1"],"confidence":"high"}`,
"null tags": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":null,"confidence":"high"}`,
"null tag member": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":[null],"confidence":"high"}`,
"unknown merchant": `{"merchant_id":"m999","new_merchant":null,"category_id":"c1","tag_ids":[],"confidence":"high"}`,
"canonical merchant": `{"merchant_id":"mer_coffee","new_merchant":null,"category_id":"c1","tag_ids":[],"confidence":"high"}`,
"both merchant modes": `{"merchant_id":"m1","new_merchant":"Coffee","category_id":"c1","tag_ids":[],"confidence":"high"}`,
"blank proposal": `{"merchant_id":null,"new_merchant":" ","category_id":"c1","tag_ids":[],"confidence":"high"}`,
"wrong scalar": `{"merchant_id":23,"new_merchant":null,"category_id":"c1","tag_ids":[],"confidence":"high"}`,
"numeric confidence": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":[],"confidence":0.9}`,
"trailing JSON": validAnswer + ` {}`,
"markdown": "```json\n" + validAnswer + "\n```",
"array": "[" + validAnswer + "]",
@@ -157,9 +208,9 @@ func TestMerchantSelectionAndLocalProposal(t *testing.T) {
name, content, merchant string
new bool
}{
{"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},
{"existing", `{"merchant_id":"m1","new_merchant":null,"category_id":"c1","tag_ids":["t1"],"confidence":"high"}`, "mer_coffee", false},
{"duplicate alias", `{"merchant_id":null,"new_merchant":"COFFEE-house","category_id":"c1","tag_ids":["t1"],"confidence":"high"}`, "mer_coffee", false},
{"new", `{"merchant_id":null,"new_merchant":"Bakery Lane","category_id":"c1","tag_ids":["t1"],"confidence":"high"}`, "", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -216,18 +267,14 @@ func TestIdentifierOnlyPromptRedactionAndRouting(t *testing.T) {
if len(messages) != 2 {
t.Fatal("unexpected messages")
}
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 {
wire, err := json.Marshal(captured)
if err != nil {
t.Fatal(err)
}
if len(prompt.Transaction) == 0 || len(prompt.Categories) == 0 || len(prompt.Merchants) == 0 {
t.Fatal("complete structured prompt missing")
for _, canonicalID := range []string{"cat_food", "cat_expenses", "cat_income", "mer_coffee", "tag_daily"} {
if strings.Contains(string(wire), canonicalID) {
t.Errorf("request or response schema exposed canonical ID %q", canonicalID)
}
}
lower := strings.ToLower(messages[1].Content)
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"} {
@@ -300,7 +347,7 @@ func TestUnsafeMerchantProposalDroppedWithoutLosingClassification(t *testing.T)
t.Run(name, func(t *testing.T) {
f, d := fixture()
f.Counterparty = "Alice Privateperson"
answer, _ := json.Marshal(map[string]any{"merchant_id": nil, "new_merchant": name, "category_id": "cat_food", "tag_ids": []string{}, "confidence": "high"})
answer, _ := json.Marshal(map[string]any{"merchant_id": nil, "new_merchant": name, "category_id": "c1", "tag_ids": []string{}, "confidence": "high"})
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) { reply(w, string(answer)) })
c.PrivateNames = []string{"Alice Privateperson"}
p, err := c.Classify(context.Background(), f, d, true)
@@ -410,33 +457,67 @@ func TestCompleteRegistryPayloadAndGlobalDuplicateDetection(t *testing.T) {
d.Tags = append(d.Tags, domain.Tag{ID: fmt.Sprintf("tag_%02d", i), Name: fmt.Sprintf("Tag %02d", i)})
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, 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.merchantIDs["mer_34"] != "mer_34" ||
set.tagIDs["tag_34"] != "tag_34" ||
set.categoryIDs["cat_34"] != "cat_34" {
t.Fatal("registry omitted real ids")
}
d.Merchants[34].Name = "Z Distant Bakery"
before := domain.Clone(d)
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
content, _ := json.Marshal(map[string]any{
"merchant_id": "mer_34",
"new_merchant": nil,
"category_id": "cat_34",
"tag_ids": []string{"tag_34"},
"confidence": "high",
for _, mode := range []string{"existing", "duplicate name"} {
t.Run(mode, func(t *testing.T) {
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
prompt := decodeClassificationPrompt(t, r)
if len(prompt.Merchants) != 35 || len(prompt.Tags) != 36 || len(prompt.Categories) != 37 {
t.Fatalf("complete candidates missing: merchants=%d tags=%d categories=%d", len(prompt.Merchants), len(prompt.Tags), len(prompt.Categories))
}
merchants, categories, tags := map[string]string{}, map[string]string{}, map[string]string{}
for _, merchant := range prompt.Merchants {
merchants[merchant.Name] = merchant.ID
}
for _, category := range prompt.Categories {
if category.Kind != "expense" {
t.Errorf("ineligible category candidate: %+v", category)
}
categories[category.Path] = category.ID
}
for _, tag := range prompt.Tags {
tags[tag.Name] = tag.ID
}
for _, merchant := range d.Merchants {
if merchants[normalize(merchant.Name)] == "" {
t.Errorf("merchant omitted: %s", merchant.Name)
}
}
for _, category := range d.Categories {
if category.Kind == "expense" && category.ID != "cat_expenses" && categories[normalize(domain.CategoryPath(d, category.ID))] == "" {
t.Errorf("eligible category omitted: %s", category.Name)
}
}
for _, tag := range d.Tags {
if tags[normalize(tag.Name)] == "" {
t.Errorf("tag omitted: %s", tag.Name)
}
}
var merchantID, newMerchant any = merchants["z distant bakery"], nil
if mode == "duplicate name" {
merchantID, newMerchant = nil, "Z Distant Bakery"
}
content, err := json.Marshal(map[string]any{
"merchant_id": merchantID,
"new_merchant": newMerchant,
"category_id": categories[normalize(domain.CategoryPath(d, "cat_34"))],
"tag_ids": []string{tags["tag 34"]},
"confidence": "high",
})
if err != nil {
t.Fatal(err)
}
reply(w, string(content))
})
p, err := c.Classify(context.Background(), f, d, true)
if err != nil || p.NewMerchant != 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("classification mutated the dataset")
}
})
reply(w, string(content))
})
p, err := c.Classify(context.Background(), f, d, true)
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")
}
}
@@ -480,7 +561,7 @@ func TestConfiguredPrivateNamesAndIdentifiersRedactWithoutRemovingPayee(t *testi
func TestLowConfidenceKeepsProposalAndRecordsConfidence(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"}`)
reply(w, `{"merchant_id":"m1","new_merchant":null,"category_id":"c1","tag_ids":["t1"],"confidence":"low"}`)
})
p, err := c.Classify(context.Background(), f, d, true)
if err != nil {
@@ -547,7 +628,7 @@ func TestPayeeAndPublicMerchantAreSentToAI(t *testing.T) {
Description string `json:"description"`
Counterparty string `json:"counterparty"`
} `json:"transaction"`
Merchants []candidate `json:"merchants"`
Merchants []merchantPrompt `json:"merchants"`
}
if err := json.Unmarshal([]byte(req.Messages[1].Content), &prompt); err != nil {
t.Fatal(err)
@@ -558,7 +639,7 @@ func TestPayeeAndPublicMerchantAreSentToAI(t *testing.T) {
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":"mer_coffee","new_merchant":null,"category_id":"cat_food","tag_ids":[],"confidence":"high"}`)
reply(w, fmt.Sprintf(`{"merchant_id":%q,"new_merchant":null,"category_id":"c1","tag_ids":[],"confidence":"high"}`, prompt.Merchants[0].ID))
})
p, err := c.Classify(context.Background(), f, d, true)
if err != nil || p.Enrichment.MerchantID != "mer_coffee" {