diff --git a/app/Jobs/RetryTransientAiCategorizationJob.php b/app/Jobs/RetryTransientAiCategorizationJob.php new file mode 100644 index 00000000..d0e99388 --- /dev/null +++ b/app/Jobs/RetryTransientAiCategorizationJob.php @@ -0,0 +1,58 @@ +user->id; + } + + public function handle(AiCategorizationGate $gate, AiCategorizer $categorizer): void + { + if (! $gate->allows($this->user)) { + return; + } + + $categorizer->backfill($this->user); + } +} diff --git a/app/Services/Ai/CategorizeTransactions.php b/app/Services/Ai/CategorizeTransactions.php index c1183975..674b3b85 100644 --- a/app/Services/Ai/CategorizeTransactions.php +++ b/app/Services/Ai/CategorizeTransactions.php @@ -4,6 +4,7 @@ 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; @@ -41,7 +42,7 @@ class CategorizeTransactions } $byRef = $transactions->keyBy(fn (Transaction $transaction): string => $transaction->id); - $results = $this->resolve($transactions, $catalog); + $results = $this->resolve($user, $transactions, $catalog); $labelBar = (float) config('ai_categorization.label_confidence'); $model = (string) config('ai_categorization.model'); @@ -103,12 +104,13 @@ class CategorizeTransactions /** * 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. + * the chunks that succeeded; a transient provider failure additionally + * schedules a deferred retry of the user's still-pending transactions. * * @param Collection $transactions * @return list> */ - private function resolve(Collection $transactions, CategoryCatalog $catalog): array + private function resolve(User $user, Collection $transactions, CategoryCatalog $catalog): array { $batchSize = max(1, (int) config('ai_categorization.group_batch_size')); $results = []; @@ -122,6 +124,9 @@ class CategorizeTransactions 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); } diff --git a/config/ai_categorization.php b/config/ai_categorization.php index 78d9f7bd..17b9e580 100644 --- a/config/ai_categorization.php +++ b/config/ai_categorization.php @@ -69,6 +69,19 @@ return [ 'queue' => env('AI_CATEGORIZATION_QUEUE', 'ai'), + /* + |-------------------------------------------------------------------------- + | Transient-failure retry delay + |-------------------------------------------------------------------------- + | + | Minutes to wait before retrying a user's still-pending transactions after + | the AI provider dropped a chunk with a transient failure (overload / rate + | limit). The delay lets the provider recover before we try again. + | + */ + + 'retry_delay' => (int) env('AI_CATEGORIZATION_RETRY_DELAY', 10), + /* |-------------------------------------------------------------------------- | Free-plan upsell nudge diff --git a/tests/Feature/Ai/CategorizeTransactionsTest.php b/tests/Feature/Ai/CategorizeTransactionsTest.php index f9526c30..b25a3993 100644 --- a/tests/Feature/Ai/CategorizeTransactionsTest.php +++ b/tests/Feature/Ai/CategorizeTransactionsTest.php @@ -4,12 +4,14 @@ use App\Ai\Agents\TransactionCategorizationAgent; use App\Enums\CategoryCashflowDirection; use App\Enums\CategorySource; use App\Enums\CategoryType; +use App\Jobs\RetryTransientAiCategorizationJob; use App\Models\Category; use App\Models\Transaction; use App\Models\User; use App\Services\Ai\CategorizeTransactions; use App\Services\Ai\CategoryCatalog; use Illuminate\Support\Facades\Exceptions; +use Illuminate\Support\Facades\Queue; use Laravel\Ai\Exceptions\ProviderOverloadedException; function leafIndex(CategoryCatalog $catalog, string $categoryId): int @@ -134,12 +136,13 @@ it('never sends client-side encrypted transactions to the model', function () { ->and($encrypted->category_id)->toBeNull(); }); -it('drops the chunk without reporting when the provider is transiently overloaded', function () { +it('drops the chunk, skips reporting and schedules a retry when the provider is transiently overloaded', function () { $user = User::factory()->create(); groceries($user); $transaction = uncategorized($user); Exceptions::fake(); + Queue::fake(); TransactionCategorizationAgent::fake(fn () => throw ProviderOverloadedException::forProvider('gemini')); @@ -151,14 +154,19 @@ it('drops the chunk without reporting when the provider is transiently overloade ->and($transaction->category_id)->toBeNull(); Exceptions::assertNothingReported(); + Queue::assertPushed( + RetryTransientAiCategorizationJob::class, + fn (RetryTransientAiCategorizationJob $job): bool => $job->user->is($user), + ); }); -it('reports unexpected failures so real bugs are not swallowed', function () { +it('reports unexpected failures and does not schedule a retry so real bugs are not swallowed', function () { $user = User::factory()->create(); groceries($user); $transaction = uncategorized($user); Exceptions::fake(); + Queue::fake(); TransactionCategorizationAgent::fake(fn () => throw new RuntimeException('malformed response')); @@ -167,6 +175,7 @@ it('reports unexpected failures so real bugs are not swallowed', function () { expect($outcomes)->toBe([]); Exceptions::assertReported(fn (RuntimeException $e): bool => $e->getMessage() === 'malformed response'); + Queue::assertNotPushed(RetryTransientAiCategorizationJob::class); }); it('skips results whose category index does not resolve', function () { diff --git a/tests/Feature/Ai/RetryTransientAiCategorizationJobTest.php b/tests/Feature/Ai/RetryTransientAiCategorizationJobTest.php new file mode 100644 index 00000000..34f82d2c --- /dev/null +++ b/tests/Feature/Ai/RetryTransientAiCategorizationJobTest.php @@ -0,0 +1,76 @@ +create(); + $user->recordAiConsent(); + + $category = Category::factory()->for($user)->create([ + 'type' => CategoryType::Expense, + 'cashflow_direction' => CategoryCashflowDirection::Outflow, + ]); + + $catalog = CategoryCatalog::forUser($user); + $index = 0; + while (($id = $catalog->categoryIdForIndex($index)) !== null && $id !== $category->id) { + $index++; + } + + $transaction = Transaction::factory()->plaintext()->create([ + 'user_id' => $user->id, + 'category_id' => null, + 'category_source' => null, + 'amount' => -4300, + 'creditor_name' => 'mercadona', + 'description' => 'mercadona compra', + ]); + + TransactionCategorizationAgent::fake([ + ['results' => [[ + 'ref' => $transaction->id, + 'category_index' => $index, + 'confidence' => 0.95, + 'merchant_unambiguous' => true, + ]]], + ]); + + app()->call([new RetryTransientAiCategorizationJob($user), 'handle']); + + $transaction->refresh(); + + expect($transaction->category_id)->toBe($category->id) + ->and($transaction->category_source)->toBe(CategorySource::Ai); +}); + +it('does nothing when the user has not consented to AI', function () { + $user = User::factory()->create(); + + Category::factory()->for($user)->create([ + 'type' => CategoryType::Expense, + 'cashflow_direction' => CategoryCashflowDirection::Outflow, + ]); + + $transaction = Transaction::factory()->plaintext()->create([ + 'user_id' => $user->id, + 'category_id' => null, + 'creditor_name' => 'mercadona', + ]); + + TransactionCategorizationAgent::fake([])->preventStrayPrompts(); + + app()->call([new RetryTransientAiCategorizationJob($user), 'handle']); + + $transaction->refresh(); + + expect($transaction->category_id)->toBeNull(); + TransactionCategorizationAgent::assertNeverPrompted(); +});