, count: int}> */ private array $descriptionCorpus = []; public function __construct( private readonly DescriptionTokenizer $tokenizer, private readonly TransactionMatcher $matcher, ) {} public function learn(CategorizationOutcome $outcome): ?AutomationRule { if (! $outcome->merchantUnambiguous) { return null; } // Never generalise a suggestion we weren't confident enough to even apply // to the single transaction. This ties tier 2 to the user's (possibly // raised) label bar: the effective rule bar is max(label bar, rule bar), // so raising the threshold to reduce automation also holds back rules. if (! $outcome->applied) { return null; } if ($outcome->confidence < (float) config('ai_categorization.rule_confidence')) { return null; } $key = $this->merchantKey($outcome->transaction); if ($key === null) { return null; } [$field, $token] = $key; $rule = $this->existingAiRule($outcome->transaction->user_id, $outcome->categoryId) ?? $this->createAiRule($outcome->transaction->user_id, $outcome->categoryId); $this->appendCondition($rule, $field, $token); $outcome->transaction->categorized_by_rule_id = $rule->id; $outcome->transaction->saveQuietly(); 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 (count($tokens) === 1 && mb_strlen($tokens[0]) < self::MIN_SOLE_TOKEN_LENGTH) { 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 { $corpus = $this->descriptionCorpus($transaction->user_id); $threshold = $corpus['count'] * (float) config('ai_suggestions.noise_token_fraction'); return $this->tokenizer->distinctiveTokens((string) $transaction->description, $corpus['frequency'], $threshold); } /** * The user's description document-frequency map and corpus size, loaded once * per instance. Safe to memoize: descriptions are never mutated by a * categorization change, so the corpus is stable across a bulk correction. * * @return array{frequency: array, count: int} */ private function descriptionCorpus(string $userId): array { return $this->descriptionCorpus[$userId] ??= (function () use ($userId): array { $descriptions = Transaction::query() ->where('user_id', $userId) ->whereNull('description_iv') ->pluck('description') ->all(); return [ 'frequency' => $this->tokenizer->documentFrequency($descriptions), 'count' => count($descriptions), ]; })(); } /** * 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] */ private function merchantKey(Transaction $transaction): ?array { foreach (['creditor_name', 'debtor_name'] as $field) { $value = $this->normalize((string) ($transaction->{$field} ?? '')); if ($value !== '') { return [$field, $value]; } } return null; } private function existingAiRule(string $userId, string $categoryId): ?AutomationRule { return AutomationRule::query() ->where('user_id', $userId) ->where('action_category_id', $categoryId) ->origin(RuleOrigin::Ai) ->first(); } private function createAiRule(string $userId, string $categoryId): AutomationRule { $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::Ai, 'rules_json' => [], 'action_category_id' => $categoryId, ]); } /** * 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 * forcing the wrong category on future transactions from that merchant. The * rule is deleted when no condition remains. */ public function forget(AutomationRule $rule, Transaction $transaction): void { $tokens = []; foreach (['creditor_name', 'debtor_name'] as $field) { $value = $this->normalize((string) ($transaction->{$field} ?? '')); if ($value !== '') { $tokens[$value] = true; } } if ($tokens === []) { return; } $clauses = $this->clauses($rule->rules_json); $remaining = array_values(array_filter($clauses, function (array $clause) use ($tokens): bool { $token = $clause['=='][1] ?? null; return ! (is_string($token) && isset($tokens[$token])); })); if (count($remaining) === count($clauses)) { return; } if ($remaining === []) { $rule->delete(); return; } $rule->rules_json = count($remaining) === 1 ? $remaining[0] : ['or' => $remaining]; $rule->title = $this->title((string) $rule->action_category_id, $this->tokens($remaining)); $rule->save(); } private function appendCondition(AutomationRule $rule, string $field, string $token): void { $this->appendClause($rule, ['==' => [['var' => $field], $token]]); } /** * The individual condition clauses of a rule, normalised to a flat list * regardless of whether it is a single clause or an OR of several. * * @param mixed $rulesJson * @return list> */ private function clauses($rulesJson): array { if (! is_array($rulesJson) || $rulesJson === []) { return []; } if (isset($rulesJson['or']) && is_array($rulesJson['or'])) { return array_values($rulesJson['or']); } return [$rulesJson]; } /** * 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 */ private function tokens(array $clauses): array { $tokens = []; foreach ($clauses as $clause) { $token = $this->clauseLabel($clause); if ($token !== null) { $tokens[] = $token; } } 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 */ private function title(string $categoryId, array $tokens): string { $categoryName = Category::query()->whereKey($categoryId)->value('name') ?? ''; if ($tokens === []) { return trim($categoryName.' (AI)'); } $label = implode(', ', array_map(fn (string $token): string => Str::title($token), array_slice($tokens, 0, 3))); if (count($tokens) > 3) { $label .= '…'; } return trim($label.' → '.$categoryName); } private function normalize(string $value): string { return trim(preg_replace('/\s+/', ' ', mb_strtolower($value)) ?? ''); } }