feat(ai): persist AI categorization suggestions below the label bar (#547)

## 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).
This commit is contained in:
Víctor Falcón 2026-06-17 10:24:30 +02:00 committed by GitHub
parent f191d74031
commit 9328cd3e1b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 69 additions and 7 deletions

View File

@ -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<Category, $this> */
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

View File

@ -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();
}

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('transactions', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -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();
});