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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user