diff --git a/app/Enums/RuleOrigin.php b/app/Enums/RuleOrigin.php index cbf1b2dc..90a3d709 100644 --- a/app/Enums/RuleOrigin.php +++ b/app/Enums/RuleOrigin.php @@ -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', }; } diff --git a/app/Http/Controllers/TransactionController.php b/app/Http/Controllers/TransactionController.php index 0f31f58e..3fea8cc1 100644 --- a/app/Http/Controllers/TransactionController.php +++ b/app/Http/Controllers/TransactionController.php @@ -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, + ], ]); } diff --git a/app/Services/Ai/AiRuleLearner.php b/app/Services/Ai/AiRuleLearner.php index e7ff6888..fe4aa2db 100644 --- a/app/Services/Ai/AiRuleLearner.php +++ b/app/Services/Ai/AiRuleLearner.php @@ -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|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|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 + */ + 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 $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 $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 $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> $clauses * @return list */ @@ -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 $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 $tokens */ diff --git a/app/Services/Ai/CategoryOverrideHandler.php b/app/Services/Ai/CategoryOverrideHandler.php index 318319e8..2897154c 100644 --- a/app/Services/Ai/CategoryOverrideHandler.php +++ b/app/Services/Ai/CategoryOverrideHandler.php @@ -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); } } diff --git a/app/Services/Ai/Contracts/TransactionMatcher.php b/app/Services/Ai/Contracts/TransactionMatcher.php index a2fb4295..1939c47b 100644 --- a/app/Services/Ai/Contracts/TransactionMatcher.php +++ b/app/Services/Ai/Contracts/TransactionMatcher.php @@ -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 $conditions + */ + public function countMatchingAll(User $user, array $conditions): int; + /** * The uncategorized transactions matching ANY of the given conditions (OR). * diff --git a/app/Services/Ai/DescriptionTokenizer.php b/app/Services/Ai/DescriptionTokenizer.php new file mode 100644 index 00000000..cf595b22 --- /dev/null +++ b/app/Services/Ai/DescriptionTokenizer.php @@ -0,0 +1,108 @@ + + */ + 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 $descriptions + * @return array + */ + 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 $documentFrequency + * @return list + */ + 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 $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)) ?? ''); + } +} diff --git a/app/Services/Ai/RuleSuggestionAggregator.php b/app/Services/Ai/RuleSuggestionAggregator.php index 251b8234..7a938694 100644 --- a/app/Services/Ai/RuleSuggestionAggregator.php +++ b/app/Services/Ai/RuleSuggestionAggregator.php @@ -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 - */ - 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 $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); } /** diff --git a/app/Services/Ai/UncategorizedTransactionMatcher.php b/app/Services/Ai/UncategorizedTransactionMatcher.php index 72b184ff..418cb943 100644 --- a/app/Services/Ai/UncategorizedTransactionMatcher.php +++ b/app/Services/Ai/UncategorizedTransactionMatcher.php @@ -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 */ @@ -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 $conditions + * @return Builder|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); diff --git a/database/factories/AutomationRuleFactory.php b/database/factories/AutomationRuleFactory.php index 1815ac4d..8a81b763 100644 --- a/database/factories/AutomationRuleFactory.php +++ b/database/factories/AutomationRuleFactory.php @@ -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, + ]); + } } diff --git a/lang/es.json b/lang/es.json index 93f89771..f72af901 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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", diff --git a/resources/js/components/automation-rules/automation-rule-title.test.tsx b/resources/js/components/automation-rules/automation-rule-title.test.tsx index ecd2fdf5..dc0d0b81 100644 --- a/resources/js/components/automation-rules/automation-rule-title.test.tsx +++ b/resources/js/components/automation-rules/automation-rule-title.test.tsx @@ -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(); + + expect(screen.getByText('Supermarket')).toBeInTheDocument(); + expect( + screen.getByLabelText('Learned from your correction'), + ).toBeInTheDocument(); + }); + it('shows no marker for user-created rules', () => { render(); @@ -37,5 +46,8 @@ describe('AutomationRuleTitle', () => { expect( screen.queryByLabelText('Created by AI'), ).not.toBeInTheDocument(); + expect( + screen.queryByLabelText('Learned from your correction'), + ).not.toBeInTheDocument(); }); }); diff --git a/resources/js/components/automation-rules/automation-rule-title.tsx b/resources/js/components/automation-rules/automation-rule-title.tsx index 9cf72261..c6a5559e 100644 --- a/resources/js/components/automation-rules/automation-rule-title.tsx +++ b/resources/js/components/automation-rules/automation-rule-title.tsx @@ -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 (
- {isAiGenerated && ( + {isAiRelated && ( diff --git a/resources/js/components/transactions/category-cell.tsx b/resources/js/components/transactions/category-cell.tsx index 044a779a..af22edf1 100644 --- a/resources/js/components/transactions/category-cell.tsx +++ b/resources/js/components/transactions/category-cell.tsx @@ -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" />
diff --git a/resources/js/services/transaction-sync.ts b/resources/js/services/transaction-sync.ts index 3e395f29..1feced46 100644 --- a/resources/js/services/transaction-sync.ts +++ b/resources/js/services/transaction-sync.ts @@ -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 { label_ids?: string[]; } @@ -96,7 +102,7 @@ class TransactionSyncService { async update( id: string, data: TransactionUpdateData, - ): Promise { + ): Promise { 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( diff --git a/resources/js/types/automation-rule.ts b/resources/js/types/automation-rule.ts index fe80f41f..43f2bf4d 100644 --- a/resources/js/types/automation-rule.ts +++ b/resources/js/types/automation-rule.ts @@ -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; diff --git a/tests/Browser/CategoryCorrectionLearningTest.php b/tests/Browser/CategoryCorrectionLearningTest.php new file mode 100644 index 00000000..6833bc5d --- /dev/null +++ b/tests/Browser/CategoryCorrectionLearningTest.php @@ -0,0 +1,63 @@ +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(); +}); diff --git a/tests/Feature/Ai/CategoryOverrideHandlerTest.php b/tests/Feature/Ai/CategoryOverrideHandlerTest.php index 164c33bf..d1504597 100644 --- a/tests/Feature/Ai/CategoryOverrideHandlerTest.php +++ b/tests/Feature/Ai/CategoryOverrideHandlerTest.php @@ -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); +}); diff --git a/tests/Feature/Ai/RuleSuggestionAggregatorTest.php b/tests/Feature/Ai/RuleSuggestionAggregatorTest.php index 1bc3c0df..0573116b 100644 --- a/tests/Feature/Ai/RuleSuggestionAggregatorTest.php +++ b/tests/Feature/Ai/RuleSuggestionAggregatorTest.php @@ -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