}> */ public function groupsFor(User $user): array { $transactions = Transaction::query() ->where('user_id', $user->id) ->whereNull('category_id') ->whereNull('description_iv') ->get(['id', 'description', 'creditor_name', 'debtor_name', 'amount']); $documentFrequency = $this->descriptionDocumentFrequency($transactions); $noiseThreshold = $transactions->count() * (float) config('ai_suggestions.noise_token_fraction'); $groups = []; foreach ($transactions as $transaction) { [$field, $rawKey] = $this->groupingSignal($transaction); $key = $field === 'description' ? $this->distinctiveKey($rawKey, $documentFrequency, $noiseThreshold) : $this->normalizeWhitespace($rawKey); if ($key === '') { continue; } $bucket = $field.'|'.$key; $groups[$bucket] ??= [ 'key' => $key, 'field' => $field, 'count' => 0, 'amount_sum' => 0, 'samples' => [], ]; $groups[$bucket]['count']++; $groups[$bucket]['amount_sum'] += (int) $transaction->amount; $sample = $this->normalizeWhitespace((string) ($transaction->description ?? $rawKey)); if ($sample !== '' && count($groups[$bucket]['samples']) < self::SAMPLE_LIMIT) { $groups[$bucket]['samples'][$sample] = $sample; } } $min = (int) config('ai_suggestions.min_group_count'); $max = (int) config('ai_suggestions.max_groups_sent'); return collect($groups) ->filter(fn (array $group): bool => $group['count'] >= $min) ->sortByDesc('count') ->take($max) ->map(function (array $group): array { $avg = $group['amount_sum'] / $group['count'] / 100; return [ 'key' => $group['key'], 'field' => $group['field'], 'count' => $group['count'], 'avg_amount' => round($avg, 2), 'direction' => $avg < 0 ? 'outflow' : 'inflow', 'samples' => array_values($group['samples']), ]; }) ->values() ->all(); } /** * The closed list of the user's categories the model must map groups into. * * @return list */ public function categoryOptions(User $user): array { $categories = Category::query() ->where('user_id', $user->id) ->get(); $byId = $categories->keyBy('id'); $parentIds = $categories->pluck('parent_id')->filter()->unique()->flip(); return $categories ->map(fn (Category $category): array => [ 'id' => $category->id, 'name' => $category->name, 'path' => $this->categoryPath($category, $byId), 'type' => $category->type->value, 'direction' => $category->cashflow_direction->value, 'is_leaf' => ! $parentIds->has($category->id), ]) ->values() ->all(); } /** * @return array{0: string, 1: string} [field, rawValue] */ private function groupingSignal(Transaction $transaction): array { if (filled($transaction->creditor_name)) { return ['creditor_name', (string) $transaction->creditor_name]; } if (filled($transaction->debtor_name)) { return ['debtor_name', (string) $transaction->debtor_name]; } return ['description', (string) $transaction->description]; } /** * Document frequency of each description token across the user's * uncategorized, description-grouped transactions. Used to identify * structural noise words without any language-specific list. * * @param Collection $transactions * @return array */ private function descriptionDocumentFrequency(Collection $transactions): array { $frequency = []; 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); } /** * @param Collection $byId */ private function categoryPath(Category $category, Collection $byId): string { $parts = [$category->name]; $current = $category; $guard = 0; while ($current->parent_id !== null && $byId->has($current->parent_id) && $guard++ < Category::MAX_DEPTH) { $current = $byId->get($current->parent_id); array_unshift($parts, $current->name); } return implode(' > ', $parts); } private function normalizeWhitespace(string $value): string { return trim(preg_replace('/\s+/', ' ', mb_strtolower($value)) ?? ''); } }