feat(ai): learn from category corrections so the AI stops repeating the same mistake (#608)
## Problem Users keep correcting the same transactions over and over. The AI mislabels a merchant (e.g. supermarket → fuel), the user fixes it, and the next near-identical transaction from that merchant gets mislabeled the same way again. Today a correction is logged and the offending ai rule is self-healed, but the system only *forgets* its mistake — it never *remembers* the user's fix. ## Approach A correction now becomes a deterministic, forward-looking `AutomationRule` (new `RuleOrigin::Correction`). The next matching transaction is categorized by that rule **before the model ever runs** (`ApplyAutomationRules` is synchronous and runs ahead of AI categorization), ending the loop. Zero model cost, instant, reuses the existing rule engine. **Matching key** (in order): 1. **Merchant** (`creditor_name`/`debtor_name`, exact `==`) when present — stable even as the description varies. 2. Otherwise the **description's distinctive tokens** (`in` / AND-of-`in`), extracted by the shared `DescriptionTokenizer` (noise tokens dropped by document frequency, language-agnostic), **guarded** against over-broad rules that could silently mis-file en masse. If guarded out → nothing is learned, silently. ## Deliberate decisions (from a design walkthrough) - **Forward-only**: never retroactively re-categorizes existing transactions. - **Learn only from system categorizations** (AI label, ai rule, or a prior correction rule) — never from one-off manual filing, bank categories, or the user's own hand-authored rules. - **A key lives in exactly one correction rule**, so changing your mind moves it to the new category. Correcting a transaction a prior correction rule categorized is also learnable, so correction rules stay fixable in-flow. - Correcting to *uncategorized* learns nothing but still self-heals the ai rule. - **Safety net**: correction rules are visible/editable in `settings/automation-rules` (marked with the AI sparkle, tooltip "Learned from your correction"); the transactions table shows a toast with an instant **Undo**. ## Review pass (two independent agents + live QA) - **HIGH fix** (`67fc4293`): an ai rule could out-rank a freshly learned correction and re-apply the wrong category (when the corrected transaction was a *direct* model label with no rule id). Now every ai rule holding the merchant is swept on correction. Regression test added. - **Refactor** (`ca743fde`): collapsed duplicated clause-append logic; removed a speculative unused enum helper. - **Coverage** (`12ceb0f7`): debtor_name path, single-token description clause, encrypted-description fail-safe. - **Toast conflict fix** (`382c8169`, found in live QA): correcting an AI transaction fired both the new "Learned …" toast and the pre-existing "Transaction categorized → Automatize" prompt, which invited the user to manually create the rule the correction had just created. Made them mutually exclusive. Adds a browser test for the inline-correction flow. - **Settings icon** (`f3f882b6`): correction rules now show the AI sparkle in settings, like ai rules. ## Verified end-to-end (against a running instance) Drove the real UI with a browser: correcting an AI-mislabeled transaction creates the correction rule, shows the "Learned · Undo" toast (no competing Automatize prompt), and Undo deletes the rule while keeping the correction. Confirmed for **merchant** keys and the **description-only** path — including that a later "practically identical" description (different surrounding text, no merchant) is caught by the rule, while a near-miss sharing only one distinctive token is correctly **not** caught. ## Open question for reviewers **`debtor_name` (P2P) as a rule key.** For incoming transfers the merchant key falls back to the sender's name, so correcting one can create a rule keyed on a person's name (useful for recurring transfers from a roommate, but a possible privacy surprise; the name appears in the rule title in settings). This matches the existing tier-2 learner's behaviour. Keep as-is, or restrict correction rules to `creditor_name` only? Happy to change. ## Testing - `tests/Feature/Ai/CategoryOverrideHandlerTest.php`: merchant + description learning, next-transaction match, over-broad rejection, change-of-mind move, correct-to-null self-heal, the HIGH regression, debtor_name, single-token, encrypted fail-safe. - `tests/Browser/CategoryCorrectionLearningTest.php`: inline correction → toast → learned rule → undo. - `automation-rule-title.test.tsx`: the AI sparkle shows for `ai` and `correction`, not `user`. - Full AI suite green (106 tests); transaction/bulk-update suites green (62). Pint + Prettier + ESLint clean. No new dependency, no migration (the `origin` column is a free-text string). The feature is implicitly gated by AI categorization — with no AI categorization there is nothing to correct and nothing is learned.
This commit is contained in:
parent
ee69c51a84
commit
6727a9c64a
|
|
@ -6,12 +6,14 @@ enum RuleOrigin: string
|
|||
{
|
||||
case User = 'user';
|
||||
case Ai = 'ai';
|
||||
case Correction = 'correction';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::User => 'User',
|
||||
self::Ai => 'AI',
|
||||
self::Correction => 'Correction',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -221,13 +221,15 @@ class TransactionController extends Controller
|
|||
$hasLabelUpdate = $request->has('label_ids');
|
||||
unset($data['label_ids']);
|
||||
|
||||
// A user-set category overrides any AI assignment: log the correction,
|
||||
// self-heal the ai rule, and reset the provenance to manual.
|
||||
$learnedRule = null;
|
||||
|
||||
// A user-set category overrides any AI assignment: learn the correction as
|
||||
// a forward-looking rule, log/self-heal as needed, and reset provenance.
|
||||
if ($request->has('category_id')) {
|
||||
$newCategoryId = $data['category_id'] ?? null;
|
||||
|
||||
if ($newCategoryId !== $transaction->category_id) {
|
||||
app(CategoryOverrideHandler::class)->record($transaction, $newCategoryId);
|
||||
$learnedRule = app(CategoryOverrideHandler::class)->record($transaction, $newCategoryId);
|
||||
|
||||
$data['category_source'] = $newCategoryId === null ? null : CategorySource::Manual->value;
|
||||
$data['ai_confidence'] = null;
|
||||
|
|
@ -259,6 +261,11 @@ class TransactionController extends Controller
|
|||
|
||||
return response()->json([
|
||||
'data' => $transaction->fresh()->load('labels'),
|
||||
'learned_rule' => $learnedRule === null ? null : [
|
||||
'id' => $learnedRule->id,
|
||||
'title' => $learnedRule->title,
|
||||
'category_id' => $learnedRule->action_category_id,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,21 +6,32 @@ use App\Enums\RuleOrigin;
|
|||
use App\Models\AutomationRule;
|
||||
use App\Models\Category;
|
||||
use App\Models\Transaction;
|
||||
use App\Services\Ai\Contracts\TransactionMatcher;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Tier 2 of AI auto-categorization: turn a confident, unambiguous, merchant-keyed
|
||||
* categorization into a deterministic rule so every future transaction from the
|
||||
* same merchant is categorized for free and consistently — no repeat model call.
|
||||
* Owns the deterministic rules that back AI auto-categorization, in two flavours:
|
||||
*
|
||||
* To avoid rule sprawl, all of a user's AI-categorizations for one category live
|
||||
* in a SINGLE ai-owned rule whose conditions are OR'd together; a new merchant is
|
||||
* appended to that rule rather than spawning another. AI never touches a rule the
|
||||
* user created or edited (origin = user). New ai rules sit at the lowest priority
|
||||
* (highest number) so a user's own rules always win.
|
||||
* - Tier 2 (learn): turn a confident, unambiguous, merchant-keyed categorization
|
||||
* into an ai-owned rule so every future transaction from the same merchant is
|
||||
* categorized for free and consistently — no repeat model call.
|
||||
* - Learning from corrections (learnFromCorrection): turn a user's correction of
|
||||
* an AI categorization into a correction-owned rule keyed on the merchant or the
|
||||
* description's distinctive tokens, so the same mistake is never repeated.
|
||||
*
|
||||
* To avoid rule sprawl, all of a user's rules for one category and origin live in
|
||||
* a SINGLE rule whose conditions are OR'd together; a new key is appended rather
|
||||
* than spawning another. AI never touches a rule the user created or edited
|
||||
* (origin = user). New ai/correction rules sit at the lowest priority (highest
|
||||
* number) so a user's own rules always win.
|
||||
*/
|
||||
class AiRuleLearner
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DescriptionTokenizer $tokenizer,
|
||||
private readonly TransactionMatcher $matcher,
|
||||
) {}
|
||||
|
||||
public function learn(CategorizationOutcome $outcome): ?AutomationRule
|
||||
{
|
||||
if (! $outcome->merchantUnambiguous) {
|
||||
|
|
@ -50,6 +61,218 @@ class AiRuleLearner
|
|||
return $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a user's correction into a deterministic, forward-looking rule so the
|
||||
* same merchant (or the same distinctive description) is never mis-categorized
|
||||
* the same way again — the next matching transaction is categorized by this
|
||||
* rule before the model ever runs.
|
||||
*
|
||||
* The rule is keyed on the merchant when one exists (stable even as the
|
||||
* description varies); otherwise on the description's distinctive tokens,
|
||||
* guarded so an over-broad token can never silently mis-file en masse. A key
|
||||
* lives in exactly one correction rule, so changing your mind moves it.
|
||||
* Returns the rule that now carries the correction, or null when nothing safe
|
||||
* could be learned (correcting to uncategorized, no usable key, or guarded).
|
||||
*/
|
||||
public function learnFromCorrection(Transaction $transaction, ?string $toCategoryId): ?AutomationRule
|
||||
{
|
||||
if ($toCategoryId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$clause = $this->correctionClause($transaction);
|
||||
|
||||
if ($clause === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->releaseClauseFromOtherCorrectionRules($transaction->user_id, $toCategoryId, $clause);
|
||||
|
||||
$rule = $this->existingCorrectionRule($transaction->user_id, $toCategoryId)
|
||||
?? $this->createCorrectionRule($transaction->user_id, $toCategoryId);
|
||||
|
||||
$this->appendClause($rule, $clause);
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* The JsonLogic clause that recognises future transactions like this one:
|
||||
* a merchant equality when a clean merchant key exists, otherwise an AND of
|
||||
* "description contains" over the distinctive tokens. Null when neither is
|
||||
* usable or the description token set is too broad to be safe.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function correctionClause(Transaction $transaction): ?array
|
||||
{
|
||||
$merchant = $this->merchantKey($transaction);
|
||||
|
||||
if ($merchant !== null) {
|
||||
[$field, $token] = $merchant;
|
||||
|
||||
return ['==' => [['var' => $field], $token]];
|
||||
}
|
||||
|
||||
return $this->descriptionClause($transaction);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function descriptionClause(Transaction $transaction): ?array
|
||||
{
|
||||
if ($transaction->description_iv !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tokens = $this->distinctiveDescriptionTokens($transaction);
|
||||
|
||||
if ($tokens === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->isOverbroad($transaction, $tokens)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$clauses = array_map(
|
||||
fn (string $token): array => ['in' => [$token, ['var' => 'description']]],
|
||||
$tokens,
|
||||
);
|
||||
|
||||
return count($clauses) === 1 ? $clauses[0] : ['and' => $clauses];
|
||||
}
|
||||
|
||||
/**
|
||||
* The distinctive description tokens of this transaction relative to the
|
||||
* user's own transaction vocabulary (the noise corpus). Corrections are rare
|
||||
* and user-driven, so loading the descriptions here is acceptable.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function distinctiveDescriptionTokens(Transaction $transaction): array
|
||||
{
|
||||
$descriptions = Transaction::query()
|
||||
->where('user_id', $transaction->user_id)
|
||||
->whereNull('description_iv')
|
||||
->pluck('description')
|
||||
->all();
|
||||
|
||||
$frequency = $this->tokenizer->documentFrequency($descriptions);
|
||||
$threshold = count($descriptions) * (float) config('ai_suggestions.noise_token_fraction');
|
||||
|
||||
return $this->tokenizer->distinctiveTokens((string) $transaction->description, $frequency, $threshold);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a description rule over these tokens would match so many of the
|
||||
* user's uncategorized transactions that it risks mis-filing en masse.
|
||||
*
|
||||
* @param list<string> $tokens
|
||||
*/
|
||||
private function isOverbroad(Transaction $transaction, array $tokens): bool
|
||||
{
|
||||
$total = $this->matcher->total($transaction->user);
|
||||
|
||||
if ($total === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$conditions = array_map(
|
||||
fn (string $token): array => ['field' => 'description', 'operator' => 'contains', 'token' => $token],
|
||||
$tokens,
|
||||
);
|
||||
|
||||
$fraction = $this->matcher->countMatchingAll($transaction->user, $conditions) / $total;
|
||||
|
||||
return $fraction > (float) config('ai_suggestions.overbroad_fraction');
|
||||
}
|
||||
|
||||
private function existingCorrectionRule(string $userId, string $categoryId): ?AutomationRule
|
||||
{
|
||||
return AutomationRule::query()
|
||||
->where('user_id', $userId)
|
||||
->where('action_category_id', $categoryId)
|
||||
->origin(RuleOrigin::Correction)
|
||||
->first();
|
||||
}
|
||||
|
||||
private function createCorrectionRule(string $userId, string $categoryId): AutomationRule
|
||||
{
|
||||
// Appended at the bottom, like ai rules. A correction and an ai rule never
|
||||
// compete on the same key — forgetFromAiRules() strips the merchant from
|
||||
// every ai rule the moment the correction is made. ponytail: cross-key
|
||||
// precedence is creation-order; band correction above ai only if it bites.
|
||||
$priority = (int) AutomationRule::query()->where('user_id', $userId)->max('priority');
|
||||
|
||||
return AutomationRule::create([
|
||||
'user_id' => $userId,
|
||||
'title' => $this->title($categoryId, []),
|
||||
'priority' => $priority + 1,
|
||||
'origin' => RuleOrigin::Correction,
|
||||
'rules_json' => [],
|
||||
'action_category_id' => $categoryId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a key in exactly one correction rule: if the user re-corrects the same
|
||||
* merchant/description to a different category, drop the identical clause from
|
||||
* any other correction rule (deleting it when it becomes empty).
|
||||
*
|
||||
* @param array<string, mixed> $clause
|
||||
*/
|
||||
private function releaseClauseFromOtherCorrectionRules(string $userId, string $keepCategoryId, array $clause): void
|
||||
{
|
||||
$rules = AutomationRule::query()
|
||||
->where('user_id', $userId)
|
||||
->where('action_category_id', '!=', $keepCategoryId)
|
||||
->origin(RuleOrigin::Correction)
|
||||
->get();
|
||||
|
||||
foreach ($rules as $rule) {
|
||||
$clauses = $this->clauses($rule->rules_json);
|
||||
$remaining = array_values(array_filter($clauses, fn (array $existing): bool => $existing != $clause));
|
||||
|
||||
if (count($remaining) === count($clauses)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($remaining === []) {
|
||||
$rule->delete();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$rule->rules_json = count($remaining) === 1 ? $remaining[0] : ['or' => $remaining];
|
||||
$rule->title = $this->title((string) $rule->action_category_id, $this->tokens($remaining));
|
||||
$rule->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a clause to a rule's OR set, skipping an identical existing one.
|
||||
*
|
||||
* @param array<string, mixed> $clause
|
||||
*/
|
||||
private function appendClause(AutomationRule $rule, array $clause): void
|
||||
{
|
||||
$clauses = $this->clauses($rule->rules_json);
|
||||
|
||||
foreach ($clauses as $existing) {
|
||||
if ($existing == $clause) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$clauses[] = $clause;
|
||||
|
||||
$rule->rules_json = count($clauses) === 1 ? $clauses[0] : ['or' => $clauses];
|
||||
$rule->title = $this->title((string) $rule->action_category_id, $this->tokens($clauses));
|
||||
$rule->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string}|null [field, token]
|
||||
*/
|
||||
|
|
@ -89,6 +312,26 @@ class AiRuleLearner
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-heal every one of the user's ai rules that carries this transaction's
|
||||
* merchant — not just the rule that happened to label this transaction. A
|
||||
* merchant can be forced by an ai rule even when the corrected transaction was
|
||||
* a direct model label (no rule id) or labeled by a different rule; unless all
|
||||
* of them release the merchant, an ai rule could out-rank the correction and
|
||||
* re-apply the wrong category to the next transaction from that merchant.
|
||||
*/
|
||||
public function forgetFromAiRules(Transaction $transaction): void
|
||||
{
|
||||
$rules = AutomationRule::query()
|
||||
->where('user_id', $transaction->user_id)
|
||||
->origin(RuleOrigin::Ai)
|
||||
->get();
|
||||
|
||||
foreach ($rules as $rule) {
|
||||
$this->forget($rule, $transaction);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-heal after a user corrects a transaction this ai-owned rule labeled:
|
||||
* drop the merchant condition(s) matching the transaction so the rule stops
|
||||
|
|
@ -135,20 +378,7 @@ class AiRuleLearner
|
|||
|
||||
private function appendCondition(AutomationRule $rule, string $field, string $token): void
|
||||
{
|
||||
$clause = ['==' => [['var' => $field], $token]];
|
||||
$clauses = $this->clauses($rule->rules_json);
|
||||
|
||||
foreach ($clauses as $existing) {
|
||||
if ($existing == $clause) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$clauses[] = $clause;
|
||||
|
||||
$rule->rules_json = count($clauses) === 1 ? $clauses[0] : ['or' => $clauses];
|
||||
$rule->title = $this->title((string) $rule->action_category_id, $this->tokens($clauses));
|
||||
$rule->save();
|
||||
$this->appendClause($rule, ['==' => [['var' => $field], $token]]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -172,6 +402,10 @@ class AiRuleLearner
|
|||
}
|
||||
|
||||
/**
|
||||
* Human-readable token per clause, for the rule title: the value of a
|
||||
* merchant equality, the needle of a "contains", or the joined needles of an
|
||||
* AND-of-contains.
|
||||
*
|
||||
* @param list<array<string, mixed>> $clauses
|
||||
* @return list<string>
|
||||
*/
|
||||
|
|
@ -180,9 +414,9 @@ class AiRuleLearner
|
|||
$tokens = [];
|
||||
|
||||
foreach ($clauses as $clause) {
|
||||
$token = $clause['=='][1] ?? null;
|
||||
$token = $this->clauseLabel($clause);
|
||||
|
||||
if (is_string($token)) {
|
||||
if ($token !== null) {
|
||||
$tokens[] = $token;
|
||||
}
|
||||
}
|
||||
|
|
@ -190,6 +424,40 @@ class AiRuleLearner
|
|||
return $tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $clause
|
||||
*/
|
||||
private function clauseLabel(array $clause): ?string
|
||||
{
|
||||
if (isset($clause['=='])) {
|
||||
$token = $clause['=='][1] ?? null;
|
||||
|
||||
return is_string($token) ? $token : null;
|
||||
}
|
||||
|
||||
if (isset($clause['in'])) {
|
||||
$token = $clause['in'][0] ?? null;
|
||||
|
||||
return is_string($token) ? $token : null;
|
||||
}
|
||||
|
||||
if (isset($clause['and']) && is_array($clause['and'])) {
|
||||
$parts = [];
|
||||
|
||||
foreach ($clause['and'] as $sub) {
|
||||
$needle = $sub['in'][0] ?? null;
|
||||
|
||||
if (is_string($needle)) {
|
||||
$parts[] = $needle;
|
||||
}
|
||||
}
|
||||
|
||||
return $parts === [] ? null : implode(' + ', $parts);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $tokens
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -10,46 +10,59 @@ use App\Models\Transaction;
|
|||
|
||||
/**
|
||||
* Runs when a user overrides a transaction's category. If the category being
|
||||
* replaced was assigned by AI — directly, or via an ai-owned rule — it records a
|
||||
* correction (the calibration signal) and self-heals the rule so the same wrong
|
||||
* category is not forced on future transactions from that merchant. User-owned
|
||||
* rules and manual categories are never touched.
|
||||
* replaced was assigned by the system — by AI directly, by an ai-owned rule, or
|
||||
* by a rule learned from an earlier correction — it learns the user's choice as
|
||||
* a deterministic, forward-looking rule so the same mistake is never repeated.
|
||||
*
|
||||
* For AI-driven corrections it also records the calibration signal and self-heals
|
||||
* the ai rule that mislabeled the merchant. User-owned rules, bank categories and
|
||||
* one-off manual categorizations are never learned from or touched.
|
||||
*
|
||||
* Must be called BEFORE the new category is written, while the transaction still
|
||||
* holds its previous categorization.
|
||||
* holds its previous categorization. Returns the rule that now carries the
|
||||
* correction (for an "undo" affordance), or null when nothing was learned.
|
||||
*/
|
||||
class CategoryOverrideHandler
|
||||
{
|
||||
public function __construct(private readonly AiRuleLearner $learner) {}
|
||||
|
||||
public function record(Transaction $transaction, ?string $newCategoryId): void
|
||||
public function record(Transaction $transaction, ?string $newCategoryId): ?AutomationRule
|
||||
{
|
||||
if ($newCategoryId === $transaction->category_id) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
$rule = $transaction->categorized_by_rule_id !== null
|
||||
? AutomationRule::query()->find($transaction->categorized_by_rule_id)
|
||||
: null;
|
||||
|
||||
$aiRule = $rule !== null && $rule->origin === RuleOrigin::Ai;
|
||||
$aiDriven = $transaction->category_source === CategorySource::Ai || $aiRule;
|
||||
$ruleOrigin = $rule?->origin;
|
||||
$aiDriven = $transaction->category_source === CategorySource::Ai || $ruleOrigin === RuleOrigin::Ai;
|
||||
$learnable = $aiDriven || $ruleOrigin === RuleOrigin::Correction;
|
||||
|
||||
if (! $aiDriven) {
|
||||
return;
|
||||
if (! $learnable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
CategoryCorrection::create([
|
||||
'user_id' => $transaction->user_id,
|
||||
'transaction_id' => $transaction->id,
|
||||
'from_category_id' => $transaction->category_id,
|
||||
'to_category_id' => $newCategoryId,
|
||||
'source' => $transaction->category_source ?? CategorySource::Rule,
|
||||
'confidence' => $transaction->ai_confidence,
|
||||
]);
|
||||
|
||||
if ($aiRule) {
|
||||
$this->learner->forget($rule, $transaction);
|
||||
// The correction signal calibrates AI accuracy, so only AI assignments
|
||||
// are logged — a correction rule overruling itself is not an AI miss.
|
||||
if ($aiDriven) {
|
||||
CategoryCorrection::create([
|
||||
'user_id' => $transaction->user_id,
|
||||
'transaction_id' => $transaction->id,
|
||||
'from_category_id' => $transaction->category_id,
|
||||
'to_category_id' => $newCategoryId,
|
||||
'source' => $transaction->category_source ?? CategorySource::Rule,
|
||||
'confidence' => $transaction->ai_confidence,
|
||||
]);
|
||||
}
|
||||
|
||||
// Stop every ai rule from forcing the wrong category on this merchant
|
||||
// again — including ai rules that did not label this transaction — so none
|
||||
// can out-rank the correction learned below. Correction rules self-correct
|
||||
// separately, when the key is re-learned.
|
||||
$this->learner->forgetFromAiRules($transaction);
|
||||
|
||||
return $this->learner->learnFromCorrection($transaction, $newCategoryId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,13 @@ interface TransactionMatcher
|
|||
*/
|
||||
public function countMatchingAny(User $user, array $conditions): int;
|
||||
|
||||
/**
|
||||
* Count uncategorized transactions matching ALL of the given conditions (AND).
|
||||
*
|
||||
* @param list<array{field: string, operator: string, token: string}> $conditions
|
||||
*/
|
||||
public function countMatchingAll(User $user, array $conditions): int;
|
||||
|
||||
/**
|
||||
* The uncategorized transactions matching ANY of the given conditions (OR).
|
||||
*
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Ai;
|
||||
|
||||
/**
|
||||
* Turns a free-text bank description into comparable, language-agnostic tokens
|
||||
* and isolates the distinctive ones — the part that stays constant across
|
||||
* "practically identical" variants of the same transaction. Structural noise
|
||||
* (words common across the corpus: "pago", "carte", a city, an operation type)
|
||||
* is dropped by document frequency rather than a hardcoded stopword list.
|
||||
*
|
||||
* Stateless and corpus-agnostic: callers supply the document frequency and the
|
||||
* noise threshold so the same logic serves both rule suggestions and learning
|
||||
* from a single corrected transaction.
|
||||
*/
|
||||
class DescriptionTokenizer
|
||||
{
|
||||
private const MIN_TOKEN_LENGTH = 3;
|
||||
|
||||
/**
|
||||
* Split a description into lowercased word tokens, with digits and
|
||||
* punctuation stripped and very short tokens dropped.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function tokens(string $value): array
|
||||
{
|
||||
$value = $this->normalize($value);
|
||||
$value = preg_replace('/[0-9]+/', ' ', $value) ?? $value;
|
||||
$value = preg_replace('/[^\p{L}\s]+/u', ' ', $value) ?? $value;
|
||||
$value = $this->normalize($value);
|
||||
|
||||
if ($value === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
explode(' ', $value),
|
||||
fn (string $token): bool => mb_strlen($token) >= self::MIN_TOKEN_LENGTH,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Document frequency of each token across a corpus of descriptions: how many
|
||||
* descriptions each token appears in (counted once per description).
|
||||
*
|
||||
* @param iterable<int, string|null> $descriptions
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public function documentFrequency(iterable $descriptions): array
|
||||
{
|
||||
$frequency = [];
|
||||
|
||||
foreach ($descriptions as $description) {
|
||||
foreach (array_unique($this->tokens((string) $description)) as $token) {
|
||||
$frequency[$token] = ($frequency[$token] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return $frequency;
|
||||
}
|
||||
|
||||
/**
|
||||
* The distinctive tokens of a description: those appearing in no more than
|
||||
* the noise threshold of the corpus, sorted for a stable key. If every token
|
||||
* is common (all noise), the full token set is kept as a fallback.
|
||||
*
|
||||
* @param array<string, int> $documentFrequency
|
||||
* @return list<string>
|
||||
*/
|
||||
public function distinctiveTokens(string $value, array $documentFrequency, float $noiseThreshold): array
|
||||
{
|
||||
$tokens = $this->tokens($value);
|
||||
|
||||
if ($tokens === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$distinctive = array_values(array_filter(
|
||||
$tokens,
|
||||
fn (string $token): bool => ($documentFrequency[$token] ?? 0) <= $noiseThreshold,
|
||||
));
|
||||
|
||||
if ($distinctive === []) {
|
||||
$distinctive = $tokens;
|
||||
}
|
||||
|
||||
$distinctive = array_values(array_unique($distinctive));
|
||||
sort($distinctive);
|
||||
|
||||
return $distinctive;
|
||||
}
|
||||
|
||||
/**
|
||||
* The distinctive tokens joined into a single stable grouping key.
|
||||
*
|
||||
* @param array<string, int> $documentFrequency
|
||||
*/
|
||||
public function distinctiveKey(string $value, array $documentFrequency, float $noiseThreshold): string
|
||||
{
|
||||
return implode(' ', $this->distinctiveTokens($value, $documentFrequency, $noiseThreshold));
|
||||
}
|
||||
|
||||
private function normalize(string $value): string
|
||||
{
|
||||
return trim(preg_replace('/\s+/', ' ', mb_strtolower($value)) ?? '');
|
||||
}
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ class RuleSuggestionAggregator
|
|||
*/
|
||||
private const SAMPLE_LIMIT = 5;
|
||||
|
||||
private const MIN_TOKEN_LENGTH = 3;
|
||||
public function __construct(private readonly DescriptionTokenizer $tokenizer) {}
|
||||
|
||||
/**
|
||||
* Build the bounded set of transaction groups worth suggesting a rule for.
|
||||
|
|
@ -42,7 +42,7 @@ class RuleSuggestionAggregator
|
|||
foreach ($transactions as $transaction) {
|
||||
[$field, $rawKey] = $this->groupingSignal($transaction);
|
||||
$key = $field === 'description'
|
||||
? $this->distinctiveKey($rawKey, $documentFrequency, $noiseThreshold)
|
||||
? $this->tokenizer->distinctiveKey($rawKey, $documentFrequency, $noiseThreshold)
|
||||
: $this->normalizeWhitespace($rawKey);
|
||||
|
||||
if ($key === '') {
|
||||
|
|
@ -144,75 +144,13 @@ class RuleSuggestionAggregator
|
|||
*/
|
||||
private function descriptionDocumentFrequency(Collection $transactions): array
|
||||
{
|
||||
$frequency = [];
|
||||
$descriptions = $transactions
|
||||
->map(fn (Transaction $transaction): array => $this->groupingSignal($transaction))
|
||||
->filter(fn (array $signal): bool => $signal[0] === 'description')
|
||||
->map(fn (array $signal): string => $signal[1])
|
||||
->all();
|
||||
|
||||
foreach ($transactions as $transaction) {
|
||||
[$field, $rawKey] = $this->groupingSignal($transaction);
|
||||
|
||||
if ($field !== 'description') {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (array_unique($this->descriptionTokens($rawKey)) as $token) {
|
||||
$frequency[$token] = ($frequency[$token] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return $frequency;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a free-text description into comparable tokens (lowercased, digits
|
||||
* and punctuation stripped, very short tokens dropped).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function descriptionTokens(string $value): array
|
||||
{
|
||||
$value = $this->normalizeWhitespace($value);
|
||||
$value = preg_replace('/[0-9]+/', ' ', $value) ?? $value;
|
||||
$value = preg_replace('/[^\p{L}\s]+/u', ' ', $value) ?? $value;
|
||||
$value = $this->normalizeWhitespace($value);
|
||||
|
||||
if ($value === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
explode(' ', $value),
|
||||
fn (string $token): bool => mb_strlen($token) >= self::MIN_TOKEN_LENGTH,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a description's grouping key from only its distinctive tokens: words
|
||||
* appearing in more than the noise threshold of transactions are dropped so
|
||||
* variants of the same merchant collapse together. Language-agnostic — if
|
||||
* every token is common, the full token set is kept as a fallback.
|
||||
*
|
||||
* @param array<string, int> $documentFrequency
|
||||
*/
|
||||
private function distinctiveKey(string $value, array $documentFrequency, float $noiseThreshold): string
|
||||
{
|
||||
$tokens = $this->descriptionTokens($value);
|
||||
|
||||
if ($tokens === []) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$distinctive = array_values(array_filter(
|
||||
$tokens,
|
||||
fn (string $token): bool => ($documentFrequency[$token] ?? 0) <= $noiseThreshold,
|
||||
));
|
||||
|
||||
if ($distinctive === []) {
|
||||
$distinctive = $tokens;
|
||||
}
|
||||
|
||||
$distinctive = array_values(array_unique($distinctive));
|
||||
sort($distinctive);
|
||||
|
||||
return implode(' ', $distinctive);
|
||||
return $this->tokenizer->documentFrequency($descriptions);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -69,6 +69,13 @@ class UncategorizedTransactionMatcher implements TransactionMatcher
|
|||
return $query->get();
|
||||
}
|
||||
|
||||
public function countMatchingAll(User $user, array $conditions): int
|
||||
{
|
||||
$query = $this->allQuery($user, $conditions);
|
||||
|
||||
return $query === null ? 0 : $query->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<Transaction>
|
||||
*/
|
||||
|
|
@ -135,6 +142,44 @@ class UncategorizedTransactionMatcher implements TransactionMatcher
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single query matching ALL of the conditions (AND). Invalid
|
||||
* conditions (unknown field or blank token) are skipped; returns null when
|
||||
* none remain.
|
||||
*
|
||||
* @param list<array{field: string, operator: string, token: string}> $conditions
|
||||
* @return Builder<Transaction>|null
|
||||
*/
|
||||
private function allQuery(User $user, array $conditions): ?Builder
|
||||
{
|
||||
$valid = array_values(array_filter(
|
||||
$conditions,
|
||||
fn (array $condition): bool => in_array($condition['field'], self::ALLOWED_FIELDS, true)
|
||||
&& trim($condition['token']) !== '',
|
||||
));
|
||||
|
||||
if ($valid === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$query = $this->baseQuery($user);
|
||||
|
||||
foreach ($valid as $condition) {
|
||||
$field = $condition['field'];
|
||||
$token = mb_strtolower(trim($condition['token']));
|
||||
|
||||
if ($condition['operator'] === 'equals') {
|
||||
$query->whereRaw("LOWER({$field}) = ?", [$token]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$query->whereRaw("LOWER({$field}) LIKE ?", ['%'.$this->escapeLike($token).'%']);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function escapeLike(string $value): string
|
||||
{
|
||||
return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value);
|
||||
|
|
|
|||
|
|
@ -43,4 +43,14 @@ class AutomationRuleFactory extends Factory
|
|||
'origin' => RuleOrigin::Ai,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A rule learned from a user's category correction.
|
||||
*/
|
||||
public function correction(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'origin' => RuleOrigin::Correction,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
{
|
||||
"Learned: similar transactions will be categorized automatically.": "Aprendido: las transacciones similares se categorizarán automáticamente.",
|
||||
"Learned from your correction": "Aprendida de tu corrección",
|
||||
"Undo": "Deshacer",
|
||||
"This subscription is no longer eligible for a self-service refund.": "Esta suscripción ya no es elegible para una devolución automática.",
|
||||
"Your payment was refunded, your subscription was canceled, and your bank connections were disconnected.": "Te hemos devuelto el pago, cancelado la suscripción y desconectado tus cuentas bancarias.",
|
||||
"Try it for :days days": "Pruébalo durante :days días",
|
||||
|
|
|
|||
|
|
@ -30,6 +30,15 @@ describe('AutomationRuleTitle', () => {
|
|||
expect(screen.getByLabelText('Created by AI')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prefixes the title with the AI sparkle when the rule was learned from a correction', () => {
|
||||
render(<AutomationRuleTitle rule={makeRule('correction')} />);
|
||||
|
||||
expect(screen.getByText('Supermarket')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByLabelText('Learned from your correction'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no marker for user-created rules', () => {
|
||||
render(<AutomationRuleTitle rule={makeRule('user')} />);
|
||||
|
||||
|
|
@ -37,5 +46,8 @@ describe('AutomationRuleTitle', () => {
|
|||
expect(
|
||||
screen.queryByLabelText('Created by AI'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByLabelText('Learned from your correction'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,16 +9,20 @@ import { type AutomationRule } from '@/types/automation-rule';
|
|||
import { __ } from '@/utils/i18n';
|
||||
|
||||
/**
|
||||
* A rule's title, prefixed with the shared AI sparkle when the rule itself was
|
||||
* generated by AI (origin === 'ai').
|
||||
* A rule's title, prefixed with the shared AI sparkle when the rule came from AI
|
||||
* — either generated by auto-categorization (origin === 'ai') or learned from a
|
||||
* user's correction of an AI categorization (origin === 'correction').
|
||||
*/
|
||||
export function AutomationRuleTitle({ rule }: { rule: AutomationRule }) {
|
||||
const isAiGenerated = rule.origin === 'ai';
|
||||
const label = __('Created by AI');
|
||||
const isAiRelated = rule.origin === 'ai' || rule.origin === 'correction';
|
||||
const label =
|
||||
rule.origin === 'correction'
|
||||
? __('Learned from your correction')
|
||||
: __('Created by AI');
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 font-medium">
|
||||
{isAiGenerated && (
|
||||
{isAiRelated && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { destroy } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController';
|
||||
import { showsAiUpsell } from '@/components/transactions/ai-upsell-sample';
|
||||
import { CategorySelect } from '@/components/transactions/category-select';
|
||||
import { AiSparkleIcon } from '@/components/ui/ai-sparkle-icon';
|
||||
|
|
@ -19,6 +20,7 @@ import { type DecryptedTransaction } from '@/types/transaction';
|
|||
import { __ } from '@/utils/i18n';
|
||||
import { router, usePage } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface CategoryCellProps {
|
||||
transaction: DecryptedTransaction;
|
||||
|
|
@ -72,7 +74,10 @@ export function CategoryCell({
|
|||
category_id: categoryId,
|
||||
};
|
||||
|
||||
await transactionSyncService.update(transaction.id, updateData);
|
||||
const result = await transactionSyncService.update(
|
||||
transaction.id,
|
||||
updateData,
|
||||
);
|
||||
|
||||
const updatedCategory = categoryId
|
||||
? categories.find((c) => c.id === categoryId) || null
|
||||
|
|
@ -98,7 +103,32 @@ export function CategoryCell({
|
|||
|
||||
onUpdate(updatedTransaction);
|
||||
|
||||
if (updatedCategory) {
|
||||
if (result.learned_rule) {
|
||||
// The correction already taught the system a forward rule, so
|
||||
// confirm that and offer an instant undo — and skip the
|
||||
// "Automatize" prompt, which would only offer to create a rule
|
||||
// that now exists.
|
||||
const ruleId = result.learned_rule.id;
|
||||
|
||||
toast.success(
|
||||
__(
|
||||
'Learned: similar transactions will be categorized automatically.',
|
||||
),
|
||||
{
|
||||
closeButton: true,
|
||||
duration: 10000,
|
||||
action: {
|
||||
label: __('Undo'),
|
||||
onClick: () => {
|
||||
router.delete(destroy(ruleId).url, {
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
} else if (updatedCategory) {
|
||||
onCategorized?.(
|
||||
updatedTransaction,
|
||||
updatedCategory,
|
||||
|
|
@ -198,6 +228,7 @@ export function CategoryCell({
|
|||
showUncategorized={true}
|
||||
withoutChevronIcon={withoutChevronIcon}
|
||||
header={aiHeader}
|
||||
data-testid="row-category-select"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
import { db } from '@/lib/dexie-db';
|
||||
import { TransactionSyncManager } from '@/lib/sync-manager';
|
||||
import type { LearnedRuleNotice } from '@/types/automation-rule';
|
||||
import type { Transaction } from '@/types/transaction';
|
||||
import type { UUID } from '@/types/uuid';
|
||||
import axios from 'axios';
|
||||
|
||||
/** A transaction update plus any rule the correction just taught the system. */
|
||||
export type UpdatedTransaction = Transaction & {
|
||||
learned_rule?: LearnedRuleNotice | null;
|
||||
};
|
||||
|
||||
interface TransactionUpdateData extends Partial<Transaction> {
|
||||
label_ids?: string[];
|
||||
}
|
||||
|
|
@ -96,7 +102,7 @@ class TransactionSyncService {
|
|||
async update(
|
||||
id: string,
|
||||
data: TransactionUpdateData,
|
||||
): Promise<Transaction> {
|
||||
): Promise<UpdatedTransaction> {
|
||||
const { label_ids, ...transactionData } = data;
|
||||
|
||||
const response = await axios.patch(`/transactions/${id}`, {
|
||||
|
|
@ -116,7 +122,8 @@ class TransactionSyncService {
|
|||
...restServerData,
|
||||
transaction_date: String(serverData.transaction_date).slice(0, 10),
|
||||
label_ids: serverLabelIds || [],
|
||||
} as Transaction;
|
||||
learned_rule: response.data.learned_rule ?? null,
|
||||
} as UpdatedTransaction;
|
||||
}
|
||||
|
||||
async updateMany(
|
||||
|
|
|
|||
|
|
@ -2,7 +2,17 @@ import type { Category } from './category';
|
|||
import type { Label } from './label';
|
||||
import { UUID } from './uuid';
|
||||
|
||||
export type RuleOrigin = 'user' | 'ai';
|
||||
export type RuleOrigin = 'user' | 'ai' | 'correction';
|
||||
|
||||
/**
|
||||
* A rule that was just learned from a category correction, returned by the
|
||||
* transaction update endpoint so the UI can offer an immediate undo.
|
||||
*/
|
||||
export interface LearnedRuleNotice {
|
||||
id: UUID;
|
||||
title: string;
|
||||
category_id: UUID | null;
|
||||
}
|
||||
|
||||
export interface AutomationRule {
|
||||
id: UUID;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\CategorySource;
|
||||
use App\Enums\RuleOrigin;
|
||||
use App\Models\Account;
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Category;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
|
||||
use function Pest\Laravel\actingAs;
|
||||
|
||||
it('learns a forward rule from an inline correction and lets the user undo it', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$bank = Bank::factory()->create(['name' => 'Learn Bank']);
|
||||
$fuel = Category::factory()->create(['user_id' => $user->id, 'name' => 'Fuel']);
|
||||
$groceries = Category::factory()->create(['user_id' => $user->id, 'name' => 'Groceries']);
|
||||
$account = Account::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'bank_id' => $bank->id,
|
||||
'name' => 'Learn Account',
|
||||
'currency_code' => 'EUR',
|
||||
'type' => 'checking',
|
||||
]);
|
||||
|
||||
// A supermarket purchase the AI mislabeled as Fuel — the exact complaint.
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $user->id,
|
||||
'account_id' => $account->id,
|
||||
'category_id' => $fuel->id,
|
||||
'category_source' => CategorySource::Ai,
|
||||
'ai_confidence' => 0.9,
|
||||
'creditor_name' => 'Mercadona',
|
||||
'description' => 'Mercadona compra',
|
||||
'amount' => -4300,
|
||||
]);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
$page = visit('/transactions');
|
||||
|
||||
$page->assertSee('Transactions')
|
||||
->waitForText('Mercadona compra', 10)
|
||||
->click('[data-testid="row-category-select"]')
|
||||
->wait(1)
|
||||
->waitForText('Groceries', 5)
|
||||
->click('Groceries')
|
||||
->waitForText('similar transactions will be categorized automatically', 5)
|
||||
->assertNoJavascriptErrors();
|
||||
|
||||
expect(
|
||||
AutomationRule::query()
|
||||
->origin(RuleOrigin::Correction)
|
||||
->where('action_category_id', $groceries->id)
|
||||
->exists()
|
||||
)->toBeTrue();
|
||||
|
||||
// Undo removes the learned rule (the correction itself stays applied).
|
||||
$page->click('Undo')->wait(2);
|
||||
|
||||
expect(AutomationRule::query()->origin(RuleOrigin::Correction)->exists())->toBeFalse();
|
||||
});
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
use App\Enums\CategoryCashflowDirection;
|
||||
use App\Enums\CategorySource;
|
||||
use App\Enums\CategoryType;
|
||||
use App\Enums\RuleOrigin;
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\Category;
|
||||
use App\Models\CategoryCorrection;
|
||||
|
|
@ -153,3 +154,229 @@ it('does nothing when the category is unchanged', function () {
|
|||
|
||||
expect(CategoryCorrection::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('learns a forward rule from an ai correction so the next merchant transaction skips ai', function () {
|
||||
$user = User::factory()->create();
|
||||
$wrong = cohCategory($user);
|
||||
$right = cohCategory($user);
|
||||
|
||||
cohLearnRule($user, $wrong->id, 'Mercadona');
|
||||
$matched = cohMatched($user, 'Mercadona');
|
||||
|
||||
$learned = app(CategoryOverrideHandler::class)->record($matched, $right->id);
|
||||
|
||||
expect($learned)->not->toBeNull()
|
||||
->and($learned->origin)->toBe(RuleOrigin::Correction)
|
||||
->and($learned->action_category_id)->toBe($right->id)
|
||||
->and($learned->rules_json)->toBe(['==' => [['var' => 'creditor_name'], 'mercadona']]);
|
||||
|
||||
$next = cohMerchantTxn($user, 'Mercadona');
|
||||
app(AutomationRuleService::class)->applyRules($next);
|
||||
$next->refresh();
|
||||
|
||||
expect($next->category_id)->toBe($right->id)
|
||||
->and($next->category_source)->toBe(CategorySource::Rule)
|
||||
->and($next->categorized_by_rule_id)->toBe($learned->id);
|
||||
});
|
||||
|
||||
it('learns a description rule when there is no merchant key', function () {
|
||||
$user = User::factory()->create();
|
||||
$to = cohCategory($user);
|
||||
|
||||
$transaction = Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $user->id,
|
||||
'category_id' => cohCategory($user)->id,
|
||||
'category_source' => CategorySource::Ai,
|
||||
'ai_confidence' => 0.9,
|
||||
'creditor_name' => null,
|
||||
'debtor_name' => null,
|
||||
'description' => 'Netflix subscription',
|
||||
]);
|
||||
|
||||
$learned = app(CategoryOverrideHandler::class)->record($transaction, $to->id);
|
||||
|
||||
expect($learned)->not->toBeNull()
|
||||
->and($learned->origin)->toBe(RuleOrigin::Correction)
|
||||
->and($learned->rules_json)->toBe([
|
||||
'and' => [
|
||||
['in' => ['netflix', ['var' => 'description']]],
|
||||
['in' => ['subscription', ['var' => 'description']]],
|
||||
],
|
||||
]);
|
||||
|
||||
$next = Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $user->id,
|
||||
'category_id' => null,
|
||||
'creditor_name' => null,
|
||||
'debtor_name' => null,
|
||||
'description' => 'NETFLIX SUBSCRIPTION 12,99',
|
||||
]);
|
||||
app(AutomationRuleService::class)->applyRules($next);
|
||||
|
||||
expect($next->refresh()->category_id)->toBe($to->id);
|
||||
});
|
||||
|
||||
it('does not learn an over-broad description rule', function () {
|
||||
config()->set('ai_suggestions.overbroad_fraction', 0.1);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$to = cohCategory($user);
|
||||
|
||||
foreach (range(1, 5) as $ignored) {
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $user->id,
|
||||
'category_id' => null,
|
||||
'creditor_name' => null,
|
||||
'debtor_name' => null,
|
||||
'description' => 'Generic payment note',
|
||||
]);
|
||||
}
|
||||
|
||||
$transaction = Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $user->id,
|
||||
'category_id' => cohCategory($user)->id,
|
||||
'category_source' => CategorySource::Ai,
|
||||
'ai_confidence' => 0.9,
|
||||
'creditor_name' => null,
|
||||
'debtor_name' => null,
|
||||
'description' => 'Generic payment note',
|
||||
]);
|
||||
|
||||
$learned = app(CategoryOverrideHandler::class)->record($transaction, $to->id);
|
||||
|
||||
expect($learned)->toBeNull()
|
||||
->and(AutomationRule::query()->origin(RuleOrigin::Correction)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('moves a merchant key to the new category when the user changes their mind', function () {
|
||||
$user = User::factory()->create();
|
||||
$first = cohCategory($user);
|
||||
$second = cohCategory($user);
|
||||
|
||||
cohLearnRule($user, cohCategory($user)->id, 'Mercadona');
|
||||
$matched = cohMatched($user, 'Mercadona');
|
||||
$firstRule = app(CategoryOverrideHandler::class)->record($matched, $first->id);
|
||||
|
||||
$later = cohMerchantTxn($user, 'Mercadona');
|
||||
app(AutomationRuleService::class)->applyRules($later);
|
||||
$later->refresh();
|
||||
|
||||
expect($later->categorized_by_rule_id)->toBe($firstRule->id);
|
||||
|
||||
$secondRule = app(CategoryOverrideHandler::class)->record($later, $second->id);
|
||||
|
||||
expect($secondRule->action_category_id)->toBe($second->id)
|
||||
->and($secondRule->rules_json)->toBe(['==' => [['var' => 'creditor_name'], 'mercadona']])
|
||||
->and(AutomationRule::query()->find($firstRule->id))->toBeNull()
|
||||
->and(CategoryCorrection::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('out-ranks an ai rule when a direct ai label is corrected for the same merchant', function () {
|
||||
$user = User::factory()->create();
|
||||
$wrong = cohCategory($user);
|
||||
$right = cohCategory($user);
|
||||
|
||||
// An ai rule already maps mercadona -> wrong (learned from an earlier txn).
|
||||
$aiRule = cohLearnRule($user, $wrong->id, 'Mercadona');
|
||||
|
||||
// A different mercadona transaction was labeled DIRECTLY by the model: it
|
||||
// carries no rule id, so the old self-heal would have left the ai rule intact.
|
||||
$direct = cohMerchantTxn($user, 'Mercadona');
|
||||
$direct->update([
|
||||
'category_id' => $wrong->id,
|
||||
'category_source' => CategorySource::Ai,
|
||||
'ai_confidence' => 0.8,
|
||||
]);
|
||||
|
||||
app(CategoryOverrideHandler::class)->record($direct, $right->id);
|
||||
|
||||
$next = cohMerchantTxn($user, 'Mercadona');
|
||||
app(AutomationRuleService::class)->applyRules($next);
|
||||
$next->refresh();
|
||||
|
||||
expect($next->category_id)->toBe($right->id)
|
||||
->and(AutomationRule::query()->find($aiRule->id))->toBeNull();
|
||||
});
|
||||
|
||||
it('self-heals but learns nothing when correcting to uncategorized', function () {
|
||||
$user = User::factory()->create();
|
||||
cohLearnRule($user, cohCategory($user)->id, 'Mercadona');
|
||||
$matched = cohMatched($user, 'Mercadona');
|
||||
$aiRuleId = $matched->categorized_by_rule_id;
|
||||
|
||||
$learned = app(CategoryOverrideHandler::class)->record($matched, null);
|
||||
|
||||
expect($learned)->toBeNull()
|
||||
->and(AutomationRule::query()->find($aiRuleId))->toBeNull()
|
||||
->and(AutomationRule::query()->origin(RuleOrigin::Correction)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('learns a debtor_name rule when only the debtor is present', function () {
|
||||
$user = User::factory()->create();
|
||||
$to = cohCategory($user);
|
||||
|
||||
$transaction = Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $user->id,
|
||||
'category_id' => cohCategory($user)->id,
|
||||
'category_source' => CategorySource::Ai,
|
||||
'ai_confidence' => 0.9,
|
||||
'creditor_name' => null,
|
||||
'debtor_name' => 'Juan Perez',
|
||||
'description' => 'Bizum recibido',
|
||||
]);
|
||||
|
||||
$learned = app(CategoryOverrideHandler::class)->record($transaction, $to->id);
|
||||
|
||||
expect($learned->rules_json)->toBe(['==' => [['var' => 'debtor_name'], 'juan perez']]);
|
||||
|
||||
$next = Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $user->id,
|
||||
'category_id' => null,
|
||||
'creditor_name' => null,
|
||||
'debtor_name' => 'Juan Perez',
|
||||
'description' => 'Bizum recibido de nuevo',
|
||||
]);
|
||||
app(AutomationRuleService::class)->applyRules($next);
|
||||
|
||||
expect($next->refresh()->category_id)->toBe($to->id);
|
||||
});
|
||||
|
||||
it('learns a single-token description rule as a bare contains clause', function () {
|
||||
$user = User::factory()->create();
|
||||
$to = cohCategory($user);
|
||||
|
||||
$transaction = Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $user->id,
|
||||
'category_id' => cohCategory($user)->id,
|
||||
'category_source' => CategorySource::Ai,
|
||||
'ai_confidence' => 0.9,
|
||||
'creditor_name' => null,
|
||||
'debtor_name' => null,
|
||||
'description' => 'Spotify',
|
||||
]);
|
||||
|
||||
$learned = app(CategoryOverrideHandler::class)->record($transaction, $to->id);
|
||||
|
||||
expect($learned->rules_json)->toBe(['in' => ['spotify', ['var' => 'description']]]);
|
||||
});
|
||||
|
||||
it('learns nothing from an encrypted-description transaction without a merchant', function () {
|
||||
$user = User::factory()->create();
|
||||
$to = cohCategory($user);
|
||||
|
||||
$transaction = Transaction::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'category_id' => cohCategory($user)->id,
|
||||
'category_source' => CategorySource::Ai,
|
||||
'ai_confidence' => 0.9,
|
||||
'creditor_name' => null,
|
||||
'debtor_name' => null,
|
||||
]);
|
||||
|
||||
expect($transaction->description_iv)->not->toBeNull();
|
||||
|
||||
$learned = app(CategoryOverrideHandler::class)->record($transaction, $to->id);
|
||||
|
||||
expect($learned)->toBeNull()
|
||||
->and(AutomationRule::query()->origin(RuleOrigin::Correction)->count())->toBe(0);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use App\Services\Ai\RuleSuggestionAggregator;
|
|||
beforeEach(function () {
|
||||
config()->set('ai_suggestions.min_group_count', 3);
|
||||
config()->set('ai_suggestions.max_groups_sent', 15);
|
||||
$this->aggregator = new RuleSuggestionAggregator;
|
||||
$this->aggregator = app(RuleSuggestionAggregator::class);
|
||||
});
|
||||
|
||||
function makeTxn(User $user, Account $account, array $attributes): void
|
||||
|
|
|
|||
Loading…
Reference in New Issue