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:
+48
-12
@@ -276,6 +276,21 @@ func classifyRange(ctx context.Context, client *classification.Client, s State,
|
||||
p.NewMerchants = append([]domain.Merchant{}, s.Data.Merchants[baseMerchants:]...)
|
||||
return p, nil
|
||||
}
|
||||
// enrichmentEqual compares enrichment semantically: tag order is not a change.
|
||||
func enrichmentEqual(a, b domain.Enrichment) bool {
|
||||
a.TagIDs = slices.Clone(a.TagIDs)
|
||||
b.TagIDs = slices.Clone(b.TagIDs)
|
||||
slices.Sort(a.TagIDs)
|
||||
slices.Sort(b.TagIDs)
|
||||
return reflect.DeepEqual(a, b)
|
||||
}
|
||||
|
||||
// ApplyPreview rebases the selected proposals onto the current journal. A
|
||||
// preview run is minutes long by design, so unrelated commits (a scheduled
|
||||
// sync, an import, an earlier partial apply of this same preview) must not
|
||||
// invalidate the review; only a selected transaction whose own enrichment
|
||||
// changed since the preview snapshot conflicts. Applied changes are pruned so
|
||||
// the remaining proposals stay appliable without another paced provider run.
|
||||
func (a *App) ApplyPreview(ctx context.Context, id, rev string, ids []string) (State, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
@@ -290,12 +305,9 @@ func (a *App) ApplyPreview(ctx context.Context, id, rev string, ids []string) (S
|
||||
if err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
if s.Revision != rev {
|
||||
return State{}, errors.New("revision conflict: data changed after preview; analyse again")
|
||||
}
|
||||
changes := map[string]domain.Enrichment{}
|
||||
changes := map[string]Change{}
|
||||
for _, c := range p.Changes {
|
||||
changes[c.ID] = c.After
|
||||
changes[c.ID] = c
|
||||
}
|
||||
selected := map[string]bool{}
|
||||
for _, id := range ids {
|
||||
@@ -307,16 +319,29 @@ func (a *App) ApplyPreview(ctx context.Context, id, rev string, ids []string) (S
|
||||
if len(selected) == 0 {
|
||||
return State{}, errors.New("select at least one change")
|
||||
}
|
||||
applied := 0
|
||||
needed := map[string]bool{}
|
||||
for i, t := range s.Data.Transactions {
|
||||
if !selected[t.Facts.ID] {
|
||||
continue
|
||||
}
|
||||
s.Data.Transactions[i].Enrichment = changes[t.Facts.ID]
|
||||
needed[changes[t.Facts.ID].MerchantID] = true
|
||||
c := changes[t.Facts.ID]
|
||||
if !enrichmentEqual(t.Enrichment, c.Before) {
|
||||
return State{}, errors.New("revision conflict: a selected transaction changed after the preview; analyse it again")
|
||||
}
|
||||
s.Data.Transactions[i].Enrichment = c.After
|
||||
needed[c.After.MerchantID] = true
|
||||
applied++
|
||||
}
|
||||
if applied != len(selected) {
|
||||
return State{}, errors.New("revision conflict: a selected transaction no longer exists; analyse again")
|
||||
}
|
||||
existing := map[string]bool{}
|
||||
for _, m := range s.Data.Merchants {
|
||||
existing[m.ID] = true
|
||||
}
|
||||
for _, m := range p.NewMerchants {
|
||||
if needed[m.ID] {
|
||||
if needed[m.ID] && !existing[m.ID] {
|
||||
s.Data.Merchants = append(s.Data.Merchants, m)
|
||||
}
|
||||
}
|
||||
@@ -327,14 +352,25 @@ func (a *App) ApplyPreview(ctx context.Context, id, rev string, ids []string) (S
|
||||
LearnAlias(&s.Data, t.Facts, t.Enrichment.MerchantID)
|
||||
}
|
||||
}
|
||||
state, err := a.commit(ctx, rev, s.Data)
|
||||
state, err := a.commit(ctx, s.Revision, s.Data)
|
||||
if err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
delete(a.previews, id)
|
||||
if job := a.previewRun; job != nil && job.status.ID == id {
|
||||
a.previewRun = nil
|
||||
kept := make([]Change, 0, len(p.Changes)-applied)
|
||||
for _, c := range p.Changes {
|
||||
if !selected[c.ID] {
|
||||
kept = append(kept, c)
|
||||
}
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
delete(a.previews, id)
|
||||
if job := a.previewRun; job != nil && job.status.ID == id {
|
||||
a.previewRun = nil
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
p.Changes = kept
|
||||
a.previews[id] = p
|
||||
return state, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user