Let imports opt out of AI classification

Classification preferences gains "Classify newly imported transactions with
AI", stored as classify_on_import in config.toml and on by default, so existing
configurations keep their behaviour. It covers CSV imports and bank
synchronization alike.

With it off, no import path contacts the provider: classification falls to the
new provider-free rules path, where an opted-in merchant rule still applies its
category and tags, an alias match still attaches its merchant, and everything
else arrives on the editable fallback without a provenance error that would
suggest the provider had failed. Analyse remains available on demand.
This commit is contained in:
Lars Nolden
2026-09-11 18:10:05 +02:00
parent dc767799bc
commit dece0d5b79
10 changed files with 217 additions and 30 deletions
+50 -22
View File
@@ -76,17 +76,63 @@ type Proposal struct {
NewMerchant *domain.Merchant `json:"new_merchant,omitempty"`
}
// Classify returns a safe fallback with error provenance on any AI failure. Callers
// must check the error before applying a proposal. No provider response is logged.
func (c *Client) Classify(ctx context.Context, facts domain.Facts, data domain.Dataset, forceAI bool) (Proposal, error) {
// ruleProposal applies deterministic local classification: an existing transfer
// keeps its enrichment, and a matching merchant alias contributes that merchant
// plus, only when the merchant opts in, its default category and tags. done
// reports that no provider call can improve the result.
func ruleProposal(facts domain.Facts, data domain.Dataset, forceAI bool) (Proposal, bool, error) {
for _, tx := range data.Transactions {
if tx.Facts.ID == facts.ID && tx.Enrichment.Kind == "transfer" {
e := tx.Enrichment
e.TagIDs = append([]string{}, e.TagIDs...)
return Proposal{Enrichment: e}, nil
return Proposal{Enrichment: e}, true, nil
}
}
p := Proposal{Enrichment: domain.Fallback(facts)}
fail := func(message string) (Proposal, bool, error) {
p.Enrichment = domain.Fallback(facts)
p.Enrichment.Classification = domain.Provenance{Source: "fallback", Timestamp: time.Now().UTC().Format(time.RFC3339), Error: message}
return p, true, errors.New(message)
}
if _, err := facts.Amount.Minor(); err != nil {
return fail("invalid transaction amount")
}
merchant := aliasMatch(facts.RawDescription+" "+facts.Counterparty, data.Merchants)
if merchant == nil || forceAI {
return p, false, nil
}
p.Enrichment.MerchantID = merchant.ID
p.Enrichment.Classification = domain.Provenance{Source: "rule", Timestamp: time.Now().UTC().Format(time.RFC3339)}
if !merchant.UseDefaults {
// The alias identifies the merchant; only an opted-in rule may classify.
return p, false, nil
}
if merchant.DefaultCategoryID != "" {
p.Enrichment.CategoryID = merchant.DefaultCategoryID
}
p.Enrichment.TagIDs = append([]string{}, merchant.DefaultTagIDs...)
if err := domain.ValidateEnrichment(data, facts, p.Enrichment); err != nil {
return fail("merchant defaults are invalid for this transaction")
}
return p, true, nil
}
// Rules classifies without contacting any provider. Imports use it when AI
// classification on import is switched off: merchant alias rules still apply,
// and everything else stays on the editable fallback without a failure that
// would suggest the provider was unreachable.
func Rules(facts domain.Facts, data domain.Dataset) (Proposal, error) {
p, _, err := ruleProposal(facts, data, false)
return p, err
}
// Classify returns a safe fallback with error provenance on any AI failure. Callers
// must check the error before applying a proposal. No provider response is logged.
func (c *Client) Classify(ctx context.Context, facts domain.Facts, data domain.Dataset, forceAI bool) (Proposal, error) {
p, done, err := ruleProposal(facts, data, forceAI)
if done || err != nil {
return p, err
}
failError := func(err error) (Proposal, error) {
p.Enrichment.Classification = domain.Provenance{Source: "fallback", Timestamp: time.Now().UTC().Format(time.RFC3339), Error: err.Error()}
return p, err
@@ -94,25 +140,7 @@ func (c *Client) Classify(ctx context.Context, facts domain.Facts, data domain.D
fail := func(message string) (Proposal, error) {
return failError(errors.New(message))
}
if _, err := facts.Amount.Minor(); err != nil {
return fail("invalid transaction amount")
}
localDescription := facts.RawDescription + " " + facts.Counterparty
if merchant := aliasMatch(localDescription, data.Merchants); merchant != nil && !forceAI {
p.Enrichment.MerchantID = merchant.ID
if merchant.UseDefaults {
if merchant.DefaultCategoryID != "" {
p.Enrichment.CategoryID = merchant.DefaultCategoryID
}
p.Enrichment.TagIDs = append([]string{}, merchant.DefaultTagIDs...)
p.Enrichment.Classification = domain.Provenance{Source: "rule", Timestamp: time.Now().UTC().Format(time.RFC3339)}
if err := domain.ValidateEnrichment(data, facts, p.Enrichment); err != nil {
p.Enrichment = domain.Fallback(facts)
return fail("merchant defaults are invalid for this transaction")
}
return p, nil
}
}
apiKey, model := c.APIKey, c.Model
includeAmount := c.IncludeAmount
if strings.TrimSpace(apiKey) == "" || strings.TrimSpace(model) == "" {