Files
finance-duck/internal/app/app.go
T
Lars Nolden 10314fb1cd Batch Analyse requests and survive opaque provider schema budgets
Analyse now classifies up to ten same-kind transactions per provider
request: the registry and history travel once per batch, so a
thousand-row backfill costs about a hundred paced requests instead of a
thousand. The answer schema appears once — an array item carrying an
enum-bound ref — because providers meter strict schemas by token cost:
duplicating registry enums per row, or bounding arrays with
minItems/maxItems that Gemini expands per element, rejects real
registries with a bare HTTP 400. Row count, duplicate refs, duplicate
tags and taxonomy bounds are all enforced server-side instead, and a
request still rejected outright halves until accepted, remembering the
working size for the run. Batch requests scale the HTTP budget by row
count, chunk failures cannot abort a run whose later rows succeeded,
and rows resolved against one snapshot share one minted merchant.

Measured on a real 165-row month over a zero-data-retention route:
165 analysed, 152 proposals, 0 errors, 17 requests, under 8 minutes.

Fresh installs default to google/gemini-3.8-flash, the model that
demonstrably honors strict structured outputs over a ZDR route. Preview
changes now carry counterparty, amount and currency, and the review
list shows the amount with a counterparty fallback for banks that leave
descriptions empty.
2026-09-13 13:37:06 +02:00

412 lines
13 KiB
Go

