new classification ui
This commit is contained in:
@@ -242,7 +242,7 @@ func TestCancelledPreviewRunProducesNoPreview(t *testing.T) {
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if _, err := a.ApplyPreview(context.Background(), start.ID, s.Revision, []string{"any"}); err == nil {
|
||||
if _, err := a.ApplyPreview(context.Background(), start.ID, s.Revision, []string{"any"}, nil); err == nil {
|
||||
t.Fatal("cancelled run produced an applicable preview")
|
||||
}
|
||||
after, err := a.Snapshot(context.Background())
|
||||
@@ -334,7 +334,7 @@ func TestPreviewIsReadOnlySelectedApplyPreservesFactsAndOtherFields(t *testing.T
|
||||
t.Fatal("preview mutated canonical records")
|
||||
}
|
||||
id := preview.Changes[0].ID
|
||||
applied, err := a.ApplyPreview(context.Background(), preview.ID, preview.Revision, []string{id})
|
||||
applied, err := a.ApplyPreview(context.Background(), preview.ID, preview.Revision, []string{id}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -356,7 +356,7 @@ func TestPreviewIsReadOnlySelectedApplyPreservesFactsAndOtherFields(t *testing.T
|
||||
t.Fatal("unselected transaction changed")
|
||||
}
|
||||
}
|
||||
if _, err = a.ApplyPreview(context.Background(), preview.ID, preview.Revision, []string{id}); err == nil {
|
||||
if _, err = a.ApplyPreview(context.Background(), preview.ID, preview.Revision, []string{id}, nil); err == nil {
|
||||
t.Fatal("consumed preview applied twice")
|
||||
}
|
||||
}
|
||||
@@ -375,7 +375,7 @@ func TestStalePreviewCannotOverwriteManualCorrection(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = a.ApplyPreview(context.Background(), p.ID, p.Revision, []string{p.Changes[0].ID}); err == nil {
|
||||
if _, err = a.ApplyPreview(context.Background(), p.ID, p.Revision, []string{p.Changes[0].ID}, nil); err == nil {
|
||||
t.Fatal("stale preview overwrote manual edit")
|
||||
}
|
||||
after, err := a.Snapshot(context.Background())
|
||||
@@ -410,13 +410,13 @@ func TestApplyPreviewSurvivesUnrelatedCommitsAndPartialApplies(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[0].ID})
|
||||
first, err := a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[0].ID}, nil)
|
||||
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})
|
||||
second, err := a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[1].ID}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("partial apply consumed the remaining proposals: %v", err)
|
||||
}
|
||||
@@ -429,11 +429,62 @@ func TestApplyPreviewSurvivesUnrelatedCommitsAndPartialApplies(t *testing.T) {
|
||||
}
|
||||
}
|
||||
// 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 {
|
||||
if _, err = a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[0].ID}, nil); err == nil {
|
||||
t.Fatal("consumed change applied twice")
|
||||
}
|
||||
}
|
||||
|
||||
// A reviewer can correct a proposal before applying it: the corrected fields
|
||||
// land instead of the model's, provenance becomes manual, and an invalid or
|
||||
// unselected correction rejects the whole apply.
|
||||
func TestApplyPreviewHonoursReviewerEdits(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
a, s := testApp(t)
|
||||
s = seed(t, a, s)
|
||||
s, err := a.Mutate(ctx, s.Revision, func(d *domain.Dataset) error {
|
||||
d.Categories = append(d.Categories, domain.Category{ID: "dining", Name: "Dining", ParentID: "cat_expenses", Kind: "expense"})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
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}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(p.Changes) != 2 {
|
||||
t.Fatalf("expected two proposed changes: %+v", p)
|
||||
}
|
||||
edited, other := p.Changes[0], p.Changes[1]
|
||||
if _, err = a.ApplyPreview(ctx, p.ID, p.Revision, []string{edited.ID}, []EnrichmentEdit{{ID: edited.ID, CategoryID: "nonexistent", TagIDs: []string{}}}); err == nil {
|
||||
t.Fatal("edit naming an unknown category was applied")
|
||||
}
|
||||
if _, err = a.ApplyPreview(ctx, p.ID, p.Revision, []string{edited.ID}, []EnrichmentEdit{{ID: other.ID, CategoryID: "dining", TagIDs: []string{}}}); err == nil {
|
||||
t.Fatal("edit for an unselected transaction was accepted")
|
||||
}
|
||||
applied, err := a.ApplyPreview(ctx, p.ID, p.Revision, []string{edited.ID, other.ID}, []EnrichmentEdit{{ID: edited.ID, CategoryID: "dining", TagIDs: []string{"home"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tx := range applied.Data.Transactions {
|
||||
e := tx.Enrichment
|
||||
switch tx.Facts.ID {
|
||||
case edited.ID:
|
||||
if e.CategoryID != "dining" || !reflect.DeepEqual(e.TagIDs, []string{"home"}) {
|
||||
t.Fatalf("reviewer correction lost: %+v", e)
|
||||
}
|
||||
if e.Classification.Source != "manual" {
|
||||
t.Fatalf("corrected change kept model provenance: %+v", e.Classification)
|
||||
}
|
||||
case other.ID:
|
||||
if e.CategoryID != "groceries" || e.Classification.Source == "manual" {
|
||||
t.Fatalf("uncorrected change altered: %+v", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -34,6 +34,16 @@ type Change struct {
|
||||
Before domain.Enrichment `json:"before"`
|
||||
After domain.Enrichment `json:"after"`
|
||||
}
|
||||
|
||||
// EnrichmentEdit is a reviewer's correction to one proposal: it replaces the
|
||||
// proposed category and tags before the change is applied. A corrected
|
||||
// transaction is classified by the human, not the model, so its provenance
|
||||
// becomes manual and later runs treat it accordingly.
|
||||
type EnrichmentEdit struct {
|
||||
ID string `json:"id"`
|
||||
CategoryID string `json:"category_id"`
|
||||
TagIDs []string `json:"tag_ids"`
|
||||
}
|
||||
type ClassificationError struct {
|
||||
ID string `json:"id"`
|
||||
Error string `json:"error"`
|
||||
@@ -329,7 +339,7 @@ func enrichmentEqual(a, b domain.Enrichment) bool {
|
||||
// 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) {
|
||||
func (a *App) ApplyPreview(ctx context.Context, id, rev string, ids []string, edits []EnrichmentEdit) (State, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
p, ok := a.previews[id]
|
||||
@@ -357,6 +367,17 @@ 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")
|
||||
}
|
||||
edited := map[string]EnrichmentEdit{}
|
||||
for _, e := range edits {
|
||||
if !selected[e.ID] {
|
||||
return State{}, errors.New("edited transaction is not selected")
|
||||
}
|
||||
edited[e.ID] = e
|
||||
}
|
||||
// Edits are validated against the dataset the change will land in, which
|
||||
// includes merchants this preview mints only when the change is applied.
|
||||
validation := s.Data
|
||||
validation.Merchants = append(append([]domain.Merchant{}, s.Data.Merchants...), p.NewMerchants...)
|
||||
applied := 0
|
||||
needed := map[string]bool{}
|
||||
for i, t := range s.Data.Transactions {
|
||||
@@ -367,8 +388,17 @@ func (a *App) ApplyPreview(ctx context.Context, id, rev string, ids []string) (S
|
||||
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
|
||||
after := c.After
|
||||
if e, ok := edited[t.Facts.ID]; ok {
|
||||
after.CategoryID = e.CategoryID
|
||||
after.TagIDs = append([]string{}, e.TagIDs...)
|
||||
after.Classification = domain.Provenance{Source: "manual", Timestamp: time.Now().UTC().Format(time.RFC3339)}
|
||||
if err := domain.ValidateEnrichment(validation, t.Facts, after); err != nil {
|
||||
return State{}, fmt.Errorf("edited classification for %s is invalid: %w", t.Facts.ID, err)
|
||||
}
|
||||
}
|
||||
s.Data.Transactions[i].Enrichment = after
|
||||
needed[after.MerchantID] = true
|
||||
applied++
|
||||
}
|
||||
if applied != len(selected) {
|
||||
|
||||
Reference in New Issue
Block a user