feat(ai): make AI categorization confidence threshold configurable

The confidence bar that decides whether the AI's suggested category is
auto-applied to a transaction was a fixed config value
(ai_categorization.label_confidence, 0.7). Let each user tune it with a
slider in Settings > Manage Plan, next to the AI categorization toggle.

- Add nullable user_settings.ai_confidence_threshold (percent, 0-100);
  null falls back to the global config default.
- User::aiConfidenceThresholdPercent() centralises the "user value else
  config default" resolution used by both the billing page and the
  categorization engine.
- CategorizeTransactions reads the per-user bar instead of the config.
- Gate tier-2 rule learning on the outcome being applied, so the
  effective rule bar becomes max(label bar, rule_confidence): raising the
  threshold to reduce automation now also holds back forward rules
  (previously a suggestion above 0.85 could still spawn a rule even when
  the user's raised bar left the transaction uncategorized).
- Slider (50-95%, debounced save) rendered when AI consent is on.
This commit is contained in:
Víctor Falcón 2026-07-17 18:43:58 +02:00
parent 5d7b655111
commit 2472260ec8
15 changed files with 308 additions and 4 deletions

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\UpdateAiConfidenceThresholdRequest;
use Illuminate\Http\RedirectResponse;
class AiConfidenceThresholdController extends Controller
{
public function update(UpdateAiConfidenceThresholdRequest $request): RedirectResponse
{
$request->user()->setting()->updateOrCreate(
['user_id' => $request->user()->id],
['ai_confidence_threshold' => $request->integer('ai_confidence_threshold')]
);
return back();
}
}

View File

@ -191,6 +191,7 @@ class SubscriptionController extends Controller
return Inertia::render('settings/billing', [
'hasAiConsent' => $user->hasActiveAiConsent(),
'aiConfidenceThreshold' => $user->aiConfidenceThresholdPercent(),
'refund' => [
'canSelfRefund' => $this->experimentOffer->canSelfRefund($user),
'deadline' => $subscription !== null && $this->experimentOffer->variantFor($user) === SubscriptionExperiment::PAY_NOW

View File

@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests\Settings;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class UpdateAiConfidenceThresholdRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'ai_confidence_threshold' => ['required', 'integer', 'min:50', 'max:95'],
];
}
}

View File

@ -285,6 +285,17 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma
return $this->aiConsents()->active($scope)->exists();
}
/**
* The minimum confidence (as a percentage, 0-100) at which an AI-suggested
* category is auto-applied, falling back to the global default when the user
* has never adjusted it. Stored in percent; the engine divides by 100.
*/
public function aiConfidenceThresholdPercent(): int
{
return $this->setting->ai_confidence_threshold
?? (int) round((float) config('ai_categorization.label_confidence') * 100);
}
/**
* Record an AI consent for the current consent version (idempotent).
*/

View File

@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property bool $include_loans_in_net_worth_chart
* @property bool $include_real_estate_in_net_worth_chart
* @property bool $notify_on_bank_transactions_synced
* @property ?int $ai_confidence_threshold
*/
class UserSetting extends Model
{
@ -26,6 +27,7 @@ class UserSetting extends Model
'include_loans_in_net_worth_chart',
'include_real_estate_in_net_worth_chart',
'notify_on_bank_transactions_synced',
'ai_confidence_threshold',
];
protected function casts(): array
@ -35,6 +37,7 @@ class UserSetting extends Model
'include_loans_in_net_worth_chart' => 'boolean',
'include_real_estate_in_net_worth_chart' => 'boolean',
'notify_on_bank_transactions_synced' => 'boolean',
'ai_confidence_threshold' => 'integer',
];
}

View File

@ -64,6 +64,14 @@ class AiRuleLearner
return null;
}
// Never generalise a suggestion we weren't confident enough to even apply
// to the single transaction. This ties tier 2 to the user's (possibly
// raised) label bar: the effective rule bar is max(label bar, rule bar),
// so raising the threshold to reduce automation also holds back rules.
if (! $outcome->applied) {
return null;
}
if ($outcome->confidence < (float) config('ai_categorization.rule_confidence')) {
return null;
}

View File

@ -44,7 +44,7 @@ class CategorizeTransactions
$byRef = $transactions->keyBy(fn (Transaction $transaction): string => $transaction->id);
$results = $this->resolve($user, $transactions, $catalog);
$labelBar = (float) config('ai_categorization.label_confidence');
$labelBar = $user->aiConfidenceThresholdPercent() / 100;
$model = (string) config('ai_categorization.model');
$outcomes = [];

View File

@ -0,0 +1,28 @@
<?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('user_settings', function (Blueprint $table) {
$table->unsignedTinyInteger('ai_confidence_threshold')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('user_settings', function (Blueprint $table) {
$table->dropColumn('ai_confidence_threshold');
});
}
};

View File

