Let manual corrections outrank the model's own precedent

History rows now carry a source label: manual edits and merchant rules
are the user's decisions, ranked ahead of equally similar rows the
model classified itself and guaranteed slots in a full history window.
Without the distinction, precedent fed the model its own uncorrected
answers as majority evidence, so a correction never won against the
rows it was meant to fix. Both system prompts state that user entries
outrank ai entries. Alias write-back on manual merchant links and the
per-merchant usual category already learned locally; this closes the
loop for categories and tags.
This commit is contained in:
Lars Nolden
2026-09-13 14:18:40 +02:00
parent 1d0e273a87
commit a1480af74d
6 changed files with 105 additions and 4 deletions
+47
View File
@@ -2,6 +2,7 @@ package classification
import (
"context"
"fmt"
"net/http"
"reflect"
"strings"
@@ -156,3 +157,49 @@ func TestBICRedactionKeepsPayeeVocabulary(t *testing.T) {
}
}
}
// One manual correction must outrank any number of the model's own past
// answers for the same payee: without source ranking, precedent feeds the
// model its uncorrected output as majority evidence and corrections never
// stick.
func TestManualCorrectionsOutrankAIPrecedent(t *testing.T) {
d, _ := ledgerFixture()
groceries, events := "", ""
for _, c := range d.Categories {
if c.Name == "groceries" {
groceries = c.ID
}
if c.Name == "events" {
events = c.ID
}
}
add := func(id, date, category, source string) {
f := domain.Facts{ID: id, Source: "test", AccountID: "acct_kontist", BookingDate: date,
Amount: "-13.00", Currency: "EUR", Counterparty: "LVR Landesmuseum Bonn", Fingerprint: id}
d.Transactions = append(d.Transactions, domain.Transaction{Facts: f, Enrichment: domain.Enrichment{
Kind: "expense", CategoryID: category, TagIDs: []string{},
Classification: domain.Provenance{Source: source},
}})
}
// Many uncorrected AI answers, one older manual correction.
for i := range 30 {
add(fmt.Sprintf("tx_ai_%02d", i), "2026-08-20", groceries, "openrouter")
}
add("tx_corrected", "2026-08-01", events, "manual")
target := domain.Facts{ID: "tx_new", AccountID: "acct_kontist", BookingDate: "2026-08-30",
Amount: "-13.00", Currency: "EUR", Counterparty: "LVR Landesmuseum Bonn"}
rows := history(target, d, func(s string) string { return normalize(s) }, 20)
if len(rows) == 0 || rows[0].Source != "user" || rows[0].CategoryID != events {
t.Fatalf("manual correction did not lead precedent: %+v", rows[0])
}
// The correction keeps its slot even in a window the AI rows could fill.
users := 0
for _, row := range rows {
if row.Source == "user" {
users++
}
}
if users == 0 {
t.Fatal("correction crowded out of the history window")
}
}