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
+5 -5
View File
@@ -54,7 +54,7 @@ func taxonomySchema() map[string]any {
"properties": map[string]any{
"name": name, "parent": map[string]any{"type": "string", "maxLength": 60},
"kind": map[string]any{"type": "string", "enum": []string{"expense", "income"}},
"hint": hint, "because": map[string]any{"type": "array", "maxItems": 8, "items": map[string]any{"type": "string", "maxLength": 500}},
"hint": hint, "because": map[string]any{"type": "array", "items": map[string]any{"type": "string", "maxLength": 500}},
},
}
tag := map[string]any{
@@ -65,15 +65,15 @@ func taxonomySchema() map[string]any {
merchant := map[string]any{
"type": "object", "additionalProperties": false,
"required": []string{"name", "aliases"},
"properties": map[string]any{"name": name, "aliases": map[string]any{"type": "array", "maxItems": 32, "items": name}},
"properties": map[string]any{"name": name, "aliases": map[string]any{"type": "array", "items": name}},
}
return map[string]any{
"type": "object", "additionalProperties": false,
"required": []string{"categories", "tags", "merchants"},
"properties": map[string]any{
"categories": map[string]any{"type": "array", "maxItems": 40, "items": category},
"tags": map[string]any{"type": "array", "maxItems": 12, "items": tag},
"merchants": map[string]any{"type": "array", "maxItems": 150, "items": merchant},
"categories": map[string]any{"type": "array", "items": category},
"tags": map[string]any{"type": "array", "items": tag},
"merchants": map[string]any{"type": "array", "items": merchant},
},
}
}