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
+48 -2
View File
@@ -160,6 +160,10 @@ type promptHistory struct {
CategoryID string `json:"category_id"`
MerchantID string `json:"merchant_id,omitempty"`
TagIDs []string `json:"tag_ids"`
// Source separates the user's own decisions ("user") from earlier model
// output ("ai"): without the distinction, precedent feeds the model its
// own past answers as evidence and a manual correction never wins.
Source string `json:"source"`
}
type candidateSet struct {
categories []categoryPrompt
@@ -314,10 +318,16 @@ func answerSchema(d domain.Dataset, kind string) map[string]any {
return retrieve("", kind, d, nil, nil).schema()
}
// history selects precedent for the prompt: the nearest rows by word overlap,
// filled out with the most recent. The user's own decisions — manual edits
// and locally applied merchant rules — outrank rows the model classified
// itself, so one correction beats any number of uncorrected AI answers for
// the same payee.
func history(f domain.Facts, d domain.Dataset, clean func(string) string, limit int) []promptHistory {
type row struct {
tx domain.Transaction
score int
user bool
}
rows := []row{}
for _, tx := range d.Transactions {
@@ -325,19 +335,50 @@ func history(f domain.Facts, d domain.Dataset, clean func(string) string, limit
if tx.Facts.ID == f.ID || e.Kind == "transfer" || e.CategoryID == "" || e.CategoryID == domain.ExpenseFallback || e.CategoryID == domain.IncomeFallback {
continue
}
rows = append(rows, row{tx: tx, score: similarity(f.RawDescription+" "+f.Counterparty, tx.Facts.RawDescription+" "+tx.Facts.Counterparty)})
source := tx.Enrichment.Classification.Source
rows = append(rows, row{
tx: tx,
score: similarity(f.RawDescription+" "+f.Counterparty, tx.Facts.RawDescription+" "+tx.Facts.Counterparty),
user: source == "manual" || source == "rule",
})
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].score != rows[j].score {
return rows[i].score > rows[j].score
}
if rows[i].user != rows[j].user {
return rows[i].user
}
if rows[i].tx.Facts.BookingDate != rows[j].tx.Facts.BookingDate {
return rows[i].tx.Facts.BookingDate > rows[j].tx.Facts.BookingDate
}
return rows[i].tx.Facts.ID < rows[j].tx.Facts.ID
})
if limit > 0 && len(rows) > limit {
rows = rows[:limit]
// Never let recent AI output crowd every correction out of a full
// window: user rows keep their slots ahead of equally similar AI rows.
kept := make([]row, 0, limit)
users := 0
for _, r := range rows {
if r.user {
users++
}
}
userBudget := min(users, limit/2)
aiBudget := limit - userBudget
for _, r := range rows {
if r.user && userBudget > 0 {
kept = append(kept, r)
userBudget--
} else if !r.user && aiBudget > 0 {
kept = append(kept, r)
aiBudget--
} else if r.user && aiBudget > 0 {
kept = append(kept, r)
aiBudget--
}
}
rows = kept
}
out := make([]promptHistory, 0, len(rows))
for _, row := range rows {
@@ -345,11 +386,16 @@ func history(f domain.Facts, d domain.Dataset, clean func(string) string, limit
if tags == nil {
tags = []string{}
}
source := "ai"
if row.user {
source = "user"
}
out = append(out, promptHistory{
Date: row.tx.Facts.BookingDate, Amount: string(row.tx.Facts.Amount),
Description: clean(row.tx.Facts.RawDescription), Counterparty: clean(row.tx.Facts.Counterparty),
CategoryID: row.tx.Enrichment.CategoryID, MerchantID: row.tx.Enrichment.MerchantID,
TagIDs: append([]string{}, tags...),
Source: source,
})
}
return out