category_id) { return null; } $rule = $transaction->categorized_by_rule_id !== null ? AutomationRule::query()->find($transaction->categorized_by_rule_id) : null; $ruleOrigin = $rule?->origin; $aiDriven = $transaction->category_source === CategorySource::Ai || $ruleOrigin === RuleOrigin::Ai; $learnable = $aiDriven || $ruleOrigin === RuleOrigin::Correction; if (! $learnable) { return null; } try { return $this->learn($transaction, $newCategoryId, $aiDriven); } catch (Throwable $e) { // Learning is a side-effect of the correction, never the point of it. // A failure here must not abort the category change the user asked // for — nor, in a bulk correction, every transaction after it. report($e); return null; } } private function learn(Transaction $transaction, ?string $newCategoryId, bool $aiDriven): ?AutomationRule { // 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); } }