Compare commits
2
Commits
ec99434002
...
1b09edc692
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b09edc692 | ||
|
|
c5999adb1b |
@@ -11,3 +11,4 @@
|
||||
*.pem
|
||||
*.duckdb
|
||||
*.duckdb.wal
|
||||
openrouter-api-key
|
||||
|
||||
+14
-9
@@ -148,15 +148,20 @@ this is not local AI and cannot promise that a remote provider honors policy.
|
||||
Each classification sends the transaction date, signed amount, currency,
|
||||
merchant and counterparty text, account institution/currency, the complete
|
||||
leaf-category registry for the transaction kind, all tags and all merchants
|
||||
with their real local IDs. Identifier-only redaction removes IBANs, BICs,
|
||||
UUIDs, URLs/emails, labeled payment or customer references, card fragments,
|
||||
long digit-bearing tokens, the row's own IDs and configured private names.
|
||||
Counterparty text is intentionally retained unless it is in Private names;
|
||||
this is the accepted recognition trade-off, not an anonymity guarantee.
|
||||
There is no Include Amount opt-in anymore. A response records high, medium or
|
||||
low confidence. Low-confidence results keep merchant and tags but use the
|
||||
kind-specific unclassified category; Transactions exposes a Needs review
|
||||
filter for low-confidence or fallback rows.
|
||||
with their real local IDs. Identifier-only redaction removes IBANs (with a
|
||||
directly attached BIC), labeled BIC/SWIFT references, UUIDs, URLs/emails,
|
||||
labeled payment or customer references, card fragments, long digit-bearing
|
||||
tokens, the row's own IDs, account labels and configured private names. A
|
||||
bare eight- or eleven-letter word is never treated as a BIC: that shape
|
||||
matches ordinary payee names, and a bank code alone reveals no more than the
|
||||
institution field already sent. Counterparty text is intentionally retained
|
||||
unless it is in Private names; this is the accepted recognition trade-off,
|
||||
not an anonymity guarantee. There is no Include Amount opt-in anymore. A
|
||||
response records high, medium or low confidence. Imports never auto-apply a
|
||||
low-confidence category: the row keeps the kind-specific unclassified
|
||||
category with merchant and confidence recorded. Analyse previews show the
|
||||
low-confidence suggestion unselected for review. Transactions exposes a
|
||||
Needs review filter for low-confidence or fallback rows.
|
||||
|
||||
Categories and tags have editable hints. Categories -> Propose taxonomy sends
|
||||
up to 300 grouped, redacted transaction samples, then shows proposed
|
||||
|
||||
@@ -421,7 +421,7 @@ Bank synchronization and recognized N26, ING, and Kontist CSV imports do **not**
|
||||
|
||||
**Classify newly imported transactions with AI** under **Classification preferences** controls whether importing contacts the provider at all. It covers CSV imports and bank synchronization, is on by default, and is stored as `classify_on_import` in `config.toml`. With it off, no import makes a provider request: enabled merchant rules still classify, and everything else arrives unclassified and editable without a failure that would suggest the provider was unreachable. **AI classification → Analyse** still works on demand, so you can review a batch deliberately instead of on every import.
|
||||
|
||||
AI classification sends only identifier-redacted text: the transaction's own IDs, account identifiers, payment references, and configured private names are removed, while merchant and counterparty text remains available for recognition. Classification responses carry `high`, `medium`, or `low` confidence; low-confidence results retain the merchant and tags but use the kind-appropriate unclassified category and appear in **Transactions → Needs review**.
|
||||
AI classification sends only identifier-redacted text: the transaction's own IDs, account identifiers and labels, payment references, labeled or IBAN-attached BICs, and configured private names are removed, while merchant and counterparty text remains available for recognition. Classification responses carry `high`, `medium`, or `low` confidence. Imports never auto-apply a low-confidence category — the row stays on the kind-appropriate unclassified category with the merchant link and confidence recorded — while **Analyse** previews show the low-confidence suggestion unselected for review, and **Transactions → Needs review** lists both.
|
||||
|
||||
From **Categories**, **Propose taxonomy** samples up to 300 redacted transactions, grouped so recurring counterparties are represented without sending raw identifiers. The proposal can suggest categories, tags, and merchants with hints and evidence. Approve each item individually; applying it also creates any approved category parents required by the hierarchy. Existing registry entries and transaction facts are never overwritten.
|
||||
|
||||
|
||||
@@ -296,7 +296,7 @@ func (c candidateSet) schema() map[string]any {
|
||||
"merchant_id": map[string]any{"type": []string{"string", "null"}, "enum": merchantEnums},
|
||||
"new_merchant": map[string]any{"type": []string{"string", "null"}, "maxLength": 100},
|
||||
"category_id": map[string]any{"type": "string", "enum": candidateIDs(c.categories)},
|
||||
"tag_ids": map[string]any{"type": "array", "uniqueItems": true, "maxItems": len(tagIDs), "items": tagItems},
|
||||
"tag_ids": map[string]any{"type": "array", "maxItems": len(tagIDs), "items": tagItems},
|
||||
"confidence": map[string]any{"type": "string", "enum": []string{"high", "medium", "low"}},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -192,7 +192,7 @@ func TestIdentifierOnlyPromptRedactionAndRouting(t *testing.T) {
|
||||
f.CounterpartyIBAN = "DE89370400440532013000"
|
||||
d.Accounts[0].IBAN = "DE44500105175407324931"
|
||||
d.Accounts[0].ExternalAccountID = "ext_local_secret"
|
||||
f.RawDescription = "Coffee House -918.27 EUR Alice Privateperson DE89 3704 0044 0532 0130 00 private_external private_fingerprint tx_private account_private ext_local_secret private_source Personal Checking Private Bank 550e8400-e29b-41d4-a716-446655440000 COBADEFFXXX ; reference secretpayment ; user@example.com"
|
||||
f.RawDescription = "Coffee House -918.27 EUR Alice Privateperson DE89 3704 0044 0532 0130 00 COBADEFFXXX private_external private_fingerprint tx_private account_private ext_local_secret private_source Personal Checking Private Bank 550e8400-e29b-41d4-a716-446655440000 ; reference secretpayment ; user@example.com"
|
||||
var captured map[string]json.RawMessage
|
||||
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/chat/completions" || r.Header.Get("Authorization") != "Bearer test-secret" {
|
||||
@@ -294,16 +294,23 @@ func TestTransactionAmountAndCounterpartyAreSent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsafeMerchantProposalRejected(t *testing.T) {
|
||||
func TestUnsafeMerchantProposalDroppedWithoutLosingClassification(t *testing.T) {
|
||||
for _, name := range []string{"Alice Privateperson", "DE89370400440532013000", "Bank 123456789", "reference secretpayment", strings.Repeat("x", 101)} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
f, d := fixture()
|
||||
f.Counterparty = "Alice Privateperson"
|
||||
answer, _ := json.Marshal(map[string]any{"merchant_id": nil, "new_merchant": name, "category_id": "c1", "tag_ids": []string{}})
|
||||
answer, _ := json.Marshal(map[string]any{"merchant_id": nil, "new_merchant": name, "category_id": "cat_food", "tag_ids": []string{}, "confidence": "high"})
|
||||
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) { reply(w, string(answer)) })
|
||||
c.PrivateNames = []string{"Alice Privateperson"}
|
||||
p, err := c.Classify(context.Background(), f, d, true)
|
||||
if err == nil || p.NewMerchant != nil {
|
||||
t.Fatalf("unsafe merchant accepted: %+v", p)
|
||||
if err != nil {
|
||||
t.Fatalf("unsafe name must degrade, not fail the row: %v", err)
|
||||
}
|
||||
if p.NewMerchant != nil || p.Enrichment.MerchantID != "" {
|
||||
t.Fatalf("unsafe merchant stored: %+v", p)
|
||||
}
|
||||
if p.Enrichment.CategoryID != "cat_food" || p.Enrichment.Classification.Confidence != "high" {
|
||||
t.Fatalf("validated classification lost with the merchant: %+v", p.Enrichment)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -340,9 +347,13 @@ func TestMalformedEnvelopesRejected(t *testing.T) {
|
||||
t.Run(fmt.Sprint(i), func(t *testing.T) {
|
||||
f, d := fixture()
|
||||
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, body) })
|
||||
if p, err := c.Classify(context.Background(), f, d, true); err == nil || p.Enrichment.Classification.Source != "fallback" {
|
||||
p, err := c.Classify(context.Background(), f, d, true)
|
||||
if err == nil || p.Enrichment.Classification.Source != "fallback" {
|
||||
t.Fatalf("bad envelope accepted: %+v %v", p, err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "private") {
|
||||
t.Fatalf("provider text leaked into the error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -435,7 +446,7 @@ func TestConfiguredPrivateNamesAndIdentifiersRedactWithoutRemovingPayee(t *testi
|
||||
f, d := fixture()
|
||||
f.Counterparty = "Coffee House"
|
||||
clean := redactor(d, f, []string{"Alice"})
|
||||
text := clean("Alice Alice Alice Coffee House cobadeffxxx DE89370400440532013000")
|
||||
text := clean("Alice Alice Alice Coffee House DE89370400440532013000 COBADEFFXXX")
|
||||
if strings.Contains(text, "alice") || strings.Contains(text, "cobadeff") || strings.Contains(text, "de893704") || !strings.Contains(text, "coffee house") {
|
||||
t.Fatalf("redaction: %q", text)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package classification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"finance-duck/internal/domain"
|
||||
)
|
||||
|
||||
// ledgerFixture mirrors a production ledger that repeatedly broke
|
||||
// classification in the field: a proposed two-level taxonomy (43 expense
|
||||
// leaves), tags, a merchant registry polluted with location-like names, and
|
||||
// German bank rows whose payee text carries reference numbers. Personal
|
||||
// names and IBANs are fabricated.
|
||||
func ledgerFixture() (domain.Dataset, domain.Facts) {
|
||||
d := domain.NewDataset()
|
||||
d.Accounts = []domain.Account{{ID: "acct_kontist", DisplayName: "Business", Institution: "Kontist", Currency: "EUR", Active: true}}
|
||||
tree := map[string][]string{
|
||||
"housing": {"rent", "utilities", "household", "maintenance"},
|
||||
"food": {"groceries", "restaurants", "takeaway"},
|
||||
"transport": {"fuel", "public-transport", "parking", "taxi", "vehicle-maintenance"},
|
||||
"shopping": {"clothing", "electronics", "household-goods", "other"},
|
||||
"pets": {"pet-food", "pet-health", "supplies"},
|
||||
"entertainment": {"games", "events", "ent-media"},
|
||||
"travel": {"accommodation", "travel-transport", "activities"},
|
||||
"health": {"medical", "pharmacy", "fitness"},
|
||||
"education": {"tuition", "books", "courses"},
|
||||
"subscriptions": {"software", "sub-media", "services"},
|
||||
"insurance": {"vehicle-insurance", "health-insurance", "other-insurance"},
|
||||
"financial": {"bank-fees", "interest-paid", "taxes"},
|
||||
"gifts": nil,
|
||||
"donations": nil,
|
||||
}
|
||||
for parent, children := range tree {
|
||||
d.Categories = append(d.Categories, domain.Category{ID: "cat_" + parent, Name: parent, ParentID: "cat_expenses", Kind: "expense"})
|
||||
for _, child := range children {
|
||||
d.Categories = append(d.Categories, domain.Category{ID: "cat_" + child, Name: child, ParentID: "cat_" + parent, Kind: "expense"})
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"personal", "business", "travel", "hobby", "home", "mx5", "education", "gift", "tax-deductible", "subscription", "groceries"} {
|
||||
d.Tags = append(d.Tags, domain.Tag{ID: "tag_" + name, Name: name})
|
||||
}
|
||||
// Location-like junk from a taxonomy proposal run: it must stay selectable
|
||||
// without breaking the strict schema or the alias matcher.
|
||||
for _, name := range []string{"smart steuerservice", "kranken", "Chittaway Bay", "Toronto", "bruhl", "brunico", "St. Ulrich", "Git Server", "Mobilfunk", "Swopper"} {
|
||||
d.Merchants = append(d.Merchants, domain.Merchant{ID: domain.NewID("mer"), Name: name, Aliases: []string{}, DefaultTagIDs: []string{}, UseDefaults: false})
|
||||
}
|
||||
facts := domain.Facts{
|
||||
ID: "tx_finanzamt", Source: "enablebanking", AccountID: "acct_kontist",
|
||||
BookingDate: "2026-08-30", ValueDate: "2026-08-30", Amount: "-849.45", Currency: "EUR",
|
||||
RawDescription: "0904303543105 224/5220/5869",
|
||||
Counterparty: "Finanzamt Bruehl", CounterpartyIBAN: "DE02120300000000202051",
|
||||
Fingerprint: "f1e2d3",
|
||||
}
|
||||
d.Transactions = []domain.Transaction{{Facts: facts, Enrichment: domain.Fallback(facts)}}
|
||||
return d, facts
|
||||
}
|
||||
|
||||
// strictKeywords is what OpenAI-family strict structured-output mode accepts.
|
||||
// uniqueItems is specifically rejected ("'uniqueItems' is not permitted") and
|
||||
// took every zero-data-retention route for those models down with HTTP 400;
|
||||
// duplicates are rejected server-side by decodeAnswer instead.
|
||||
var strictKeywords = map[string]bool{
|
||||
"type": true, "properties": true, "required": true, "additionalProperties": true,
|
||||
"items": true, "enum": true, "maxItems": true, "maxLength": true, "minLength": true,
|
||||
}
|
||||
|
||||
func checkStrict(t *testing.T, path string, value any) {
|
||||
t.Helper()
|
||||
switch v := value.(type) {
|
||||
case map[string]any:
|
||||
for key, child := range v {
|
||||
if path == "" || strings.HasSuffix(path, ".properties") {
|
||||
// Property names and the schema root are not keywords.
|
||||
} else if !strictKeywords[key] {
|
||||
t.Errorf("%s uses %q, which strict structured-output mode rejects", path, key)
|
||||
}
|
||||
checkStrict(t, path+"."+key, child)
|
||||
}
|
||||
case []any:
|
||||
for _, child := range v {
|
||||
checkStrict(t, path+"[]", child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWireSchemasUseOnlyStrictModeKeywords(t *testing.T) {
|
||||
d, facts := ledgerFixture()
|
||||
set := retrieve(facts.RawDescription, "expense", d, nil, nil)
|
||||
for name, schema := range map[string]map[string]any{
|
||||
"classification": set.schema(),
|
||||
"taxonomy": taxonomySchema(),
|
||||
"csv": csvMappingSchema(CSVMappingRequest{Headers: []string{"Buchung", "Betrag"}}),
|
||||
} {
|
||||
checkStrict(t, name, map[string]any{"properties": schema["properties"]})
|
||||
}
|
||||
}
|
||||
|
||||
// The exact answer a live gpt-5.6-luna-pro returned for this row over a
|
||||
// zero-data-retention route must land as reviewable enrichment: taxes
|
||||
// category, a new public merchant seeded with the counterparty alias, no
|
||||
// tags, recorded confidence.
|
||||
func TestLedgerRowClassifiesThroughStrictSchema(t *testing.T) {
|
||||
d, facts := ledgerFixture()
|
||||
taxes := ""
|
||||
for _, c := range d.Categories {
|
||||
if c.Name == "taxes" {
|
||||
taxes = c.ID
|
||||
}
|
||||
}
|
||||
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
reply(w, `{"merchant_id":null,"new_merchant":"Finanzamt Bruehl","category_id":"`+taxes+`","tag_ids":[],"confidence":"high"}`)
|
||||
})
|
||||
p, err := c.Classify(context.Background(), facts, d, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Enrichment.CategoryID != taxes || p.Enrichment.Classification.Confidence != "high" {
|
||||
t.Fatalf("classification lost: %+v", p.Enrichment)
|
||||
}
|
||||
if p.NewMerchant == nil || p.NewMerchant.Name != "Finanzamt Bruehl" ||
|
||||
!reflect.DeepEqual(p.NewMerchant.Aliases, []string{"Finanzamt Bruehl"}) {
|
||||
t.Fatalf("merchant proposal lost: %+v", p.NewMerchant)
|
||||
}
|
||||
if len(p.Enrichment.TagIDs) != 0 {
|
||||
t.Fatalf("unexpected tags: %+v", p.Enrichment.TagIDs)
|
||||
}
|
||||
}
|
||||
|
||||
// Identifier redaction must not eat ordinary 8- and 11-letter payee words,
|
||||
// which blinded the model to the merchant it was asked to classify
|
||||
// ("WWW.RACETRACKER.DE" became "WWW. .DE"). A bare bank-code-shaped token is
|
||||
// vocabulary; real BICs still die labeled or trailing their IBAN.
|
||||
func TestBICRedactionKeepsPayeeVocabulary(t *testing.T) {
|
||||
d, facts := ledgerFixture()
|
||||
clean := redactor(d, facts, nil)
|
||||
for _, keep := range []string{"Openbank", "OPENBANK", "Baumarkt", "BAUMARKT", "RACETRACKER", "toom Baumarkt"} {
|
||||
if got := clean(keep); got != normalize(keep) {
|
||||
t.Errorf("payee word %q was redacted to %q", keep, got)
|
||||
}
|
||||
}
|
||||
for name, text := range map[string]string{
|
||||
"labeled iban": "IBAN DE89370400440532013000 COBADEFFXXX invoice",
|
||||
"trailing bic": "pay DE89370400440532013000 COBADEFFXXX today",
|
||||
"labeled bic": "BIC DEUTDEDBFRA",
|
||||
"labeled swift": "SWIFT GENODED1SPO",
|
||||
} {
|
||||
got := clean(text)
|
||||
if strings.Contains(got, "de8937") || strings.Contains(got, "cobadeff") || strings.Contains(got, "deutdedb") || strings.Contains(got, "genoded1") {
|
||||
t.Errorf("%s: identifier survived redaction: %q", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -65,7 +65,7 @@ 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, "uniqueItems": true, "items": name}},
|
||||
"properties": map[string]any{"name": name, "aliases": map[string]any{"type": "array", "maxItems": 32, "items": name}},
|
||||
}
|
||||
return map[string]any{
|
||||
"type": "object", "additionalProperties": false,
|
||||
|
||||
Reference in New Issue
Block a user