@ -22,6 +22,8 @@
"With your permission, we send merchant names from your transactions to our AI provider so it can suggest categories. You can revoke this at any time.": "Con tu permiso, enviamos los nombres de los comercios de tus transacciones a nuestro proveedor de IA para que pueda sugerir categorías. Puedes revocarlo en cualquier momento.",
"AI categorization enabled": "Categorización con IA activada",
"AI categorization disabled": "Categorización con IA desactivada",
"Confidence threshold": "Umbral de confianza",
"Only auto-apply a suggested category when the AI is at least this confident. Higher keeps more transactions uncategorized but makes the ones it does apply more accurate. Changes apply to transactions categorized from now on.": "Aplica automáticamente una categoría sugerida solo cuando la IA tenga al menos esta confianza. Un valor más alto deja más transacciones sin categorizar, pero hace que las que sí se aplican sean más precisas. Los cambios se aplican a las transacciones categorizadas a partir de ahora.",
"AI enabled. You can change this anytime in Settings > Manage Plan.": "IA activada. Puedes cambiar esta opción cuando quieras en Configuración > Gestionar Plan.",
"AI not enabled. You can turn it on anytime in Settings > Manage Plan.": "IA no activada. Puedes activarla cuando quieras en Configuración > Gestionar Plan.",
"Dismiss": "Descartar",

View File

