From 9328cd3e1ba53218b521d6501d8750bb483a2ea6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Wed, 17 Jun 2026 10:24:30 +0200 Subject: [PATCH] feat(ai): persist AI categorization suggestions below the label bar (#547) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Contexto El categorizador solo persistía un resultado cuando la confianza superaba la barra (`ai_categorization.label_confidence`, 0.7). Las sugerencias por debajo del umbral se calculaban y se descartaban, dejándonos sin ninguna visibilidad de qué proponía el modelo para las transacciones que no se autocategorizaban. ## Cambio Se guarda la sugerencia del modelo en **toda** transacción que el modelo coloca, mediante columnas tipadas (en vez de un blob JSON): - `ai_suggested_category_id` (FK a `categories`, nullable) - `ai_suggested_category_at` (timestamp, nullable) - `ai_confidence` (ya existía) — ahora se rellena **siempre**, no solo al aplicar Por debajo de la barra la transacción sigue sin categorizar, pero la sugerencia queda guardada; a partir de la barra, además se autoaplica la categoría como hasta ahora. ## Para qué sirve - **Afinar el umbral 0.7 con datos reales**: `where ai_suggested_category_id is not null and category_id is null` muestra exactamente qué nos perdemos. - Habilita una futura UI de "la IA cree que es X, ¿confirmas?" directamente desde el FK. ## Tests - `CategorizeTransactionsTest`: el caso sub-umbral ahora verifica que la sugerencia se persiste; el caso aplicado verifica que además se guardan los campos de sugerencia. - Suite de IA + tests que tocan transacciones en verde (111 tests). --- app/Models/Transaction.php | 11 ++++++ app/Services/Ai/CategorizeTransactions.php | 22 ++++++++---- ...ggested_category_to_transactions_table.php | 36 +++++++++++++++++++ .../Feature/Ai/CategorizeTransactionsTest.php | 7 +++- 4 files changed, 69 insertions(+), 7 deletions(-) create mode 100644 database/migrations/2026_06_17_081319_add_ai_suggested_category_to_transactions_table.php diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index 4728207c..b3def01d 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -27,6 +27,8 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @property ?CategorySource $category_source * @property ?float $ai_confidence * @property ?string $categorized_by_rule_id + * @property ?string $ai_suggested_category_id + * @property ?Carbon $ai_suggested_category_at */ class Transaction extends Model { @@ -47,6 +49,8 @@ class Transaction extends Model 'category_source', 'ai_confidence', 'categorized_by_rule_id', + 'ai_suggested_category_id', + 'ai_suggested_category_at', 'description', 'description_iv', 'original_description', @@ -86,6 +90,7 @@ class Transaction extends Model 'source' => TransactionSource::class, 'category_source' => CategorySource::class, 'ai_confidence' => 'float', + 'ai_suggested_category_at' => 'datetime', 'raw_data' => 'array', ]; } @@ -114,6 +119,12 @@ class Transaction extends Model return $this->belongsTo(AutomationRule::class, 'categorized_by_rule_id'); } + /** @return BelongsTo */ + public function suggestedCategory(): BelongsTo + { + return $this->belongsTo(Category::class, 'ai_suggested_category_id'); + } + /** * Whether AI assigned this transaction's category — either directly or via an * AI-owned rule. Not appended by default; surfaces opt in (e.g. the index diff --git a/app/Services/Ai/CategorizeTransactions.php b/app/Services/Ai/CategorizeTransactions.php index 14b090b0..b36c2137 100644 --- a/app/Services/Ai/CategorizeTransactions.php +++ b/app/Services/Ai/CategorizeTransactions.php @@ -62,9 +62,7 @@ class CategorizeTransactions $confidence = (float) ($result['confidence'] ?? 0.0); $applied = $confidence >= $labelBar; - if ($applied) { - $this->applyLabel($transaction, $categoryId, $confidence); - } + $this->recordOutcome($transaction, $categoryId, $confidence, $applied); $outcomes[] = new CategorizationOutcome( transaction: $transaction, @@ -78,11 +76,23 @@ class CategorizeTransactions return $outcomes; } - private function applyLabel(Transaction $transaction, string $categoryId, float $confidence): void + /** + * 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): void { - $transaction->category_id = $categoryId; - $transaction->category_source = CategorySource::Ai; + $transaction->ai_suggested_category_id = $categoryId; $transaction->ai_confidence = $confidence; + $transaction->ai_suggested_category_at = now(); + + if ($applied) { + $transaction->category_id = $categoryId; + $transaction->category_source = CategorySource::Ai; + } + $transaction->save(); } diff --git a/database/migrations/2026_06_17_081319_add_ai_suggested_category_to_transactions_table.php b/database/migrations/2026_06_17_081319_add_ai_suggested_category_to_transactions_table.php new file mode 100644 index 00000000..bd0a8f46 --- /dev/null +++ b/database/migrations/2026_06_17_081319_add_ai_suggested_category_to_transactions_table.php @@ -0,0 +1,36 @@ +foreignUuid('ai_suggested_category_id') + ->nullable() + ->after('categorized_by_rule_id') + ->constrained('categories') + ->nullOnDelete(); + $table->timestamp('ai_suggested_category_at') + ->nullable() + ->after('ai_suggested_category_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('transactions', function (Blueprint $table) { + $table->dropConstrainedForeignId('ai_suggested_category_id'); + $table->dropColumn('ai_suggested_category_at'); + }); + } +}; diff --git a/tests/Feature/Ai/CategorizeTransactionsTest.php b/tests/Feature/Ai/CategorizeTransactionsTest.php index e8a27cf7..08951f3c 100644 --- a/tests/Feature/Ai/CategorizeTransactionsTest.php +++ b/tests/Feature/Ai/CategorizeTransactionsTest.php @@ -67,12 +67,14 @@ it('auto-applies the category when confidence clears the label bar', function () expect($transaction->category_id)->toBe($category->id) ->and($transaction->category_source)->toBe(CategorySource::Ai) ->and($transaction->ai_confidence)->toEqual(0.95) + ->and($transaction->ai_suggested_category_id)->toBe($category->id) + ->and($transaction->ai_suggested_category_at)->not->toBeNull() ->and($outcomes)->toHaveCount(1) ->and($outcomes[0]->applied)->toBeTrue() ->and($outcomes[0]->merchantUnambiguous)->toBeTrue(); }); -it('leaves the transaction blank when confidence is below the label bar', function () { +it('leaves the transaction blank but records the suggestion when confidence is below the label bar', function () { $user = User::factory()->create(); $category = groceries($user); $transaction = uncategorized($user); @@ -94,6 +96,9 @@ it('leaves the transaction blank when confidence is below the label bar', functi expect($transaction->category_id)->toBeNull() ->and($transaction->category_source)->toBeNull() + ->and($transaction->ai_suggested_category_id)->toBe($category->id) + ->and($transaction->ai_confidence)->toEqual(0.5) + ->and($transaction->ai_suggested_category_at)->not->toBeNull() ->and($outcomes)->toHaveCount(1) ->and($outcomes[0]->applied)->toBeFalse(); });