From f9e829e6ba88e347c5f82be48294b3604fcec4ba Mon Sep 17 00:00:00 2001 From: Lars Nolden Date: Mon, 14 Sep 2026 10:01:57 +0200 Subject: [PATCH] fix refresh --- OPERATIONS.txt | 7 +++- internal/app/app_test.go | 68 ++++++++++++++++++++++++++------- internal/app/openrouter_test.go | 14 +++---- internal/app/reclassify.go | 23 +++++------ web/src/Classification.tsx | 4 +- 5 files changed, 78 insertions(+), 38 deletions(-) diff --git a/OPERATIONS.txt b/OPERATIONS.txt index 8e6d1dd..0b1b6e5 100644 --- a/OPERATIONS.txt +++ b/OPERATIONS.txt @@ -739,6 +739,8 @@ and revisited, and Stop abandons the run without writing anything. A run that has produced no successful proposal and fails three times in a row with the same error stops early and reports that error instead of repeating it across the whole range. Only one run exists at a time. +Starting analysis reads the latest journal, independent of the page's revision. +The page refreshes registry labels before starting; analysis itself writes nothing. History precedent sent with each request marks the user's own decisions (manual edits and merchant rules) as source user, ranks them ahead of the model's earlier answers, and reserves window slots for them, so one manual @@ -746,8 +748,9 @@ correction outweighs repeated uncorrected AI output for the same payee. Manually linking a merchant also records the counterparty as an alias, so recurring payees classify locally without any provider request. The finished run is a read-only preview. Apply all/selected writes all -selected changes in one canonical commit; financial facts never change. A -manual edit, external journal change or taxonomy change invalidates old previews. +selected changes in one canonical commit; financial facts never change. Apply +checks selected transactions against the preview snapshot; unrelated journal +commits do not require another analysis. Previews are kept in memory for up to 24 hours from the start of analysis and disappear on restart. Cancel writes nothing. Transfers and broker facts are skipped, and unselected fields are preserved. diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 0361771..025e725 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -171,10 +171,8 @@ func TestPreviewCooldownProtectsLaterPreviewsAndImports(t *testing.T) { defer cancel() for _, model := range []string{"test/model", "test/another-model"} { - preview, err := runPreview(t, a, PreviewRequest{ - Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", - Model: model, Fields: Fields{Category: true}, - }) + preview, err := runPreview(t, a, PreviewRequest{From: "2026-09-01", To: "2026-09-30", + Model: model, Fields: Fields{Category: true}}) if err != nil { t.Fatal(err) } @@ -224,10 +222,8 @@ func TestCancelledPreviewRunProducesNoPreview(t *testing.T) { })) defer provider.Close() a.classifier = classification.Client{APIKey: "test", Model: "test/model", BaseURL: provider.URL} - 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}, - }) + start, err := a.StartPreview(context.Background(), PreviewRequest{From: "2026-09-09", To: "2026-09-09", + Model: "test/model", Fields: Fields{Category: true}}) if err != nil { t.Fatal(err) } @@ -319,7 +315,7 @@ func TestPreviewIsReadOnlySelectedApplyPreservesFactsAndOtherFields(t *testing.T } mockClassifier(t, a) before := domain.Clone(s.Data) - preview, err := runPreview(t, a, 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{From: "2026-09-01", To: "2026-09-30", Model: "improved/model", Fields: Fields{Category: true}}) if err != nil { t.Fatal(err) } @@ -361,6 +357,50 @@ func TestPreviewIsReadOnlySelectedApplyPreservesFactsAndOtherFields(t *testing.T } } +func TestPreviewUsesLatestSnapshotWithoutClientRevision(t *testing.T) { + ctx := context.Background() + a, page := testApp(t) + page = seed(t, a, page) + mockClassifier(t, a) + request := PreviewRequest{From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}} + id := page.Data.Transactions[0].Facts.ID + // Another writer changes the journal after the page loaded its state. + current, err := a.Mutate(ctx, page.Revision, func(d *domain.Dataset) error { + for i := range d.Transactions { + if d.Transactions[i].Facts.ID == id { + d.Transactions[i].Enrichment.TagIDs = []string{"home"} + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } + preview, err := runPreview(t, a, request) + if err != nil { + t.Fatal(err) + } + if preview.Revision != current.Revision || len(preview.Changes) != 2 { + t.Fatalf("preview did not use the latest snapshot: %+v", preview) + } + unchanged, err := a.Snapshot(ctx) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(unchanged.Data, current.Data) { + t.Fatal("starting analysis changed the journal") + } + applied, err := a.ApplyPreview(ctx, preview.ID, preview.Revision, []string{id}, nil) + if err != nil { + t.Fatal(err) + } + for _, tx := range applied.Data.Transactions { + if tx.Facts.ID == id && (tx.Enrichment.CategoryID != "groceries" || !reflect.DeepEqual(tx.Enrichment.TagIDs, []string{"home"})) { + t.Fatalf("analysis overwrote an edit made after the page loaded: %+v", tx.Enrichment) + } + } +} + func TestPreviewExpiresAfterTwentyFourHours(t *testing.T) { for _, tc := range []struct { name string @@ -378,7 +418,7 @@ func TestPreviewExpiresAfterTwentyFourHours(t *testing.T) { a, s := testApp(t) s = seed(t, a, s) mockClassifier(t, a) - p, err := runPreview(t, a, 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{From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}}) if err != nil { t.Fatal(err) } @@ -392,7 +432,7 @@ func TestPreviewExpiresAfterTwentyFourHours(t *testing.T) { if tc.newPreview { // Completing another run performs expired-preview cleanup. // An empty range needs no additional provider request. - if _, err := runPreview(t, a, PreviewRequest{Revision: s.Revision, From: "2025-01-01", To: "2025-01-31", Model: "test/model", Fields: Fields{Category: true}}); err != nil { + if _, err := runPreview(t, a, PreviewRequest{From: "2025-01-01", To: "2025-01-31", Model: "test/model", Fields: Fields{Category: true}}); err != nil { t.Fatal(err) } } @@ -423,7 +463,7 @@ func TestStalePreviewCannotOverwriteManualCorrection(t *testing.T) { a, s := testApp(t) s = seed(t, a, s) mockClassifier(t, a) - p, err := runPreview(t, a, 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{From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}}) if err != nil { t.Fatal(err) } @@ -455,7 +495,7 @@ func TestApplyPreviewSurvivesUnrelatedCommitsAndPartialApplies(t *testing.T) { a, s := testApp(t) s = seed(t, a, s) mockClassifier(t, a) - p, err := runPreview(t, a, 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{From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}}) if err != nil { t.Fatal(err) } @@ -508,7 +548,7 @@ func TestApplyPreviewHonoursReviewerEdits(t *testing.T) { t.Fatal(err) } mockClassifier(t, a) - p, err := runPreview(t, a, PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true, Tags: true}}) + p, err := runPreview(t, a, PreviewRequest{From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true, Tags: true}}) if err != nil { t.Fatal(err) } diff --git a/internal/app/openrouter_test.go b/internal/app/openrouter_test.go index 4d4a1dd..427878f 100644 --- a/internal/app/openrouter_test.go +++ b/internal/app/openrouter_test.go @@ -10,9 +10,9 @@ import ( "testing" ) -func checkOpenRouterPreview(t *testing.T, a *App, s State, auth <-chan string, key string) { +func checkOpenRouterPreview(t *testing.T, a *App, auth <-chan string, key string) { t.Helper() - p, err := runPreview(t, a, 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{From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}}) if err != nil { t.Fatal(err) } @@ -68,7 +68,7 @@ func TestOpenRouterKeyRotationChangesProviderAuthorization(t *testing.T) { if strings.Contains(string(encoded), "private-key") { t.Fatal("saved credential leaked into browser state") } - checkOpenRouterPreview(t, a, s, auth, key) + checkOpenRouterPreview(t, a, auth, key) } } @@ -101,7 +101,7 @@ func TestOpenRouterSavedKeyAndDisableSurviveRestartOverrideEnvironment(t *testin } }) reopen() - checkOpenRouterPreview(t, a, s, auth, "environment-private-key") + checkOpenRouterPreview(t, a, auth, "environment-private-key") for _, key := range []string{"saved-private-key", ""} { var err error s, err = a.SaveOpenRouterKey(context.Background(), key) @@ -119,7 +119,7 @@ func TestOpenRouterSavedKeyAndDisableSurviveRestartOverrideEnvironment(t *testin if s.Status.AIConfigured != (key != "") { t.Fatal("restarted credential status ignored saved preference") } - checkOpenRouterPreview(t, a, s, auth, key) + checkOpenRouterPreview(t, a, auth, key) } } @@ -187,7 +187,7 @@ func TestOpenRouterRejectedKeysPreserveActiveCredential(t *testing.T) { } }) } - checkOpenRouterPreview(t, a, s, auth, key) + checkOpenRouterPreview(t, a, auth, key) } func TestOpenRouterFailedWritePreservesActiveCredential(t *testing.T) { @@ -216,5 +216,5 @@ func TestOpenRouterFailedWritePreservesActiveCredential(t *testing.T) { t.Fatal("persistence error leaked credential content") } } - checkOpenRouterPreview(t, a, s, auth, "active-private-key") + checkOpenRouterPreview(t, a, auth, "active-private-key") } diff --git a/internal/app/reclassify.go b/internal/app/reclassify.go index 2e5b07d..738504a 100644 --- a/internal/app/reclassify.go +++ b/internal/app/reclassify.go @@ -21,11 +21,10 @@ type Fields struct { Tags bool `json:"tags"` } type PreviewRequest struct { - Revision string `json:"revision"` - From string `json:"from"` - To string `json:"to"` - Model string `json:"model"` - Fields Fields `json:"fields"` + From string `json:"from"` + To string `json:"to"` + Model string `json:"model"` + Fields Fields `json:"fields"` } type Change struct { ID string `json:"id"` @@ -119,12 +118,11 @@ func previewEligible(t domain.Transaction, r PreviewRequest) bool { 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. +// StartPreview takes a fresh journal snapshot and starts a read-only +// classification run. It does not require the page's revision: a sync or edit +// while the page is open must not block analysis. ApplyPreview checks for +// conflicting changes before writing. Only one run exists at a time; callers +// poll PreviewProgress instead of holding an HTTP request open. func (a *App) StartPreview(ctx context.Context, r PreviewRequest) (PreviewProgress, error) { if err := validatePreviewRequest(r); err != nil { return PreviewProgress{}, err @@ -138,9 +136,6 @@ func (a *App) StartPreview(ctx context.Context, r PreviewRequest) (PreviewProgre if err != nil { return PreviewProgress{}, err } - if r.Revision != s.Revision { - return PreviewProgress{}, errors.New("revision conflict: reload before analysing") - } client := a.classifier.WithModel(r.Model) total := 0 for _, t := range s.Data.Transactions { diff --git a/web/src/Classification.tsx b/web/src/Classification.tsx index b632502..f743bd4 100644 --- a/web/src/Classification.tsx +++ b/web/src/Classification.tsx @@ -292,10 +292,12 @@ export function Classification({ setBusy(true); setError(""); try { + // Refresh registry labels for the review, but let the server + // take its own snapshot so another write cannot race analysis. + acceptState(await request("/api/state")); const start = await request( "/api/reclassify/preview", { - revision: state.revision, from, to, model: model.trim(),