whisper-money/app/Http/Controllers/Ai/RuleSuggestionController.php

268 lines
10 KiB
PHP

<?php
namespace App\Http\Controllers\Ai;
use App\Enums\PlanFeature;
use App\Enums\RuleSuggestionStatus;
use App\Enums\SuggestionRunStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\Ai\AcceptRuleSuggestionsRequest;
use App\Http\Requests\Ai\PreviewRuleSuggestionRequest;
use App\Jobs\GenerateRuleSuggestionsJob;
use App\Models\RuleSuggestion;
use App\Models\SuggestionRun;
use App\Models\Transaction;
use App\Models\User;
use App\Services\Ai\ApplyRuleSuggestions;
use App\Services\Ai\Contracts\TransactionMatcher;
use App\Services\Ai\RuleSuggestionAvailability;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
class RuleSuggestionController extends Controller
{
public function __construct(
private readonly RuleSuggestionAvailability $availability,
private readonly TransactionMatcher $matcher,
) {}
/**
* Return the current suggestion state (used for polling + review).
*/
public function show(Request $request): JsonResponse
{
$user = $request->user();
return response()->json($this->state($user));
}
/**
* Kick off a generation run, reusing the latest run while throttled.
*/
public function generate(Request $request): JsonResponse
{
$user = $request->user();
abort_unless($user->hasActiveAiConsent(), 403);
if (! $this->availability->isEligible($user)) {
return response()->json($this->state($user), 422);
}
if (! $this->availability->isThrottled($user)) {
$run = $user->suggestionRuns()->create(['status' => SuggestionRunStatus::Pending]);
GenerateRuleSuggestionsJob::dispatch($run);
}
return response()->json($this->state($user->refresh()));
}
/**
* Live preview of the transactions a group of candidate conditions would
* match (OR'd together), recomputed whenever the user edits a value.
*/
public function preview(PreviewRuleSuggestionRequest $request): JsonResponse
{
$user = $request->user();
$conditions = $this->conditions($request->validated('conditions'));
$matches = $this->matcher->matchingAny($user, $conditions, 100);
return response()->json([
'match_count' => $this->matcher->countMatchingAny($user, $conditions),
'total_uncategorized' => $this->matcher->total($user),
'transactions' => $matches->map(fn (Transaction $transaction): array => [
'id' => $transaction->id,
'description' => $transaction->description,
'creditor_name' => $transaction->creditor_name,
'debtor_name' => $transaction->debtor_name,
'amount' => (int) $transaction->amount,
'currency_code' => $transaction->currency_code,
'transaction_date' => $transaction->transaction_date->format('Y-m-d'),
])->all(),
]);
}
/**
* Accept (and optionally tweak) suggestion groups: create one OR rule per
* group and, during onboarding, apply them to existing uncategorized
* transactions right away.
*/
public function accept(AcceptRuleSuggestionsRequest $request, ApplyRuleSuggestions $applier): JsonResponse
{
$user = $request->user();
abort_unless($user->hasActiveAiConsent(), 403);
$run = $this->availability->latestSuccessfulRun($user);
abort_if($run === null, 404);
$groups = collect($request->validated('suggestions'));
$referencedIds = $groups->flatMap(fn (array $group): array => $group['ids'])->unique()->all();
/** @var Collection<string, RuleSuggestion> $pending */
$pending = $run->suggestions()
->whereIn('id', $referencedIds)
->where('status', RuleSuggestionStatus::Pending)
->get()
->keyBy('id');
$descriptors = $groups
->map(function (array $group) use ($pending): ?array {
$rows = collect($group['ids'])
->map(fn (string $id): ?RuleSuggestion => $pending->get($id))
->filter();
if ($rows->isEmpty()) {
return null;
}
$existingCategory = $group['proposed_category_id'] ?? null;
return [
'conditions' => collect($group['values'])->map(fn (array $value): array => [
'field' => (string) $value['match_field'],
'operator' => (string) $value['match_operator'],
'token' => mb_strtolower(trim((string) $value['match_token'])),
])->all(),
'proposed_category_id' => $existingCategory,
'new_category_name' => $existingCategory ? null : ($group['new_category_name'] ?? null),
'new_category_direction' => $existingCategory ? null : ($group['new_category_direction'] ?? null),
'confidence' => (float) $rows->max('confidence'),
];
})
->filter()
->values()
->all();
$applyToExisting = ! $user->isOnboarded();
$summary = $applier->apply($user, $descriptors, $applyToExisting);
$run->suggestions()
->whereIn('id', $pending->keys()->all())
->update(['status' => RuleSuggestionStatus::Accepted]);
$run->suggestions()
->where('status', RuleSuggestionStatus::Pending)
->update(['status' => RuleSuggestionStatus::Dismissed]);
return response()->json([
'summary' => $summary,
'applied_to_existing' => $applyToExisting,
]);
}
/**
* @return array<string, mixed>
*/
private function state(User $user): array
{
$run = $this->availability->latestRun($user);
$throttledUntil = $this->availability->throttledUntil($user);
return [
'available' => true,
'consented' => $user->hasActiveAiConsent(),
'requires_upgrade' => $this->requiresUpgrade($user),
'eligible' => $this->availability->isEligible($user),
'transaction_count' => $this->availability->transactionCount($user),
'min_transactions' => $this->availability->minTransactions(),
'auto_select_confidence' => (float) config('ai_suggestions.auto_select_confidence'),
'throttled' => $throttledUntil !== null,
'throttled_until' => $throttledUntil?->toIso8601String(),
'run' => $run === null ? null : [
'id' => $run->id,
'status' => $run->status->value,
'suggestions_count' => $run->suggestions_count,
],
'suggestions' => $run !== null && $run->status === SuggestionRunStatus::Completed
? $this->serializeSuggestions($run)
: [],
];
}
/**
* Whether enabling AI suggestions would force the user onto a paid plan.
*
* Connected accounts already require a paid plan, so a user who has linked
* a bank gains AI suggestions at no extra cost — we only warn the rest.
*/
private function requiresUpgrade(User $user): bool
{
if ($user->canUseFeature(PlanFeature::AiSuggestions)) {
return false;
}
return ! $user->bankingConnections()->exists();
}
/**
* Pending suggestions, grouped by their target category into one card each:
* the matched values are OR'd, so merchants heading to the same category are
* reviewed (and later created) as a single rule.
*
* @return list<array<string, mixed>>
*/
private function serializeSuggestions(SuggestionRun $run): array
{
$user = $run->user;
$minMatchCount = (int) config('ai_suggestions.min_match_count');
return $run->suggestions()
->with('proposedCategory')
->where('status', RuleSuggestionStatus::Pending)
->orderByDesc('confidence')
->orderByDesc('group_size')
->get()
->groupBy(fn (RuleSuggestion $suggestion): string => $suggestion->categoryGroupKey())
->map(function ($items, string $key) use ($user): array {
/** @var Collection<int, RuleSuggestion> $items */
$first = $items->first();
$values = $items->map(fn (RuleSuggestion $suggestion): array => [
'id' => $suggestion->id,
'match_field' => $suggestion->match_field,
'match_operator' => $suggestion->match_operator,
'match_token' => $suggestion->match_token,
'group_size' => $suggestion->group_size,
])->values()->all();
return [
'id' => $key,
'confidence' => (float) $items->max('confidence'),
'group_size' => $this->matcher->countMatchingAny($user, $this->conditions($values)),
'sample_descriptions' => $items->flatMap(fn (RuleSuggestion $suggestion): array => $suggestion->sample_descriptions ?? [])->unique()->take(3)->values()->all(),
'proposed_category' => $first->proposedCategory === null ? null : [
'id' => $first->proposedCategory->id,
'name' => $first->proposedCategory->name,
],
'new_category_name' => $first->new_category_name,
'new_category_direction' => $first->new_category_direction,
'values' => $values,
];
})
->filter(fn (array $suggestion): bool => $suggestion['group_size'] >= $minMatchCount)
->sortByDesc('group_size')
->values()
->all();
}
/**
* Normalize match-value arrays (as sent by the client or stored on a row)
* into the matcher's condition shape.
*
* @param list<array<string, mixed>> $values
* @return list<array{field: string, operator: string, token: string}>
*/
private function conditions(array $values): array
{
return array_map(fn (array $value): array => [
'field' => (string) $value['match_field'],
'operator' => (string) $value['match_operator'],
'token' => (string) $value['match_token'],
], $values);
}
}