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:
parent
f191d74031
commit
9328cd3e1b
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue