fix refresh

This commit is contained in:
Lars Nolden
2026-09-14 10:01:57 +02:00
parent 46e02d95cb
commit f9e829e6ba
5 changed files with 78 additions and 38 deletions
+5 -2
View File
@@ -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 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 same error stops early and reports that error instead of repeating it across
the whole range. Only one run exists at a time. 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 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 (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 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 Manually linking a merchant also records the counterparty as an alias, so
recurring payees classify locally without any provider request. recurring payees classify locally without any provider request.
The finished run is a read-only preview. Apply all/selected writes all The finished run is a read-only preview. Apply all/selected writes all
selected changes in one canonical commit; financial facts never change. A selected changes in one canonical commit; financial facts never change. Apply
manual edit, external journal change or taxonomy change invalidates old previews. 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 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 disappear on restart. Cancel writes nothing. Transfers and broker facts are
skipped, and unselected fields are preserved. skipped, and unselected fields are preserved.
+54 -14
View File
@@ -171,10 +171,8 @@ func TestPreviewCooldownProtectsLaterPreviewsAndImports(t *testing.T) {
defer cancel() defer cancel()
for _, model := range []string{"test/model", "test/another-model"} { for _, model := range []string{"test/model", "test/another-model"} {
preview, err := runPreview(t, a, PreviewRequest{ preview, err := runPreview(t, a, PreviewRequest{From: "2026-09-01", To: "2026-09-30",
Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: model, Fields: Fields{Category: true}})
Model: model, Fields: Fields{Category: true},
})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -224,10 +222,8 @@ func TestCancelledPreviewRunProducesNoPreview(t *testing.T) {
})) }))
defer provider.Close() defer provider.Close()
a.classifier = classification.Client{APIKey: "test", Model: "test/model", BaseURL: provider.URL} a.classifier = classification.Client{APIKey: "test", Model: "test/model", BaseURL: provider.URL}
start, err := a.StartPreview(context.Background(), PreviewRequest{ start, err := a.StartPreview(context.Background(), PreviewRequest{From: "2026-09-09", To: "2026-09-09",
Revision: s.Revision, From: "2026-09-09", To: "2026-09-09", Model: "test/model", Fields: Fields{Category: true}})
Model: "test/model", Fields: Fields{Category: true},
})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -319,7 +315,7 @@ func TestPreviewIsReadOnlySelectedApplyPreservesFactsAndOtherFields(t *testing.T
} }
mockClassifier(t, a) mockClassifier(t, a)
before := domain.Clone(s.Data) 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 { if err != nil {
t.Fatal(err) 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) { func TestPreviewExpiresAfterTwentyFourHours(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
name string name string
@@ -378,7 +418,7 @@ func TestPreviewExpiresAfterTwentyFourHours(t *testing.T) {
a, s := testApp(t) a, s := testApp(t)
s = seed(t, a, s) s = seed(t, a, s)
mockClassifier(t, a) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -392,7 +432,7 @@ func TestPreviewExpiresAfterTwentyFourHours(t *testing.T) {
if tc.newPreview { if tc.newPreview {
// Completing another run performs expired-preview cleanup. // Completing another run performs expired-preview cleanup.
// An empty range needs no additional provider request. // 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) t.Fatal(err)
} }
} }
@@ -423,7 +463,7 @@ func TestStalePreviewCannotOverwriteManualCorrection(t *testing.T) {
a, s := testApp(t) a, s := testApp(t)
s = seed(t, a, s) s = seed(t, a, s)
mockClassifier(t, a) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -455,7 +495,7 @@ func TestApplyPreviewSurvivesUnrelatedCommitsAndPartialApplies(t *testing.T) {
a, s := testApp(t) a, s := testApp(t)
s = seed(t, a, s) s = seed(t, a, s)
mockClassifier(t, a) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -508,7 +548,7 @@ func TestApplyPreviewHonoursReviewerEdits(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
mockClassifier(t, a) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+7 -7
View File
@@ -10,9 +10,9 @@ import (
"testing" "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() 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -68,7 +68,7 @@ func TestOpenRouterKeyRotationChangesProviderAuthorization(t *testing.T) {
if strings.Contains(string(encoded), "private-key") { if strings.Contains(string(encoded), "private-key") {
t.Fatal("saved credential leaked into browser state") 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() reopen()
checkOpenRouterPreview(t, a, s, auth, "environment-private-key") checkOpenRouterPreview(t, a, auth, "environment-private-key")
for _, key := range []string{"saved-private-key", ""} { for _, key := range []string{"saved-private-key", ""} {
var err error var err error
s, err = a.SaveOpenRouterKey(context.Background(), key) s, err = a.SaveOpenRouterKey(context.Background(), key)
@@ -119,7 +119,7 @@ func TestOpenRouterSavedKeyAndDisableSurviveRestartOverrideEnvironment(t *testin
if s.Status.AIConfigured != (key != "") { if s.Status.AIConfigured != (key != "") {
t.Fatal("restarted credential status ignored saved preference") 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) { func TestOpenRouterFailedWritePreservesActiveCredential(t *testing.T) {
@@ -216,5 +216,5 @@ func TestOpenRouterFailedWritePreservesActiveCredential(t *testing.T) {
t.Fatal("persistence error leaked credential content") t.Fatal("persistence error leaked credential content")
} }
} }
checkOpenRouterPreview(t, a, s, auth, "active-private-key") checkOpenRouterPreview(t, a, auth, "active-private-key")
} }
+9 -14
View File
@@ -21,11 +21,10 @@ type Fields struct {
Tags bool `json:"tags"` Tags bool `json:"tags"`
} }
type PreviewRequest struct { type PreviewRequest struct {
Revision string `json:"revision"` From string `json:"from"`
From string `json:"from"` To string `json:"to"`
To string `json:"to"` Model string `json:"model"`
Model string `json:"model"` Fields Fields `json:"fields"`
Fields Fields `json:"fields"`
} }
type Change struct { type Change struct {
ID string `json:"id"` 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 t.Enrichment.Kind != "transfer" && t.Enrichment.Kind != domain.KindInvestment
} }
// StartPreview validates the request against the current journal and starts a // StartPreview takes a fresh journal snapshot and starts a read-only
// background classification run. The provider is paced to one request every // classification run. It does not require the page's revision: a sync or edit
// few seconds, so any real range takes minutes: the caller polls // while the page is open must not block analysis. ApplyPreview checks for
// PreviewProgress instead of holding an HTTP request open for the duration. // conflicting changes before writing. Only one run exists at a time; callers
// Only one run exists at a time; the run owns its own snapshot and never // poll PreviewProgress instead of holding an HTTP request open.
// touches canonical data.
func (a *App) StartPreview(ctx context.Context, r PreviewRequest) (PreviewProgress, error) { func (a *App) StartPreview(ctx context.Context, r PreviewRequest) (PreviewProgress, error) {
if err := validatePreviewRequest(r); err != nil { if err := validatePreviewRequest(r); err != nil {
return PreviewProgress{}, err return PreviewProgress{}, err
@@ -138,9 +136,6 @@ func (a *App) StartPreview(ctx context.Context, r PreviewRequest) (PreviewProgre
if err != nil { if err != nil {
return PreviewProgress{}, err return PreviewProgress{}, err
} }
if r.Revision != s.Revision {
return PreviewProgress{}, errors.New("revision conflict: reload before analysing")
}
client := a.classifier.WithModel(r.Model) client := a.classifier.WithModel(r.Model)
total := 0 total := 0
for _, t := range s.Data.Transactions { for _, t := range s.Data.Transactions {
+3 -1
View File
@@ -292,10 +292,12 @@ export function Classification({
setBusy(true); setBusy(true);
setError(""); setError("");
try { try {
// Refresh registry labels for the review, but let the server
// take its own snapshot so another write cannot race analysis.
acceptState(await request<State>("/api/state"));
const start = await request<PreviewProgress>( const start = await request<PreviewProgress>(
"/api/reclassify/preview", "/api/reclassify/preview",
{ {
revision: state.revision,
from, from,
to, to,
model: model.trim(), model: model.trim(),