Make OpenAI-family strict mode routable and stop redacting payee words

Strict structured-output mode rejects uniqueItems, so every request to a
gpt-5.6-family zero-data-retention endpoint failed with HTTP 400 behind
a generic error; duplicates were already rejected server-side, so the
keyword leaves the wire schemas, pinned by a strict-keyword allowlist
test built from the ledger that hit this.

The bare-BIC redaction pattern deleted every 8- and 11-letter word —
Openbank, BAUMARKT, RACETRACKER — blinding the model to the payee it
was asked to classify and tripping the unsafe-merchant check on honest
answers. BICs now die only labeled or attached to their IBAN, account
labels join the redaction secrets, an identifier-shaped merchant name
degrades to a merchant-less proposal instead of failing the row, and a
provider error inside an HTTP 200 envelope is reported as such (numeric
code only) instead of as envelope corruption.
This commit is contained in:
Lars Nolden
2026-09-12 23:17:24 +02:00
parent ec99434002
commit c5999adb1b
8 changed files with 223 additions and 25 deletions
+22 -4
View File
@@ -241,10 +241,13 @@ func (c *Client) Classify(ctx context.Context, facts domain.Facts, data domain.D
}
if answer.NewMerchant != nil {
name := strings.Join(strings.Fields(*answer.NewMerchant), " ")
// An identifier-shaped or oversized name is dropped, never stored, but
// the row keeps its independently enum-validated category and tags: a
// legitimate payee whose spelling trips the redactor (observed in the
// field) must not lose its whole classification.
if !utf8.ValidString(name) || utf8.RuneCountInString(name) > 100 || normalize(name) == "" || normalize(clean(name)) != normalize(name) {
return fail("AI proposed an unsafe merchant name")
}
if existing := duplicateMerchant(name, data.Merchants); existing != nil {
// no merchant
} else if existing := duplicateMerchant(name, data.Merchants); existing != nil {
e.MerchantID = existing.ID
} else {
aliases := []string{}
@@ -352,7 +355,22 @@ func (c *Client) complete(ctx context.Context, gate *ratelimit.Controller, r com
} `json:"message"`
} `json:"choices"`
}
if json.Unmarshal(raw, &envelope) != nil || (len(envelope.Error) > 0 && string(envelope.Error) != "null") || len(envelope.Choices) != 1 {
if json.Unmarshal(raw, &envelope) != nil {
return "", errors.New("invalid AI response envelope")
}
if len(envelope.Error) > 0 && string(envelope.Error) != "null" {
// The provider reported a failure inside an HTTP 200 envelope. Only
// its numeric code is safe to surface; the message may quote content.
var detail struct {
Code int `json:"code"`
}
_ = json.Unmarshal(envelope.Error, &detail)
if detail.Code != 0 {
return "", fmt.Errorf("AI provider reported an error (code %d)", detail.Code)
}
return "", errors.New("AI provider reported an error")
}
if len(envelope.Choices) != 1 {
return "", errors.New("invalid AI response envelope")
}
choice := envelope.Choices[0]