Batch Analyse requests and survive opaque provider schema budgets
Analyse now classifies up to ten same-kind transactions per provider request: the registry and history travel once per batch, so a thousand-row backfill costs about a hundred paced requests instead of a thousand. The answer schema appears once — an array item carrying an enum-bound ref — because providers meter strict schemas by token cost: duplicating registry enums per row, or bounding arrays with minItems/maxItems that Gemini expands per element, rejects real registries with a bare HTTP 400. Row count, duplicate refs, duplicate tags and taxonomy bounds are all enforced server-side instead, and a request still rejected outright halves until accepted, remembering the working size for the run. Batch requests scale the HTTP budget by row count, chunk failures cannot abort a run whose later rows succeeded, and rows resolved against one snapshot share one minted merchant. Measured on a real 165-row month over a zero-data-retention route: 165 analysed, 152 proposals, 0 errors, 17 requests, under 8 minutes. Fresh installs default to google/gemini-3.8-flash, the model that demonstrably honors strict structured outputs over a ZDR route. Preview changes now carry counterparty, amount and currency, and the review list shows the amount with a counterparty fallback for banks that leave descriptions empty.
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
package classification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"finance-duck/internal/domain"
|
||||
"finance-duck/internal/ratelimit"
|
||||
)
|
||||
|
||||
func batchRows() (domain.Facts, domain.Facts, domain.Dataset) {
|
||||
f1, d := fixture()
|
||||
f1.Counterparty = "Coffee House"
|
||||
f2 := f1
|
||||
f2.ID, f2.Fingerprint, f2.ExternalID = "tx_two", "fp_two", "ext_two"
|
||||
f2.Amount = "-4.30"
|
||||
f2.Counterparty = "Kleins Backstube"
|
||||
return f1, f2, d
|
||||
}
|
||||
|
||||
// One request classifies every row: the prompt carries all transactions with
|
||||
// refs, and each answer resolves independently against the registry.
|
||||
func TestBatchClassifiesEveryRowInOneRequest(t *testing.T) {
|
||||
f1, f2, d := batchRows()
|
||||
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)
|
||||
return
|
||||
}
|
||||
var prompt struct {
|
||||
Transactions []struct{ Ref, Counterparty, Amount, Currency string } `json:"transactions"`
|
||||
}
|
||||
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 _, 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"}]}`)
|
||||
})
|
||||
results := c.ClassifyBatch(context.Background(), []domain.Facts{f1, f2}, d)
|
||||
if calls != 1 {
|
||||
t.Fatalf("expected one provider request for the batch, got %d", calls)
|
||||
}
|
||||
if results[0].Err != nil || results[1].Err != nil {
|
||||
t.Fatalf("batch rows failed: %v %v", results[0].Err, results[1].Err)
|
||||
}
|
||||
first := results[0].Proposal.Enrichment
|
||||
if first.MerchantID != "mer_coffee" || first.CategoryID != "cat_food" ||
|
||||
!reflect.DeepEqual(first.TagIDs, []string{"tag_daily"}) || first.Classification.Confidence != "high" {
|
||||
t.Fatalf("first row lost: %+v", first)
|
||||
}
|
||||
second := results[1].Proposal
|
||||
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.Classification.Confidence != "medium" {
|
||||
t.Fatalf("second row lost: %+v", second)
|
||||
}
|
||||
}
|
||||
|
||||
// One row's out-of-registry answer fails only that row.
|
||||
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"}]}`)
|
||||
})
|
||||
results := c.ClassifyBatch(context.Background(), []domain.Facts{f1, f2}, d)
|
||||
if results[0].Err != nil || results[0].Proposal.Enrichment.CategoryID != "cat_food" {
|
||||
t.Fatalf("healthy row poisoned: %+v", results[0])
|
||||
}
|
||||
if results[1].Err == nil || results[1].Proposal.Enrichment.Classification.Source != "fallback" {
|
||||
t.Fatalf("forged category accepted: %+v", results[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Two rows naming the same new business share one minted merchant.
|
||||
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"}]}`)
|
||||
})
|
||||
results := c.ClassifyBatch(context.Background(), []domain.Facts{f1, f2}, d)
|
||||
if results[0].Err != nil || results[1].Err != nil {
|
||||
t.Fatalf("batch failed: %v %v", results[0].Err, results[1].Err)
|
||||
}
|
||||
a, b := results[0].Proposal, results[1].Proposal
|
||||
if a.NewMerchant == nil || b.NewMerchant == nil || a.NewMerchant.ID != b.NewMerchant.ID ||
|
||||
a.Enrichment.MerchantID != b.Enrichment.MerchantID {
|
||||
t.Fatalf("duplicate merchants minted: %+v %+v", a.NewMerchant, b.NewMerchant)
|
||||
}
|
||||
}
|
||||
|
||||
// A request-level rate limit fails every row and arms the shared cooldown.
|
||||
func TestBatchRateLimitFailsAllRowsAndArmsCooldown(t *testing.T) {
|
||||
f1, f2, d := batchRows()
|
||||
calls := 0
|
||||
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
_, _ = io.WriteString(w, `{"error":{"code":429,"message":"private"},"choices":[]}`)
|
||||
})
|
||||
c.rate.Store(&ratelimit.Controller{InitialBackoff: time.Minute})
|
||||
results := c.ClassifyBatch(context.Background(), []domain.Facts{f1, f2}, d)
|
||||
var limit *ratelimit.RateLimitError
|
||||
for _, result := range results {
|
||||
if result.Err == nil || !errors.As(result.Err, &limit) || strings.Contains(result.Err.Error(), "private") {
|
||||
t.Fatalf("row not failed as rate limit: %v", result.Err)
|
||||
}
|
||||
}
|
||||
again := c.ClassifyBatch(context.Background(), []domain.Facts{f1, f2}, d)
|
||||
if again[0].Err == nil || !errors.As(again[0].Err, &limit) || calls != 1 {
|
||||
t.Fatalf("cooldown not armed: %v after %d calls", again[0].Err, calls)
|
||||
}
|
||||
}
|
||||
|
||||
// A provider that rejects large schemas outright (Gemini's complexity cap
|
||||
// scales with the registry) must not fail the rows: the chunk halves until
|
||||
// accepted and the client remembers the working size.
|
||||
func TestBatchSplitsOnProviderSchemaRejection(t *testing.T) {
|
||||
f1, f2, d := batchRows()
|
||||
f3 := f1
|
||||
f3.ID, f3.Fingerprint, f3.Counterparty = "tx_three", "fp_three", "Aral"
|
||||
f4 := f1
|
||||
f4.ID, f4.Fingerprint, f4.Counterparty = "tx_four", "fp_four", "ALDI"
|
||||
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)
|
||||
if len(prompt.Transactions) > 2 {
|
||||
oversized++
|
||||
w.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
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"}`)
|
||||
}
|
||||
reply(w, `{"transactions":[`+strings.Join(answers, ",")+`]}`)
|
||||
})
|
||||
results := c.ClassifyBatch(context.Background(), []domain.Facts{f1, f2, f3, f4}, d)
|
||||
for i, result := range results {
|
||||
if result.Err != nil || result.Proposal.Enrichment.CategoryID != "cat_food" {
|
||||
t.Fatalf("row %d lost to schema rejection: %+v", i, result)
|
||||
}
|
||||
}
|
||||
if oversized != 1 || calls != 3 {
|
||||
t.Fatalf("expected one rejected probe then two halves, got %d calls (%d oversized)", calls, oversized)
|
||||
}
|
||||
if c.batchCap() != 2 {
|
||||
t.Fatalf("working batch size not learned: %d", c.batchCap())
|
||||
}
|
||||
// The learned cap is respected up front on the next batch.
|
||||
before := calls
|
||||
_ = c.ClassifyBatch(context.Background(), []domain.Facts{f1, f2, f3, f4}, d)
|
||||
if calls-before != 2 {
|
||||
t.Fatalf("learned cap ignored: %d extra calls", calls-before)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user