new classification ui

This commit is contained in:
Lars Nolden
2026-09-13 13:52:30 +02:00
parent 10314fb1cd
commit 62a7d6daf4
7 changed files with 494 additions and 115 deletions
+33 -3
View File
@@ -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) {