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
+62 -10
View File
@@ -28,6 +28,32 @@ type Client struct {
BaseURL string
rate atomic.Pointer[ratelimit.Controller]
// batchRows is the learned per-request row cap; zero means MaxBatch.
// Providers reject overly complex schemas outright, so ClassifyBatch
// halves and remembers the size that a provider actually accepts.
batchRows atomic.Int32
}
func (c *Client) batchCap() int {
if v := c.batchRows.Load(); v > 0 {
return int(v)
}
return MaxBatch
}
func (c *Client) shrinkBatchCap(n int) {
if n < 1 {
n = 1
}
for {
current := c.batchRows.Load()
if current > 0 && int32(n) >= current {
return
}
if c.batchRows.CompareAndSwap(current, int32(n)) {
return
}
}
}
// WithModel snapshots the configuration while sharing the original client's
@@ -218,24 +244,37 @@ func (c *Client) Classify(ctx context.Context, facts domain.Facts, data domain.D
if err != nil {
return fail("AI classification did not match the required schema")
}
result, err := resolveAnswer(answer, facts, data, candidates, clean, model, map[string]*domain.Merchant{})
if err != nil {
return failError(err)
}
return result, nil
}
// resolveAnswer maps one schema-valid provider answer onto enrichment,
// revalidating every id against the local registry. proposed collects newly
// minted merchants by normalized name so several rows resolved against the
// same snapshot — a batch request — share one proposal instead of minting
// duplicates.
func resolveAnswer(answer answer, facts domain.Facts, data domain.Dataset, candidates candidateSet, clean func(string) string, model string, proposed map[string]*domain.Merchant) (Proposal, error) {
categoryID, ok := candidates.categoryIDs[answer.CategoryID]
if !ok {
return fail("AI selected a category outside the supplied registry")
return Proposal{}, errors.New("AI selected a category outside the supplied registry")
}
e := domain.Fallback(facts)
e.CategoryID = categoryID
for _, id := range answer.TagIDs {
real, ok := candidates.tagIDs[id]
if !ok {
return fail("AI selected a tag outside the supplied registry")
return Proposal{}, errors.New("AI selected a tag outside the supplied registry")
}
e.TagIDs = append(e.TagIDs, real)
}
var proposed *domain.Merchant
var minted *domain.Merchant
if answer.MerchantID != nil {
id, ok := candidates.merchantIDs[*answer.MerchantID]
if !ok {
return fail("AI selected a merchant outside the supplied registry")
return Proposal{}, errors.New("AI selected a merchant outside the supplied registry")
}
e.MerchantID = id
}
@@ -249,24 +288,31 @@ func (c *Client) Classify(ctx context.Context, facts domain.Facts, data domain.D
// no merchant
} else if existing := duplicateMerchant(name, data.Merchants); existing != nil {
e.MerchantID = existing.ID
} else if prior, ok := proposed[normalize(name)]; ok {
minted = prior
e.MerchantID = prior.ID
} else {
aliases := []string{}
if alias := strings.Join(strings.Fields(facts.Counterparty), " "); alias != "" {
aliases = append(aliases, alias)
}
proposed = &domain.Merchant{ID: domain.NewID("mer"), Name: name, Aliases: aliases, DefaultTagIDs: []string{}, UseDefaults: false}
e.MerchantID = proposed.ID
minted = &domain.Merchant{ID: domain.NewID("mer"), Name: name, Aliases: aliases, DefaultTagIDs: []string{}, UseDefaults: false}
proposed[normalize(name)] = minted
e.MerchantID = minted.ID
}
}
e.Classification = domain.Provenance{Source: "openrouter", Model: model, Confidence: answer.Confidence, Timestamp: time.Now().UTC().Format(time.RFC3339)}
validationData := data
if proposed != nil {
validationData.Merchants = append(append([]domain.Merchant{}, data.Merchants...), *proposed)
if len(proposed) > 0 || minted != nil {
validationData.Merchants = append([]domain.Merchant{}, data.Merchants...)
for _, m := range proposed {
validationData.Merchants = append(validationData.Merchants, *m)
}
}
if err := domain.ValidateEnrichment(validationData, facts, e); err != nil {
return fail("AI classification violates domain constraints")
return Proposal{}, errors.New("AI classification violates domain constraints")
}
return Proposal{Enrichment: e, NewMerchant: proposed}, nil
return Proposal{Enrichment: e, NewMerchant: minted}, nil
}
// completion is one strict structured provider request. operation names the
@@ -279,6 +325,9 @@ type completion struct {
schema map[string]any
system string
user string
// timeout raises the per-request budget above the 45-second single-row
// default; a batch answer does one row's work per ref.
timeout time.Duration
}
// complete performs one private structured provider request under an already
@@ -312,6 +361,9 @@ func (c *Client) complete(ctx context.Context, gate *ratelimit.Controller, r com
return "", err
}
client := c.httpClient()
if r.timeout > client.Timeout {
client.Timeout = r.timeout
}
resp, err := gate.Do(ctx, func(ctx context.Context) (*http.Response, error) {
// Each attempt uses identical serialized bytes, credentials and controls.
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/chat/completions", bytes.NewReader(body))