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
|
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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ 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"`
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user