whisper-money/app/Services/Ai/CategorizeTransactions.php

182 lines
6.3 KiB
PHP

<?php
namespace App\Services\Ai;
use App\Ai\Agents\TransactionCategorizationAgent;
use App\Enums\CategorySource;
use App\Jobs\RetryTransientAiCategorizationJob;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Exceptions\FailoverableException;
use Throwable;
/**
* Tier 1 of AI auto-categorization: ask the model to assign each transaction to
* one of the user's leaf categories and auto-apply the label when it clears the
* label confidence bar. Returns an outcome per transaction the model placed so
* the caller can drive tier 2 (rule learning) off the high-confidence ones.
*/
class CategorizeTransactions
{
/**
* @param Collection<int, Transaction> $transactions
* @return list<CategorizationOutcome>
*/
public function forTransactions(User $user, Collection $transactions): array
{
$transactions = $transactions->filter(
fn (Transaction $transaction): bool => $transaction->description_iv === null,
)->values();
if ($transactions->isEmpty()) {
return [];
}
$catalog = CategoryCatalog::forUser($user);
if ($catalog->isEmpty()) {
return [];
}
$byRef = $transactions->keyBy(fn (Transaction $transaction): string => $transaction->id);
$results = $this->resolve($user, $transactions, $catalog);
$labelBar = (float) config('ai_categorization.label_confidence');
$model = (string) config('ai_categorization.model');
$outcomes = [];
foreach ($results as $result) {
$transaction = $byRef->get((string) ($result['ref'] ?? ''));
if ($transaction === null) {
continue;
}
$categoryId = $catalog->categoryIdForIndex(
isset($result['category_index']) ? (int) $result['category_index'] : null,
);
if ($categoryId === null) {
continue;
}
$confidence = (float) ($result['confidence'] ?? 0.0);
$applied = $confidence >= $labelBar;
$this->recordOutcome($transaction, $categoryId, $confidence, $applied, $model);
$outcomes[] = new CategorizationOutcome(
transaction: $transaction,
categoryId: $categoryId,
confidence: $confidence,
merchantUnambiguous: (bool) ($result['merchant_unambiguous'] ?? false),
applied: $applied,
);
}
return $outcomes;
}
/**
* Persist the model's suggestion on the transaction whether or not it clears
* the label bar. Below the bar the transaction stays uncategorized but the
* suggestion is kept (for confidence-bar tuning and a future confirm UI);
* at or above it the category is also auto-applied.
*/
private function recordOutcome(Transaction $transaction, string $categoryId, float $confidence, bool $applied, string $model): void
{
$transaction->ai_suggested_category_id = $categoryId;
$transaction->ai_confidence = $confidence;
$transaction->ai_suggested_category_at = now();
$transaction->ai_model = $model;
if ($applied) {
$transaction->category_id = $categoryId;
$transaction->category_source = CategorySource::Ai;
}
$transaction->save();
}
/**
* Send the transactions to the model in bounded chunks and merge the
* results. A chunk that fails after a retry is dropped without discarding
* the chunks that succeeded; a transient provider failure additionally
* schedules a deferred retry of the user's still-pending transactions.
*
* @param Collection<int, Transaction> $transactions
* @return list<array<string, mixed>>
*/
private function resolve(User $user, Collection $transactions, CategoryCatalog $catalog): array
{
$batchSize = max(1, (int) config('ai_categorization.group_batch_size'));
$results = [];
foreach ($transactions->chunk($batchSize) as $chunk) {
try {
foreach ($this->resolveChunkWithRetry($chunk, $catalog) as $result) {
$results[] = $result;
}
} catch (FailoverableException $exception) {
Log::warning('AI categorization chunk dropped: provider transient failure.', [
'exception' => $exception->getMessage(),
]);
RetryTransientAiCategorizationJob::dispatch($user)
->delay(now()->addMinutes((int) config('ai_categorization.retry_delay')));
} catch (Throwable $exception) {
report($exception);
}
}
return $results;
}
/**
* @param Collection<int, Transaction> $chunk
* @return list<array<string, mixed>>
*/
private function resolveChunkWithRetry(Collection $chunk, CategoryCatalog $catalog): array
{
try {
return $this->resolveChunk($chunk, $catalog);
} catch (Throwable) {
return $this->resolveChunk($chunk, $catalog);
}
}
/**
* @param Collection<int, Transaction> $chunk
* @return list<array<string, mixed>>
*/
private function resolveChunk(Collection $chunk, CategoryCatalog $catalog): array
{
$items = $chunk->map(fn (Transaction $transaction): array => [
'ref' => $transaction->id,
'text' => (string) $transaction->description,
'amount' => $transaction->amount / 100,
'direction' => $transaction->amount < 0 ? 'outflow' : 'inflow',
'creditor_name' => $transaction->creditor_name,
'debtor_name' => $transaction->debtor_name,
])->values()->all();
$payload = json_encode([
'transactions' => $items,
'categories' => $catalog->options(),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$response = (new TransactionCategorizationAgent)->prompt(
$payload,
provider: Lab::Gemini,
model: (string) config('ai_categorization.model'),
);
$results = $response['results'] ?? [];
return is_array($results) ? array_values($results) : [];
}
}