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:
@@ -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) == "" {
|
||||
|
||||
Reference in New Issue
Block a user