package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
"finance-duck/internal/analytics"
"finance-duck/internal/banking"
"finance-duck/internal/classification"
"finance-duck/internal/domain"
"finance-duck/internal/journal"
"finance-duck/internal/quotes"
)
// Settings holds preferences only, never credentials. ClassifyOnImport controls
// whether newly imported transactions are sent to the model at all; merchant
// rules always apply. PrivateNames is a semicolon-separated household redaction
// list when persisted in config.toml.
type Settings struct {
Model string `json:"model"`
ClassifyOnImport bool `json:"classify_on_import"`
PrivateNames []string `json:"private_names"`
}
type Status struct {
SyncError string `json:"sync_error"`
IndexError string `json:"index_error"`
LastSync string `json:"last_sync"`
// SyncRetryAt is set only when every sync failure is a bank rate limit that
// clears on its own; it is the earliest time an automatic retry is allowed.
SyncRetryAt string `json:"sync_retry_at,omitempty"`
BankingConfigured bool `json:"banking_configured"`
AIConfigured bool `json:"ai_configured"`
}
type State struct {
Data domain.Dataset `json:"data"`
Revision string `json:"revision"`
Status Status `json:"status"`
Settings Settings `json:"settings"`
Sessions []banking.Session `json:"sessions"`
CallbackURL string `json:"callback_url"`
BankingAppID string `json:"banking_app_id"`
Connections []Connection `json:"connections"`
}
type operational struct {
Sessions []banking.Session `json:"sessions"`
LastSync string `json:"last_sync"`
SyncError string `json:"sync_error"`
SyncRetryAt string `json:"sync_retry_at,omitempty"`
Consents map[string]Consent `json:"consents"`
AccountSync map[string]string `json:"account_sync"`
BankingScope string `json:"banking_scope"`
}
type App struct {
mu sync.Mutex
dir string
journal *journal.Store
index *analytics.Store
indexed string
indexError string
settings Settings
ops operational
bank banking.Provider
classifier classification.Client
previews map[string]Preview
previewRun *previewJob
taxonomies map[string]TaxonomyPreview
csvImports map[string]CSVImport
authStates map[string]authorization
callbackURL string
bankingSettings bankingSettings
verifiedModels []classification.VerifiedModel
verifiedModelsAt time.Time
// quotes needs no configuration: it reads a public endpoint, so its zero
// value is the working client and tests replace it with a stub.
quotes quotes.Client
syncRequested chan struct{}
}
// Settings this application has retired. They are read and discarded: a
// config.toml written by an older binary must never stop the new one from
// starting, and the next SaveSettings rewrites the file without them. An
// unrecognised key is still refused, so a typo cannot silently lose a
// preference.
var retiredSettings = map[string]bool{"include_amount": true}
func Open(dir string) (*App, error) {
j, err := journal.Open(dir)
if err != nil {
return nil, err
}
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
fail := func(e error) (*App, error) { j.Close(); return nil, e }
if err = os.MkdirAll(filepath.Join(dir, "state"), 0700); err != nil {
return fail(err)
}
if err = os.MkdirAll(filepath.Join(dir, "cache"), 0700); err != nil {
return fail(err)
}
if b, e := os.ReadFile(filepath.Join(dir, "config.toml")); e == nil {
for n, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
return fail(fmt.Errorf("config.toml:%d: expected key = value", n+1))
}
k = strings.TrimSpace(k)
v = strings.TrimSpace(v)
switch k {
case "classification_model":
a.settings.Model, err = strconv.Unquote(v)
case "private_names":
a.settings.PrivateNames, err = parseNames(v)
case "classify_on_import":
a.settings.ClassifyOnImport, err = strconv.ParseBool(v)
default:
if !retiredSettings[k] {
err = fmt.Errorf("unknown setting %q", k)
}
}
if err != nil {
return fail(fmt.Errorf("config.toml:%d: %w", n+1, err))
}
}
} else if !os.IsNotExist(e) {
return fail(e)
}
// A fresh install classifies with a fast, inexpensive model that
// demonstrably honors strict structured outputs over a zero-data-retention
// route; an explicit config.toml entry always wins.
if strings.TrimSpace(a.settings.Model) == "" {
a.settings.Model = "google/gemini-3.8-flash"
}
if b, e := os.ReadFile(filepath.Join(dir, "state", "sync-state.json")); e == nil {
if err = json.Unmarshal(b, &a.ops); err != nil {
return fail(fmt.Errorf("sync state: %w", err))
}
} else if !os.IsNotExist(e) {
return fail(e)
}
if a.ops.Consents == nil {
a.ops.Consents = make(map[string]Consent)
}
if a.ops.AccountSync == nil {
a.ops.AccountSync = make(map[string]string)
}
apiKey, err := loadOpenRouterKey(filepath.Join(dir, "state", "openrouter.json"))
if err != nil {
return fail(err)
}
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)
}
a.index, err = analytics.Open(filepath.Join(dir, "cache", "finance.duckdb"))
if err != nil {
return fail(err)
}
if _, err = a.snapshot(context.Background()); err != nil {
a.index.Close()
return fail(err)
}
return a, nil
}
func (a *App) Close() error {
a.mu.Lock()
defer a.mu.Unlock()
return errors.Join(a.index.Close(), a.journal.Close())
}
func (a *App) snapshot(ctx context.Context) (State, error) {
d, rev, err := a.journal.Load()
if err != nil {
return State{}, err
}
if rev != a.indexed {
if err = a.index.Rebuild(ctx, d); err != nil {
a.indexError = err.Error()
} else {
a.indexed = rev
a.indexError = ""
}
}
status := Status{SyncError: a.ops.SyncError, SyncRetryAt: a.ops.SyncRetryAt, LastSync: a.ops.LastSync, IndexError: a.indexError, BankingConfigured: a.bank != nil, AIConfigured: a.classifier.APIKey != ""}
return State{Data: d, Revision: rev, Settings: a.settings, Sessions: copySessions(a.ops.Sessions), CallbackURL: a.callbackURL, BankingAppID: a.bankingSettings.AppID, Connections: a.connections(d), Status: status}, nil
}
func (a *App) Snapshot(ctx context.Context) (State, error) {
a.mu.Lock()
defer a.mu.Unlock()
return a.snapshot(ctx)
}
func (a *App) commit(ctx context.Context, rev string, d domain.Dataset) (State, error) {
if rev == "" {
return State{}, errors.New("revision is required")
}
if _, err := a.journal.Commit(rev, d); err != nil {
return State{}, err
}
return a.snapshot(ctx)
}
func (a *App) Mutate(ctx context.Context, rev string, fn func(*domain.Dataset) error) (State, error) {
a.mu.Lock()
defer a.mu.Unlock()
s, err := a.snapshot(ctx)
if err != nil {
return State{}, err
}
if rev != s.Revision {
return State{}, errors.New("revision conflict: reload before editing")
}
if err = fn(&s.Data); err != nil {
return State{}, err
}
return a.commit(ctx, rev, s.Data)
}
func (a *App) Dashboard(ctx context.Context, f analytics.Filter) (analytics.Dashboard, error) {
a.mu.Lock()
defer a.mu.Unlock()
if _, err := a.snapshot(ctx); err != nil {
return analytics.Dashboard{}, err
}
if a.indexError != "" {
return analytics.Dashboard{}, errors.New(a.indexError)
}
return a.index.Query(ctx, f)
}
func (a *App) Rebuild(ctx context.Context) (State, error) {
a.mu.Lock()
defer a.mu.Unlock()
a.indexed = ""
return a.snapshot(ctx)
}
func atomicFile(path string, b []byte) error {
f, err := os.CreateTemp(filepath.Dir(path), ".state-*")
if err != nil {
return err
}
name := f.Name()
defer os.Remove(name)
if err = f.Chmod(0600); err == nil {
_, err = f.Write(b)
}
if err == nil {
err = f.Sync()
}
err = errors.Join(err, f.Close())
if err != nil {
return err
}
if err = os.Rename(name, path); err != nil {
return err
}
dir, err := os.Open(filepath.Dir(path))
if err != nil {
return err
}
defer dir.Close()
return dir.Sync()
}
func (a *App) saveOps() error {
b, err := json.MarshalIndent(a.ops, "", " ")
if err != nil {
return err
}
return atomicFile(filepath.Join(a.dir, "state", "sync-state.json"), append(b, '\n'))
}
func normalizeOpenRouterKey(key string) (string, error) {
key = strings.TrimSpace(key)
const invalid = "OpenRouter API key must be at most 4096 bytes and contain only non-whitespace ASCII characters"
if len(key) > 4096 {
return "", errors.New(invalid)
}
for i := range len(key) {
if key[i] < 0x21 || key[i] > 0x7e {
return "", errors.New(invalid)
}
}
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) {
return normalizeOpenRouterKey(os.Getenv("OPENROUTER_API_KEY"))
}
if err != nil {
return "", errors.New("cannot read saved OpenRouter credential")
}
defer f.Close()
// Bound encoded storage too, allowing JSON escapes for a maximum-size key.
b, err := io.ReadAll(io.LimitReader(f, 32*1024+1))
if err != nil {
return "", errors.New("cannot read saved OpenRouter credential")
}
invalid := errors.New("invalid saved OpenRouter credential")
if len(b) > 32*1024 {
return "", invalid
}
// Require exactly one case-sensitive string field, rejecting duplicates,
// unknown fields, null, and trailing JSON rather than silently disabling AI.
dec := json.NewDecoder(bytes.NewReader(b))
if token, err := dec.Token(); err != nil || token != json.Delim('{') {
return "", invalid
}
if token, err := dec.Token(); err != nil || token != "api_key" {
return "", invalid
}
token, err := dec.Token()
key, ok := token.(string)
if err != nil || !ok {
return "", invalid
}
if token, err := dec.Token(); err != nil || token != json.Delim('}') {
return "", invalid
}
if _, err := dec.Token(); err != io.EOF {
return "", invalid
}
key, err = normalizeOpenRouterKey(key)
if err != nil {
return "", invalid
}
return key, nil
}
func (a *App) SaveOpenRouterKey(ctx context.Context, key string) (State, error) {
a.mu.Lock()
defer a.mu.Unlock()
key, err := normalizeOpenRouterKey(key)
if err != nil {
return State{}, err
}
b, err := json.Marshal(struct {
APIKey string `json:"api_key"`
}{APIKey: key})
if err != nil {
return State{}, errors.New("cannot encode OpenRouter credential")
}
if err := atomicFile(filepath.Join(a.dir, "state", "openrouter.json"), append(b, '\n')); err != nil {
return State{}, errors.New("cannot save OpenRouter credential")
}
a.classifier.APIKey = key
return a.snapshot(ctx)
}
func (a *App) SaveSettings(ctx context.Context, s Settings) (State, error) {
a.mu.Lock()
defer a.mu.Unlock()
s.Model = strings.TrimSpace(s.Model)
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" +
"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.PrivateNames = append([]string{}, s.PrivateNames...)
return a.snapshot(ctx)
}