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
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.
+54 -14
View File
@@ -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)
}
+7 -7
View File
@@ -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")
}
+9 -14
View File
@@ -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 {
+3 -1
View File
@@ -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<State>("/api/state"));
const start = await request<PreviewProgress>(
"/api/reclassify/preview",
{
revision: state.revision,
from,
to,
model: model.trim(),