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
+32 -32
View File
@@ -2,7 +2,6 @@ package classification
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
@@ -32,28 +31,34 @@ func TestBatchClassifiesEveryRowInOneRequest(t *testing.T) {
calls := 0
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
calls++
var req struct {
Messages []struct {
Content string `json:"content"`
} `json:"messages"`
}
if json.NewDecoder(r.Body).Decode(&req) != nil || len(req.Messages) != 2 {
w.WriteHeader(400)
prompt := decodeClassificationPrompt(t, r)
if len(prompt.Transactions) != 2 {
t.Errorf("batch prompt missing transactions: %+v", prompt.Transactions)
w.WriteHeader(http.StatusBadRequest)
return
}
var prompt struct {
Transactions []struct{ Ref, Counterparty, Amount, Currency string } `json:"transactions"`
category := categoryRefForPath(t, prompt.Categories, normalize(domain.CategoryPath(d, "cat_food")))
merchant, tag := "", ""
for _, candidate := range prompt.Merchants {
if candidate.Name == "coffee house" {
merchant = candidate.ID
}
}
if json.Unmarshal([]byte(req.Messages[1].Content), &prompt) != nil || len(prompt.Transactions) != 2 {
t.Errorf("batch prompt missing transactions: %s", req.Messages[1].Content)
for _, candidate := range prompt.Tags {
if candidate.Name == "daily" {
tag = candidate.ID
}
}
if merchant == "" || tag == "" {
t.Error("batch prompt lost Coffee House or Daily")
}
for _, row := range prompt.Transactions {
if row.Amount == "" || row.Currency != "EUR" {
t.Errorf("row %s lost amount or currency: %+v", row.Ref, row)
}
}
reply(w, `{"transactions":[{"ref":"r1","merchant_id":"mer_coffee","new_merchant":null,"category_id":"cat_food","tag_ids":["tag_daily"],"confidence":"high"},`+
`{"ref":"r2","merchant_id":null,"new_merchant":"Kleins Backstube","category_id":"cat_food","tag_ids":[],"confidence":"medium"}]}`)
reply(w, `{"transactions":[{"ref":"`+prompt.Transactions[1].Ref+`","merchant_id":null,"new_merchant":"Kleins Backstube","category_id":"`+category+`","tag_ids":[],"confidence":"medium"},`+
`{"ref":"`+prompt.Transactions[0].Ref+`","merchant_id":"`+merchant+`","new_merchant":null,"category_id":"`+category+`","tag_ids":["`+tag+`"],"confidence":"high"}]}`)
})
results := c.ClassifyBatch(context.Background(), []domain.Facts{f1, f2}, d)
if calls != 1 {
@@ -71,6 +76,8 @@ func TestBatchClassifiesEveryRowInOneRequest(t *testing.T) {
if second.NewMerchant == nil || second.NewMerchant.Name != "Kleins Backstube" ||
!reflect.DeepEqual(second.NewMerchant.Aliases, []string{"Kleins Backstube"}) ||
second.Enrichment.MerchantID != second.NewMerchant.ID ||
second.Enrichment.CategoryID != "cat_food" ||
len(second.Enrichment.TagIDs) != 0 ||
second.Enrichment.Classification.Confidence != "medium" {
t.Fatalf("second row lost: %+v", second)
}
@@ -80,8 +87,10 @@ func TestBatchClassifiesEveryRowInOneRequest(t *testing.T) {
func TestBatchIsolatesInvalidRows(t *testing.T) {
f1, f2, d := batchRows()
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
reply(w, `{"transactions":[{"ref":"r1","merchant_id":null,"new_merchant":null,"category_id":"cat_food","tag_ids":[],"confidence":"high"},`+
`{"ref":"r2","merchant_id":null,"new_merchant":null,"category_id":"cat_forged","tag_ids":[],"confidence":"high"}]}`)
prompt := decodeClassificationPrompt(t, r)
category := categoryRefForPath(t, prompt.Categories, normalize(domain.CategoryPath(d, "cat_food")))
reply(w, `{"transactions":[{"ref":"`+prompt.Transactions[0].Ref+`","merchant_id":null,"new_merchant":null,"category_id":"`+category+`","tag_ids":[],"confidence":"high"},`+
`{"ref":"`+prompt.Transactions[1].Ref+`","merchant_id":null,"new_merchant":null,"category_id":"c999999","tag_ids":[],"confidence":"high"}]}`)
})
results := c.ClassifyBatch(context.Background(), []domain.Facts{f1, f2}, d)
if results[0].Err != nil || results[0].Proposal.Enrichment.CategoryID != "cat_food" {
@@ -96,8 +105,10 @@ func TestBatchIsolatesInvalidRows(t *testing.T) {
func TestBatchSharesOneMintedMerchant(t *testing.T) {
f1, f2, d := batchRows()
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
reply(w, `{"transactions":[{"ref":"r1","merchant_id":null,"new_merchant":"REWE","category_id":"cat_food","tag_ids":[],"confidence":"high"},`+
`{"ref":"r2","merchant_id":null,"new_merchant":"REWE","category_id":"cat_food","tag_ids":[],"confidence":"high"}]}`)
prompt := decodeClassificationPrompt(t, r)
category := categoryRefForPath(t, prompt.Categories, normalize(domain.CategoryPath(d, "cat_food")))
reply(w, `{"transactions":[{"ref":"`+prompt.Transactions[0].Ref+`","merchant_id":null,"new_merchant":"REWE","category_id":"`+category+`","tag_ids":[],"confidence":"high"},`+
`{"ref":"`+prompt.Transactions[1].Ref+`","merchant_id":null,"new_merchant":"REWE","category_id":"`+category+`","tag_ids":[],"confidence":"high"}]}`)
})
results := c.ClassifyBatch(context.Background(), []domain.Facts{f1, f2}, d)
if results[0].Err != nil || results[1].Err != nil {
@@ -144,19 +155,8 @@ func TestBatchSplitsOnProviderSchemaRejection(t *testing.T) {
calls, oversized := 0, 0
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
calls++
var req struct {
Messages []struct {
Content string `json:"content"`
} `json:"messages"`
}
if json.NewDecoder(r.Body).Decode(&req) != nil {
w.WriteHeader(500)
return
}
var prompt struct {
Transactions []struct{ Ref string } `json:"transactions"`
}
_ = json.Unmarshal([]byte(req.Messages[1].Content), &prompt)
prompt := decodeClassificationPrompt(t, r)
category := categoryRefForPath(t, prompt.Categories, normalize(domain.CategoryPath(d, "cat_food")))
if len(prompt.Transactions) > 2 {
oversized++
w.WriteHeader(400)
@@ -164,7 +164,7 @@ func TestBatchSplitsOnProviderSchemaRejection(t *testing.T) {
}
answers := make([]string, 0, len(prompt.Transactions))
for _, row := range prompt.Transactions {
answers = append(answers, `{"ref":"`+row.Ref+`","merchant_id":null,"new_merchant":null,"category_id":"cat_food","tag_ids":[],"confidence":"high"}`)
answers = append(answers, `{"ref":"`+row.Ref+`","merchant_id":null,"new_merchant":null,"category_id":"`+category+`","tag_ids":[],"confidence":"high"}`)
}
reply(w, `{"transactions":[`+strings.Join(answers, ",")+`]}`)
})