Implement classification redesign

This commit is contained in:
Lars Nolden
2026-09-11 22:46:17 +02:00
parent cc43a2f9a7
commit 87f052a3ea
23 changed files with 1602 additions and 296 deletions
+44 -10
View File
@@ -12,6 +12,7 @@ import (
"strconv"
"strings"
"sync"
"unicode/utf8"
"finance-duck/internal/analytics"
"finance-duck/internal/banking"
@@ -22,11 +23,12 @@ import (
// Settings holds preferences only, never credentials. ClassifyOnImport controls
// whether newly imported transactions are sent to the model at all; merchant
// rules always apply.
// rules always apply. PrivateNames is a semicolon-separated household redaction
// list when persisted in config.toml.
type Settings struct {
Model string `json:"model"`
IncludeAmount bool `json:"include_amount"`
ClassifyOnImport bool `json:"classify_on_import"`
Model string `json:"model"`
ClassifyOnImport bool `json:"classify_on_import"`
PrivateNames []string `json:"private_names"`
}
type Status struct {
SyncError string `json:"sync_error"`
@@ -69,6 +71,7 @@ type App struct {
bank banking.Provider
classifier classification.Client
previews map[string]Preview
taxonomies map[string]TaxonomyPreview
csvImports map[string]CSVImport
authStates map[string]authorization
callbackURL string
@@ -81,7 +84,7 @@ func Open(dir string) (*App, error) {
if err != nil {
return nil, err
}
a := &App{dir: dir, journal: j, previews: make(map[string]Preview), csvImports: make(map[string]CSVImport), authStates: make(map[string]authorization), syncRequested: make(chan struct{}, 1)}
a := &App{dir: dir, journal: j, previews: make(map[string]Preview), taxonomies: make(map[string]TaxonomyPreview), csvImports: make(map[string]CSVImport), authStates: make(map[string]authorization), syncRequested: make(chan struct{}, 1)}
// Configurations written before this preference existed keep classifying
// imports; only an explicit key switches it off.
a.settings.ClassifyOnImport = true
@@ -107,8 +110,8 @@ func Open(dir string) (*App, error) {
switch k {
case "classification_model":
a.settings.Model, err = strconv.Unquote(v)
case "include_amount":
a.settings.IncludeAmount, err = strconv.ParseBool(v)
case "private_names":
a.settings.PrivateNames, err = parseNames(v)
case "classify_on_import":
a.settings.ClassifyOnImport, err = strconv.ParseBool(v)
default:
@@ -138,7 +141,7 @@ func Open(dir string) (*App, error) {
if err != nil {
return fail(err)
}
a.classifier = classification.Client{APIKey: apiKey, Model: a.settings.Model, IncludeAmount: a.settings.IncludeAmount}
a.classifier = classification.Client{APIKey: apiKey, Model: a.settings.Model, PrivateNames: append([]string{}, a.settings.PrivateNames...)}
if err = a.loadBankingSettings(); err != nil {
return fail(err)
}
@@ -268,6 +271,32 @@ func normalizeOpenRouterKey(key string) (string, error) {
return key, nil
}
func normalizePrivateNames(values []string) ([]string, error) {
out := make([]string, 0, len(values))
for _, raw := range values {
if !utf8.ValidString(raw) {
return nil, errors.New("private names must be valid UTF-8")
}
name := strings.Join(strings.Fields(raw), " ")
if name == "" {
continue
}
if utf8.RuneCountInString(name) > 200 || strings.ContainsRune(name, ';') {
return nil, errors.New("private names must be at most 200 characters and cannot contain semicolons")
}
out = append(out, name)
}
return out, nil
}
func parseNames(v string) ([]string, error) {
raw, err := strconv.Unquote(v)
if err != nil {
return nil, err
}
return normalizePrivateNames(strings.Split(raw, ";"))
}
func loadOpenRouterKey(path string) (string, error) {
f, err := os.Open(path)
if os.IsNotExist(err) {
@@ -340,15 +369,20 @@ func (a *App) SaveSettings(ctx context.Context, s Settings) (State, error) {
if len(s.Model) > 200 {
return State{}, errors.New("model name is too long")
}
names, err := normalizePrivateNames(s.PrivateNames)
if err != nil {
return State{}, err
}
s.PrivateNames = names
b := []byte("# Preferences only. Manage secrets in Settings or environment variables, never this file.\n" +
"classification_model = " + strconv.Quote(s.Model) + "\n" +
"include_amount = " + strconv.FormatBool(s.IncludeAmount) + "\n" +
"private_names = " + strconv.Quote(strings.Join(s.PrivateNames, "; ")) + "\n" +
"classify_on_import = " + strconv.FormatBool(s.ClassifyOnImport) + "\n")
if err := atomicFile(filepath.Join(a.dir, "config.toml"), b); err != nil {
return State{}, err
}
a.settings = s
a.classifier.Model = s.Model
a.classifier.IncludeAmount = s.IncludeAmount
a.classifier.PrivateNames = append([]string{}, s.PrivateNames...)
return a.snapshot(ctx)
}