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:
parent
5d7b655111
commit
2472260ec8
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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).
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue