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
+10 -2
View File
@@ -12,10 +12,15 @@ import (
var bankingPatterns = []*regexp.Regexp{
// Apply before tokenization to capture formatted identifiers as a unit.
regexp.MustCompile(`(?i)\b[a-z]{2}\s*\d{2}(?:[ -]?[a-z0-9]){11,30}\b`),
// An IBAN may carry its BIC as the next token; both go as one unit. A
// *bare* BIC-shaped token is deliberately not redacted: the shape matches
// every 8- or 11-letter word ("Openbank", "BAUMARKT", "RACETRACKER"),
// which blinded the model to the very payee it should classify, and a
// bank code reveals nothing the prompt's institution field does not.
// Labeled forms ("BIC ...", "SWIFT ...") die with the label below.
regexp.MustCompile(`(?i)\b[a-z]{2}\s*\d{2}(?:[ -]?[a-z0-9]){11,30}\b(?:\s+[a-z]{6}[a-z0-9]{2}(?:[a-z0-9]{3})?\b)?`),
regexp.MustCompile(`(?i)\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b`),
regexp.MustCompile(`(?i)\b(?:iban|bic|swift|account(?:\s*(?:number|no))?|konto(?:nummer)?|reference|ref|payment\s*(?:id|reference)|end\s*to\s*end(?:\s*id)?|e2e|eref|mref|kref|cred|mandate|mandat(?:sreferenz)?|kunden(?:nummer|referenz)|kreditornummer|glaeubiger\s*id|gläubiger\s*id)\b[^;\n|]*`),
regexp.MustCompile(`(?i)\b[A-Z]{6}[A-Z0-9]{2}(?:[A-Z0-9]{3})?\b`),
regexp.MustCompile(`(?i)\b(?:https?://|www\.)\S+|\b[^\s@]+@[^\s@]+\b`),
}
@@ -54,6 +59,9 @@ func redactor(d domain.Dataset, f domain.Facts, private []string) func(string) s
addSecret(secrets, a.ID)
addSecret(secrets, a.IBAN)
addSecret(secrets, a.ExternalAccountID)
// People put their own name in the account label; the label is never
// sent as a field and its text is own-identity data, like PrivateNames.
addSecret(secrets, a.DisplayName)
}
for _, value := range []string{f.ID, f.ExternalID, f.Fingerprint, f.CounterpartyIBAN} {
addSecret(secrets, value)