fix refresh
This commit is contained in:
+5
-2
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user