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:
@@ -71,6 +71,7 @@ type App struct {
|
||||
bank banking.Provider
|
||||
classifier classification.Client
|
||||
previews map[string]Preview
|
||||
previewRun *previewJob
|
||||
taxonomies map[string]TaxonomyPreview
|
||||
csvImports map[string]CSVImport
|
||||
authStates map[string]authorization
|
||||
|
||||
+51
-10
@@ -129,6 +129,32 @@ func TestFailedClassificationStillImportsAndRetryIsIdempotent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// runPreview drives the background preview job to completion the way the UI
|
||||
// does: start the run, then poll progress until it reports done.
|
||||
func runPreview(t *testing.T, a *App, r PreviewRequest) (Preview, error) {
|
||||
t.Helper()
|
||||
start, err := a.StartPreview(context.Background(), r)
|
||||
if err != nil {
|
||||
return Preview{}, err
|
||||
}
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
for {
|
||||
p, err := a.PreviewProgress(start.ID)
|
||||
if err != nil {
|
||||
return Preview{}, err
|
||||
}
|
||||
if p.Done {
|
||||
if p.Error != "" {
|
||||
return Preview{}, errors.New(p.Error)
|
||||
}
|
||||
return *p.Preview, nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("preview run did not finish")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
func TestPreviewCooldownProtectsLaterPreviewsAndImports(t *testing.T) {
|
||||
a, s := testApp(t)
|
||||
s = seed(t, a, s)
|
||||
@@ -145,7 +171,7 @@ func TestPreviewCooldownProtectsLaterPreviewsAndImports(t *testing.T) {
|
||||
defer cancel()
|
||||
|
||||
for _, model := range []string{"test/model", "test/another-model"} {
|
||||
preview, err := a.Preview(ctx, PreviewRequest{
|
||||
preview, err := runPreview(t, a, PreviewRequest{
|
||||
Revision: s.Revision, From: "2026-09-01", To: "2026-09-30",
|
||||
Model: model, Fields: Fields{Category: true},
|
||||
})
|
||||
@@ -185,24 +211,39 @@ func TestPreviewCooldownProtectsLaterPreviewsAndImports(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelledLastClassificationDoesNotProducePreview(t *testing.T) {
|
||||
func TestCancelledPreviewRunProducesNoPreview(t *testing.T) {
|
||||
a, s := testApp(t)
|
||||
s = seed(t, a, s)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
ids := make(chan string, 1)
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Stop the run from within its first provider call, as the UI's Stop
|
||||
// button would mid-request.
|
||||
a.CancelPreview(<-ids)
|
||||
w.Header().Set("Retry-After", "60")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
cancel()
|
||||
}))
|
||||
defer provider.Close()
|
||||
a.classifier = classification.Client{APIKey: "test", Model: "test/model", BaseURL: provider.URL}
|
||||
p, err := a.Preview(ctx, PreviewRequest{
|
||||
start, err := a.StartPreview(context.Background(), PreviewRequest{
|
||||
Revision: s.Revision, From: "2026-09-09", To: "2026-09-09",
|
||||
Model: "test/model", Fields: Fields{Category: true},
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) || p.ID != "" {
|
||||
t.Fatalf("cancelled final record produced a preview: id=%q, error=%v", p.ID, err)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids <- start.ID
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
if _, err := a.PreviewProgress(start.ID); err != nil {
|
||||
break // the cancelled run is gone, never a finished preview
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("cancelled preview run still reports progress")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if _, err := a.ApplyPreview(context.Background(), start.ID, s.Revision, []string{"any"}); err == nil {
|
||||
t.Fatal("cancelled run produced an applicable preview")
|
||||
}
|
||||
after, err := a.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
@@ -261,7 +302,7 @@ func TestPreviewIsReadOnlySelectedApplyPreservesFactsAndOtherFields(t *testing.T
|
||||
}
|
||||
mockClassifier(t, a)
|
||||
before := domain.Clone(s.Data)
|
||||
preview, err := a.Preview(context.Background(), PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "improved/model", Fields: Fields{Category: true}})
|
||||
preview, err := runPreview(t, a, PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "improved/model", Fields: Fields{Category: true}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -306,7 +347,7 @@ func TestStalePreviewCannotOverwriteManualCorrection(t *testing.T) {
|
||||
a, s := testApp(t)
|
||||
s = seed(t, a, s)
|
||||
mockClassifier(t, a)
|
||||
p, err := a.Preview(context.Background(), PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}})
|
||||
p, err := runPreview(t, a, PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
func checkOpenRouterPreview(t *testing.T, a *App, s State, auth <-chan string, key string) {
|
||||
t.Helper()
|
||||
p, err := a.Preview(context.Background(), PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}})
|
||||
p, err := runPreview(t, a, PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
+173
-25
@@ -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)
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ func New(a *app.App, assets fs.FS, publicURL string) (http.Handler, error) {
|
||||
respond(w, v, e)
|
||||
})
|
||||
s.mux.HandleFunc("POST /api/reclassify/preview", s.preview)
|
||||
s.mux.HandleFunc("POST /api/reclassify/progress", s.previewProgress)
|
||||
s.mux.HandleFunc("POST /api/reclassify/apply", s.apply)
|
||||
s.mux.HandleFunc("POST /api/reclassify/cancel", s.cancel)
|
||||
s.mux.HandleFunc("POST /api/taxonomy/propose", s.taxonomyPropose)
|
||||
@@ -490,7 +491,17 @@ func (s *Server) preview(w http.ResponseWriter, r *http.Request) {
|
||||
if !decode(w, r, &b) {
|
||||
return
|
||||
}
|
||||
v, e := s.app.Preview(r.Context(), b)
|
||||
v, e := s.app.StartPreview(r.Context(), b)
|
||||
respond(w, v, e)
|
||||
}
|
||||
func (s *Server) previewProgress(w http.ResponseWriter, r *http.Request) {
|
||||
var b struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if !decode(w, r, &b) {
|
||||
return
|
||||
}
|
||||
v, e := s.app.PreviewProgress(b.ID)
|
||||
respond(w, v, e)
|
||||
}
|
||||
func (s *Server) apply(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Reference in New Issue
Block a user