Run classification previews in the background with live progress

The preview endpoint held one HTTP request open while classifying
serially at three-second pacing, so any real range meant minutes of a
grayed-out button and per-row errors were invisible until the loop
ended. Analyse now starts a single background run against its own
snapshot; a progress endpoint reports analysed counts, proposed
changes and errors as they happen, and the page polls it with a
progress bar, pace-based estimate and a Stop button. Navigating away
no longer orphans the run: the page re-attaches to it on return.

A run that has produced no successful proposal and fails three times
in a row with the identical error stops early and reports that error,
so a wrong key or unsupported model surfaces in seconds instead of
repeating across the whole paced range.

Also normalize a null settings.private_names, which crashed the whole
UI on a workspace that had never saved preferences.
This commit is contained in:
Lars Nolden
2026-09-12 12:24:50 +02:00
parent 635c11be56
commit 9092c5721d
10 changed files with 449 additions and 91 deletions
+173 -25
View File
@@ -3,11 +3,13 @@ package app
import (
"context"
"errors"
"fmt"
"reflect"
"slices"
"strings"
"time"
"finance-duck/internal/classification"
"finance-duck/internal/domain"
)
@@ -44,6 +46,33 @@ type Preview struct {
created time.Time
}
// PreviewProgress is the live state of one preview run. Errors accumulate as
// they happen so a failing provider is visible after seconds, not after the
// whole paced range. Preview is set only when Done with an empty Error.
type PreviewProgress struct {
ID string `json:"id"`
Total int `json:"total"`
Analysed int `json:"analysed"`
Changes int `json:"changes"`
Unchanged int `json:"unchanged"`
Errors []ClassificationError `json:"errors"`
Done bool `json:"done"`
Error string `json:"error,omitempty"`
Preview *Preview `json:"preview,omitempty"`
}
func (p PreviewProgress) clone() PreviewProgress {
p.Errors = append([]ClassificationError{}, p.Errors...)
return p
}
// previewJob is the single in-flight (or most recently finished) preview run.
// status is guarded by App.mu; cancel stops the goroutine cooperatively.
type previewJob struct {
cancel context.CancelFunc
status PreviewProgress
}
func validRange(from, to string) error {
f, e := time.Parse("2006-01-02", from)
if e != nil {
@@ -58,49 +87,162 @@ func validRange(from, to string) error {
}
return nil
}
func (a *App) Preview(ctx context.Context, r PreviewRequest) (Preview, error) {
func validatePreviewRequest(r PreviewRequest) error {
if err := validRange(r.From, r.To); err != nil {
return Preview{}, err
return err
}
if !r.Fields.Merchant && !r.Fields.Category && !r.Fields.Tags {
return Preview{}, errors.New("select at least one enrichment field")
return errors.New("select at least one enrichment field")
}
if strings.TrimSpace(r.Model) == "" {
return Preview{}, errors.New("model is required")
return errors.New("model is required")
}
return nil
}
func previewEligible(t domain.Transaction, r PreviewRequest) bool {
return t.Facts.BookingDate >= r.From && t.Facts.BookingDate <= r.To &&
t.Enrichment.Kind != "transfer" && t.Enrichment.Kind != domain.KindInvestment
}
// StartPreview validates the request against the current journal and starts a
// background classification run. The provider is paced to one request every
// few seconds, so any real range takes minutes: the caller polls
// PreviewProgress instead of holding an HTTP request open for the duration.
// Only one run exists at a time; the run owns its own snapshot and never
// touches canonical data.
func (a *App) StartPreview(ctx context.Context, r PreviewRequest) (PreviewProgress, error) {
if err := validatePreviewRequest(r); err != nil {
return PreviewProgress{}, err
}
a.mu.Lock()
defer a.mu.Unlock()
if a.previewRun != nil && !a.previewRun.status.Done {
return PreviewProgress{}, errors.New("a preview is already being generated; stop it first")
}
s, err := a.snapshot(ctx)
client := a.classifier.WithModel(r.Model)
a.mu.Unlock()
if err != nil {
return Preview{}, err
return PreviewProgress{}, err
}
if r.Revision != s.Revision {
return Preview{}, errors.New("revision conflict: reload before analysing")
return PreviewProgress{}, errors.New("revision conflict: reload before analysing")
}
p := Preview{ID: domain.NewID("preview"), Revision: s.Revision, Changes: []Change{}, Errors: []ClassificationError{}, created: time.Now()}
baseMerchants := len(s.Data.Merchants)
client := a.classifier.WithModel(r.Model)
total := 0
for _, t := range s.Data.Transactions {
if t.Facts.BookingDate < r.From || t.Facts.BookingDate > r.To || t.Enrichment.Kind == "transfer" || t.Enrichment.Kind == domain.KindInvestment {
if previewEligible(t, r) {
total++
}
}
runCtx, cancel := context.WithCancel(context.Background())
job := &previewJob{cancel: cancel, status: PreviewProgress{ID: domain.NewID("preview"), Total: total, Errors: []ClassificationError{}}}
a.previewRun = job
go a.runPreview(runCtx, cancel, client, s, r, job)
return job.status.clone(), nil
}
func (a *App) runPreview(ctx context.Context, cancel context.CancelFunc, client *classification.Client, s State, r PreviewRequest, job *previewJob) {
defer cancel()
p, err := classifyRange(ctx, client, s, r, job.status.ID, func(u PreviewProgress) {
a.mu.Lock()
if a.previewRun == job {
job.status = u
}
a.mu.Unlock()
})
a.mu.Lock()
defer a.mu.Unlock()
if a.previewRun != job {
return // stopped by CancelPreview; discard the result
}
job.status.Done = true
if err != nil {
job.status.Error = err.Error()
return
}
for id, old := range a.previews {
if time.Since(old.created) > time.Hour {
delete(a.previews, id)
}
}
if len(a.previews) >= 20 {
job.status.Error = "too many active previews; cancel one first"
return
}
a.previews[p.ID] = p
job.status.Analysed = p.Analysed
job.status.Changes = len(p.Changes)
job.status.Unchanged = p.Unchanged
job.status.Errors = append([]ClassificationError{}, p.Errors...)
job.status.Preview = &p
}
// PreviewProgress reports the current (or most recently finished) preview run.
// An empty id re-attaches to whatever run exists, so navigating away from the
// page does not orphan a run that is still spending provider requests.
func (a *App) PreviewProgress(id string) (PreviewProgress, error) {
a.mu.Lock()
defer a.mu.Unlock()
job := a.previewRun
if job == nil || (id != "" && job.status.ID != id) {
return PreviewProgress{}, errors.New("no matching preview run; analyse again")
}
return job.status.clone(), nil
}
// classifyRange proposes enrichment for every eligible transaction in the
// snapshot, reporting progress after each one. It stops early when the run has
// produced no successful proposal yet and the same error message repeats three
// times in a row: an identical repeated failure is a configuration or provider
// problem, and grinding through the rest of the paced range would only repeat
// it a few seconds apart.
func classifyRange(ctx context.Context, client *classification.Client, s State, r PreviewRequest, id string, report func(PreviewProgress)) (Preview, error) {
p := Preview{ID: id, Revision: s.Revision, Changes: []Change{}, Errors: []ClassificationError{}, created: time.Now()}
baseMerchants := len(s.Data.Merchants)
total := 0
for _, t := range s.Data.Transactions {
if previewEligible(t, r) {
total++
}
}
progress := func() {
if report != nil {
report(PreviewProgress{ID: id, Total: total, Analysed: p.Analysed, Changes: len(p.Changes), Unchanged: p.Unchanged, Errors: append([]ClassificationError{}, p.Errors...)})
}
}
succeeded := false
repeated := 0
for _, t := range s.Data.Transactions {
if !previewEligible(t, r) {
continue
}
if err = ctx.Err(); err != nil {
if err := ctx.Err(); err != nil {
return Preview{}, err
}
p.Analysed++
proposal, e := client.Classify(ctx, t.Facts, s.Data, true)
if err = ctx.Err(); err != nil {
if err := ctx.Err(); err != nil {
return Preview{}, err
}
if e != nil {
if n := len(p.Errors); n > 0 && p.Errors[n-1].Error == e.Error() {
repeated++
} else {
repeated = 1
}
p.Errors = append(p.Errors, ClassificationError{t.Facts.ID, e.Error()})
if !succeeded && repeated >= 3 {
return Preview{}, fmt.Errorf("stopped after %d identical failures — %s — with %d of %d transactions not analysed", repeated, e.Error(), total-p.Analysed, total)
}
progress()
continue
}
succeeded = true
after := t.Enrichment
if r.Fields.Merchant {
after.MerchantID = proposal.Enrichment.MerchantID
if e = addProposal(&s.Data, proposal, t.Facts); e != nil {
p.Errors = append(p.Errors, ClassificationError{t.Facts.ID, e.Error()})
progress()
continue
}
}
@@ -112,6 +254,7 @@ func (a *App) Preview(ctx context.Context, r PreviewRequest) (Preview, error) {
}
if e = domain.ValidateEnrichment(s.Data, t.Facts, after); e != nil {
p.Errors = append(p.Errors, ClassificationError{t.Facts.ID, e.Error()})
progress()
continue
}
beforeComparable, afterComparable := t.Enrichment, after
@@ -123,23 +266,14 @@ func (a *App) Preview(ctx context.Context, r PreviewRequest) (Preview, error) {
slices.Sort(afterComparable.TagIDs)
if reflect.DeepEqual(beforeComparable, afterComparable) {
p.Unchanged++
progress()
continue
}
after.Classification = proposal.Enrichment.Classification
p.Changes = append(p.Changes, Change{t.Facts.ID, t.Facts.RawDescription, t.Enrichment, after})
progress()
}
p.NewMerchants = append([]domain.Merchant{}, s.Data.Merchants[baseMerchants:]...)
a.mu.Lock()
defer a.mu.Unlock()
for id, old := range a.previews {
if time.Since(old.created) > time.Hour {
delete(a.previews, id)
}
}
if len(a.previews) >= 20 {
return Preview{}, errors.New("too many active previews; cancel one first")
}
a.previews[p.ID] = p
return p, nil
}
func (a *App) ApplyPreview(ctx context.Context, id, rev string, ids []string) (State, error) {
@@ -198,6 +332,20 @@ func (a *App) ApplyPreview(ctx context.Context, id, rev string, ids []string) (S
return State{}, err
}
delete(a.previews, id)
if job := a.previewRun; job != nil && job.status.ID == id {
a.previewRun = nil
}
return state, nil
}
func (a *App) CancelPreview(id string) { a.mu.Lock(); defer a.mu.Unlock(); delete(a.previews, id) }
// CancelPreview stops a running preview job and discards a finished preview.
// A run and its stored preview share one id, so a single cancel covers both.
func (a *App) CancelPreview(id string) {
a.mu.Lock()
defer a.mu.Unlock()
if job := a.previewRun; job != nil && job.status.ID == id {
job.cancel()
a.previewRun = nil
}
delete(a.previews, id)
}