Let reviews see low-confidence suggestions and rebase preview applies
A low-confidence answer was discarded inside Classify, so Analyse showed the row as unchanged instead of a reviewable suggestion; the fallback decision moves to the import path, which keeps the merchant link and the recorded confidence. ApplyPreview now rebases onto the current journal: unrelated commits during a minutes-long paced run no longer invalidate the review, only an edit to a selected transaction itself conflicts, and applied changes are pruned so the rest stay appliable.
This commit is contained in:
@@ -369,6 +369,82 @@ func TestStalePreviewCannotOverwriteManualCorrection(t *testing.T) {
|
||||
t.Fatal("stale apply partially changed records")
|
||||
}
|
||||
}
|
||||
|
||||
// A preview run is minutes long by design (paced provider calls), so a
|
||||
// scheduled sync, an import, or an earlier partial apply committing in the
|
||||
// meantime must not invalidate the review: only an edit to a selected
|
||||
// transaction itself conflicts.
|
||||
func TestApplyPreviewSurvivesUnrelatedCommitsAndPartialApplies(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
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}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(p.Changes) != 2 {
|
||||
t.Fatalf("expected two proposed changes: %+v", p)
|
||||
}
|
||||
// An unrelated registry edit moves the journal revision after the preview.
|
||||
if _, err = a.Mutate(ctx, s.Revision, func(d *domain.Dataset) error {
|
||||
d.Tags = append(d.Tags, domain.Tag{ID: "travel", Name: "travel"})
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[0].ID})
|
||||
if err != nil {
|
||||
t.Fatalf("unrelated commit invalidated the preview: %v", err)
|
||||
}
|
||||
// The partial apply moved the revision again; the remaining proposal must
|
||||
// still apply without another paced provider run.
|
||||
second, err := a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[1].ID})
|
||||
if err != nil {
|
||||
t.Fatalf("partial apply consumed the remaining proposals: %v", err)
|
||||
}
|
||||
if second.Revision == first.Revision {
|
||||
t.Fatal("second apply committed nothing")
|
||||
}
|
||||
for _, tx := range second.Data.Transactions {
|
||||
if tx.Enrichment.CategoryID != "groceries" {
|
||||
t.Fatalf("applied categories lost: %+v", tx.Enrichment)
|
||||
}
|
||||
}
|
||||
// Both changes are consumed now; re-applying must fail, not double-write.
|
||||
if _, err = a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[0].ID}); err == nil {
|
||||
t.Fatal("consumed change applied twice")
|
||||
}
|
||||
}
|
||||
|
||||
// Imports auto-apply only what the model is sure about: a low-confidence
|
||||
// category lands on the editable fallback while the merchant link and the
|
||||
// recorded confidence survive for review in Analyse.
|
||||
func TestImportNeverAutoAppliesLowConfidenceCategory(t *testing.T) {
|
||||
a, s := testApp(t)
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
content := `{"merchant_id":null,"new_merchant":"REWE","category_id":"groceries","tag_ids":[],"confidence":"low"}`
|
||||
json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{
|
||||
"finish_reason": "stop",
|
||||
"message": map[string]any{"content": content},
|
||||
}}})
|
||||
}))
|
||||
defer provider.Close()
|
||||
a.classifier = classification.Client{APIKey: "test", Model: "test/model", BaseURL: provider.URL}
|
||||
s = seed(t, a, s)
|
||||
if len(s.Data.Transactions) != 2 {
|
||||
t.Fatalf("import lost transactions: %d", len(s.Data.Transactions))
|
||||
}
|
||||
for _, tx := range s.Data.Transactions {
|
||||
e := tx.Enrichment
|
||||
if e.CategoryID != domain.ExpenseFallback {
|
||||
t.Fatalf("low-confidence category was auto-applied: %+v", e)
|
||||
}
|
||||
if e.MerchantID == "" || e.Classification.Confidence != "low" || e.Classification.Source != "openrouter" {
|
||||
t.Fatalf("merchant link or provenance lost: %+v", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestTaxonomyProposalApprovalMintsOnlyApprovedEntries(t *testing.T) {
|
||||
a, s := testApp(t)
|
||||
s = seed(t, a, s)
|
||||
|
||||
Reference in New Issue
Block a user