@ -29,7 +29,7 @@ import {
SparklesIcon,
ZapIcon,
} from 'lucide-react';
import { useState } from 'react';
import { useRef, useState } from 'react';
import { toast } from 'sonner';
const breadcrumbs: BreadcrumbItem[] = [
@ -389,12 +389,66 @@ function SubscribedSection({
);
}
function ConfidenceThresholdControl({ initial }: { initial: number }) {
const [value, setValue] = useState(initial);
const saveTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
const handleChange = (next: number) => {
setValue(next);
clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
router.patch(
'/settings/ai-confidence-threshold',
{ ai_confidence_threshold: next },
{
preserveScroll: true,
preserveState: true,
only: ['aiConfidenceThreshold'],
},
);
}, 400);
};
return (
<div className="mt-4 border-t pt-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{__('Confidence threshold')}
</span>
<span className="text-sm font-medium text-muted-foreground tabular-nums">
{value}%
</span>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{__(
'Only auto-apply a suggested category when the AI is at least this confident. Higher keeps more transactions uncategorized but makes the ones it does apply more accurate. Changes apply to transactions categorized from now on.',
)}
</p>
<input
type="range"
min={50}
max={95}
step={5}
value={value}
aria-label={__('Confidence threshold')}
aria-valuetext={`${value}%`}
onChange={(event) =>
handleChange(Number(event.currentTarget.value))
}
className="mt-3 w-full accent-emerald-600"
/>
</div>
);
}
function AiConsentSection({
initialConsent,
hasProPlan,
initialConfidenceThreshold,
}: {
initialConsent: boolean;
hasProPlan: boolean;
initialConfidenceThreshold: number;
}) {
const [consented, setConsented] = useState(initialConsent);
const [saving, setSaving] = useState(false);
@ -458,6 +512,12 @@ function AiConsentSection({
</div>
</label>
{consented && (
<ConfidenceThresholdControl
initial={initialConfidenceThreshold}
/>
)}
{!hasProPlan && (
<p className="mt-3 border-t pt-3 text-sm text-amber-600 dark:text-amber-400">
{__(
@ -471,8 +531,19 @@ function AiConsentSection({
}
export default function Billing() {
const { auth, pricing, locale, hasAiConsent, refund } = usePage<
SharedData & { hasAiConsent: boolean; refund?: RefundInfo }
const {
auth,
pricing,
locale,
hasAiConsent,
aiConfidenceThreshold,
refund,
} = usePage<
SharedData & {
hasAiConsent: boolean;
aiConfidenceThreshold: number;
refund?: RefundInfo;
}
>().props;
const isDemoAccount = auth?.isDemoAccount ?? false;
const hasProPlan = auth?.hasProPlan ?? false;
@ -505,6 +576,7 @@ export default function Billing() {
<AiConsentSection
initialConsent={hasAiConsent}
hasProPlan={hasProPlan}
initialConfidenceThreshold={aiConfidenceThreshold}
/>
</div>
</SettingsLayout>

View File

@ -2,6 +2,7 @@
use App\Http\Controllers\OpenBanking\ConnectionController;
use App\Http\Controllers\Settings\AccountController;
use App\Http\Controllers\Settings\AiConfidenceThresholdController;
use App\Http\Controllers\Settings\AutomationRuleApplicationController;
use App\Http\Controllers\Settings\AutomationRuleController;
use App\Http\Controllers\Settings\BankController;
@ -97,6 +98,9 @@ Route::middleware('auth')->group(function () {
Route::patch('settings/net-worth-chart-real-estate-preference', [NetWorthChartRealEstatePreferenceController::class, 'update'])
->name('net-worth-chart-real-estate-preference.update');
Route::patch('settings/ai-confidence-threshold', [AiConfidenceThresholdController::class, 'update'])
->name('ai-confidence-threshold.update');
Route::get('settings/billing', [SubscriptionController::class, 'billing'])->name('settings.billing');
Route::get('settings/billing/portal', [SubscriptionController::class, 'billingPortal'])->name('settings.billing.portal');
Route::post('settings/billing/refund', [SubscriptionController::class, 'refund'])

View File

@ -52,6 +52,19 @@ it('creates an ai-owned rule at the lowest priority and links the transaction',
->and($transaction->refresh()->categorized_by_rule_id)->toBe($rule->id);
});
it('does not learn a rule from a confident suggestion that was not applied', function () {
$user = User::factory()->create();
$category = expenseCategory($user);
$transaction = merchantTransaction($user, 'Mercadona');
// Above rule_confidence and unambiguous, but the user's raised label bar kept
// it from being applied (applied = false) — no forward rule should be learned.
$notApplied = new CategorizationOutcome($transaction, $category->id, 0.9, true, false);
expect(app(AiRuleLearner::class)->learn($notApplied))->toBeNull()
->and($transaction->refresh()->categorized_by_rule_id)->toBeNull();
});
it('resolves a fresh instance per container lookup so the memoized corpus cannot leak or go stale', function () {
// The per-user corpus cache has no invalidation and is safe only while the
// learner is never a singleton. Guard that invariant.

View File

@ -79,6 +79,32 @@ it('auto-applies the category when confidence clears the label bar', function ()
->and($outcomes[0]->merchantUnambiguous)->toBeTrue();
});
it('honours the user confidence threshold over the config default', function () {
$user = User::factory()->create();
$user->setting()->create(['ai_confidence_threshold' => 90]);
$category = groceries($user);
$transaction = uncategorized($user);
$index = leafIndex(CategoryCatalog::forUser($user), $category->id);
TransactionCategorizationAgent::fake([
['results' => [[
'ref' => $transaction->id,
'category_index' => $index,
'confidence' => 0.8,
'merchant_unambiguous' => true,
]]],
]);
$outcomes = app(CategorizeTransactions::class)->forTransactions($user, collect([$transaction]));
$transaction->refresh();
expect($transaction->category_id)->toBeNull()
->and($transaction->ai_suggested_category_id)->toBe($category->id)
->and($outcomes[0]->applied)->toBeFalse();
});
it('leaves the transaction blank but records the suggestion when confidence is below the label bar', function () {
$user = User::factory()->create();
$category = groceries($user);

View File

@ -23,6 +23,23 @@ test('billing page reports current ai consent state', function () {
);
});
test('billing page reports the ai confidence threshold, defaulting to the config bar', function () {
config(['subscriptions.enabled' => true, 'ai_categorization.label_confidence' => 0.7]);
$user = User::factory()->onboarded()->create();
actingAs($user)->withoutVite()->get(route('settings.billing'))
->assertInertia(fn (Assert $page) => $page
->where('aiConfidenceThreshold', 70)
);
$user->setting()->create(['ai_confidence_threshold' => 85]);
actingAs($user->fresh())->withoutVite()->get(route('settings.billing'))
->assertInertia(fn (Assert $page) => $page
->where('aiConfidenceThreshold', 85)
);
});
test('transactions page reports current ai consent state', function () {
$user = User::factory()->onboarded()->create();

View File

@ -0,0 +1,70 @@
<?php
use App\Models\User;
use App\Models\UserSetting;
beforeEach(function () {
config(['landing.hide_auth_buttons' => false]);
});
test('ai confidence threshold can be updated', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->patch(route('ai-confidence-threshold.update'), [
'ai_confidence_threshold' => 85,
]);
$response->assertSessionHasNoErrors()->assertRedirect();
expect($user->fresh()->setting->ai_confidence_threshold)->toBe(85);
});
test('ai confidence threshold rejects values out of range', function () {
$user = User::factory()->create();
$this
->actingAs($user)
->patch(route('ai-confidence-threshold.update'), [
'ai_confidence_threshold' => 10,
])
->assertSessionHasErrors('ai_confidence_threshold');
$this
->actingAs($user)
->patch(route('ai-confidence-threshold.update'), [
'ai_confidence_threshold' => 100,
])
->assertSessionHasErrors('ai_confidence_threshold');
});
test('ai confidence threshold rejects non-integer values', function () {
$user = User::factory()->create();
$this
->actingAs($user)
->patch(route('ai-confidence-threshold.update'), [
'ai_confidence_threshold' => 'high',
])
->assertSessionHasErrors('ai_confidence_threshold');
});
test('ai confidence threshold requires authentication', function () {
$this->patch(route('ai-confidence-threshold.update'), [
'ai_confidence_threshold' => 80,
])->assertRedirect(route('register'));
});
test('ai confidence threshold creates setting when none exists', function () {
$user = User::factory()->create();
expect(UserSetting::where('user_id', $user->id)->exists())->toBeFalse();
$this->actingAs($user)
->patch(route('ai-confidence-threshold.update'), [
'ai_confidence_threshold' => 60,
]);
expect($user->fresh()->setting->ai_confidence_threshold)->toBe(60);
});