Implement classification redesign
This commit is contained in:
+44
-10
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user