feat(ai): record the model behind each AI categorization (#594)

## Why

We pay for AI transaction categorization on Gemini, but we couldn't tell
which model produced any given suggestion. The `gemini-flash-latest`
alias used by categorization is **floating** — it silently drifted from
the `flash-lite` tier up to `gemini-3.5-flash`, which is now the
dominant cost on the Gemini dashboard. Before swapping categorization to
a cheaper pinned model we need to measure accuracy per model, and for
that we have to know which model labelled each row.

We already store the AI-suggested category and its confidence on
`transactions`; this adds the missing third axis: the model.

## What

- New nullable `ai_model` column on `transactions`, written by
`CategorizeTransactions::recordOutcome` from
`config('ai_categorization.model')` whenever the model produces a
suggestion (above or below the label bar).
- The migration backfills existing AI-categorized rows
(`ai_suggested_category_at` not null) with `gemini-3.5-flash` — the
model `gemini-flash-latest` resolved to when those suggestions were
made. Done as a raw update so it doesn't bump every row's `updated_at`.
- `ai_model` added to `$fillable` and to `$hidden` (pure ops/measurement
metadata, no need to ship it to the frontend).

## Testing

- Extended `CategorizeTransactionsTest` to assert `ai_model` is stored
on both the auto-applied and below-bar paths.
- `php artisan test tests/Feature/Ai/CategorizeTransactionsTest.php` → 5
passed.

## Follow-ups (not in this PR)

- Pin `AI_CATEGORIZATION_MODEL` to an explicit cheaper tier (mirror the
existing `AI_SUGGESTIONS_MODEL=gemini-2.5-flash-lite`) and kill the
floating alias in the config defaults.
- Capture token usage from the SDK response for cost tracking.
This commit is contained in:
Víctor Falcón 2026-06-26 14:53:55 +02:00 committed by GitHub
parent 094ff4d5ac
commit 291cfbe261
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 47 additions and 2 deletions

View File

@ -29,6 +29,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property ?string $categorized_by_rule_id
* @property ?string $ai_suggested_category_id
* @property ?Carbon $ai_suggested_category_at
* @property ?string $ai_model
*/
class Transaction extends Model
{
@ -51,6 +52,7 @@ class Transaction extends Model
'categorized_by_rule_id',
'ai_suggested_category_id',
'ai_suggested_category_at',
'ai_model',
'description',
'description_iv',
'original_description',
@ -79,6 +81,7 @@ class Transaction extends Model
'dedup_fingerprint',
'raw_data',
'categorized_by_rule_id',
'ai_model',
'deleted_at',
];

View File

@ -42,6 +42,7 @@ class CategorizeTransactions
$results = $this->resolve($transactions, $catalog);
$labelBar = (float) config('ai_categorization.label_confidence');
$model = (string) config('ai_categorization.model');
$outcomes = [];
foreach ($results as $result) {
@ -62,7 +63,7 @@ class CategorizeTransactions
$confidence = (float) ($result['confidence'] ?? 0.0);
$applied = $confidence >= $labelBar;
$this->recordOutcome($transaction, $categoryId, $confidence, $applied);
$this->recordOutcome($transaction, $categoryId, $confidence, $applied, $model);
$outcomes[] = new CategorizationOutcome(
transaction: $transaction,
@ -82,11 +83,12 @@ class CategorizeTransactions
* 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
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;

View File

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('transactions', function (Blueprint $table) {
$table->string('ai_model')->nullable()->after('ai_suggested_category_at');
});
// Existing AI suggestions predate this column. They were produced by the
// floating `gemini-flash-latest` alias, which by then resolved to
// gemini-3.5-flash, so stamp that as the model used.
// ponytail: raw update so the backfill doesn't bump every row's updated_at.
DB::table('transactions')
->whereNotNull('ai_suggested_category_at')
->whereNull('ai_model')
->update(['ai_model' => 'gemini-3.5-flash']);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('transactions', function (Blueprint $table) {
$table->dropColumn('ai_model');
});
}
};

View File

@ -69,6 +69,7 @@ it('auto-applies the category when confidence clears the label bar', function ()
->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($transaction->ai_model)->toBe((string) config('ai_categorization.model'))
->and($outcomes)->toHaveCount(1)
->and($outcomes[0]->applied)->toBeTrue()
->and($outcomes[0]->merchantUnambiguous)->toBeTrue();
@ -99,6 +100,7 @@ it('leaves the transaction blank but records the suggestion when confidence is b
->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($transaction->ai_model)->toBe((string) config('ai_categorization.model'))
->and($outcomes)->toHaveCount(1)
->and($outcomes[0]->applied)->toBeFalse();
});