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:
Lars Nolden
2026-09-13 13:37:06 +02:00
parent 4d8a187079
commit 10314fb1cd
17 changed files with 706 additions and 89 deletions
+189
View File
@@ -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)
}
}