This commit is contained in:
Lars Nolden
2026-09-10 12:30:42 +02:00
commit 9843fe0c50
79 changed files with 16318 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
package classification
import (
"regexp"
"sort"
"strings"
"unicode"
"finance-duck/internal/domain"
)
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`),
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`),
}
// No raw bank object is serialized. Known private values are removed from every
// allowlisted text field; all digit-bearing tokens are additionally discarded.
// This deliberately sacrifices numeric/BIC-shaped merchant names and reference-heavy text.
// It is data minimization, not a guarantee of anonymization of arbitrary prose.
func newSanitizer(facts domain.Facts, data domain.Dataset, publicMerchantLabels bool) func(string) string {
secrets := map[string]bool{}
publicNames := map[string]bool{}
if publicMerchantLabels {
for _, merchant := range data.Merchants {
publicNames[normalize(merchant.Name)] = true
}
}
add := func(value string) {
normalized := normalize(value)
if normalized != "" {
secrets[normalized] = true
}
for _, part := range strings.Fields(normalized) {
if len([]rune(part)) >= 2 {
secrets[part] = true
}
}
}
addFacts := func(f domain.Facts) {
add(f.ID)
add(f.Source)
add(f.AccountID)
add(f.ExternalID)
add(f.Fingerprint)
add(f.CounterpartyIBAN)
// This exception applies only to registered public merchant labels, never
// transaction prose or raw payee fields. Banking identifiers remain private.
if !publicNames[normalize(f.Counterparty)] {
add(f.Counterparty)
}
}
addFacts(facts)
for _, tx := range data.Transactions {
addFacts(tx.Facts)
}
for _, account := range data.Accounts {
add(account.ID)
add(account.ExternalAccountID)
add(account.IBAN)
add(account.DisplayName)
add(account.Institution)
}
values := make([]string, 0, len(secrets))
for value := range secrets {
values = append(values, value)
}
sort.Slice(values, func(i, j int) bool {
if len(values[i]) != len(values[j]) {
return len(values[i]) > len(values[j])
}
return values[i] < values[j]
})
return func(text string) string {
for _, pattern := range bankingPatterns {
text = pattern.ReplaceAllString(text, " ")
}
text = " " + normalize(text) + " "
for _, value := range values {
needle := " " + value + " "
for strings.Contains(text, needle) {
text = strings.ReplaceAll(text, needle, " ")
}
}
tokens := strings.Fields(text)
kept := make([]string, 0, len(tokens))
length := 0
for _, token := range tokens {
if strings.IndexFunc(token, unicode.IsDigit) >= 0 || len([]rune(token)) > 40 {
continue
}
if length+len(token) > 500 {
break
}
kept = append(kept, token)
length += len(token) + 1
}
return strings.Join(kept, " ")
}
}