feat(ai): suggest automation rules during onboarding (#523)
Suggests transaction categorization rules during onboarding.
After a sync or import, it groups the uncategorized transactions, asks
Gemini (via laravel/ai) to map the common merchants to categories, and
shows the results for review. The user edits or drops any and creates
the ones they want. During onboarding the accepted rules also categorize
existing transactions right away.
Off by default: it needs the `AiRuleSuggestions` Pennant flag and a
per-user AI consent. The model and thresholds are config-driven.
`ai:suggest-rules {user}` prints what a user would get.
The settings-page surface and monthly regeneration are a follow-up.
This commit is contained in:
parent
0f48ffbbef
commit
8056ede636
|
|
@ -0,0 +1,436 @@
|
|||
---
|
||||
name: ai-sdk-development
|
||||
description: TRIGGER when working with ai-sdk which is Laravel official first-party AI SDK. Activate when building, editing AI agents, chatbots, text generation, image generation, audio/TTS, transcription/STT, embeddings, RAG, vector stores, reranking, structured output, streaming, conversation memory, tools, queueing, broadcasting, and provider failover across OpenAI, Anthropic, Gemini, Azure, Groq, xAI, DeepSeek, Mistral, Ollama, ElevenLabs, Cohere, Jina, and VoyageAI. Invoke when the user references ai-sdk, the `Laravel\Ai\` namespace, or this project's AI features — not for other AI packages used directly.
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Developing with the Laravel AI SDK
|
||||
|
||||
The Laravel AI SDK (`laravel/ai`) is the official AI package for Laravel, providing a unified API for agents, images, audio, transcription, embeddings, reranking, vector stores, and file management across multiple AI providers.
|
||||
|
||||
## Searching the Documentation
|
||||
|
||||
This package is new. Always search the documentation before implementing any feature. Never guess at APIs — the documentation is the single source of truth.
|
||||
|
||||
- Use broad, simple queries that match the documentation section headings below.
|
||||
- Do not add package names to queries — package information is shared automatically. Use `test agent fake`, not `laravel ai test agent fake`.
|
||||
- Run multiple queries at once — the most relevant results are returned first.
|
||||
|
||||
### Documentation Sections
|
||||
|
||||
Use these section headings as query terms for accurate results:
|
||||
|
||||
- Introduction, Installation, Configuration, Provider Support
|
||||
- Agents: Prompting, Conversation Context, Structured Output, Attachments, Streaming, Broadcasting, Queueing, Tools, Provider Tools, Middleware, Anonymous Agents, Agent Configuration
|
||||
- Images
|
||||
- Audio (TTS)
|
||||
- Transcription (STT)
|
||||
- Embeddings: Querying Embeddings, Caching Embeddings
|
||||
- Reranking
|
||||
- Files
|
||||
- Vector Stores: Adding Files to Stores
|
||||
- Failover
|
||||
- Testing: Agents, Images, Audio, Transcriptions, Embeddings, Reranking, Files, Vector Stores
|
||||
- Events
|
||||
|
||||
## Decision Workflow
|
||||
|
||||
Determine the right entry point before writing code:
|
||||
|
||||
Text generation or chat? → Agent class with `Promptable` trait
|
||||
Chat with conversation history? → Agent + `Conversational` interface (manual) or `RemembersConversations` trait (automatic)
|
||||
Structured JSON output? → Agent + `HasStructuredOutput` interface
|
||||
Image generation? → `Image::of()->generate()`
|
||||
Audio synthesis? → `Audio::of()->generate()`
|
||||
Transcription? → `Transcription::fromPath()->generate()`
|
||||
Embeddings? → `Embeddings::for()->generate()`
|
||||
Reranking? → `Reranking::of()->rerank()`
|
||||
File storage? → `Document::fromPath()->put()`
|
||||
Vector stores? → `Stores::create()`
|
||||
|
||||
## Basic Usage Examples
|
||||
|
||||
### Agents
|
||||
|
||||
```php
|
||||
use Laravel\Ai\Contracts\Agent;
|
||||
use Laravel\Ai\Enums\Lab;
|
||||
use Laravel\Ai\Promptable;
|
||||
|
||||
class SalesCoach implements Agent
|
||||
{
|
||||
use Promptable;
|
||||
|
||||
public function instructions(): string
|
||||
{
|
||||
return 'You are a sales coach.';
|
||||
}
|
||||
}
|
||||
|
||||
// Prompting
|
||||
$response = (new SalesCoach)->prompt('Analyze this transcript...');
|
||||
echo $response->text;
|
||||
|
||||
// Container resolution with dependency injection
|
||||
$agent = SalesCoach::make(user: $user);
|
||||
|
||||
// Override provider, model, or timeout per-prompt
|
||||
$response = (new SalesCoach)->prompt(
|
||||
'Analyze this transcript...',
|
||||
provider: Lab::Anthropic,
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
timeout: 120,
|
||||
);
|
||||
|
||||
// Streaming (returns SSE response from a route)
|
||||
return (new SalesCoach)->stream('Analyze this transcript...');
|
||||
|
||||
// Queueing
|
||||
(new SalesCoach)->queue('Analyze this transcript...')
|
||||
->then(fn ($response) => /* ... */);
|
||||
|
||||
// Anonymous agents
|
||||
use function Laravel\Ai\{agent};
|
||||
|
||||
$response = agent(instructions: 'You are a helpful assistant.')->prompt('Hello');
|
||||
```
|
||||
|
||||
### Conversation Context
|
||||
|
||||
Manual conversation history via the `Conversational` interface:
|
||||
|
||||
```php
|
||||
use Laravel\Ai\Contracts\Agent;
|
||||
use Laravel\Ai\Contracts\Conversational;
|
||||
use Laravel\Ai\Messages\Message;
|
||||
use Laravel\Ai\Promptable;
|
||||
|
||||
class SalesCoach implements Agent, Conversational
|
||||
{
|
||||
use Promptable;
|
||||
|
||||
public function __construct(public User $user) {}
|
||||
|
||||
public function instructions(): string { return 'You are a sales coach.'; }
|
||||
|
||||
public function messages(): iterable
|
||||
{
|
||||
return History::where('user_id', $this->user->id)
|
||||
->latest()->limit(50)->get()->reverse()
|
||||
->map(fn ($m) => new Message($m->role, $m->content))
|
||||
->all();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Automatic conversation persistence via the `RemembersConversations` trait:
|
||||
|
||||
```php
|
||||
use Laravel\Ai\Concerns\RemembersConversations;
|
||||
use Laravel\Ai\Contracts\Agent;
|
||||
use Laravel\Ai\Contracts\Conversational;
|
||||
use Laravel\Ai\Promptable;
|
||||
|
||||
class SalesCoach implements Agent, Conversational
|
||||
{
|
||||
use Promptable, RemembersConversations;
|
||||
|
||||
public function instructions(): string { return 'You are a sales coach.'; }
|
||||
}
|
||||
|
||||
// Start a new conversation
|
||||
$response = (new SalesCoach)->forUser($user)->prompt('Hello!');
|
||||
$conversationId = $response->conversationId;
|
||||
|
||||
// Continue an existing conversation
|
||||
$response = (new SalesCoach)->continue($conversationId, as: $user)->prompt('Tell me more.');
|
||||
```
|
||||
|
||||
### Structured Output
|
||||
|
||||
```php
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Ai\Contracts\Agent;
|
||||
use Laravel\Ai\Contracts\HasStructuredOutput;
|
||||
use Laravel\Ai\Promptable;
|
||||
|
||||
class Reviewer implements Agent, HasStructuredOutput
|
||||
{
|
||||
use Promptable;
|
||||
|
||||
public function instructions(): string { return 'Review and score content.'; }
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'feedback' => $schema->string()->required(),
|
||||
'score' => $schema->integer()->min(1)->max(10)->required(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$response = (new Reviewer)->prompt('Review this...');
|
||||
echo $response['score']; // Access like an array
|
||||
```
|
||||
|
||||
### Images
|
||||
|
||||
```php
|
||||
use Laravel\Ai\Image;
|
||||
|
||||
$image = Image::of('A sunset over mountains')
|
||||
->landscape()
|
||||
->quality('high')
|
||||
->generate();
|
||||
|
||||
$path = $image->store(); // Store to default disk
|
||||
```
|
||||
|
||||
### Audio
|
||||
|
||||
```php
|
||||
use Laravel\Ai\Audio;
|
||||
|
||||
$audio = Audio::of('Hello from Laravel.')
|
||||
->female()
|
||||
->instructions('Speak warmly')
|
||||
->generate();
|
||||
|
||||
$path = $audio->store();
|
||||
```
|
||||
|
||||
### Transcription
|
||||
|
||||
```php
|
||||
use Laravel\Ai\Transcription;
|
||||
|
||||
$transcript = Transcription::fromStorage('audio.mp3')
|
||||
->diarize()
|
||||
->generate();
|
||||
|
||||
echo (string) $transcript;
|
||||
```
|
||||
|
||||
### Embeddings
|
||||
|
||||
```php
|
||||
use Laravel\Ai\Embeddings;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
$response = Embeddings::for(['Text one', 'Text two'])
|
||||
->dimensions(1536)
|
||||
->cache()
|
||||
->generate();
|
||||
|
||||
// Single string via Stringable
|
||||
$embedding = Str::of('Napa Valley has great wine.')->toEmbeddings();
|
||||
```
|
||||
|
||||
### Reranking
|
||||
|
||||
```php
|
||||
use Laravel\Ai\Reranking;
|
||||
|
||||
$response = Reranking::of(['Django is Python.', 'Laravel is PHP.', 'React is JS.'])
|
||||
->limit(5)
|
||||
->rerank('PHP frameworks');
|
||||
|
||||
$response->first()->document; // "Laravel is PHP."
|
||||
```
|
||||
|
||||
### Files and Vector Stores
|
||||
|
||||
```php
|
||||
use Laravel\Ai\Files\Document;
|
||||
use Laravel\Ai\Stores;
|
||||
|
||||
// Store a file with the provider
|
||||
$file = Document::fromPath('/path/to/doc.pdf')->put();
|
||||
|
||||
// Create a vector store and add files
|
||||
$store = Stores::create('Knowledge Base');
|
||||
$store->add($file->id);
|
||||
$store->add(Document::fromStorage('manual.pdf')); // Store + add in one step
|
||||
```
|
||||
|
||||
## Agent Configuration
|
||||
|
||||
### PHP Attributes
|
||||
|
||||
```php
|
||||
use Laravel\Ai\Attributes\{Provider, Model, MaxSteps, MaxTokens, Temperature, Timeout};
|
||||
use Laravel\Ai\Enums\Lab;
|
||||
|
||||
#[Provider(Lab::Anthropic)]
|
||||
#[Model('claude-haiku-4-5-20251001')]
|
||||
#[MaxSteps(10)]
|
||||
#[MaxTokens(4096)]
|
||||
#[Temperature(0.7)]
|
||||
#[Timeout(120)]
|
||||
class MyAgent implements Agent
|
||||
{
|
||||
use Promptable;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
The `#[UseCheapestModel]` and `#[UseSmartestModel]` attributes are also available for automatic model selection.
|
||||
|
||||
### Tools
|
||||
|
||||
Implement the `HasTools` interface and scaffold tools with `php artisan make:tool`:
|
||||
|
||||
```php
|
||||
use Laravel\Ai\Contracts\HasTools;
|
||||
|
||||
class MyAgent implements Agent, HasTools
|
||||
{
|
||||
use Promptable;
|
||||
|
||||
public function tools(): iterable
|
||||
{
|
||||
return [new MyCustomTool];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Provider Tools
|
||||
|
||||
```php
|
||||
use Laravel\Ai\Providers\Tools\{WebSearch, WebFetch, FileSearch};
|
||||
|
||||
public function tools(): iterable
|
||||
{
|
||||
return [
|
||||
(new WebSearch)->max(5)->allow(['laravel.com']),
|
||||
new WebFetch,
|
||||
new FileSearch(stores: ['store_id']),
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### Conversation Memory
|
||||
|
||||
```php
|
||||
use Laravel\Ai\Concerns\RemembersConversations;
|
||||
use Laravel\Ai\Contracts\Conversational;
|
||||
|
||||
class ChatBot implements Agent, Conversational
|
||||
{
|
||||
use Promptable, RemembersConversations;
|
||||
// ...
|
||||
}
|
||||
|
||||
$response = (new ChatBot)->forUser($user)->prompt('Hello!');
|
||||
$response = (new ChatBot)->continue($conversationId, as: $user)->prompt('More...');
|
||||
```
|
||||
|
||||
### Failover
|
||||
|
||||
```php
|
||||
$response = (new MyAgent)->prompt('Hello', provider: [Lab::OpenAI, Lab::Anthropic]);
|
||||
```
|
||||
|
||||
## Testing and Faking
|
||||
|
||||
Each capability supports `fake()` with assertions:
|
||||
|
||||
```php
|
||||
use App\Ai\Agents\SalesCoach;
|
||||
use Laravel\Ai\{Image, Audio, Transcription, Embeddings, Reranking, Files, Stores};
|
||||
|
||||
// Agents
|
||||
SalesCoach::fake(['Response 1', 'Response 2']);
|
||||
SalesCoach::assertPrompted('query');
|
||||
SalesCoach::assertNotPrompted('query');
|
||||
SalesCoach::assertNeverPrompted();
|
||||
SalesCoach::fake()->preventStrayPrompts();
|
||||
|
||||
// Images
|
||||
Image::fake();
|
||||
Image::assertGenerated(fn ($prompt) => $prompt->contains('sunset'));
|
||||
Image::assertNothingGenerated();
|
||||
|
||||
// Audio
|
||||
Audio::fake();
|
||||
Audio::assertGenerated(fn ($prompt) => $prompt->contains('Hello'));
|
||||
|
||||
// Transcription
|
||||
Transcription::fake(['Transcribed text.']);
|
||||
Transcription::assertGenerated(fn ($prompt) => $prompt->isDiarized());
|
||||
|
||||
// Embeddings
|
||||
Embeddings::fake();
|
||||
Embeddings::assertGenerated(fn ($prompt) => $prompt->contains('Laravel'));
|
||||
|
||||
// Reranking
|
||||
Reranking::fake();
|
||||
Reranking::assertReranked(fn ($prompt) => $prompt->contains('PHP'));
|
||||
|
||||
// Files
|
||||
Files::fake();
|
||||
Files::assertStored(fn ($file) => $file->mimeType() === 'text/plain');
|
||||
|
||||
// Stores
|
||||
Stores::fake();
|
||||
Stores::assertCreated('Knowledge Base');
|
||||
$store = Stores::get('id');
|
||||
$store->assertAdded('file_id');
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- Namespace: `Laravel\Ai\`
|
||||
- Package: `composer require laravel/ai`
|
||||
- Agent pattern: Implement the `Agent` interface and use the `Promptable` trait
|
||||
- Optional interfaces: `HasTools`, `HasMiddleware`, `HasStructuredOutput`, `Conversational`
|
||||
- Entry-point classes: `Image`, `Audio`, `Transcription`, `Embeddings`, `Reranking`, `Stores`
|
||||
- Provider enum: `Laravel\Ai\Enums\Lab` (prefer over plain strings)
|
||||
- Artisan commands: `php artisan make:agent`, `php artisan make:tool`
|
||||
- Global helper: `agent()` for anonymous agents
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Wrong Namespace
|
||||
|
||||
The namespace is `Laravel\Ai`, not `Illuminate\Ai` or `Laravel\AI`.
|
||||
|
||||
```php
|
||||
// Correct
|
||||
use Laravel\Ai\Image;
|
||||
use Laravel\Ai\Contracts\Agent;
|
||||
use Laravel\Ai\Promptable;
|
||||
|
||||
// Wrong — these do not exist
|
||||
use Illuminate\Ai\Image;
|
||||
use Laravel\AI\Agent;
|
||||
```
|
||||
|
||||
### Unsupported Provider Capability
|
||||
|
||||
Calling a capability not supported by a provider throws a `LogicException`. Refer to the provider support table below.
|
||||
|
||||
## Provider Support
|
||||
|
||||
| Feature | Providers |
|
||||
| ---------- | --------------------------------------------------------------- |
|
||||
| Text | OpenAI, Anthropic, Gemini, Azure, Groq, xAI, DeepSeek, Mistral, Ollama |
|
||||
| Images | OpenAI, Gemini, xAI |
|
||||
| TTS | OpenAI, ElevenLabs |
|
||||
| STT | OpenAI, ElevenLabs, Mistral |
|
||||
| Embeddings | OpenAI, Gemini, Azure, Cohere, Mistral, Jina, VoyageAI |
|
||||
| Reranking | Cohere, Jina |
|
||||
| Files | OpenAI, Anthropic, Gemini |
|
||||
|
||||
Use the `Laravel\Ai\Enums\Lab` enum to reference providers in code instead of plain strings:
|
||||
|
||||
```php
|
||||
use Laravel\Ai\Enums\Lab;
|
||||
|
||||
Lab::Anthropic;
|
||||
Lab::OpenAI;
|
||||
Lab::Gemini;
|
||||
// ...
|
||||
```
|
||||
|
|
@ -16,7 +16,7 @@ about prod, since those default to the local connection.
|
|||
|
||||
## The `agent:db` command
|
||||
|
||||
Runs a read query (`SELECT`, `SHOW`, `DESCRIBE`, etc.) and prints the result.
|
||||
Runs a query (`SELECT`, `SHOW`, `DESCRIBE`, `DELETE`, etc.) and prints the result.
|
||||
|
||||
```bash
|
||||
php artisan agent:db "<query>"
|
||||
|
|
@ -45,8 +45,6 @@ php artisan agent:db --prod --format=table "select status, count(*) from subscri
|
|||
|
||||
## Guidelines
|
||||
|
||||
- **Read-only**: the command uses `DB::select()`, so only read statements work.
|
||||
It will not run `INSERT`/`UPDATE`/`DELETE`. Never attempt to mutate prod data this way.
|
||||
- **Be careful with `--prod`**: this is live customer data. Only run prod queries the
|
||||
user explicitly asked for, keep them scoped (add `LIMIT`, filter by id), and never
|
||||
dump large or sensitive datasets unprompted. This app is privacy-first.
|
||||
|
|
|
|||
17
.env.example
17
.env.example
|
|
@ -121,3 +121,20 @@ ENABLEBANKING_REDIRECT_URL="${APP_URL}/open-banking/callback"
|
|||
DEMO_EMAIL=demo@whisper.money
|
||||
DEMO_PASSWORD=demo
|
||||
DEMO_ENCRYPTION_KEY=demo
|
||||
|
||||
# AI rule suggestions (powered by laravel/ai + Gemini)
|
||||
GEMINI_API_KEY=
|
||||
GEMINI_BASE_URL=
|
||||
# Model is overridable without a deploy; defaults to a Flash-tier model.
|
||||
# GEMINI_BASE_URL is optional — leave empty to use Google AI Studio
|
||||
# (https://generativelanguage.googleapis.com/v1beta). Only set it for a
|
||||
# proxy or Vertex AI gateway.
|
||||
AI_SUGGESTIONS_MODEL=gemini-flash-latest
|
||||
AI_SUGGESTIONS_MIN_GROUP_COUNT=2
|
||||
AI_SUGGESTIONS_MAX_GROUPS=40
|
||||
AI_SUGGESTIONS_CONFIDENCE_FLOOR=0.3
|
||||
AI_SUGGESTIONS_AUTO_SELECT=0.6
|
||||
AI_SUGGESTIONS_OVERBROAD_FRACTION=0.4
|
||||
AI_SUGGESTIONS_MIN_TRANSACTIONS=50
|
||||
AI_SUGGESTIONS_THROTTLE_DAYS=30
|
||||
AI_SUGGESTIONS_CONSENT_VERSION=1
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
namespace App\Ai\Agents;
|
||||
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Ai\Contracts\Agent;
|
||||
use Laravel\Ai\Contracts\HasStructuredOutput;
|
||||
use Laravel\Ai\Promptable;
|
||||
use Stringable;
|
||||
|
||||
/**
|
||||
* Maps pre-aggregated transaction groups to categorization rules. The agent
|
||||
* only ever sees merchant/description signals and the user's own category list
|
||||
* — never full account context — and returns a strictly-typed suggestion set.
|
||||
*/
|
||||
class RuleSuggestionAgent implements Agent, HasStructuredOutput
|
||||
{
|
||||
use Promptable;
|
||||
|
||||
public function instructions(): Stringable|string
|
||||
{
|
||||
return <<<'PROMPT'
|
||||
You help a personal-finance app suggest automation rules that auto-categorize
|
||||
a user's transactions. You are given a JSON object with:
|
||||
- "transaction_groups": recurring groups of uncategorized transactions. Each has
|
||||
a "field" (description, creditor_name or debtor_name), a "key", an example
|
||||
"samples" list, an occurrence "count", an "avg_amount" and a "direction"
|
||||
(outflow = money spent, inflow = money received).
|
||||
- "categories": the user's existing categories, each with an "id", a "path"
|
||||
(parent > child), a "type" and a "direction". Prefer leaf categories.
|
||||
|
||||
For each group that clearly belongs to a single category, return one suggestion:
|
||||
- "group_key": echo the group's "key".
|
||||
- "match_field": which field to match on (use the group's field).
|
||||
- "match_operator": "contains" for free-text descriptions, "equals" for clean
|
||||
counterparty names.
|
||||
- "match_token": a short, lowercase, DISTINCTIVE substring that appears VERBATIM
|
||||
in the group's samples (e.g. "mercadona", "netflix"). Never invent text that is
|
||||
not present. Never use a token so generic it would match unrelated transactions
|
||||
(avoid words like "compra", "payment", "card").
|
||||
- "category_id": the id of the best-fitting existing category. An outflow group
|
||||
must map to a spending category, an inflow group to an income category.
|
||||
- If, and only if, no existing category fits, leave "category_id" empty and instead
|
||||
propose "new_category_name" and "new_category_direction" (inflow or outflow).
|
||||
- "confidence": 0.0–1.0, how sure you are.
|
||||
|
||||
Aim for broad coverage: return a suggestion for EVERY group you can map to a
|
||||
category with reasonable confidence. Only skip a group when it is genuinely
|
||||
ambiguous — for example internal transfers between the user's own accounts, or a
|
||||
catch-all group mixing unrelated transactions. Never invent a category you are
|
||||
unsure about; instead let "confidence" honestly reflect your certainty (the app
|
||||
filters out low-confidence suggestions itself).
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'suggestions' => $schema->array()->items(
|
||||
$schema->object(fn (JsonSchema $schema): array => [
|
||||
'group_key' => $schema->string()->required(),
|
||||
'match_field' => $schema->string()->enum(['description', 'creditor_name', 'debtor_name'])->required(),
|
||||
'match_operator' => $schema->string()->enum(['contains', 'equals'])->required(),
|
||||
'match_token' => $schema->string()->required(),
|
||||
'category_id' => $schema->string(),
|
||||
'new_category_name' => $schema->string(),
|
||||
'new_category_direction' => $schema->string()->enum(['inflow', 'outflow']),
|
||||
'confidence' => $schema->number()->required(),
|
||||
])
|
||||
)->required(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\SuggestionRunStatus;
|
||||
use App\Models\RuleSuggestion;
|
||||
use App\Models\User;
|
||||
use App\Services\Ai\Contracts\RuleSuggestionGenerator;
|
||||
use App\Services\Ai\GenerateRuleSuggestions;
|
||||
use App\Services\Ai\RuleSuggestionAggregator;
|
||||
use App\Services\Ai\RuleSuggestionAvailability;
|
||||
use App\Services\Ai\RuleSuggestionGuard;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
class SuggestRulesCommand extends Command
|
||||
{
|
||||
protected $signature = 'ai:suggest-rules
|
||||
{user : User id or email}
|
||||
{--persist : Run through the real pipeline and store a SuggestionRun instead of a dry run}';
|
||||
|
||||
protected $description = 'Generate AI rule suggestions for a user and print what each stage produces';
|
||||
|
||||
public function handle(
|
||||
RuleSuggestionAggregator $aggregator,
|
||||
RuleSuggestionGenerator $generator,
|
||||
RuleSuggestionGuard $guard,
|
||||
RuleSuggestionAvailability $availability,
|
||||
): int {
|
||||
$user = $this->resolveUser((string) $this->argument('user'));
|
||||
|
||||
if ($user === null) {
|
||||
$this->error('User not found.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->line("User: <info>{$user->email}</info> ({$user->id})");
|
||||
$this->line(sprintf(
|
||||
'Transactions: %d · eligible: %s · throttled: %s',
|
||||
$availability->transactionCount($user),
|
||||
$availability->isEligible($user) ? 'yes' : 'no',
|
||||
$availability->isThrottled($user) ? 'yes' : 'no',
|
||||
));
|
||||
$this->newLine();
|
||||
|
||||
if ($this->option('persist')) {
|
||||
return $this->runPersisted($user);
|
||||
}
|
||||
|
||||
return $this->runDryRun($user, $aggregator, $generator, $guard);
|
||||
}
|
||||
|
||||
private function runDryRun(
|
||||
User $user,
|
||||
RuleSuggestionAggregator $aggregator,
|
||||
RuleSuggestionGenerator $generator,
|
||||
RuleSuggestionGuard $guard,
|
||||
): int {
|
||||
$groups = $aggregator->groupsFor($user);
|
||||
|
||||
if ($groups === []) {
|
||||
$this->warn('No transaction groups to suggest from (need uncategorized, server-readable transactions).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->components->info(count($groups).' transaction group(s) sent to the model');
|
||||
$this->table(
|
||||
['Key', 'Field', 'Count', 'Direction', 'Avg', 'Samples'],
|
||||
array_map(fn (array $group): array => [
|
||||
$group['key'],
|
||||
$group['field'],
|
||||
$group['count'],
|
||||
$group['direction'],
|
||||
$group['avg_amount'],
|
||||
Str::limit(implode(' | ', $group['samples']), 50),
|
||||
], $groups),
|
||||
);
|
||||
|
||||
$categories = $aggregator->categoryOptions($user);
|
||||
|
||||
try {
|
||||
$raw = $generator->generate($groups, $categories);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error('Model call failed: '.$exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->components->info(count($raw).' raw suggestion(s) returned by the model');
|
||||
$this->table(
|
||||
['Group', 'Field', 'Op', 'Token', 'Category', 'Confidence'],
|
||||
array_map(fn (array $suggestion): array => [
|
||||
$suggestion['group_key'] ?? '',
|
||||
$suggestion['match_field'] ?? '',
|
||||
$suggestion['match_operator'] ?? '',
|
||||
$suggestion['match_token'] ?? '',
|
||||
$this->describeRawCategory($suggestion, $categories),
|
||||
$suggestion['confidence'] ?? '',
|
||||
], $raw),
|
||||
);
|
||||
|
||||
$validated = $guard->validate($user, $raw, $categories);
|
||||
|
||||
$this->components->info(count($validated).' suggestion(s) survived the guards');
|
||||
$this->table(
|
||||
['Field', 'Op', 'Token', 'Category', 'Confidence', 'Matches'],
|
||||
array_map(fn (array $suggestion): array => [
|
||||
$suggestion['match_field'],
|
||||
$suggestion['match_operator'],
|
||||
$suggestion['match_token'],
|
||||
$this->describeValidatedCategory($suggestion, $categories),
|
||||
$suggestion['confidence'],
|
||||
$suggestion['group_size'],
|
||||
], $validated),
|
||||
);
|
||||
|
||||
$this->newLine();
|
||||
$this->line('<comment>Dry run — nothing was saved. Use --persist to store a SuggestionRun.</comment>');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function runPersisted(User $user): int
|
||||
{
|
||||
$run = $user->suggestionRuns()->create(['status' => SuggestionRunStatus::Pending]);
|
||||
|
||||
app(GenerateRuleSuggestions::class)->run($run);
|
||||
|
||||
$run->refresh()->load('suggestions.proposedCategory');
|
||||
|
||||
$this->components->info("Run {$run->id} finished with status: {$run->status->value}");
|
||||
|
||||
if ($run->error) {
|
||||
$this->error($run->error);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($run->suggestions->isEmpty()) {
|
||||
$this->warn('No suggestions were produced.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['Field', 'Op', 'Token', 'Category', 'Confidence', 'Matches'],
|
||||
$run->suggestions->map(fn (RuleSuggestion $suggestion): array => [
|
||||
$suggestion->match_field,
|
||||
$suggestion->match_operator,
|
||||
$suggestion->match_token,
|
||||
$suggestion->proposed_category_id !== null
|
||||
? $suggestion->proposedCategory->name
|
||||
: ($suggestion->new_category_name !== null
|
||||
? "NEW: {$suggestion->new_category_name}"
|
||||
: '—'),
|
||||
$suggestion->confidence,
|
||||
$suggestion->group_size,
|
||||
])->all(),
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function resolveUser(string $identifier): ?User
|
||||
{
|
||||
return User::query()->where('email', $identifier)->first()
|
||||
?? User::query()->find($identifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $suggestion
|
||||
* @param list<array<string, mixed>> $categories
|
||||
*/
|
||||
private function describeRawCategory(array $suggestion, array $categories): string
|
||||
{
|
||||
$id = $suggestion['category_id'] ?? null;
|
||||
|
||||
if (filled($id)) {
|
||||
foreach ($categories as $category) {
|
||||
if ($category['id'] === $id) {
|
||||
return (string) $category['path'];
|
||||
}
|
||||
}
|
||||
|
||||
return (string) $id;
|
||||
}
|
||||
|
||||
$name = $suggestion['new_category_name'] ?? null;
|
||||
|
||||
return filled($name) ? "NEW: {$name}" : '—';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $suggestion
|
||||
* @param list<array<string, mixed>> $categories
|
||||
*/
|
||||
private function describeValidatedCategory(array $suggestion, array $categories): string
|
||||
{
|
||||
if (filled($suggestion['proposed_category_id'])) {
|
||||
foreach ($categories as $category) {
|
||||
if ($category['id'] === $suggestion['proposed_category_id']) {
|
||||
return (string) $category['path'];
|
||||
}
|
||||
}
|
||||
|
||||
return (string) $suggestion['proposed_category_id'];
|
||||
}
|
||||
|
||||
return filled($suggestion['new_category_name'])
|
||||
? "NEW: {$suggestion['new_category_name']} ({$suggestion['new_category_direction']})"
|
||||
: '—';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum PlanFeature: string
|
||||
{
|
||||
case ConnectedAccounts = 'connected_accounts';
|
||||
case AiSuggestions = 'ai_suggestions';
|
||||
|
||||
/**
|
||||
* Whether access to this feature is gated behind a paid (Pro) plan.
|
||||
*/
|
||||
public function requiresProPlan(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::ConnectedAccounts, self::AiSuggestions => true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum RuleSuggestionStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Accepted = 'accepted';
|
||||
case Dismissed = 'dismissed';
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum SuggestionRunStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Processing = 'processing';
|
||||
case Completed = 'completed';
|
||||
case Empty = 'empty';
|
||||
case Failed = 'failed';
|
||||
|
||||
/**
|
||||
* Whether this run produced usable suggestions and should count against
|
||||
* the monthly throttle.
|
||||
*/
|
||||
public function countsTowardThrottle(): bool
|
||||
{
|
||||
return $this === self::Completed;
|
||||
}
|
||||
|
||||
public function isFinished(): bool
|
||||
{
|
||||
return in_array($this, [self::Completed, self::Empty, self::Failed], true);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Ai;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AiConsentController extends Controller
|
||||
{
|
||||
/**
|
||||
* Record the user's broad "use AI to help understand my finances" consent.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$request->user()->recordAiConsent();
|
||||
|
||||
return response()->json(['consented' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke the user's AI consent.
|
||||
*/
|
||||
public function destroy(Request $request): JsonResponse
|
||||
{
|
||||
$request->user()->revokeAiConsent();
|
||||
|
||||
return response()->json(['consented' => false]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,267 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -29,6 +29,7 @@ class OnboardingController extends Controller
|
|||
'customize-categories',
|
||||
'smart-rules',
|
||||
'syncing',
|
||||
'ai-suggestions',
|
||||
'categorize-transactions',
|
||||
'complete',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Http\Controllers\OpenBanking\Concerns;
|
||||
|
||||
use App\Enums\PlanFeature;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
|
@ -18,7 +19,7 @@ trait HandlesSubscriptionGate
|
|||
return false;
|
||||
}
|
||||
|
||||
return ! $user->hasProPlan();
|
||||
return ! $user->canUseFeature(PlanFeature::ConnectedAccounts);
|
||||
}
|
||||
|
||||
private function subscribeJsonResponse(): JsonResponse
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Ai;
|
||||
|
||||
use App\Http\Requests\Concerns\ValidatesUserOwnedResources;
|
||||
use App\Services\Ai\UncategorizedTransactionMatcher;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class AcceptRuleSuggestionsRequest extends FormRequest
|
||||
{
|
||||
use ValidatesUserOwnedResources;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'suggestions' => ['required', 'array', 'min:1'],
|
||||
'suggestions.*.ids' => ['required', 'array', 'min:1'],
|
||||
'suggestions.*.ids.*' => ['uuid'],
|
||||
'suggestions.*.values' => ['required', 'array', 'min:1'],
|
||||
'suggestions.*.values.*.match_field' => ['required', 'string', Rule::in(UncategorizedTransactionMatcher::ALLOWED_FIELDS)],
|
||||
'suggestions.*.values.*.match_operator' => ['required', 'string', Rule::in(['contains', 'equals'])],
|
||||
'suggestions.*.values.*.match_token' => ['required', 'string', 'min:1', 'max:255'],
|
||||
'suggestions.*.proposed_category_id' => ['nullable', 'string', $this->userOwned('categories')],
|
||||
'suggestions.*.new_category_name' => ['nullable', 'string', 'max:255'],
|
||||
'suggestions.*.new_category_direction' => ['nullable', 'string', Rule::in(['inflow', 'outflow'])],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Each accepted group must resolve to a category target.
|
||||
*/
|
||||
public function withValidator($validator): void
|
||||
{
|
||||
$validator->after(function ($validator): void {
|
||||
foreach ((array) $this->input('suggestions', []) as $index => $group) {
|
||||
$hasExisting = filled($group['proposed_category_id'] ?? null);
|
||||
$hasNew = filled($group['new_category_name'] ?? null);
|
||||
|
||||
if (! $hasExisting && ! $hasNew) {
|
||||
$validator->errors()->add(
|
||||
"suggestions.{$index}.proposed_category_id",
|
||||
'Each suggestion needs an existing category or a new category name.',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Ai;
|
||||
|
||||
use App\Services\Ai\UncategorizedTransactionMatcher;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class PreviewRuleSuggestionRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'conditions' => ['required', 'array', 'min:1'],
|
||||
'conditions.*.match_field' => ['required', 'string', Rule::in(UncategorizedTransactionMatcher::ALLOWED_FIELDS)],
|
||||
'conditions.*.match_operator' => ['required', 'string', Rule::in(['contains', 'equals'])],
|
||||
'conditions.*.match_token' => ['required', 'string', 'min:1', 'max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\SuggestionRun;
|
||||
use App\Services\Ai\GenerateRuleSuggestions;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
class GenerateRuleSuggestionsJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* The generation can call a slow external model, so give it room.
|
||||
*/
|
||||
public int $timeout = 120;
|
||||
|
||||
public function __construct(public SuggestionRun $run) {}
|
||||
|
||||
public function handle(GenerateRuleSuggestions $generator): void
|
||||
{
|
||||
$this->run->loadMissing('user');
|
||||
|
||||
$generator->run($this->run);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\AiConsentFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AiConsent extends Model
|
||||
{
|
||||
/** @use HasFactory<AiConsentFactory> */
|
||||
use HasFactory, HasUuids;
|
||||
|
||||
/**
|
||||
* The broad "use AI to help understand your finances" consent scope.
|
||||
*/
|
||||
public const SCOPE_FINANCE = 'finance';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'scope',
|
||||
'version',
|
||||
'accepted_at',
|
||||
'revoked_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'accepted_at' => 'datetime',
|
||||
'revoked_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to consents that are currently active for the given scope and the
|
||||
* current consent version (un-revoked and matching the live copy version).
|
||||
*
|
||||
* @param Builder<AiConsent> $query
|
||||
* @return Builder<AiConsent>
|
||||
*/
|
||||
public function scopeActive(Builder $query, string $scope = self::SCOPE_FINANCE): Builder
|
||||
{
|
||||
return $query
|
||||
->where('scope', $scope)
|
||||
->where('version', (string) config('ai_suggestions.consent_version'))
|
||||
->whereNull('revoked_at');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\RuleSuggestionStatus;
|
||||
use Database\Factories\RuleSuggestionFactory;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class RuleSuggestion extends Model
|
||||
{
|
||||
/** @use HasFactory<RuleSuggestionFactory> */
|
||||
use HasFactory, HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
'suggestion_run_id',
|
||||
'group_key',
|
||||
'match_field',
|
||||
'match_operator',
|
||||
'match_token',
|
||||
'proposed_category_id',
|
||||
'new_category_name',
|
||||
'new_category_parent_id',
|
||||
'new_category_direction',
|
||||
'confidence',
|
||||
'group_size',
|
||||
'sample_descriptions',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'confidence' => 'float',
|
||||
'group_size' => 'integer',
|
||||
'sample_descriptions' => 'array',
|
||||
'status' => RuleSuggestionStatus::class,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<SuggestionRun, $this> */
|
||||
public function run(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SuggestionRun::class, 'suggestion_run_id');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Category, $this> */
|
||||
public function proposedCategory(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class, 'proposed_category_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this suggestion proposes creating a brand-new category.
|
||||
*/
|
||||
public function proposesNewCategory(): bool
|
||||
{
|
||||
return $this->proposed_category_id === null && $this->new_category_name !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable key identifying the category this suggestion targets, so suggestions
|
||||
* heading to the same category can be grouped into a single OR rule.
|
||||
*/
|
||||
public function categoryGroupKey(): string
|
||||
{
|
||||
if ($this->proposed_category_id !== null) {
|
||||
return 'cat:'.$this->proposed_category_id;
|
||||
}
|
||||
|
||||
return 'new:'.((string) $this->new_category_direction).':'.mb_strtolower(trim((string) $this->new_category_name));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\SuggestionRunStatus;
|
||||
use Database\Factories\SuggestionRunFactory;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property SuggestionRunStatus $status
|
||||
*/
|
||||
class SuggestionRun extends Model
|
||||
{
|
||||
/** @use HasFactory<SuggestionRunFactory> */
|
||||
use HasFactory, HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'status',
|
||||
'transactions_considered',
|
||||
'suggestions_count',
|
||||
'error',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => SuggestionRunStatus::class,
|
||||
'transactions_considered' => 'integer',
|
||||
'suggestions_count' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<RuleSuggestion, $this> */
|
||||
public function suggestions(): HasMany
|
||||
{
|
||||
return $this->hasMany(RuleSuggestion::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Models;
|
||||
|
||||
use App\Enums\DripEmailType;
|
||||
use App\Enums\PlanFeature;
|
||||
use App\Notifications\VerifyEmailNotification;
|
||||
use Carbon\Carbon;
|
||||
use Database\Factories\UserFactory;
|
||||
|
|
@ -135,6 +136,49 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma
|
|||
return $this->hasMany(AutomationRule::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<AiConsent, $this> */
|
||||
public function aiConsents(): HasMany
|
||||
{
|
||||
return $this->hasMany(AiConsent::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<SuggestionRun, $this> */
|
||||
public function suggestionRuns(): HasMany
|
||||
{
|
||||
return $this->hasMany(SuggestionRun::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user has an active, current-version AI consent.
|
||||
*/
|
||||
public function hasActiveAiConsent(string $scope = AiConsent::SCOPE_FINANCE): bool
|
||||
{
|
||||
return $this->aiConsents()->active($scope)->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an AI consent for the current consent version (idempotent).
|
||||
*/
|
||||
public function recordAiConsent(string $scope = AiConsent::SCOPE_FINANCE): AiConsent
|
||||
{
|
||||
return $this->aiConsents()->firstOrCreate(
|
||||
[
|
||||
'scope' => $scope,
|
||||
'version' => (string) config('ai_suggestions.consent_version'),
|
||||
'revoked_at' => null,
|
||||
],
|
||||
['accepted_at' => now()],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke any active AI consents for the given scope.
|
||||
*/
|
||||
public function revokeAiConsent(string $scope = AiConsent::SCOPE_FINANCE): void
|
||||
{
|
||||
$this->aiConsents()->active($scope)->update(['revoked_at' => now()]);
|
||||
}
|
||||
|
||||
/** @return HasMany<Label, $this> */
|
||||
public function labels(): HasMany
|
||||
{
|
||||
|
|
@ -173,6 +217,18 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma
|
|||
return $this->subscribed('default');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user can access the given feature on their current plan.
|
||||
*/
|
||||
public function canUseFeature(PlanFeature $feature): bool
|
||||
{
|
||||
if (! $feature->requiresProPlan()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->hasProPlan();
|
||||
}
|
||||
|
||||
public function hasPastDueSubscription(): bool
|
||||
{
|
||||
if (! config('subscriptions.enabled')) {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ use App\Listeners\AssignTransactionToBudget;
|
|||
use App\Listeners\PostStripeEventToDiscord;
|
||||
use App\Listeners\UnassignTransactionFromBudget;
|
||||
use App\Listeners\UpdateLastLoggedInAt;
|
||||
use App\Services\Ai\Contracts\RuleSuggestionGenerator;
|
||||
use App\Services\Ai\Contracts\TransactionMatcher;
|
||||
use App\Services\Ai\LaravelAiRuleSuggestionGenerator;
|
||||
use App\Services\Ai\UncategorizedTransactionMatcher;
|
||||
use App\Services\Banking\EnableBankingProvider;
|
||||
use App\Services\Discord\DiscordWebhook;
|
||||
use Illuminate\Auth\Events\Login;
|
||||
|
|
@ -44,6 +48,9 @@ class AppServiceProvider extends ServiceProvider
|
|||
$this->app->bind(DiscordWebhook::class, function () {
|
||||
return new DiscordWebhook(config('services.discord.webhook_url'));
|
||||
});
|
||||
|
||||
$this->app->bind(TransactionMatcher::class, UncategorizedTransactionMatcher::class);
|
||||
$this->app->bind(RuleSuggestionGenerator::class, LaravelAiRuleSuggestionGenerator::class);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Ai;
|
||||
|
||||
use App\Enums\CategoryCashflowDirection;
|
||||
use App\Enums\CategoryType;
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\Category;
|
||||
use App\Models\User;
|
||||
use App\Services\Ai\Contracts\TransactionMatcher;
|
||||
use App\Services\AutomationRuleService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Turns accepted suggestion groups into real automation rules and (during
|
||||
* onboarding) applies them immediately to the user's uncategorized transactions.
|
||||
*
|
||||
* Each group becomes ONE rule whose conditions are OR'd together, so several
|
||||
* merchants heading to the same category live in a single, reviewable rule.
|
||||
* Rules are created narrowest-first (fewest matches, then highest confidence):
|
||||
* because evaluation stops at the first matching rule by ascending priority, a
|
||||
* specific rule is reached before a broader one and can't be overridden by it.
|
||||
*/
|
||||
class ApplyRuleSuggestions
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AutomationRuleService $automationRules,
|
||||
private readonly TransactionMatcher $matcher,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param list<array{
|
||||
* conditions: list<array{field: string, operator: string, token: string}>,
|
||||
* proposed_category_id: ?string,
|
||||
* new_category_name: ?string,
|
||||
* new_category_direction: ?string,
|
||||
* confidence: float,
|
||||
* }> $groups
|
||||
* @return array{rules_created: int, transactions_categorized: int}
|
||||
*/
|
||||
public function apply(User $user, array $groups, bool $applyToExisting): array
|
||||
{
|
||||
$groups = array_values(array_filter($groups, fn (array $group): bool => $group['conditions'] !== []));
|
||||
|
||||
if ($groups === []) {
|
||||
return ['rules_created' => 0, 'transactions_categorized' => 0];
|
||||
}
|
||||
|
||||
// Narrower rules (fewer matches) first, then higher confidence, so the
|
||||
// more specific rule gets the lower priority and wins the overlap.
|
||||
$groups = array_map(function (array $group) use ($user): array {
|
||||
$group['match_count'] = $this->matcher->countMatchingAny($user, $group['conditions']);
|
||||
|
||||
return $group;
|
||||
}, $groups);
|
||||
|
||||
usort($groups, fn (array $a, array $b): int => [$a['match_count'], -$a['confidence']] <=> [$b['match_count'], -$b['confidence']]);
|
||||
|
||||
$priority = (int) AutomationRule::query()->where('user_id', $user->id)->max('priority');
|
||||
$rulesCreated = 0;
|
||||
$categorized = 0;
|
||||
|
||||
foreach ($groups as $group) {
|
||||
$rule = DB::transaction(function () use ($user, $group, &$priority): AutomationRule {
|
||||
$categoryId = $this->resolveCategoryId($user, $group);
|
||||
|
||||
return AutomationRule::create([
|
||||
'user_id' => $user->id,
|
||||
'title' => $this->title($group, $categoryId),
|
||||
'priority' => ++$priority,
|
||||
'rules_json' => $this->rulesJson($group['conditions']),
|
||||
'action_category_id' => $categoryId,
|
||||
]);
|
||||
});
|
||||
|
||||
$rulesCreated++;
|
||||
|
||||
if ($applyToExisting) {
|
||||
$matches = $this->matcher->matchingAny($user, $group['conditions']);
|
||||
|
||||
if ($matches->isNotEmpty()) {
|
||||
$categorized += $this->automationRules->applyRuleActionsToTransactions($matches, $rule);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ['rules_created' => $rulesCreated, 'transactions_categorized' => $categorized];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the rule's target category, creating a proposed new category when
|
||||
* the group calls for one.
|
||||
*
|
||||
* @param array<string, mixed> $group
|
||||
*/
|
||||
private function resolveCategoryId(User $user, array $group): string
|
||||
{
|
||||
if (! empty($group['proposed_category_id'])) {
|
||||
return (string) $group['proposed_category_id'];
|
||||
}
|
||||
|
||||
$direction = ($group['new_category_direction'] ?? null) === CategoryCashflowDirection::Inflow->value
|
||||
? CategoryCashflowDirection::Inflow
|
||||
: CategoryCashflowDirection::Outflow;
|
||||
|
||||
$category = Category::query()->firstOrCreate(
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'parent_id' => null,
|
||||
'name' => $group['new_category_name'],
|
||||
],
|
||||
[
|
||||
'type' => $direction === CategoryCashflowDirection::Inflow
|
||||
? CategoryType::Income
|
||||
: CategoryType::Expense,
|
||||
'cashflow_direction' => $direction,
|
||||
],
|
||||
);
|
||||
|
||||
return $category->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the OR'd JSON-Logic rule for the group's conditions. A single
|
||||
* condition stays flat; multiple are wrapped in an `or` (matching the shape
|
||||
* the settings rule editor produces and parses back).
|
||||
*
|
||||
* @param list<array{field: string, operator: string, token: string}> $conditions
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function rulesJson(array $conditions): array
|
||||
{
|
||||
$clauses = array_map(function (array $condition): array {
|
||||
$variable = ['var' => $condition['field']];
|
||||
|
||||
return $condition['operator'] === 'equals'
|
||||
? ['==' => [$variable, $condition['token']]]
|
||||
: ['in' => [$condition['token'], $variable]];
|
||||
}, $conditions);
|
||||
|
||||
return count($clauses) === 1 ? $clauses[0] : ['or' => $clauses];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $group
|
||||
*/
|
||||
private function title(array $group, string $categoryId): string
|
||||
{
|
||||
$categoryName = Category::query()->whereKey($categoryId)->value('name') ?? '';
|
||||
|
||||
$tokens = array_map(fn (array $condition): string => Str::title($condition['token']), $group['conditions']);
|
||||
$label = implode(', ', array_slice($tokens, 0, 3));
|
||||
|
||||
if (count($tokens) > 3) {
|
||||
$label .= '…';
|
||||
}
|
||||
|
||||
return trim($label.' → '.$categoryName);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Ai\Contracts;
|
||||
|
||||
interface RuleSuggestionGenerator
|
||||
{
|
||||
/**
|
||||
* Ask the model to map pre-aggregated transaction groups to categorization
|
||||
* rules. Returns the raw, unvalidated suggestions (guards run separately).
|
||||
*
|
||||
* @param list<array<string, mixed>> $groups
|
||||
* @param list<array<string, mixed>> $categoryOptions
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function generate(array $groups, array $categoryOptions): array;
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Ai\Contracts;
|
||||
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
interface TransactionMatcher
|
||||
{
|
||||
/**
|
||||
* Total number of the user's uncategorized, server-readable transactions.
|
||||
*/
|
||||
public function total(User $user): int;
|
||||
|
||||
/**
|
||||
* Count uncategorized transactions a candidate token would match.
|
||||
*/
|
||||
public function countMatching(User $user, string $field, string $operator, string $token): int;
|
||||
|
||||
/**
|
||||
* The uncategorized transactions a candidate token matches.
|
||||
*
|
||||
* @return Collection<int, Transaction>
|
||||
*/
|
||||
public function matching(User $user, string $field, string $operator, string $token, ?int $limit = null): Collection;
|
||||
|
||||
/**
|
||||
* Count uncategorized transactions matching ANY of the given conditions (OR).
|
||||
*
|
||||
* @param list<array{field: string, operator: string, token: string}> $conditions
|
||||
*/
|
||||
public function countMatchingAny(User $user, array $conditions): int;
|
||||
|
||||
/**
|
||||
* The uncategorized transactions matching ANY of the given conditions (OR).
|
||||
*
|
||||
* @param list<array{field: string, operator: string, token: string}> $conditions
|
||||
* @return Collection<int, Transaction>
|
||||
*/
|
||||
public function matchingAny(User $user, array $conditions, ?int $limit = null): Collection;
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Ai;
|
||||
|
||||
use App\Enums\SuggestionRunStatus;
|
||||
use App\Models\SuggestionRun;
|
||||
use App\Services\Ai\Contracts\RuleSuggestionGenerator;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Runs the full suggestion pipeline for a pending run: aggregate → generate →
|
||||
* guard → persist. Sets the run's terminal status. Only a run that yields at
|
||||
* least one usable suggestion is marked Completed (and therefore counts against
|
||||
* the monthly throttle); empty results and failures stay re-runnable.
|
||||
*/
|
||||
class GenerateRuleSuggestions
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RuleSuggestionAggregator $aggregator,
|
||||
private readonly RuleSuggestionGenerator $generator,
|
||||
private readonly RuleSuggestionGuard $guard,
|
||||
private readonly RuleSuggestionConsolidator $consolidator,
|
||||
) {}
|
||||
|
||||
public function run(SuggestionRun $run): SuggestionRun
|
||||
{
|
||||
$run->forceFill(['status' => SuggestionRunStatus::Processing])->save();
|
||||
|
||||
try {
|
||||
$groups = $this->aggregator->groupsFor($run->user);
|
||||
|
||||
$run->transactions_considered = array_sum(array_column($groups, 'count'));
|
||||
|
||||
if ($groups === []) {
|
||||
return $this->finishEmpty($run);
|
||||
}
|
||||
|
||||
$categories = $this->aggregator->categoryOptions($run->user);
|
||||
$raw = $this->generator->generate($groups, $categories);
|
||||
$validated = $this->consolidator->consolidate(
|
||||
$this->guard->validate($run->user, $raw, $categories),
|
||||
);
|
||||
|
||||
if ($validated === []) {
|
||||
return $this->finishEmpty($run);
|
||||
}
|
||||
|
||||
foreach ($validated as $suggestion) {
|
||||
$run->suggestions()->create($suggestion);
|
||||
}
|
||||
|
||||
$run->forceFill([
|
||||
'status' => SuggestionRunStatus::Completed,
|
||||
'suggestions_count' => count($validated),
|
||||
])->save();
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
$run->forceFill([
|
||||
'status' => SuggestionRunStatus::Failed,
|
||||
'error' => Str::limit($exception->getMessage(), 500),
|
||||
])->save();
|
||||
}
|
||||
|
||||
return $run;
|
||||
}
|
||||
|
||||
private function finishEmpty(SuggestionRun $run): SuggestionRun
|
||||
{
|
||||
$run->forceFill([
|
||||
'status' => SuggestionRunStatus::Empty,
|
||||
'suggestions_count' => 0,
|
||||
])->save();
|
||||
|
||||
return $run;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Ai;
|
||||
|
||||
use App\Ai\Agents\RuleSuggestionAgent;
|
||||
use App\Services\Ai\Contracts\RuleSuggestionGenerator;
|
||||
use Laravel\Ai\Enums\Lab;
|
||||
use Throwable;
|
||||
|
||||
class LaravelAiRuleSuggestionGenerator implements RuleSuggestionGenerator
|
||||
{
|
||||
public function generate(array $groups, array $categoryOptions): array
|
||||
{
|
||||
if ($groups === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$batchSize = max(1, (int) config('ai_suggestions.group_batch_size'));
|
||||
$batches = array_chunk($groups, $batchSize);
|
||||
|
||||
$suggestions = [];
|
||||
$failures = 0;
|
||||
$lastError = null;
|
||||
|
||||
foreach ($batches as $batch) {
|
||||
try {
|
||||
foreach ($this->generateBatchWithRetry($batch, $categoryOptions) as $suggestion) {
|
||||
$suggestions[] = $suggestion;
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
// A single batch failing must not discard the suggestions from
|
||||
// the batches that did succeed (a run can span many batches).
|
||||
$failures++;
|
||||
$lastError = $exception;
|
||||
report($exception);
|
||||
}
|
||||
}
|
||||
|
||||
// Only surface an error when every batch failed — otherwise a transient
|
||||
// hiccup would masquerade as "no suggestions" and silently swallow it.
|
||||
if ($lastError !== null && $failures === count($batches)) {
|
||||
throw $lastError;
|
||||
}
|
||||
|
||||
return $suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one batch, retrying once on a transient failure before giving up.
|
||||
*
|
||||
* @param list<array<string, mixed>> $groups
|
||||
* @param list<array<string, mixed>> $categoryOptions
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function generateBatchWithRetry(array $groups, array $categoryOptions): array
|
||||
{
|
||||
try {
|
||||
return $this->generateBatch($groups, $categoryOptions);
|
||||
} catch (Throwable) {
|
||||
return $this->generateBatch($groups, $categoryOptions);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one bounded batch of groups to the model. Large single payloads make
|
||||
* the model silently skip groups, so callers chunk and merge the results.
|
||||
*
|
||||
* @param list<array<string, mixed>> $groups
|
||||
* @param list<array<string, mixed>> $categoryOptions
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function generateBatch(array $groups, array $categoryOptions): array
|
||||
{
|
||||
$payload = json_encode([
|
||||
'transaction_groups' => $groups,
|
||||
'categories' => $categoryOptions,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$response = (new RuleSuggestionAgent)->prompt(
|
||||
$payload,
|
||||
provider: Lab::Gemini,
|
||||
model: (string) config('ai_suggestions.model'),
|
||||
);
|
||||
|
||||
$suggestions = $response['suggestions'] ?? [];
|
||||
|
||||
return is_array($suggestions) ? array_values($suggestions) : [];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Ai;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Pre-aggregates a user's uncategorized transactions into a small, deduped set
|
||||
* of merchant/description groups before anything is sent to the model. This is
|
||||
* the cheap, deterministic step that keeps the LLM payload (and cost) bounded.
|
||||
*/
|
||||
class RuleSuggestionAggregator
|
||||
{
|
||||
/**
|
||||
* The fields, in priority order, used to derive a group's signal.
|
||||
*/
|
||||
private const SAMPLE_LIMIT = 5;
|
||||
|
||||
private const MIN_TOKEN_LENGTH = 3;
|
||||
|
||||
/**
|
||||
* Build the bounded set of transaction groups worth suggesting a rule for.
|
||||
*
|
||||
* @return list<array{key: string, field: string, count: int, avg_amount: float, direction: string, samples: list<string>}>
|
||||
*/
|
||||
public function groupsFor(User $user): array
|
||||
{
|
||||
$transactions = Transaction::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('category_id')
|
||||
->whereNull('description_iv')
|
||||
->get(['id', 'description', 'creditor_name', 'debtor_name', 'amount']);
|
||||
|
||||
$documentFrequency = $this->descriptionDocumentFrequency($transactions);
|
||||
$noiseThreshold = $transactions->count() * (float) config('ai_suggestions.noise_token_fraction');
|
||||
|
||||
$groups = [];
|
||||
|
||||
foreach ($transactions as $transaction) {
|
||||
[$field, $rawKey] = $this->groupingSignal($transaction);
|
||||
$key = $field === 'description'
|
||||
? $this->distinctiveKey($rawKey, $documentFrequency, $noiseThreshold)
|
||||
: $this->normalizeWhitespace($rawKey);
|
||||
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$bucket = $field.'|'.$key;
|
||||
|
||||
$groups[$bucket] ??= [
|
||||
'key' => $key,
|
||||
'field' => $field,
|
||||
'count' => 0,
|
||||
'amount_sum' => 0,
|
||||
'samples' => [],
|
||||
];
|
||||
|
||||
$groups[$bucket]['count']++;
|
||||
$groups[$bucket]['amount_sum'] += (int) $transaction->amount;
|
||||
|
||||
$sample = $this->normalizeWhitespace((string) ($transaction->description ?? $rawKey));
|
||||
if ($sample !== '' && count($groups[$bucket]['samples']) < self::SAMPLE_LIMIT) {
|
||||
$groups[$bucket]['samples'][$sample] = $sample;
|
||||
}
|
||||
}
|
||||
|
||||
$min = (int) config('ai_suggestions.min_group_count');
|
||||
$max = (int) config('ai_suggestions.max_groups_sent');
|
||||
|
||||
return collect($groups)
|
||||
->filter(fn (array $group): bool => $group['count'] >= $min)
|
||||
->sortByDesc('count')
|
||||
->take($max)
|
||||
->map(function (array $group): array {
|
||||
$avg = $group['amount_sum'] / $group['count'] / 100;
|
||||
|
||||
return [
|
||||
'key' => $group['key'],
|
||||
'field' => $group['field'],
|
||||
'count' => $group['count'],
|
||||
'avg_amount' => round($avg, 2),
|
||||
'direction' => $avg < 0 ? 'outflow' : 'inflow',
|
||||
'samples' => array_values($group['samples']),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* The closed list of the user's categories the model must map groups into.
|
||||
*
|
||||
* @return list<array{id: string, name: string, path: string, type: string, direction: string, is_leaf: bool}>
|
||||
*/
|
||||
public function categoryOptions(User $user): array
|
||||
{
|
||||
$categories = Category::query()
|
||||
->where('user_id', $user->id)
|
||||
->get();
|
||||
|
||||
$byId = $categories->keyBy('id');
|
||||
$parentIds = $categories->pluck('parent_id')->filter()->unique()->flip();
|
||||
|
||||
return $categories
|
||||
->map(fn (Category $category): array => [
|
||||
'id' => $category->id,
|
||||
'name' => $category->name,
|
||||
'path' => $this->categoryPath($category, $byId),
|
||||
'type' => $category->type->value,
|
||||
'direction' => $category->cashflow_direction->value,
|
||||
'is_leaf' => ! $parentIds->has($category->id),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string} [field, rawValue]
|
||||
*/
|
||||
private function groupingSignal(Transaction $transaction): array
|
||||
{
|
||||
if (filled($transaction->creditor_name)) {
|
||||
return ['creditor_name', (string) $transaction->creditor_name];
|
||||
}
|
||||
|
||||
if (filled($transaction->debtor_name)) {
|
||||
return ['debtor_name', (string) $transaction->debtor_name];
|
||||
}
|
||||
|
||||
return ['description', (string) $transaction->description];
|
||||
}
|
||||
|
||||
/**
|
||||
* Document frequency of each description token across the user's
|
||||
* uncategorized, description-grouped transactions. Used to identify
|
||||
* structural noise words without any language-specific list.
|
||||
*
|
||||
* @param Collection<int, Transaction> $transactions
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function descriptionDocumentFrequency(Collection $transactions): array
|
||||
{
|
||||
$frequency = [];
|
||||
|
||||
foreach ($transactions as $transaction) {
|
||||
[$field, $rawKey] = $this->groupingSignal($transaction);
|
||||
|
||||
if ($field !== 'description') {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (array_unique($this->descriptionTokens($rawKey)) as $token) {
|
||||
$frequency[$token] = ($frequency[$token] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return $frequency;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a free-text description into comparable tokens (lowercased, digits
|
||||
* and punctuation stripped, very short tokens dropped).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function descriptionTokens(string $value): array
|
||||
{
|
||||
$value = $this->normalizeWhitespace($value);
|
||||
$value = preg_replace('/[0-9]+/', ' ', $value) ?? $value;
|
||||
$value = preg_replace('/[^\p{L}\s]+/u', ' ', $value) ?? $value;
|
||||
$value = $this->normalizeWhitespace($value);
|
||||
|
||||
if ($value === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
explode(' ', $value),
|
||||
fn (string $token): bool => mb_strlen($token) >= self::MIN_TOKEN_LENGTH,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a description's grouping key from only its distinctive tokens: words
|
||||
* appearing in more than the noise threshold of transactions are dropped so
|
||||
* variants of the same merchant collapse together. Language-agnostic — if
|
||||
* every token is common, the full token set is kept as a fallback.
|
||||
*
|
||||
* @param array<string, int> $documentFrequency
|
||||
*/
|
||||
private function distinctiveKey(string $value, array $documentFrequency, float $noiseThreshold): string
|
||||
{
|
||||
$tokens = $this->descriptionTokens($value);
|
||||
|
||||
if ($tokens === []) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$distinctive = array_values(array_filter(
|
||||
$tokens,
|
||||
fn (string $token): bool => ($documentFrequency[$token] ?? 0) <= $noiseThreshold,
|
||||
));
|
||||
|
||||
if ($distinctive === []) {
|
||||
$distinctive = $tokens;
|
||||
}
|
||||
|
||||
$distinctive = array_values(array_unique($distinctive));
|
||||
sort($distinctive);
|
||||
|
||||
return implode(' ', $distinctive);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<string, Category> $byId
|
||||
*/
|
||||
private function categoryPath(Category $category, Collection $byId): string
|
||||
{
|
||||
$parts = [$category->name];
|
||||
$current = $category;
|
||||
$guard = 0;
|
||||
|
||||
while ($current->parent_id !== null
|
||||
&& $byId->has($current->parent_id)
|
||||
&& $guard++ < Category::MAX_DEPTH) {
|
||||
$current = $byId->get($current->parent_id);
|
||||
array_unshift($parts, $current->name);
|
||||
}
|
||||
|
||||
return implode(' > ', $parts);
|
||||
}
|
||||
|
||||
private function normalizeWhitespace(string $value): string
|
||||
{
|
||||
return trim(preg_replace('/\s+/', ' ', mb_strtolower($value)) ?? '');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Ai;
|
||||
|
||||
use App\Enums\SuggestionRunStatus;
|
||||
use App\Models\SuggestionRun;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
/**
|
||||
* Answers "can this user generate suggestions right now?" — eligibility (enough
|
||||
* data) and the once-a-month throttle (only successful runs count).
|
||||
*/
|
||||
class RuleSuggestionAvailability
|
||||
{
|
||||
public function transactionCount(User $user): int
|
||||
{
|
||||
return $user->transactions()->count();
|
||||
}
|
||||
|
||||
public function minTransactions(): int
|
||||
{
|
||||
return (int) config('ai_suggestions.eligibility_min_transactions');
|
||||
}
|
||||
|
||||
public function isEligible(User $user): bool
|
||||
{
|
||||
return $this->transactionCount($user) >= $this->minTransactions();
|
||||
}
|
||||
|
||||
/**
|
||||
* The most recent run that produced usable suggestions.
|
||||
*/
|
||||
public function latestSuccessfulRun(User $user): ?SuggestionRun
|
||||
{
|
||||
return $user->suggestionRuns()
|
||||
->where('status', SuggestionRunStatus::Completed)
|
||||
->latest()
|
||||
->first();
|
||||
}
|
||||
|
||||
public function latestRun(User $user): ?SuggestionRun
|
||||
{
|
||||
return $user->suggestionRuns()->latest()->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* The moment the throttle lifts, or null if the user may generate now.
|
||||
*/
|
||||
public function throttledUntil(User $user): ?CarbonImmutable
|
||||
{
|
||||
$run = $this->latestSuccessfulRun($user);
|
||||
|
||||
if ($run === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$until = CarbonImmutable::parse($run->created_at)
|
||||
->addDays((int) config('ai_suggestions.throttle_days'));
|
||||
|
||||
return $until->isFuture() ? $until : null;
|
||||
}
|
||||
|
||||
public function isThrottled(User $user): bool
|
||||
{
|
||||
return $this->throttledUntil($user) !== null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Ai;
|
||||
|
||||
/**
|
||||
* Removes redundant suggestions before they are ever persisted or shown. Two
|
||||
* `contains` tokens that point at the SAME category and where one is a substring
|
||||
* of the other are wasteful: the broader token already matches everything the
|
||||
* narrower one would (e.g. "endesa" already covers "endesa energia" → both
|
||||
* Electricity), so the narrower token is dropped.
|
||||
*
|
||||
* Tokens that overlap but target DIFFERENT categories are left intact — those
|
||||
* are resolved later by rule priority (the more specific rule wins), not by
|
||||
* pruning.
|
||||
*/
|
||||
class RuleSuggestionConsolidator
|
||||
{
|
||||
/**
|
||||
* @param list<array<string, mixed>> $validated
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function consolidate(array $validated): array
|
||||
{
|
||||
/** @var array<string, list<int>> $byCategory */
|
||||
$byCategory = [];
|
||||
|
||||
foreach ($validated as $index => $suggestion) {
|
||||
$byCategory[$this->categoryKey($suggestion)][] = $index;
|
||||
}
|
||||
|
||||
$drop = [];
|
||||
|
||||
foreach ($byCategory as $indices) {
|
||||
foreach ($indices as $broadIndex) {
|
||||
if (isset($drop[$broadIndex])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($indices as $narrowIndex) {
|
||||
if ($broadIndex === $narrowIndex || isset($drop[$narrowIndex])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->subsumes($validated[$broadIndex], $validated[$narrowIndex])) {
|
||||
$drop[$narrowIndex] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
$validated,
|
||||
fn (mixed $suggestion, int $index): bool => ! isset($drop[$index]),
|
||||
ARRAY_FILTER_USE_BOTH,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the broad suggestion's token already covers the narrow one: same
|
||||
* field, both `contains`, and the broad token is a (shorter) substring of
|
||||
* the narrow token.
|
||||
*
|
||||
* @param array<string, mixed> $broad
|
||||
* @param array<string, mixed> $narrow
|
||||
*/
|
||||
private function subsumes(array $broad, array $narrow): bool
|
||||
{
|
||||
if ($broad['match_field'] !== $narrow['match_field']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($broad['match_operator'] !== 'contains' || $narrow['match_operator'] !== 'contains') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$broadToken = (string) $broad['match_token'];
|
||||
$narrowToken = (string) $narrow['match_token'];
|
||||
|
||||
return $broadToken !== $narrowToken && str_contains($narrowToken, $broadToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $suggestion
|
||||
*/
|
||||
private function categoryKey(array $suggestion): string
|
||||
{
|
||||
if (! empty($suggestion['proposed_category_id'])) {
|
||||
return 'cat:'.$suggestion['proposed_category_id'];
|
||||
}
|
||||
|
||||
$name = mb_strtolower(trim((string) ($suggestion['new_category_name'] ?? '')));
|
||||
$direction = (string) ($suggestion['new_category_direction'] ?? '');
|
||||
|
||||
return 'new:'.$direction.':'.$name;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Ai;
|
||||
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use App\Services\Ai\Contracts\TransactionMatcher;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* The model-independent safety layer. Every raw model suggestion is checked
|
||||
* against the user's real transactions before it can ever reach the UI: the
|
||||
* token must literally match, it must not be so broad it would mis-categorize
|
||||
* en masse, the confidence must clear the floor, and the category must exist
|
||||
* and agree with the group's cash-flow direction.
|
||||
*/
|
||||
class RuleSuggestionGuard
|
||||
{
|
||||
private const MIN_TOKEN_LENGTH = 3;
|
||||
|
||||
private const SAMPLE_LIMIT = 3;
|
||||
|
||||
public function __construct(private readonly TransactionMatcher $matcher) {}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rawSuggestions
|
||||
* @param list<array<string, mixed>> $categoryOptions
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function validate(User $user, array $rawSuggestions, array $categoryOptions): array
|
||||
{
|
||||
$total = $this->matcher->total($user);
|
||||
|
||||
if ($total === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$floor = (float) config('ai_suggestions.confidence_floor');
|
||||
$overbroad = (float) config('ai_suggestions.overbroad_fraction');
|
||||
$minMatches = max(1, (int) config('ai_suggestions.min_group_count'));
|
||||
|
||||
/** @var Collection<string, array<string, mixed>> $categoriesById */
|
||||
$categoriesById = collect($categoryOptions)->keyBy('id');
|
||||
|
||||
$validated = [];
|
||||
|
||||
foreach ($rawSuggestions as $raw) {
|
||||
$candidate = $this->validateOne($user, $raw, $categoriesById, $total, $floor, $overbroad, $minMatches);
|
||||
|
||||
if ($candidate === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $candidate['match_field'].'|'.$candidate['match_operator'].'|'.$candidate['match_token'];
|
||||
|
||||
// Keep only the highest-confidence suggestion per identical matcher.
|
||||
if (! isset($validated[$key]) || $validated[$key]['confidence'] < $candidate['confidence']) {
|
||||
$validated[$key] = $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($validated);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $raw
|
||||
* @param Collection<string, array<string, mixed>> $categoriesById
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function validateOne(User $user, array $raw, Collection $categoriesById, int $total, float $floor, float $overbroad, int $minMatches): ?array
|
||||
{
|
||||
$field = is_string($raw['match_field'] ?? null) ? $raw['match_field'] : '';
|
||||
$operator = ($raw['match_operator'] ?? '') === 'equals' ? 'equals' : 'contains';
|
||||
$token = mb_strtolower(trim((string) ($raw['match_token'] ?? '')));
|
||||
$confidence = (float) ($raw['confidence'] ?? 0);
|
||||
|
||||
if (! in_array($field, UncategorizedTransactionMatcher::ALLOWED_FIELDS, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (mb_strlen($token) < self::MIN_TOKEN_LENGTH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($confidence < $floor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$matchCount = $this->matcher->countMatching($user, $field, $operator, $token);
|
||||
|
||||
// Token must match enough transactions to be worth a rule (single-match
|
||||
// one-offs are dropped), and must not be so broad it mis-categorises en masse.
|
||||
if ($matchCount < $minMatches || ($matchCount / $total) > $overbroad) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$matches = $this->matcher->matching($user, $field, $operator, $token, 25);
|
||||
$direction = $this->directionFor($matches);
|
||||
|
||||
$category = $this->resolveCategory($raw, $categoriesById, $direction);
|
||||
|
||||
if ($category === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'group_key' => is_string($raw['group_key'] ?? null) && $raw['group_key'] !== '' ? $raw['group_key'] : $token,
|
||||
'match_field' => $field,
|
||||
'match_operator' => $operator,
|
||||
'match_token' => $token,
|
||||
'proposed_category_id' => $category['proposed_category_id'],
|
||||
'new_category_name' => $category['new_category_name'],
|
||||
'new_category_parent_id' => null,
|
||||
'new_category_direction' => $category['new_category_direction'],
|
||||
'confidence' => round($confidence, 3),
|
||||
'group_size' => $matchCount,
|
||||
'sample_descriptions' => $this->samplesFor($matches, $field),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the suggestion's category target: an existing category (sign-checked)
|
||||
* or a brand-new-category proposal.
|
||||
*
|
||||
* @param array<string, mixed> $raw
|
||||
* @param Collection<string, array<string, mixed>> $categoriesById
|
||||
* @return array{proposed_category_id: ?string, new_category_name: ?string, new_category_direction: ?string}|null
|
||||
*/
|
||||
private function resolveCategory(array $raw, Collection $categoriesById, string $direction): ?array
|
||||
{
|
||||
$categoryId = is_string($raw['category_id'] ?? null) ? $raw['category_id'] : '';
|
||||
|
||||
if ($categoryId !== '' && $categoriesById->has($categoryId)) {
|
||||
$category = $categoriesById->get($categoryId);
|
||||
|
||||
if ($this->conflictsWithDirection($direction, (string) ($category['type'] ?? ''))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'proposed_category_id' => $categoryId,
|
||||
'new_category_name' => null,
|
||||
'new_category_direction' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$newName = trim((string) ($raw['new_category_name'] ?? ''));
|
||||
|
||||
if ($newName === '' || mb_strlen($newName) > 255) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$newDirection = ($raw['new_category_direction'] ?? '') === 'inflow' ? 'inflow' : 'outflow';
|
||||
|
||||
// If the model omitted a direction, fall back to the observed cash-flow.
|
||||
if (! in_array($raw['new_category_direction'] ?? null, ['inflow', 'outflow'], true)) {
|
||||
$newDirection = $direction;
|
||||
}
|
||||
|
||||
return [
|
||||
'proposed_category_id' => null,
|
||||
'new_category_name' => $newName,
|
||||
'new_category_direction' => $newDirection,
|
||||
];
|
||||
}
|
||||
|
||||
private function conflictsWithDirection(string $direction, string $categoryType): bool
|
||||
{
|
||||
return ($direction === 'outflow' && $categoryType === 'income')
|
||||
|| ($direction === 'inflow' && $categoryType === 'expense');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Transaction> $matches
|
||||
*/
|
||||
private function directionFor(Collection $matches): string
|
||||
{
|
||||
return $matches->sum(fn (Transaction $transaction): int => (int) $transaction->amount) < 0
|
||||
? 'outflow'
|
||||
: 'inflow';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Transaction> $matches
|
||||
* @return list<string>
|
||||
*/
|
||||
private function samplesFor(Collection $matches, string $field): array
|
||||
{
|
||||
return $matches
|
||||
->map(fn (Transaction $transaction): string => trim((string) ($transaction->description ?: $transaction->{$field})))
|
||||
->filter()
|
||||
->unique()
|
||||
->take(self::SAMPLE_LIMIT)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Ai;
|
||||
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use App\Services\Ai\Contracts\TransactionMatcher;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class UncategorizedTransactionMatcher implements TransactionMatcher
|
||||
{
|
||||
/**
|
||||
* Fields a suggested rule is allowed to match against (server-readable,
|
||||
* never the encrypted description/notes blobs).
|
||||
*/
|
||||
public const ALLOWED_FIELDS = ['description', 'creditor_name', 'debtor_name'];
|
||||
|
||||
public function total(User $user): int
|
||||
{
|
||||
return $this->baseQuery($user)->count();
|
||||
}
|
||||
|
||||
public function countMatching(User $user, string $field, string $operator, string $token): int
|
||||
{
|
||||
$query = $this->matchQuery($user, $field, $operator, $token);
|
||||
|
||||
return $query === null ? 0 : $query->count();
|
||||
}
|
||||
|
||||
public function matching(User $user, string $field, string $operator, string $token, ?int $limit = null): Collection
|
||||
{
|
||||
$query = $this->matchQuery($user, $field, $operator, $token);
|
||||
|
||||
if ($query === null) {
|
||||
return new Collection;
|
||||
}
|
||||
|
||||
$query->orderByDesc('transaction_date')->orderByDesc('id');
|
||||
|
||||
if ($limit !== null) {
|
||||
$query->limit($limit);
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
public function countMatchingAny(User $user, array $conditions): int
|
||||
{
|
||||
$query = $this->anyQuery($user, $conditions);
|
||||
|
||||
return $query === null ? 0 : $query->count();
|
||||
}
|
||||
|
||||
public function matchingAny(User $user, array $conditions, ?int $limit = null): Collection
|
||||
{
|
||||
$query = $this->anyQuery($user, $conditions);
|
||||
|
||||
if ($query === null) {
|
||||
return new Collection;
|
||||
}
|
||||
|
||||
$query->orderByDesc('transaction_date')->orderByDesc('id');
|
||||
|
||||
if ($limit !== null) {
|
||||
$query->limit($limit);
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<Transaction>
|
||||
*/
|
||||
private function baseQuery(User $user): Builder
|
||||
{
|
||||
return Transaction::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('category_id')
|
||||
->whereNull('description_iv');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<Transaction>|null
|
||||
*/
|
||||
private function matchQuery(User $user, string $field, string $operator, string $token): ?Builder
|
||||
{
|
||||
if (! in_array($field, self::ALLOWED_FIELDS, true) || trim($token) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$query = $this->baseQuery($user);
|
||||
$token = mb_strtolower(trim($token));
|
||||
|
||||
if ($operator === 'equals') {
|
||||
return $query->whereRaw("LOWER({$field}) = ?", [$token]);
|
||||
}
|
||||
|
||||
return $query->whereRaw("LOWER({$field}) LIKE ?", ['%'.$this->escapeLike($token).'%']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single query matching ANY of the conditions (OR). Invalid
|
||||
* conditions (unknown field or blank token) are skipped; returns null when
|
||||
* none remain.
|
||||
*
|
||||
* @param list<array{field: string, operator: string, token: string}> $conditions
|
||||
* @return Builder<Transaction>|null
|
||||
*/
|
||||
private function anyQuery(User $user, array $conditions): ?Builder
|
||||
{
|
||||
$valid = array_values(array_filter(
|
||||
$conditions,
|
||||
fn (array $condition): bool => in_array($condition['field'], self::ALLOWED_FIELDS, true)
|
||||
&& trim($condition['token']) !== '',
|
||||
));
|
||||
|
||||
if ($valid === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->baseQuery($user)->where(function (Builder $builder) use ($valid): void {
|
||||
foreach ($valid as $condition) {
|
||||
$field = $condition['field'];
|
||||
$token = mb_strtolower(trim($condition['token']));
|
||||
|
||||
if ($condition['operator'] === 'equals') {
|
||||
$builder->orWhereRaw("LOWER({$field}) = ?", [$token]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$builder->orWhereRaw("LOWER({$field}) LIKE ?", ['%'.$this->escapeLike($token).'%']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function escapeLike(string $value): string
|
||||
{
|
||||
return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
# Autoresearch Dashboard: ai-rule-suggestion-coverage
|
||||
|
||||
**Status:** STOPPED by user after run 7.
|
||||
**Runs:** 7 | **Kept:** 7 | **Discarded:** 0 | **Crashed:** 0
|
||||
|
||||
**Headline:** live-model coverage for victoor89@gmail.com
|
||||
**real_tx 416 → 1122** of 1329 uncategorized tx (**31% → 84%**).
|
||||
|
||||
Segment 0 primary = oracle_tx (saturated near 1329 by run 2).
|
||||
Segment 1 primary = reachable_tx (realizable ceiling). real_tx = live-Gemini
|
||||
ground truth (median of repeated runs; the user-facing number).
|
||||
|
||||
Note: the small per-run commits were later squashed into two feature commits
|
||||
(`max_groups_sent 40->150` and `resilient batched generation`); the `commit`
|
||||
column below is historical. Final code state is at HEAD `133c95f0`.
|
||||
|
||||
Ground truth (live Gemini) progression:
|
||||
- baseline (40 groups): 416 (437/410/400)
|
||||
- 150 groups, single payload: 711 (711/214/774) — unstable tail
|
||||
- 150 groups + batching: 903 (970/903/885)
|
||||
- + frequency grouping: 925 (950/900/925)
|
||||
- + send all groups (min1/cap500): 1124, then 1122 (resilient) — 84%
|
||||
|
||||
| # | seg | reachable_tx | oracle_tx | real_tx | status | description |
|
||||
|---|-----|--------------|-----------|---------|--------|-------------|
|
||||
| 1 | 0 | 515 | 830 | 416 | keep | baseline + frozen oracle benchmark |
|
||||
| 2 | 0 | 828 | 1229 | 711 | keep | max_groups_sent 40->150 |
|
||||
| 3 | 0 | 828 | 1229 | 903 | keep | batch Gemini calls (group_batch_size=40) |
|
||||
| 4 | 0 | 1027 | 1222 | 925 | keep | language-agnostic frequency grouping |
|
||||
| 5 | 1 | 1027 | 1222 | 925 | keep | segment baseline (primary -> reachable_tx) |
|
||||
| 6 | 1 | 1329 | 1329 | 1124 | keep | send all groups (min_group_count=1, cap=500) |
|
||||
| 7 | 1 | 1329 | 1329 | 1122 | keep | resilient batched generation + tests |
|
||||
|
||||
## What shipped (all language-agnostic, pan-EU safe)
|
||||
1. `max_groups_sent` 40 → 150 then cap raised; send every group.
|
||||
2. `min_group_count` 2 → 1 (include one-off merchants).
|
||||
3. Batch the Gemini call (`group_batch_size`=40) so a large payload doesn't make
|
||||
the model under-enumerate.
|
||||
4. Frequency-based description grouping (drop tokens appearing in >2% of the
|
||||
user's tx) — merges merchant variants in any language; no wordlists.
|
||||
5. Resilient batching: retry once, tolerate partial-batch failure, only error
|
||||
if every batch fails.
|
||||
|
||||
## Cost / UX tradeoff to decide before shipping defaults
|
||||
min_group_count=1 + cap=500 sends ~441 groups → ~12 Gemini calls per run and
|
||||
creates one-off rules for single transactions. It buys ~+200 real
|
||||
categorizations (925→1122). A more economical default (min_group_count=2,
|
||||
cap~200, reachable≈1049, ~5 calls, recurring merchants only) captures most of
|
||||
the gain without the one-off-rule clutter. See worklog "Next Ideas".
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{"type":"config","name":"ai-rule-suggestion-coverage","metricName":"oracle_tx","metricUnit":"tx","bestDirection":"higher"}
|
||||
{"run":1,"commit":"592afa7","metric":830,"metrics":{"reachable_tx":515,"groups_sent":40,"validated_count":29,"coverage_pct":62.45,"real_tx":416},"status":"keep","description":"baseline + frozen oracle benchmark scaffold","timestamp":1781346014,"segment":0}
|
||||
{"run":2,"commit":"4fcbaed","metric":1229,"metrics":{"reachable_tx":828,"groups_sent":150,"validated_count":72,"coverage_pct":92.48,"real_tx":711},"status":"keep","description":"max_groups_sent 40->150 (cover all count>=2 groups)","timestamp":1781346311,"segment":0}
|
||||
{"run":3,"commit":"8e56467","metric":1229,"metrics":{"reachable_tx":828,"groups_sent":150,"validated_count":72,"coverage_pct":92.48,"real_tx":903},"status":"keep","description":"batch Gemini calls (group_batch_size=40) - stabilizes+raises real_tx 416->903","timestamp":1781346996,"segment":0}
|
||||
{"run":4,"commit":"5b644a0","metric":1222,"metrics":{"reachable_tx":1027,"groups_sent":150,"validated_count":115,"coverage_pct":91.95,"real_tx":925},"status":"keep","description":"language-agnostic frequency-based description grouping (reachable 828->1027)","timestamp":1781347867,"segment":0}
|
||||
{"type":"config","name":"ai-rule-suggestion-coverage","metricName":"reachable_tx","metricUnit":"tx","bestDirection":"higher"}
|
||||
{"run":5,"commit":"5b644a0","metric":1027,"metrics":{"oracle_tx":1222,"groups_sent":150,"real_tx":925},"status":"keep","description":"segment baseline: primary switched to reachable_tx (oracle_tx saturated near total 1329)","timestamp":1781347867,"segment":1}
|
||||
{"run":6,"commit":"3fa505a","metric":1329,"metrics":{"oracle_tx":1329,"groups_sent":441,"validated_count":267,"coverage_pct":100,"real_tx":1124},"status":"keep","description":"min_group_count=1 + max_groups_sent=500: send all groups incl singletons (reachable 1027->1329, real_tx 925->1124)","timestamp":1781348587,"segment":1}
|
||||
{"run":7,"commit":"67d8484","metric":1329,"metrics":{"oracle_tx":1329,"groups_sent":441,"validated_count":267,"coverage_pct":100,"real_tx":1122},"status":"keep","description":"resilient batched generation (retry once, tolerate partial-batch failure, rethrow only if all fail) + generator tests","timestamp":1781349488,"segment":1}
|
||||
101
autoresearch.md
101
autoresearch.md
|
|
@ -1,38 +1,91 @@
|
|||
# Autoresearch: CI total execution time
|
||||
# Autoresearch: AI rule-suggestion coverage
|
||||
|
||||
## Objective
|
||||
Reduce GitHub Actions CI wall-clock completion time for pull requests without weakening coverage or hiding failures.
|
||||
Maximize how many of a user's uncategorized transactions the AI rule-suggestion
|
||||
pipeline can categorize. Driven by `php artisan ai:suggest-rules <user>`.
|
||||
|
||||
Workload: user `victoor89@gmail.com`, reset to a clean slate (0 automation
|
||||
rules, **1329** uncategorized transactions, all server-readable, 64 categories).
|
||||
The data is description-dominated: ~94% of groups key off the free-text
|
||||
`description` field (noisy Spanish bank descriptors); only ~62 transactions
|
||||
carry a `creditor_name`/`debtor_name`.
|
||||
|
||||
The pipeline: `RuleSuggestionAggregator` groups uncategorized tx → caps to
|
||||
`max_groups_sent` groups with count ≥ `min_group_count` → Gemini maps groups to
|
||||
(field, operator, token, category) suggestions → `RuleSuggestionGuard` validates
|
||||
(token ≥3 chars, literal match ≥1, not over-broad > `overbroad_fraction`,
|
||||
confidence ≥ `confidence_floor`, category direction agrees) → persisted as rules.
|
||||
|
||||
## Metrics
|
||||
- **Primary**: ci_total_s (s, lower is better) — modeled PR workflow critical path from current `.github/workflows/ci.yml` plus recent successful CI job timings.
|
||||
- **Secondary**: build_assets_s, tests_s, browser_matrix_s, linter_s, static_analysis_s, performance_tests_s, job_count — tradeoff and bottleneck monitors.
|
||||
- **Primary**: `oracle_tx` (count, higher is better) — distinct uncategorized tx
|
||||
that an ideal model + the REAL guard would categorize, given the groups the
|
||||
REAL aggregator sends. Deterministic, zero-variance, instant. Measured by
|
||||
`experiments/bench.php` with a FROZEN oracle (distinctive-token picker).
|
||||
- **Secondary**:
|
||||
- `reachable_tx` — tx living in the groups the aggregator sends (hard ceiling).
|
||||
- `groups_sent`, `validated_count`, `coverage_pct`.
|
||||
- `real_tx` — GROUND TRUTH: distinct tx the live Gemini run + guard categorize.
|
||||
Noisy (~±30 over runs: 437/410/400 at baseline). Measured at milestones only
|
||||
via `experiments/calibrate_real.php` (one Gemini call); not in the loop.
|
||||
|
||||
## How to Run
|
||||
`./autoresearch.sh` — validates workflow structure, samples recent successful `CI` pull_request runs through `gh`, and outputs `METRIC name=value` lines.
|
||||
`./autoresearch.sh` — outputs `METRIC name=number` lines (oracle benchmark).
|
||||
Milestone ground truth: `php artisan tinker experiments/calibrate_real.php`.
|
||||
|
||||
## Files in Scope
|
||||
- `.github/workflows/ci.yml` — CI graph, job dependencies, sharding, commands.
|
||||
- `.github/actions/setup-php-deps/action.yml` — PHP/composer setup cache behavior.
|
||||
- `.github/actions/setup-bun-deps/action.yml` — Bun/node setup cache behavior.
|
||||
- `phpunit.xml`, `tests/Pest.php` — test suite partitioning only when coverage remains equivalent.
|
||||
- `package.json`, `composer.json` — CI scripts only, no dependency changes without approval.
|
||||
- `config/ai_suggestions.php` — thresholds (max_groups_sent, min_group_count,
|
||||
confidence_floor, overbroad_fraction). **Single source of truth**: the tunable
|
||||
`AI_SUGGESTIONS_*` overrides were removed from `.env`, so editing the config
|
||||
default here actually takes effect. Threshold experiments live here.
|
||||
- `app/Services/Ai/RuleSuggestionAggregator.php` — grouping + key normalization
|
||||
(the clustering lever; better merchant extraction merges more tx into bigger
|
||||
groups so they pass min_group_count and produce clean tokens).
|
||||
- `app/Services/Ai/RuleSuggestionGuard.php` — validation logic.
|
||||
- `app/Ai/Agents/RuleSuggestionAgent.php` — the prompt (realization lever;
|
||||
judge ONLY via `real_tx`, never oracle, since the oracle assumes ideal model).
|
||||
- `app/Services/Ai/LaravelAiRuleSuggestionGenerator.php` — batching the model
|
||||
call would let us send more groups without one giant payload.
|
||||
|
||||
## Off Limits
|
||||
- No deleting or skipping real checks to win time.
|
||||
- No weakening assertions, lowering static-analysis level, or excluding tests unless moved to an equivalent job.
|
||||
- No production secrets or `.env` reads.
|
||||
- No dependency changes without explicit approval.
|
||||
- `experiments/bench.php` oracle token heuristic + stoplist are FROZEN. Improving
|
||||
them games the metric. Only real pipeline code/config may change.
|
||||
- No deleting/weakening tests. No new dependencies without approval.
|
||||
- Do not re-add `AI_SUGGESTIONS_*` threshold overrides to `.env`.
|
||||
- Do not touch the user's DB rows further (already reset).
|
||||
|
||||
## Constraints
|
||||
- Small experiments.
|
||||
- Keep improvements only when primary metric improves.
|
||||
- Preserve CI correctness and failure visibility.
|
||||
- Prefer workflow graph/cache improvements before test-suite rewrites.
|
||||
- LANGUAGE-AGNOSTIC: the user base is pan-European (ES/DE/FR/IT/…), so NO product
|
||||
change may hardcode Spanish (or any single-language) wordlists. Clustering /
|
||||
noise removal must be statistical (e.g. per-user token frequency) or delegated
|
||||
to the multilingual model. The Spanish stoplist in `experiments/bench.php` is a
|
||||
measurement-only artifact for this one Spanish user — never ship its approach.
|
||||
- Every kept change must keep the AI test suite green
|
||||
(`php artisan test --compact tests/Feature/Ai`).
|
||||
- Keep PHP style (`vendor/bin/pint --dirty`).
|
||||
- Threshold/clustering wins are judged by `oracle_tx`. Prompt/batching wins are
|
||||
judged by `real_tx` (median of 2–3 runs, since it is noisy).
|
||||
- Validate `real_tx` after any kept ceiling change to confirm the live model
|
||||
actually realizes part of the new ceiling (oracle is only an upper bound).
|
||||
|
||||
## What's Been Tried
|
||||
- Baseline: modeled PR CI critical path 406.50s. Browser path dominated: build-assets 65.5s + browser matrix 339s + aggregate.
|
||||
- Kept: manual Browser class filters over 5 shards. Modeled 355.76s. Same Browser classes covered exactly once.
|
||||
- Kept: skip separate build-assets job on PR and build assets inside Browser shards. Modeled 326.84s. Removes build-assets gate from PR critical path.
|
||||
- Kept: rebalance Browser filters over 6 shards. Modeled 293.24s.
|
||||
- Kept: run Browser filter shards with Pest `--parallel --processes=3`. Modeled 234.27s.
|
||||
- Discarded: 4 Browser processes. No modeled improvement over 3; individual slow tests dominate.
|
||||
- Baseline (max_groups_sent=40, min_group_count=2, overbroad=0.4, floor=0.3):
|
||||
`oracle_tx=830`, `reachable_tx=515`, `groups_sent=40`, real_tx≈416 (median
|
||||
437/410/400). The live model covers only ~25 of 40 groups → big gap between
|
||||
real (≈416) and the oracle ceiling (830). Two independent levers:
|
||||
raise the ceiling (aggregation/clustering) AND close the realization gap
|
||||
(prompt/batching).
|
||||
|
||||
## Idea Backlog (rough priority)
|
||||
1. [DONE r2] max_groups_sent 40 → 150 (covers all count≥2 groups).
|
||||
2. [DONE r3] Batch the Gemini call so a big payload doesn't make the model
|
||||
under-enumerate (real_tx).
|
||||
3. LANGUAGE-AGNOSTIC clustering: strip noise tokens by per-user document
|
||||
frequency (words shared across many of THIS user's groups are noise in any
|
||||
language), not by a hardcoded wordlist. Merges merchant variants → more
|
||||
count≥2 groups, cleaner tokens. Replaces the old "strip Spanish noise" idea.
|
||||
4. min_group_count 2 → 1 (adds 499 singleton groups; ceiling toward 1300).
|
||||
5. Tune overbroad_fraction / confidence_floor.
|
||||
6. Prompt: insist on covering EVERY group + multilingual merchant-token
|
||||
extraction (real_tx).
|
||||
7. Use AI over all transaction descriptions (CSV-style) to discover groups —
|
||||
user idea; explore as an alternative to PHP pre-aggregation.
|
||||
|
|
|
|||
201
autoresearch.sh
201
autoresearch.sh
|
|
@ -1,186 +1,33 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Autoresearch benchmark: AI rule-suggestion coverage for a single user.
|
||||
#
|
||||
# Runs the DETERMINISTIC oracle benchmark (experiments/bench.php) which exercises
|
||||
# the real aggregator + guard + config and emits METRIC lines. The live Gemini
|
||||
# call is NOT used here (too slow / nondeterministic for a tight loop); ground
|
||||
# truth is measured separately at milestones via experiments/calibrate_real.php.
|
||||
#
|
||||
# Primary metric: oracle_tx (distinct uncategorized tx an ideal model+guard
|
||||
# would categorize given the groups the aggregator sends). Higher is better.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RUN_ID="${RUN_ID:-}"
|
||||
BRANCH="${BRANCH:-$(git branch --show-current)}"
|
||||
WORKFLOW="${WORKFLOW:-CI}"
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
current_head() {
|
||||
git rev-parse HEAD
|
||||
}
|
||||
export BENCH_USER="${BENCH_USER:-victoor89@gmail.com}"
|
||||
|
||||
latest_run_for_head() {
|
||||
local event="$1"
|
||||
local head_sha="$2"
|
||||
gh run list --workflow="$WORKFLOW" --branch="$BRANCH" --event="$event" --limit=20 --json databaseId,headSha,status,conclusion \
|
||||
--jq ".[] | select(.headSha == \"$head_sha\") | .databaseId" | head -1
|
||||
}
|
||||
# Fast syntax precheck (<1s) on the files experiments touch.
|
||||
php -l experiments/bench.php >/dev/null
|
||||
php -l app/Services/Ai/RuleSuggestionAggregator.php >/dev/null
|
||||
php -l app/Services/Ai/RuleSuggestionGuard.php >/dev/null
|
||||
php -l config/ai_suggestions.php >/dev/null
|
||||
|
||||
wait_for_run_for_head() {
|
||||
local event="$1"
|
||||
local head_sha="$2"
|
||||
local run_id=""
|
||||
OUT="$(php artisan tinker experiments/bench.php 2>&1)"
|
||||
|
||||
for _ in {1..90}; do
|
||||
run_id="$(latest_run_for_head "$event" "$head_sha")"
|
||||
if [[ -n "$run_id" ]]; then
|
||||
echo "$run_id"
|
||||
return 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
|
||||
echo "No $event CI run appeared for $head_sha" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
commit_and_push_if_dirty() {
|
||||
local message="$1"
|
||||
|
||||
if ! git diff --quiet -- .github/workflows composer.json composer.lock tests/.pest autoresearch.md autoresearch.sh autoresearch.jsonl || \
|
||||
[[ -n "$(git ls-files --others --exclude-standard .github/workflows tests/.pest 2>/dev/null)" ]]; then
|
||||
git add .github/workflows composer.json composer.lock tests/.pest autoresearch.md autoresearch.sh autoresearch.jsonl
|
||||
git commit -m "$message"
|
||||
git push
|
||||
fi
|
||||
}
|
||||
|
||||
# Time-balanced sharding experiment bootstrap:
|
||||
# 1. Push Pest/update-shards workflow changes.
|
||||
# 2. Run CI workflow_dispatch with build_only=false to produce tests/.pest/shards.json.
|
||||
# 3. Download and commit shards.json.
|
||||
# 4. Wait for PR CI for the committed shard timings and measure it.
|
||||
if [[ -z "$RUN_ID" && ! -f tests/.pest/shards.json && "${GENERATE_BROWSER_SHARDS:-1}" == "1" ]]; then
|
||||
commit_and_push_if_dirty "Experiment: update Pest and enable browser shard timings"
|
||||
|
||||
head_sha="$(current_head)"
|
||||
dispatch_run="$(latest_run_for_head workflow_dispatch "$head_sha")"
|
||||
if [[ -z "$dispatch_run" ]]; then
|
||||
gh workflow run "$WORKFLOW" --ref "$BRANCH" -f build_only=false
|
||||
dispatch_run="$(wait_for_run_for_head workflow_dispatch "$head_sha")"
|
||||
fi
|
||||
|
||||
gh run watch "$dispatch_run" --exit-status --interval 10 >/dev/null
|
||||
|
||||
tmp_dir="$(mktemp -d)"
|
||||
gh run download "$dispatch_run" --name browser-shards --dir "$tmp_dir" >/dev/null
|
||||
mkdir -p tests/.pest
|
||||
cp "$tmp_dir/shards.json" tests/.pest/shards.json
|
||||
rm -rf "$tmp_dir"
|
||||
|
||||
commit_and_push_if_dirty "Experiment: add Pest browser shard timings"
|
||||
head_sha="$(current_head)"
|
||||
RUN_ID="$(wait_for_run_for_head pull_request "$head_sha")"
|
||||
gh run watch "$RUN_ID" --exit-status --interval 10 >/dev/null
|
||||
elif [[ -z "$RUN_ID" ]]; then
|
||||
commit_and_push_if_dirty "Experiment CI change"
|
||||
head_sha="$(current_head)"
|
||||
RUN_ID="$(wait_for_run_for_head pull_request "$head_sha")"
|
||||
gh run watch "$RUN_ID" --exit-status --interval 10 >/dev/null
|
||||
if ! grep -q '^METRIC oracle_tx=' <<<"$OUT"; then
|
||||
echo "BENCH FAILED:" >&2
|
||||
echo "$OUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python3 - <<'PY' "$RUN_ID" "$BRANCH" "$WORKFLOW"
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
|
||||
run_id, branch, workflow = sys.argv[1:4]
|
||||
|
||||
|
||||
def gh_json(args: list[str]) -> object:
|
||||
raw = subprocess.check_output(['gh', *args], text=True, stderr=subprocess.DEVNULL)
|
||||
return json.loads(raw)
|
||||
|
||||
if not run_id:
|
||||
head_sha = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip()
|
||||
runs = gh_json([
|
||||
'run', 'list',
|
||||
'--workflow', workflow,
|
||||
'--branch', branch,
|
||||
'--event', 'pull_request',
|
||||
'--limit', '20',
|
||||
'--json', 'databaseId,status,conclusion,headSha',
|
||||
])
|
||||
for run in runs:
|
||||
if run.get('headSha') == head_sha and run.get('status') == 'completed' and run.get('conclusion') == 'success':
|
||||
run_id = str(run['databaseId'])
|
||||
break
|
||||
|
||||
if not run_id:
|
||||
print('No successful completed pull_request CI run found for HEAD', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
payload = gh_json(['run', 'view', run_id, '--json', 'jobs,status,conclusion,headSha,headBranch,event'])
|
||||
if payload.get('status') != 'completed' or payload.get('conclusion') != 'success':
|
||||
print(f'Run {run_id} not successful: {payload.get("status")} {payload.get("conclusion")}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def parse_time(value: str) -> dt.datetime:
|
||||
return dt.datetime.fromisoformat(value.replace('Z', '+00:00'))
|
||||
|
||||
|
||||
def seconds(start: str, end: str) -> float:
|
||||
return max(0.0, (parse_time(end) - parse_time(start)).total_seconds())
|
||||
|
||||
jobs = [job for job in payload.get('jobs', []) if job.get('startedAt') and job.get('completedAt')]
|
||||
if not jobs:
|
||||
print(f'Run {run_id} has no timed jobs', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
measured_jobs = [
|
||||
job for job in jobs
|
||||
if job.get('conclusion') != 'skipped' or job['name'] in {'build-assets'}
|
||||
]
|
||||
measured_jobs = [
|
||||
job for job in measured_jobs
|
||||
if not job['name'].startswith('build-image') and not job['name'].startswith('deploy')
|
||||
]
|
||||
|
||||
start = min(parse_time(job['startedAt']) for job in measured_jobs)
|
||||
end = max(parse_time(job['completedAt']) for job in measured_jobs)
|
||||
total = (end - start).total_seconds()
|
||||
|
||||
buckets: dict[str, list[float]] = defaultdict(list)
|
||||
for job in jobs:
|
||||
name = job['name']
|
||||
duration = seconds(job['startedAt'], job['completedAt'])
|
||||
if name == 'tests':
|
||||
buckets['tests_s'].append(duration)
|
||||
elif name == 'linter':
|
||||
buckets['linter_s'].append(duration)
|
||||
elif name == 'static-analysis':
|
||||
buckets['static_analysis_s'].append(duration)
|
||||
elif name == 'performance-tests':
|
||||
buckets['performance_tests_s'].append(duration)
|
||||
elif name == 'build-assets':
|
||||
buckets['build_assets_s'].append(duration if job.get('conclusion') != 'skipped' else 0.0)
|
||||
elif name == 'browser-tests':
|
||||
buckets['browser_aggregate_s'].append(duration)
|
||||
elif name.startswith('browser-tests-matrix'):
|
||||
buckets['browser_matrix_shard_s'].append(duration)
|
||||
elif name == 'update-browser-shards':
|
||||
buckets['update_browser_shards_s'].append(duration)
|
||||
|
||||
browser_shards = buckets['browser_matrix_shard_s']
|
||||
metrics = {
|
||||
'github_ci_total_s': total,
|
||||
'tests_s': max(buckets['tests_s'] or [0.0]),
|
||||
'linter_s': max(buckets['linter_s'] or [0.0]),
|
||||
'static_analysis_s': max(buckets['static_analysis_s'] or [0.0]),
|
||||
'performance_tests_s': max(buckets['performance_tests_s'] or [0.0]),
|
||||
'build_assets_s': max(buckets['build_assets_s'] or [0.0]),
|
||||
'browser_matrix_s': max(browser_shards or [0.0]),
|
||||
'browser_aggregate_s': max(buckets['browser_aggregate_s'] or [0.0]),
|
||||
'browser_shards': float(len(browser_shards)),
|
||||
'job_count': float(len(jobs)),
|
||||
'run_id': float(run_id),
|
||||
}
|
||||
|
||||
for key, value in metrics.items():
|
||||
print(f'METRIC {key}={value:.3f}')
|
||||
PY
|
||||
grep -E '^METRIC ' <<<"$OUT"
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
{
|
||||
"agents": [
|
||||
"claude_code",
|
||||
"opencode",
|
||||
"copilot"
|
||||
"claude_code"
|
||||
],
|
||||
"guidelines": true,
|
||||
"herd_mcp": false,
|
||||
"mcp": true,
|
||||
"nightwatch_mcp": false,
|
||||
"packages": [
|
||||
"laravel/fortify"
|
||||
"laravel/fortify",
|
||||
"laravel/ai"
|
||||
],
|
||||
"sail": false,
|
||||
"skills": [
|
||||
|
|
@ -19,6 +18,7 @@
|
|||
"pest-testing",
|
||||
"inertia-react-development",
|
||||
"tailwindcss-development",
|
||||
"ai-sdk-development",
|
||||
"fortify-development"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
"inertiajs/inertia-laravel": "^2.0",
|
||||
"intervention/image": "^3.0",
|
||||
"jwadhams/json-logic-php": "^1.5",
|
||||
"laravel/ai": "^0.7.2",
|
||||
"laravel/cashier": "^16.1",
|
||||
"laravel/fortify": "^1.30",
|
||||
"laravel/framework": "^13.0",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "c9e011d539b5a884b9672683f99b26e6",
|
||||
"content-hash": "a740337bb85751f3ad1ead3774dd1e02",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
|
|
@ -1692,6 +1692,76 @@
|
|||
},
|
||||
"time": "2024-07-09T15:20:54+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/ai",
|
||||
"version": "v0.7.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/ai.git",
|
||||
"reference": "9154118af9328132f5a17e41c70fdcd0a4f21eec"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/ai/zipball/9154118af9328132f5a17e41c70fdcd0a4f21eec",
|
||||
"reference": "9154118af9328132f5a17e41c70fdcd0a4f21eec",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"aws/aws-sdk-php": "^3.339",
|
||||
"illuminate/console": "^12.0|^13.0",
|
||||
"illuminate/container": "^12.0|^13.0",
|
||||
"illuminate/contracts": "^12.0|^13.0",
|
||||
"illuminate/database": "^12.0|^13.0",
|
||||
"illuminate/filesystem": "^12.0|^13.0",
|
||||
"illuminate/json-schema": "^12.0|^13.0",
|
||||
"illuminate/support": "^12.0|^13.0",
|
||||
"laravel/prompts": "^0.3.6",
|
||||
"laravel/serializable-closure": "^2.0",
|
||||
"php": "^8.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/pint": "^1.26",
|
||||
"mockery/mockery": "^1.6.12",
|
||||
"orchestra/testbench": "^10.6|^11.0",
|
||||
"pestphp/pest": "^3.0|^4.0",
|
||||
"pestphp/pest-plugin-laravel": "^3.0|^4.0",
|
||||
"phpstan/phpstan": "^2.1"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Laravel\\Ai\\AiServiceProvider"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "1.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Laravel\\Ai\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "The official AI SDK for Laravel.",
|
||||
"homepage": "https://github.com/laravel/ai",
|
||||
"keywords": [
|
||||
"ai",
|
||||
"laravel"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/ai/issues",
|
||||
"source": "https://github.com/laravel/ai"
|
||||
},
|
||||
"time": "2026-05-28T19:11:59+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/cashier",
|
||||
"version": "v16.5.0",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Model
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The Gemini model used to generate automation-rule suggestions. Kept in
|
||||
| config (env-overridable) so the model can be swapped without a deploy.
|
||||
| Any Flash-tier model is appropriate for this constrained task.
|
||||
|
|
||||
*/
|
||||
|
||||
'model' => env('AI_SUGGESTIONS_MODEL', 'gemini-flash-latest'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Aggregation thresholds
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| "min_group_count" is the minimum number of transactions a group must
|
||||
| contain before it is worth suggesting a rule for (filters one-offs); a
|
||||
| single-transaction match is never worth a rule.
|
||||
| "max_groups_sent" caps how many groups are sent to the model per run,
|
||||
| keeping the payload — and the cost — bounded.
|
||||
| "group_batch_size" splits those groups into smaller per-request batches:
|
||||
| a large single payload makes the model under-enumerate (it silently skips
|
||||
| groups), so we send reliable-size chunks and merge the suggestions.
|
||||
|
|
||||
*/
|
||||
|
||||
'min_group_count' => (int) env('AI_SUGGESTIONS_MIN_GROUP_COUNT', 2),
|
||||
|
||||
'max_groups_sent' => (int) env('AI_SUGGESTIONS_MAX_GROUPS', 500),
|
||||
|
||||
'group_batch_size' => (int) env('AI_SUGGESTIONS_GROUP_BATCH_SIZE', 50),
|
||||
|
||||
/*
|
||||
| "noise_token_fraction" makes description grouping language-agnostic: a word
|
||||
| appearing in more than this fraction of the user's uncategorized
|
||||
| transactions is treated as structural noise ("pago", "carte", "zahlung", a
|
||||
| city, …) and dropped from the grouping key, so variants of the same
|
||||
| merchant collapse into one group regardless of language. If every word in a
|
||||
| description is common, the full set is kept as a fallback.
|
||||
*/
|
||||
|
||||
'noise_token_fraction' => (float) env('AI_SUGGESTIONS_NOISE_FRACTION', 0.02),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Quality guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| "confidence_floor" drops suggestions the model is not confident about.
|
||||
| "overbroad_fraction" rejects a match token that would match more than
|
||||
| this fraction of the user's uncategorized transactions (a token so
|
||||
| broad it would mis-categorise en masse).
|
||||
|
|
||||
| "confidence_floor" is the minimum confidence for a suggestion to be SHOWN
|
||||
| at all. "auto_select_confidence" is the higher bar at or above which a
|
||||
| shown suggestion is pre-selected for the user; suggestions between the two
|
||||
| are shown but left unchecked so the user opts in deliberately.
|
||||
|
|
||||
*/
|
||||
|
||||
'confidence_floor' => (float) env('AI_SUGGESTIONS_CONFIDENCE_FLOOR', 0.3),
|
||||
|
||||
'auto_select_confidence' => (float) env('AI_SUGGESTIONS_AUTO_SELECT', 0.6),
|
||||
|
||||
'overbroad_fraction' => (float) env('AI_SUGGESTIONS_OVERBROAD_FRACTION', 0.4),
|
||||
|
||||
/*
|
||||
| "min_match_count" hides a suggestion card unless the rule it represents
|
||||
| would match at least this many of the user's uncategorized transactions.
|
||||
| Unlike "min_group_count" (which filters raw groups before the model runs),
|
||||
| this is applied to the final OR-rule's real match count at display time, so
|
||||
| low-impact rules are never shown. Set to 1 to keep every suggestion visible.
|
||||
*/
|
||||
|
||||
'min_match_count' => (int) env('AI_SUGGESTIONS_MIN_MATCH_COUNT', 10),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Eligibility & throttle
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| A run only happens when the user has at least "eligibility_min_transactions"
|
||||
| transactions. "throttle_days" is the minimum spacing between successful
|
||||
| runs (a fresh run before this window is blocked to avoid extra cost).
|
||||
|
|
||||
*/
|
||||
|
||||
'eligibility_min_transactions' => (int) env('AI_SUGGESTIONS_MIN_TRANSACTIONS', 50),
|
||||
|
||||
'throttle_days' => (int) env('AI_SUGGESTIONS_THROTTLE_DAYS', 30),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Consent version
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The current version of the AI consent copy. Bumping this invalidates
|
||||
| prior consents so users are re-prompted when the terms change.
|
||||
|
|
||||
*/
|
||||
|
||||
'consent_version' => (string) env('AI_SUGGESTIONS_CONSENT_VERSION', '1'),
|
||||
|
||||
];
|
||||
|
|
@ -54,6 +54,8 @@ return [
|
|||
'billing_period' => 'month',
|
||||
'trial_days' => (int) env('STRIPE_PRO_MONTHLY_TRIAL_DAYS', 15),
|
||||
'features' => [
|
||||
'Connect bank accounts',
|
||||
'AI Suggestions',
|
||||
'Unlimited accounts',
|
||||
'Unlimited transactions',
|
||||
'Your data stays yours',
|
||||
|
|
@ -71,6 +73,8 @@ return [
|
|||
'billing_period' => 'year',
|
||||
'trial_days' => (int) env('STRIPE_PRO_YEARLY_TRIAL_DAYS', 15),
|
||||
'features' => [
|
||||
'Connect bank accounts',
|
||||
'AI Suggestions',
|
||||
'Unlimited accounts',
|
||||
'Unlimited transactions',
|
||||
'Your data stays yours',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\AiConsent;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<AiConsent>
|
||||
*/
|
||||
class AiConsentFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => User::factory(),
|
||||
'scope' => AiConsent::SCOPE_FINANCE,
|
||||
'version' => (string) config('ai_suggestions.consent_version'),
|
||||
'accepted_at' => now(),
|
||||
'revoked_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function revoked(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'revoked_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\RuleSuggestionStatus;
|
||||
use App\Models\RuleSuggestion;
|
||||
use App\Models\SuggestionRun;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<RuleSuggestion>
|
||||
*/
|
||||
class RuleSuggestionFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$token = $this->faker->unique()->company();
|
||||
|
||||
return [
|
||||
'suggestion_run_id' => SuggestionRun::factory(),
|
||||
'group_key' => mb_strtolower($token),
|
||||
'match_field' => 'description',
|
||||
'match_operator' => 'contains',
|
||||
'match_token' => mb_strtolower($token),
|
||||
'proposed_category_id' => null,
|
||||
'new_category_name' => null,
|
||||
'new_category_parent_id' => null,
|
||||
'new_category_direction' => null,
|
||||
'confidence' => $this->faker->randomFloat(3, 0.7, 1),
|
||||
'group_size' => $this->faker->numberBetween(3, 30),
|
||||
'sample_descriptions' => [$token.' 1234', $token.' 5678'],
|
||||
'status' => RuleSuggestionStatus::Pending,
|
||||
];
|
||||
}
|
||||
|
||||
public function proposesNewCategory(string $name = 'Pet care', string $direction = 'outflow'): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'proposed_category_id' => null,
|
||||
'new_category_name' => $name,
|
||||
'new_category_direction' => $direction,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\SuggestionRunStatus;
|
||||
use App\Models\SuggestionRun;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<SuggestionRun>
|
||||
*/
|
||||
class SuggestionRunFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => User::factory(),
|
||||
'status' => SuggestionRunStatus::Completed,
|
||||
'transactions_considered' => $this->faker->numberBetween(50, 500),
|
||||
'suggestions_count' => $this->faker->numberBetween(1, 15),
|
||||
'error' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function pending(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'status' => SuggestionRunStatus::Pending,
|
||||
'suggestions_count' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
public function empty(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'status' => SuggestionRunStatus::Empty,
|
||||
'suggestions_count' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
public function failed(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'status' => SuggestionRunStatus::Failed,
|
||||
'suggestions_count' => 0,
|
||||
'error' => 'Generation failed.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<?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::create('ai_consents', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('scope')->default('finance');
|
||||
$table->string('version');
|
||||
$table->timestamp('accepted_at');
|
||||
$table->timestamp('revoked_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['user_id', 'scope']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('ai_consents');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<?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::create('suggestion_runs', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('status')->default('pending');
|
||||
$table->unsignedInteger('transactions_considered')->default(0);
|
||||
$table->unsignedInteger('suggestions_count')->default(0);
|
||||
$table->text('error')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['user_id', 'status']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('suggestion_runs');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<?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::create('rule_suggestions', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('suggestion_run_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('group_key');
|
||||
$table->string('match_field');
|
||||
$table->string('match_operator')->default('contains');
|
||||
$table->string('match_token');
|
||||
$table->foreignUuid('proposed_category_id')->nullable()->constrained('categories')->nullOnDelete();
|
||||
$table->string('new_category_name')->nullable();
|
||||
$table->foreignUuid('new_category_parent_id')->nullable()->constrained('categories')->nullOnDelete();
|
||||
$table->string('new_category_direction')->nullable();
|
||||
$table->decimal('confidence', 4, 3)->default(0);
|
||||
$table->unsignedInteger('group_size')->default(0);
|
||||
$table->json('sample_descriptions')->nullable();
|
||||
$table->string('status')->default('pending');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['suggestion_run_id', 'status']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('rule_suggestions');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Deterministic coverage benchmark for the AI rule-suggestion pipeline.
|
||||
*
|
||||
* It exercises the REAL aggregator + guard (so any change to those, or to
|
||||
* config/ai_suggestions.php, is reflected here), but replaces the live Gemini
|
||||
* call with a frozen "oracle" model proxy. The oracle assumes an ideal model:
|
||||
* for every group the aggregator sends, it proposes the most powerful safe
|
||||
* match token derivable from that group and maps it to a (new) category so the
|
||||
* category step never binds. What the guard then accepts is the deterministic
|
||||
* ceiling that the non-AI parts of the pipeline allow.
|
||||
*
|
||||
* - reachable_tx : uncategorized tx living in groups the aggregator sends
|
||||
* (hard ceiling — the model can never categorize beyond this)
|
||||
* - oracle_tx : distinct uncategorized tx an ideal model+guard would
|
||||
* categorize given those groups (PRIMARY metric)
|
||||
*
|
||||
* The oracle token heuristic is FROZEN: improvements must come from real
|
||||
* pipeline code (aggregator/guard/config), never from editing this file.
|
||||
*/
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Ai\Contracts\TransactionMatcher;
|
||||
use App\Services\Ai\RuleSuggestionAggregator;
|
||||
use App\Services\Ai\RuleSuggestionGuard;
|
||||
|
||||
$email = getenv('BENCH_USER') ?: 'victoor89@gmail.com';
|
||||
|
||||
$user = User::query()->where('email', $email)->first();
|
||||
|
||||
if ($user === null) {
|
||||
fwrite(STDERR, "BENCH user not found: {$email}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/** @var RuleSuggestionAggregator $aggregator */
|
||||
$aggregator = app(RuleSuggestionAggregator::class);
|
||||
/** @var RuleSuggestionGuard $guard */
|
||||
$guard = app(RuleSuggestionGuard::class);
|
||||
/** @var TransactionMatcher $matcher */
|
||||
$matcher = app(TransactionMatcher::class);
|
||||
|
||||
$total = $matcher->total($user);
|
||||
$groups = $aggregator->groupsFor($user);
|
||||
$categories = $aggregator->categoryOptions($user);
|
||||
|
||||
$reachable = array_sum(array_column($groups, 'count'));
|
||||
$overbroad = (float) config('ai_suggestions.overbroad_fraction');
|
||||
|
||||
/**
|
||||
* Frozen list of structural Spanish-bank / geographic noise words that carry no
|
||||
* merchant or category identity. Excluding them stops the oracle from picking a
|
||||
* generic descriptor (e.g. "pago", "tarjeta") that would span unrelated
|
||||
* merchants and wildly overstate reachable coverage. FROZEN — do not tune.
|
||||
*
|
||||
* @var array<string, true>
|
||||
*/
|
||||
$STOP = array_fill_keys([
|
||||
'pago', 'pagos', 'compra', 'compras', 'recibo', 'recibos', 'adeudo', 'adeudos',
|
||||
'transferencia', 'transferencias', 'realizada', 'realizado', 'emitida', 'recibida',
|
||||
'devolucion', 'devolución', 'tarjeta', 'tarj', 'debito', 'débito', 'credito', 'crédito',
|
||||
'efectivo', 'cajero', 'cajeros', 'ingreso', 'ingresos', 'traspaso', 'traspasos',
|
||||
'para', 'por', 'con', 'del', 'las', 'los', 'una', 'uno', 'unos', 'unas', 'que',
|
||||
'num', 'nro', 'ref', 'concepto', 'fecha', 'importe', 'cuenta', 'banco', 'comision', 'comisión',
|
||||
'madrid', 'barcelona', 'sevilla', 'valencia', 'malaga', 'bilbao', 'esp', 'espana', 'españa',
|
||||
'spain', 'www', 'http', 'https', 'eur', 'usd', 'slu', 'mediante',
|
||||
], true);
|
||||
|
||||
/**
|
||||
* Frozen oracle: pick the most distinctive safe match token for a group.
|
||||
* - counterparty fields: the whole (clean) key, matched with `equals`.
|
||||
* - description: the non-noise key word that matches the most transactions
|
||||
* without tripping the over-broad guard (ties broken by longer word). The
|
||||
* noise stoplist keeps the choice on a merchant/category-bearing token,
|
||||
* approximating a competent model reading a messy bank descriptor.
|
||||
*
|
||||
* @return array{0:string,1:string}|null [operator, token]
|
||||
*/
|
||||
$pickToken = function (array $group) use ($matcher, $user, $total, $overbroad, $STOP): ?array {
|
||||
$field = $group['field'];
|
||||
|
||||
if ($field !== 'description') {
|
||||
$token = $group['key'];
|
||||
$count = $matcher->countMatching($user, $field, 'equals', $token);
|
||||
|
||||
if ($count >= 1 && $total > 0 && ($count / $total) <= $overbroad) {
|
||||
return ['equals', $token];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$words = array_values(array_unique(array_filter(
|
||||
explode(' ', $group['key']),
|
||||
fn (string $w): bool => mb_strlen($w) >= 3 && ! isset($STOP[$w]),
|
||||
)));
|
||||
|
||||
$best = null;
|
||||
$bestCount = 0;
|
||||
$bestLen = 0;
|
||||
|
||||
foreach ($words as $word) {
|
||||
$count = $matcher->countMatching($user, 'description', 'contains', $word);
|
||||
|
||||
if ($count < 1 || ($total > 0 && ($count / $total) > $overbroad)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$len = mb_strlen($word);
|
||||
|
||||
if ($count > $bestCount || ($count === $bestCount && $len > $bestLen)) {
|
||||
$best = $word;
|
||||
$bestCount = $count;
|
||||
$bestLen = $len;
|
||||
}
|
||||
}
|
||||
|
||||
return $best === null ? null : ['contains', $best];
|
||||
};
|
||||
|
||||
$rawSuggestions = [];
|
||||
|
||||
foreach ($groups as $group) {
|
||||
$picked = $pickToken($group);
|
||||
|
||||
if ($picked === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$operator, $token] = $picked;
|
||||
|
||||
$rawSuggestions[] = [
|
||||
'group_key' => $group['key'],
|
||||
'match_field' => $group['field'],
|
||||
'match_operator' => $operator,
|
||||
'match_token' => $token,
|
||||
// Propose a new category so the guard's category step never binds —
|
||||
// we are measuring the token/clustering ceiling, not category mapping.
|
||||
'category_id' => '',
|
||||
'new_category_name' => mb_substr('grp '.$group['key'], 0, 255),
|
||||
'new_category_direction' => $group['direction'] === 'inflow' ? 'inflow' : 'outflow',
|
||||
'confidence' => 1.0,
|
||||
];
|
||||
}
|
||||
|
||||
$validated = $guard->validate($user, $rawSuggestions, $categories);
|
||||
|
||||
$matchedIds = [];
|
||||
|
||||
foreach ($validated as $suggestion) {
|
||||
$ids = $matcher
|
||||
->matching($user, $suggestion['match_field'], $suggestion['match_operator'], $suggestion['match_token'])
|
||||
->pluck('id');
|
||||
|
||||
foreach ($ids as $id) {
|
||||
$matchedIds[$id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$oracleTx = count($matchedIds);
|
||||
$coverage = $total > 0 ? round($oracleTx / $total * 100, 2) : 0.0;
|
||||
|
||||
echo 'METRIC oracle_tx='.$oracleTx."\n";
|
||||
echo 'METRIC reachable_tx='.$reachable."\n";
|
||||
echo 'METRIC total_uncat='.$total."\n";
|
||||
echo 'METRIC groups_sent='.count($groups)."\n";
|
||||
echo 'METRIC validated_count='.count($validated)."\n";
|
||||
echo 'METRIC coverage_pct='.$coverage."\n";
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Ground-truth calibration: run the REAL Gemini generator through the real
|
||||
* guard and count distinct matched tx the same way bench.php does, so the
|
||||
* oracle metric can be compared against what the live model actually achieves.
|
||||
* Costs one Gemini call. Not part of the tight loop.
|
||||
*/
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Ai\Contracts\RuleSuggestionGenerator;
|
||||
use App\Services\Ai\Contracts\TransactionMatcher;
|
||||
use App\Services\Ai\RuleSuggestionAggregator;
|
||||
use App\Services\Ai\RuleSuggestionGuard;
|
||||
|
||||
$email = getenv('BENCH_USER') ?: 'victoor89@gmail.com';
|
||||
$user = User::query()->where('email', $email)->first();
|
||||
|
||||
$aggregator = app(RuleSuggestionAggregator::class);
|
||||
$guard = app(RuleSuggestionGuard::class);
|
||||
$generator = app(RuleSuggestionGenerator::class);
|
||||
$matcher = app(TransactionMatcher::class);
|
||||
|
||||
$total = $matcher->total($user);
|
||||
$groups = $aggregator->groupsFor($user);
|
||||
$categories = $aggregator->categoryOptions($user);
|
||||
|
||||
$raw = $generator->generate($groups, $categories);
|
||||
$validated = $guard->validate($user, $raw, $categories);
|
||||
|
||||
$matchedIds = [];
|
||||
foreach ($validated as $s) {
|
||||
foreach ($matcher->matching($user, $s['match_field'], $s['match_operator'], $s['match_token'])->pluck('id') as $id) {
|
||||
$matchedIds[$id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
echo 'METRIC real_tx='.count($matchedIds)."\n";
|
||||
echo 'METRIC real_raw='.count($raw)."\n";
|
||||
echo 'METRIC real_validated='.count($validated)."\n";
|
||||
echo 'METRIC real_coverage_pct='.($total > 0 ? round(count($matchedIds) / $total * 100, 2) : 0)."\n";
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
# Autoresearch Worklog: AI rule-suggestion coverage
|
||||
|
||||
**Session goal:** maximize uncategorized tx the `ai:suggest-rules` pipeline can
|
||||
categorize for `victoor89@gmail.com`.
|
||||
|
||||
## Data summary (clean slate)
|
||||
- 1329 uncategorized tx (all server-readable), 64 categories, 0 rules.
|
||||
- 650 distinct normalized groups; 499 singletons; 151 groups with count≥2
|
||||
(covering 830 tx); 90 groups count≥3 (708 tx).
|
||||
- Description-dominated: ~1267 tx key off `description`, ~62 off creditor/debtor.
|
||||
- Ceiling map (sum of top-N group counts): top40=515, top60=602, top100=728,
|
||||
top150=828, top200=879, top300=979, all650=1300.
|
||||
|
||||
## Metric
|
||||
- Primary `oracle_tx` (deterministic, frozen oracle in experiments/bench.php).
|
||||
- Ground truth `real_tx` (live Gemini, noisy, milestone-only).
|
||||
|
||||
---
|
||||
|
||||
### Run 1: baseline — oracle_tx=830 (KEEP)
|
||||
- What changed: nothing; scaffold + frozen oracle benchmark established.
|
||||
- Result: oracle_tx=830, reachable_tx=515, groups_sent=40, validated_count=29,
|
||||
coverage_pct=62.45. Ground truth real_tx median ≈416 (437/410/400).
|
||||
- Insight: the live model realizes only ~half the oracle ceiling and ~80% of
|
||||
reachable; it covers ~25 of 40 sent groups. Both the ceiling (aggregation)
|
||||
and the realization gap (prompt/batching) are open levers.
|
||||
- Next: raise max_groups_sent toward 150 (cover all count≥2 groups).
|
||||
|
||||
### Run 2: max_groups_sent 40 -> 150 — oracle_tx=1229 (KEEP)
|
||||
- Timestamp: 2026-06-13
|
||||
- What changed: config/ai_suggestions.php default max_groups_sent 40 -> 150.
|
||||
- Result: oracle_tx 830->1229 (+48%), reachable 515->828, groups_sent 150,
|
||||
coverage 92.48%. Tests 24/24 green. Real_tx (3 runs): 711 / 214 / 774,
|
||||
median 711 (baseline 416).
|
||||
- Insight: ceiling lift works AND the median real run nearly doubles. BUT the
|
||||
live model is now UNSTABLE on the big single payload — one run returned only
|
||||
9 suggestions (real_tx 214 < baseline). gemini-flash under-enumerates a large
|
||||
structured-output request. The realization axis is now the bottleneck.
|
||||
- Next: BATCH the Gemini call (chunk groups into reliable-size requests, merge
|
||||
raw suggestions). Should stabilize + realize the high ceiling. Judge by
|
||||
real_tx (oracle unaffected — same groups/guard).
|
||||
|
||||
### Run 3: batch Gemini calls (group_batch_size=40) — real_tx 416->903 (KEEP)
|
||||
- Timestamp: 2026-06-13
|
||||
- What changed: LaravelAiRuleSuggestionGenerator now array_chunks groups into
|
||||
group_batch_size(40) per-request batches and merges suggestions. New config
|
||||
key ai_suggestions.group_batch_size.
|
||||
- Result: oracle_tx unchanged (1229 — bench bypasses generator). real_tx (3
|
||||
runs): 970/903/885, median 903; raw suggestions ~125 stable (was 9..50);
|
||||
validated ~88. Tests 24/24 green, pint clean.
|
||||
- Insight: the 150-group instability was purely a single-payload enumeration
|
||||
failure. Smaller batches make the multilingual model reliably enumerate every
|
||||
group. Real coverage 416->903 (+117%), now 68% of all tx and 74% of the oracle
|
||||
ceiling (was 50%). Decision metric here is real_tx (primary oracle unmoved).
|
||||
- Next: language-agnostic clustering (per-user token document-frequency noise
|
||||
removal) to merge merchant variants — must NOT hardcode Spanish (pan-EU users).
|
||||
|
||||
### Run 4: language-agnostic frequency-based grouping — reachable 828->1027 (KEEP)
|
||||
- Timestamp: 2026-06-13
|
||||
- What changed: RuleSuggestionAggregator keys descriptions on distinctive
|
||||
(low document-frequency) tokens; drops words in >noise_token_fraction(2%) of
|
||||
tx (language-agnostic, no wordlist); fallback keeps all if all common. New
|
||||
config ai_suggestions.noise_token_fraction.
|
||||
- Result: reachable_tx 828->1027 (singletons 499->291), validated 72->115,
|
||||
oracle_tx ~flat (1229->1222, saturated). real_tx (3 runs) 950/900/925 median
|
||||
925 — FLAT vs batching's 903 (mean 919 vs 925). Tests 24/24, pint clean.
|
||||
- Insight: clustering raises the realizable ceiling (+199 reachable) but the
|
||||
live model already covered most via good tokens, so real_tx barely moved THIS
|
||||
step. The headroom matters for the next levers. oracle_tx is now saturated
|
||||
near the 1329 total and is a poor primary.
|
||||
- Decision: KEEP (language-agnostic per user steer, cleaner groups, +200
|
||||
reachable headroom, no real regression).
|
||||
|
||||
### Segment switch (run 5): primary metric -> reachable_tx
|
||||
- oracle_tx saturated (~1222/1329); switched the deterministic loop primary to
|
||||
reachable_tx (sensitive to clustering). real_tx stays the milestone ground
|
||||
truth; prompt/batch experiments are judged by real_tx regardless.
|
||||
- Segment-1 baseline: reachable_tx=1027, real_tx≈925.
|
||||
|
||||
### Run 6: send all groups (min_group_count=1, max_groups_sent=500) — reachable 1329 (KEEP)
|
||||
- What changed: config min_group_count 2->1, max_groups_sent 150->500.
|
||||
- Result: reachable_tx 1027->1329 (100%), groups_sent 441. real_tx=1124 (84.6%,
|
||||
one clean sample; 2 of 3 sample runs failed operationally — 12 batches/run is
|
||||
slow + fragile, one failed batch lost the whole run). Tests green.
|
||||
- Insight: singletons (one-off merchants) ARE realizable — adding them lifted
|
||||
real coverage 925->1124. But high batch counts need resilience.
|
||||
|
||||
### Run 7: resilient batched generation — real_tx 1122 reliable (KEEP)
|
||||
- What changed: LaravelAiRuleSuggestionGenerator retries each batch once,
|
||||
tolerates partial-batch failures (keeps successful batches), only rethrows if
|
||||
every batch fails. Added 3 generator tests.
|
||||
- Result: reachable_tx 1329 (unchanged). real_tx=1122 reproducible (was 1124).
|
||||
Full AI suite 27/27, pint clean.
|
||||
- Insight: the run-6 fragility was operational, not a model limit. With
|
||||
resilience the min1/cap500 config reliably lands ~1122 (84%).
|
||||
|
||||
### STOPPED by user after run 7.
|
||||
|
||||
## Final Result
|
||||
- real_tx 416 -> 1122 of 1329 (31% -> 84%) for victoor89@gmail.com.
|
||||
- All changes language-agnostic (pan-EU safe): frequency clustering, batching,
|
||||
caps, resilience. No hardcoded wordlists in product code.
|
||||
|
||||
## Key Insights
|
||||
- `max_groups_sent=40` is the first hard cap: reachable=515 of a possible 830
|
||||
at count≥2. Lifting it is the cheapest ceiling win.
|
||||
- Tokens legitimately span multiple groups (same merchant, different noise), so
|
||||
oracle_tx (830) > reachable_tx (515) — token extraction is high-value.
|
||||
- Real model is conservative (skips groups). Prompt/batching may matter as much
|
||||
as thresholds, but must be judged by real_tx.
|
||||
|
||||
## Next Ideas (not yet tried)
|
||||
- Decide the production default: aggressive min1/cap500 (max coverage, ~12
|
||||
Gemini calls/run, one-off rules) vs balanced min2/cap~200 (reachable≈1049,
|
||||
~5 calls, recurring merchants only). Quantify real_tx for the balanced config.
|
||||
- Prompt experiment (real_tx-judged): close the 1122->1329 gap by insisting on
|
||||
exhaustive mapping + multilingual merchant-token extraction.
|
||||
- group_batch_size tuning (speed vs enumeration quality).
|
||||
- Run batches concurrently to cut wall-clock of the many-batch config.
|
||||
- noise_token_fraction sweep (0.015 looked marginally best deterministically).
|
||||
50
lang/es.json
50
lang/es.json
|
|
@ -505,6 +505,7 @@
|
|||
"Create rules like \"If description contains 'AMAZON', categorize as Shopping\"": "Crea reglas como \"Si la descripción contiene 'AMAZON', categorizar como Compras\"",
|
||||
"Create rules like \"If description contains \\'AMAZON\\', categorize as Shopping\"": "Crea reglas como \"Si la descripción contiene 'AMAZON', categorizar como Compras\"",
|
||||
"Create rules to automatically categorize your transactions based on patterns you define.": "Crea reglas para categorizar automáticamente tus transacciones basándote en patrones que definas.",
|
||||
"Create the account now, update at any moment": "Crea la cuenta ahora, actualízala en cualquier momento",
|
||||
"Create your account": "Crear mi cuenta",
|
||||
"Create, edit, and delete rules anytime": "Crea, edita y elimina reglas en cualquier momento",
|
||||
"Creating subscription...": "Creando suscripción...",
|
||||
|
|
@ -2027,5 +2028,52 @@
|
|||
"I'm genuinely happy using an open-source project with a real commitment to privacy — that's exactly what I want from a finance app.": "Estoy realmente contento usando un proyecto open-source con un compromiso real con la privacidad: es exactamente lo que quiero de una app de finanzas.",
|
||||
"I can't wait to discover everything the app can do. Thank you — it must have taken a tremendous effort. Congratulations!": "Con muchas ganas de ir descubriendo todo lo que se puede hacer con la aplicación. ¡Gracias! Debe haber sido un esfuerzo tremendo. ¡Enhorabuena!",
|
||||
"The app is intuitive, functional, and a real help for managing my finances day to day. What stands out most is how much the free version offers — it really shows your commitment to your users. I’ll keep recommending it!": "La aplicación es intuitiva, funcional y de gran ayuda para gestionar mis finanzas en el día a día. Lo que más destaca es la cantidad de opciones de la versión gratuita: demuestra vuestro compromiso con los usuarios. ¡Seguiré recomendándola!",
|
||||
"I built the app I needed to make better decisions. Understanding how I spend and where my income comes from has brought me real financial peace of mind.": "He construido la app que necesitaba para tomar mejores decisiones. Entender cómo gasto y de dónde vienen mis ingresos me ha dado una verdadera tranquilidad financiera."
|
||||
"I built the app I needed to make better decisions. Understanding how I spend and where my income comes from has brought me real financial peace of mind.": "He construido la app que necesitaba para tomar mejores decisiones. Entender cómo gasto y de dónde vienen mis ingresos me ha dado una verdadera tranquilidad financiera.",
|
||||
"AI Suggestions": "Sugerencias de IA",
|
||||
"AI suggestions are a Standard Plan feature. You'll choose a plan at the end of the onboarding.": "Las sugerencias de IA son una función del plan Standard. Elegirás un plan al final del proceso de incorporación.",
|
||||
"Rules created": "Reglas creadas",
|
||||
"We created :rules rules and categorized :count transactions for you.": "Hemos creado :rules reglas y categorizado :count transacciones por ti.",
|
||||
"Looking for patterns": "Buscando patrones",
|
||||
"We’re finding the rules that will categorize most of your transactions automatically.": "Estamos encontrando las reglas que categorizarán la mayoría de tus transacciones automáticamente.",
|
||||
"Analysing your transactions…": "Analizando tus transacciones…",
|
||||
"Finding related groups…": "Buscando grupos relacionados…",
|
||||
"Finding the right categories…": "Encontrando las categorías adecuadas…",
|
||||
"Grouping everything together…": "Agrupándolo todo…",
|
||||
"Polishing your suggestions…": "Puliendo tus sugerencias…",
|
||||
"This can take up to two minutes.": "Esto puede tardar hasta dos minutos.",
|
||||
"Let AI organize your money": "Deja que la IA organice tu dinero",
|
||||
"With your permission, we’ll send merchant names from your transactions to our AI provider to suggest categorization rules. We never send your full financial picture, and you review every rule before it’s created.": "Con tu permiso, enviaremos los nombres de los comercios de tus transacciones a nuestro proveedor de IA para sugerir reglas de categorización. Nunca enviamos tu situación financiera completa y revisas cada regla antes de crearla.",
|
||||
"Suggest my rules with AI": "Sugerir mis reglas con IA",
|
||||
"No thanks": "No, gracias",
|
||||
"AI suggestions need more data": "Las sugerencias de IA necesitan más datos",
|
||||
"Once you have at least :count transactions, you can generate rule suggestions from Settings → Automation rules.": "Cuando tengas al menos :count transacciones, podrás generar sugerencias de reglas desde Ajustes → Reglas de automatización.",
|
||||
"We couldn’t generate suggestions": "No pudimos generar sugerencias",
|
||||
"Something went wrong. You can try again or skip for now.": "Algo salió mal. Puedes intentarlo de nuevo u omitirlo por ahora.",
|
||||
"Try again": "Intentar de nuevo",
|
||||
"No clear patterns yet": "Aún no hay patrones claros",
|
||||
"We couldn’t find confident rules to suggest right now. You can categorize your transactions in the next step.": "No encontramos reglas fiables que sugerir ahora mismo. Puedes categorizar tus transacciones en el siguiente paso.",
|
||||
"Review your suggested rules": "Revisa las reglas sugeridas",
|
||||
"We found these patterns. Tweak anything you like, then create the rules — we’ll apply them to your transactions right away.": "Encontramos estos patrones. Ajusta lo que quieras y crea las reglas: las aplicaremos a tus transacciones de inmediato.",
|
||||
"Create :count rules & apply": "Crear :count reglas y aplicar",
|
||||
"Applying…": "Aplicando…",
|
||||
"Include this rule": "Incluir esta regla",
|
||||
"Payee": "Beneficiario",
|
||||
"Sender": "Remitente",
|
||||
"is": "es",
|
||||
"Match text": "Texto a coincidir",
|
||||
"Categorize as": "Categorizar como",
|
||||
"New: :name": "Nueva: :name",
|
||||
"Preview matching transactions": "Ver transacciones coincidentes",
|
||||
"Loading…": "Cargando…",
|
||||
":count of :total uncategorized transactions match": ":count de :total transacciones sin categorizar coinciden",
|
||||
"Matching transactions": "Transacciones coincidentes",
|
||||
"Preview :count matching transactions": "Ver :count transacciones coincidentes",
|
||||
"If the transaction": "Si la transacción",
|
||||
"If the transaction matches any of": "Si la transacción coincide con alguno de",
|
||||
"Add value": "Añadir valor",
|
||||
"Remove value": "Eliminar valor",
|
||||
"or": "o",
|
||||
"Create rule": "Crear regla",
|
||||
"Ignore rule": "Ignorar regla",
|
||||
":count matches": ":count coincidencias"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -342,7 +342,7 @@ function ConditionRow({
|
|||
</div>
|
||||
|
||||
{showAmountHint && (
|
||||
<p className="text-xs pl-2 py-2 text-muted-foreground">
|
||||
<p className="py-2 pl-2 text-xs text-muted-foreground">
|
||||
{__(
|
||||
'Use a negative value for expenses (e.g. -21.99) and a positive value for income.',
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,430 @@
|
|||
import { CategoryCombobox } from '@/components/shared/category-combobox';
|
||||
import { AmountDisplay } from '@/components/ui/amount-display';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { preview } from '@/routes/ai/rule-suggestions';
|
||||
import { type Category } from '@/types/category';
|
||||
import { formatDate } from '@/utils/date';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
Plus,
|
||||
Sparkles,
|
||||
TextSearch,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
export interface AiSuggestionValue {
|
||||
id: string;
|
||||
match_field: string;
|
||||
match_operator: string;
|
||||
match_token: string;
|
||||
}
|
||||
|
||||
export interface AiSuggestion {
|
||||
id: string;
|
||||
confidence: number;
|
||||
group_size: number;
|
||||
sample_descriptions: string[];
|
||||
proposed_category: { id: string; name: string } | null;
|
||||
new_category_name: string | null;
|
||||
new_category_direction: string | null;
|
||||
values: AiSuggestionValue[];
|
||||
}
|
||||
|
||||
export interface ValueDraft {
|
||||
field: string;
|
||||
operator: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface SuggestionDraft {
|
||||
include: boolean;
|
||||
categoryId: string | null;
|
||||
values: ValueDraft[];
|
||||
}
|
||||
|
||||
interface PreviewTransaction {
|
||||
id: string;
|
||||
description: string | null;
|
||||
amount: number;
|
||||
currency_code: string;
|
||||
transaction_date: string;
|
||||
}
|
||||
|
||||
interface PreviewResponse {
|
||||
match_count: number;
|
||||
total_uncategorized: number;
|
||||
transactions: PreviewTransaction[];
|
||||
}
|
||||
|
||||
interface AiSuggestionCardProps {
|
||||
suggestion: AiSuggestion;
|
||||
draft: SuggestionDraft;
|
||||
categories: Category[];
|
||||
onChange: (draft: SuggestionDraft) => void;
|
||||
}
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
description: 'Description',
|
||||
creditor_name: 'Payee',
|
||||
debtor_name: 'Sender',
|
||||
};
|
||||
|
||||
function operatorLabel(operator: string): string {
|
||||
return operator === 'equals' ? __('is') : __('contains');
|
||||
}
|
||||
|
||||
function conditionsFor(values: ValueDraft[]) {
|
||||
return values
|
||||
.filter((value) => value.token.trim() !== '')
|
||||
.map((value) => ({
|
||||
match_field: value.field,
|
||||
match_operator: value.operator,
|
||||
match_token: value.token.trim(),
|
||||
}));
|
||||
}
|
||||
|
||||
export function AiSuggestionCard({
|
||||
suggestion,
|
||||
draft,
|
||||
categories,
|
||||
onChange,
|
||||
}: AiSuggestionCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [previewData, setPreviewData] = useState<PreviewResponse | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const conditions = useMemo(
|
||||
() => conditionsFor(draft.values),
|
||||
[draft.values],
|
||||
);
|
||||
const conditionsKey = useMemo(
|
||||
() => JSON.stringify(conditions),
|
||||
[conditions],
|
||||
);
|
||||
|
||||
// The server already counted the original values; only refetch once the
|
||||
// user has edited them, so an untouched card costs no request.
|
||||
const initialKey = useRef(conditionsKey).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (conditionsKey === initialKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (conditions.length === 0) {
|
||||
setPreviewData((current) => ({
|
||||
match_count: 0,
|
||||
total_uncategorized: current?.total_uncategorized ?? 0,
|
||||
transactions: [],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const handle = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await axios.post<PreviewResponse>(
|
||||
preview().url,
|
||||
{ conditions },
|
||||
);
|
||||
setPreviewData(data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 400);
|
||||
|
||||
return () => clearTimeout(handle);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [conditionsKey]);
|
||||
|
||||
const matchCount = previewData?.match_count ?? suggestion.group_size;
|
||||
|
||||
const selectedCategory = categories.find((c) => c.id === draft.categoryId);
|
||||
const categoryLabel =
|
||||
selectedCategory?.name ??
|
||||
(suggestion.new_category_name
|
||||
? __('New: :name', { name: suggestion.new_category_name })
|
||||
: '—');
|
||||
|
||||
const valuesSummary = draft.values
|
||||
.filter((value) => value.token.trim() !== '')
|
||||
.map((value) => `“${value.token}”`)
|
||||
.join(` ${__('or')} `);
|
||||
|
||||
const updateValue = (index: number, patch: Partial<ValueDraft>) => {
|
||||
onChange({
|
||||
...draft,
|
||||
values: draft.values.map((value, i) =>
|
||||
i === index ? { ...value, ...patch } : value,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const removeValue = (index: number) => {
|
||||
onChange({
|
||||
...draft,
|
||||
values: draft.values.filter((_, i) => i !== index),
|
||||
});
|
||||
};
|
||||
|
||||
const addValue = () => {
|
||||
onChange({
|
||||
...draft,
|
||||
values: [
|
||||
...draft.values,
|
||||
{ field: 'description', operator: 'contains', token: '' },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const openPreview = async () => {
|
||||
setOpen(true);
|
||||
|
||||
if (previewData || conditions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await axios.post<PreviewResponse>(preview().url, {
|
||||
conditions,
|
||||
});
|
||||
setPreviewData(data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border bg-card">
|
||||
{/* Collapsed header: select + summary + expand toggle */}
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
<Checkbox
|
||||
checked={draft.include}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...draft, include: checked === true })
|
||||
}
|
||||
aria-label={__('Include this rule')}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
aria-expanded={expanded}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
<span className="font-medium">{valuesSummary}</span>
|
||||
<span className="text-muted-foreground"> → </span>
|
||||
<span>{categoryLabel}</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{__(':count matches', { count: matchCount })}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`size-4 shrink-0 text-muted-foreground transition-transform ${expanded ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expanded body: edit values, category, preview */}
|
||||
{expanded && (
|
||||
<div className="space-y-3 border-t p-4">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{__('If the transaction matches any of')}
|
||||
</Label>
|
||||
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
{draft.values.map((value, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col gap-2 sm:flex-row sm:items-center"
|
||||
>
|
||||
<Input
|
||||
value={__(
|
||||
FIELD_LABELS[value.field] ??
|
||||
'Description',
|
||||
)}
|
||||
className="h-9 w-full"
|
||||
disabled
|
||||
/>
|
||||
<Input
|
||||
value={operatorLabel(value.operator)}
|
||||
className="h-9 w-full"
|
||||
disabled
|
||||
/>
|
||||
<Input
|
||||
value={value.token}
|
||||
onChange={(event) =>
|
||||
updateValue(index, {
|
||||
token: event.target.value,
|
||||
})
|
||||
}
|
||||
className="h-9 w-full"
|
||||
aria-label={__('Match text')}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeValue(index)}
|
||||
disabled={draft.values.length === 1}
|
||||
aria-label={__('Remove value')}
|
||||
className="size-9 shrink-0"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={addValue}
|
||||
className="gap-2"
|
||||
>
|
||||
<Plus className="size-4 shrink-0" />
|
||||
{__('Add value')}
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="pb-1 text-xs text-muted-foreground">
|
||||
{__('Categorize as')}
|
||||
</Label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CategoryCombobox
|
||||
value={draft.categoryId}
|
||||
onValueChange={(value) =>
|
||||
onChange({ ...draft, categoryId: value })
|
||||
}
|
||||
categories={categories}
|
||||
/>
|
||||
{!draft.categoryId &&
|
||||
suggestion.new_category_name && (
|
||||
<Badge className="gap-1 bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300">
|
||||
<Sparkles className="size-3" />
|
||||
{__('New: :name', {
|
||||
name: suggestion.new_category_name,
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={openPreview}
|
||||
className="w-full justify-center gap-2"
|
||||
>
|
||||
<TextSearch className="size-4 shrink-0" />
|
||||
<span className="truncate">
|
||||
{__('Preview :count matching transactions', {
|
||||
count: matchCount,
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-h-[85vh] gap-0 overflow-hidden p-0 sm:max-w-2xl">
|
||||
<DialogHeader className="space-y-1 p-6 pb-4">
|
||||
<DialogTitle>{__('Matching transactions')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{previewData
|
||||
? __(
|
||||
':count of :total uncategorized transactions match',
|
||||
{
|
||||
count: previewData.match_count,
|
||||
total: previewData.total_uncategorized,
|
||||
},
|
||||
)
|
||||
: __('Loading…')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[60vh] overflow-y-auto border-t">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 p-8 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{__('Loading…')}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background">
|
||||
<TableRow>
|
||||
<TableHead>{__('Date')}</TableHead>
|
||||
<TableHead>
|
||||
{__('Description')}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{__('Amount')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{previewData?.transactions.map(
|
||||
(transaction) => (
|
||||
<TableRow key={transaction.id}>
|
||||
<TableCell className="whitespace-nowrap text-muted-foreground">
|
||||
{formatDate(
|
||||
transaction.transaction_date,
|
||||
'd MMM yyyy',
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[18rem] truncate">
|
||||
{transaction.description ??
|
||||
'—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<AmountDisplay
|
||||
amountInCents={
|
||||
transaction.amount
|
||||
}
|
||||
currencyCode={
|
||||
transaction.currency_code
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { StepAiSuggestions } from './step-ai-suggestions';
|
||||
|
||||
const UPGRADE_NOTICE =
|
||||
"AI suggestions are a Standard Plan feature. You'll choose a plan at the end of the onboarding.";
|
||||
|
||||
// Mutable so each test can flip whether an upgrade is required before render.
|
||||
const state = {
|
||||
available: true,
|
||||
consented: false,
|
||||
requires_upgrade: true,
|
||||
eligible: true,
|
||||
transaction_count: 0,
|
||||
min_transactions: 50,
|
||||
auto_select_confidence: 0.8,
|
||||
throttled: false,
|
||||
throttled_until: null,
|
||||
run: null,
|
||||
suggestions: [],
|
||||
};
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: {
|
||||
get: () => Promise.resolve({ data: state }),
|
||||
post: () => new Promise(() => {}),
|
||||
isAxiosError: () => false,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@inertiajs/react', () => ({
|
||||
router: { reload: vi.fn() },
|
||||
usePage: () => ({
|
||||
props: {
|
||||
locale: 'en',
|
||||
pricing: {
|
||||
plans: {
|
||||
yearly: {
|
||||
name: 'Standard Yearly',
|
||||
price: 23.88,
|
||||
original_price: 47.88,
|
||||
stripe_lookup_key: null,
|
||||
billing_period: 'year',
|
||||
features: [],
|
||||
},
|
||||
},
|
||||
defaultPlan: 'yearly',
|
||||
bestValuePlan: 'yearly',
|
||||
promo: { enabled: false, code: '', description: '', badge: '' },
|
||||
currency: 'EUR',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('StepAiSuggestions upgrade notice', () => {
|
||||
it('warns free users that AI suggestions require a paid plan', async () => {
|
||||
state.requires_upgrade = true;
|
||||
render(<StepAiSuggestions categories={[]} onComplete={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText(UPGRADE_NOTICE)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the notice when no upgrade is required', async () => {
|
||||
state.requires_upgrade = false;
|
||||
render(<StepAiSuggestions categories={[]} onComplete={vi.fn()} />);
|
||||
|
||||
expect(
|
||||
await screen.findByText('Suggest my rules with AI'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(UPGRADE_NOTICE)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { act, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { StepAiSuggestions } from './step-ai-suggestions';
|
||||
|
||||
// Keep the initial state request pending so the component stays in its
|
||||
// "generating" branch for the duration of the test.
|
||||
vi.mock('axios', () => ({
|
||||
default: {
|
||||
get: () => new Promise(() => {}),
|
||||
post: () => new Promise(() => {}),
|
||||
isAxiosError: () => false,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@inertiajs/react', () => ({
|
||||
router: { reload: vi.fn() },
|
||||
}));
|
||||
|
||||
describe('StepAiSuggestions generating state', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function renderStep() {
|
||||
return render(
|
||||
<StepAiSuggestions categories={[]} onComplete={vi.fn()} />,
|
||||
);
|
||||
}
|
||||
|
||||
it('renders skeleton cards and the duration hint while generating', () => {
|
||||
renderStep();
|
||||
|
||||
expect(
|
||||
screen.getByText('This can take up to two minutes.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Analysing your transactions…'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('advances through the status messages over time', () => {
|
||||
renderStep();
|
||||
|
||||
expect(
|
||||
screen.getByText('Analysing your transactions…'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3500);
|
||||
});
|
||||
expect(screen.getByText('Finding related groups…')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3500);
|
||||
});
|
||||
expect(
|
||||
screen.getByText('Finding the right categories…'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('holds on the final message instead of looping back', () => {
|
||||
renderStep();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3500 * 10);
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText('Polishing your suggestions…'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Analysing your transactions…'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,523 @@
|
|||
import {
|
||||
AiSuggestion,
|
||||
AiSuggestionCard,
|
||||
SuggestionDraft,
|
||||
} from '@/components/onboarding/ai-suggestion-card';
|
||||
import { StepButton } from '@/components/onboarding/step-button';
|
||||
import { StepHeader } from '@/components/onboarding/step-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { store as storeConsent } from '@/routes/ai/consent';
|
||||
import { accept, generate, show } from '@/routes/ai/rule-suggestions';
|
||||
import { type SharedData } from '@/types';
|
||||
import { type Category } from '@/types/category';
|
||||
import { formatCurrency } from '@/utils/currency';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { router, usePage } from '@inertiajs/react';
|
||||
import axios from 'axios';
|
||||
import { Loader2, PartyPopper, Sparkles, Wand2 } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
interface SuggestionState {
|
||||
available: boolean;
|
||||
consented: boolean;
|
||||
requires_upgrade: boolean;
|
||||
eligible: boolean;
|
||||
transaction_count: number;
|
||||
min_transactions: number;
|
||||
auto_select_confidence: number;
|
||||
throttled: boolean;
|
||||
throttled_until: string | null;
|
||||
run: { id: string; status: string; suggestions_count: number } | null;
|
||||
suggestions: AiSuggestion[];
|
||||
}
|
||||
|
||||
interface AcceptResponse {
|
||||
summary: { rules_created: number; transactions_categorized: number };
|
||||
applied_to_existing: boolean;
|
||||
}
|
||||
|
||||
interface StepAiSuggestionsProps {
|
||||
categories: Category[];
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export function StepAiSuggestions({
|
||||
categories,
|
||||
onComplete,
|
||||
}: StepAiSuggestionsProps) {
|
||||
const [state, setState] = useState<SuggestionState | null>(null);
|
||||
const [drafts, setDrafts] = useState<Record<string, SuggestionDraft>>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [summary, setSummary] = useState<AcceptResponse['summary'] | null>(
|
||||
null,
|
||||
);
|
||||
const pollRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const onCompleteRef = useRef(onComplete);
|
||||
onCompleteRef.current = onComplete;
|
||||
|
||||
const applyState = useCallback((data: SuggestionState) => {
|
||||
setState(data);
|
||||
setDrafts((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const suggestion of data.suggestions) {
|
||||
if (!next[suggestion.id]) {
|
||||
next[suggestion.id] = {
|
||||
// Auto-select only confident suggestions; weaker ones
|
||||
// are shown but left for the user to opt into.
|
||||
include:
|
||||
suggestion.confidence >=
|
||||
data.auto_select_confidence,
|
||||
categoryId: suggestion.proposed_category?.id ?? null,
|
||||
values: suggestion.values.map((value) => ({
|
||||
field: value.match_field,
|
||||
operator: value.match_operator,
|
||||
token: value.match_token,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isRunning = (data: SuggestionState | null): boolean =>
|
||||
data?.run?.status === 'pending' || data?.run?.status === 'processing';
|
||||
|
||||
const poll = useCallback(async () => {
|
||||
const { data } = await axios.get<SuggestionState>(show().url);
|
||||
applyState(data);
|
||||
if (isRunning(data)) {
|
||||
pollRef.current = setTimeout(poll, 3000);
|
||||
}
|
||||
}, [applyState]);
|
||||
|
||||
const startGenerate = useCallback(async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const { data } = await axios.post<SuggestionState>(generate().url);
|
||||
applyState(data);
|
||||
if (isRunning(data)) {
|
||||
poll();
|
||||
}
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 422) {
|
||||
applyState(error.response.data as SuggestionState);
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [applyState, poll]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const { data } = await axios.get<SuggestionState>(show().url);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
applyState(data);
|
||||
|
||||
if (isRunning(data)) {
|
||||
poll();
|
||||
} else if (
|
||||
data.consented &&
|
||||
data.eligible &&
|
||||
!data.throttled &&
|
||||
!data.run
|
||||
) {
|
||||
startGenerate();
|
||||
}
|
||||
} catch {
|
||||
// Never block onboarding if the AI step can't load.
|
||||
onCompleteRef.current();
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(pollRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const acceptConsent = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await axios.post(storeConsent().url);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
startGenerate();
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chosen = state.suggestions.filter((s) => {
|
||||
const draft = drafts[s.id];
|
||||
return (
|
||||
draft?.include &&
|
||||
draft.values.some((value) => value.token.trim() !== '')
|
||||
);
|
||||
});
|
||||
|
||||
if (chosen.length === 0) {
|
||||
onCompleteRef.current();
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const payload = chosen.map((suggestion) => {
|
||||
const draft = drafts[suggestion.id];
|
||||
const categoryId =
|
||||
draft.categoryId && draft.categoryId !== 'uncategorized'
|
||||
? draft.categoryId
|
||||
: null;
|
||||
|
||||
return {
|
||||
ids: suggestion.values.map((value) => value.id),
|
||||
values: draft.values
|
||||
.filter((value) => value.token.trim() !== '')
|
||||
.map((value) => ({
|
||||
match_field: value.field,
|
||||
match_operator: value.operator,
|
||||
match_token: value.token.trim(),
|
||||
})),
|
||||
proposed_category_id: categoryId,
|
||||
new_category_name: categoryId
|
||||
? null
|
||||
: suggestion.new_category_name,
|
||||
new_category_direction: categoryId
|
||||
? null
|
||||
: suggestion.new_category_direction,
|
||||
};
|
||||
});
|
||||
|
||||
const { data } = await axios.post<AcceptResponse>(accept().url, {
|
||||
suggestions: payload,
|
||||
});
|
||||
|
||||
// The rules were created via axios, so refresh the Inertia props the
|
||||
// later onboarding steps rely on (newly created rules + categories,
|
||||
// and the transactions that just got categorized) before advancing.
|
||||
router.reload({
|
||||
only: ['automationRules', 'categories', 'transactions'],
|
||||
onFinish: () => {
|
||||
setSummary(data.summary);
|
||||
setSubmitting(false);
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Render states -----------------------------------------------------
|
||||
|
||||
if (summary) {
|
||||
return (
|
||||
<Centered>
|
||||
<StepHeader
|
||||
icon={PartyPopper}
|
||||
iconContainerClassName="bg-gradient-to-br from-emerald-400 to-green-500"
|
||||
title={__('Rules created')}
|
||||
description={__(
|
||||
'We created :rules rules and categorized :count transactions for you.',
|
||||
{
|
||||
rules: summary.rules_created,
|
||||
count: summary.transactions_categorized,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
<StepButton text={__('Continue')} onClick={onComplete} />
|
||||
</Centered>
|
||||
);
|
||||
}
|
||||
|
||||
if (!state || busy || isRunning(state)) {
|
||||
return (
|
||||
<Centered>
|
||||
<StepHeader
|
||||
icon={Wand2}
|
||||
iconContainerClassName="bg-gradient-to-br from-violet-500 to-purple-600"
|
||||
title={__('Looking for patterns')}
|
||||
description={__(
|
||||
'We’re finding the rules that will categorize most of your transactions automatically.',
|
||||
)}
|
||||
/>
|
||||
<GeneratingMessages />
|
||||
<div className="w-full max-w-2xl space-y-3">
|
||||
<SuggestionCardSkeleton />
|
||||
<SuggestionCardSkeleton />
|
||||
<SuggestionCardSkeleton />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('This can take up to two minutes.')}
|
||||
</p>
|
||||
</Centered>
|
||||
);
|
||||
}
|
||||
|
||||
if (!state.consented) {
|
||||
return (
|
||||
<Centered>
|
||||
<StepHeader
|
||||
icon={Sparkles}
|
||||
iconContainerClassName="bg-gradient-to-br from-violet-500 to-purple-600"
|
||||
title={__('Let AI organize your money')}
|
||||
description={__(
|
||||
'With your permission, we’ll send merchant names from your transactions to our AI provider to suggest categorization rules. We never send your full financial picture, and you review every rule before it’s created.',
|
||||
)}
|
||||
/>
|
||||
{state.requires_upgrade && <UpgradeNotice />}
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<StepButton
|
||||
text={__('Suggest my rules with AI')}
|
||||
onClick={acceptConsent}
|
||||
loading={busy}
|
||||
/>
|
||||
<Button variant="ghost" onClick={onComplete}>
|
||||
{__('No thanks')}
|
||||
</Button>
|
||||
</div>
|
||||
</Centered>
|
||||
);
|
||||
}
|
||||
|
||||
if (!state.eligible) {
|
||||
return (
|
||||
<Centered>
|
||||
<StepHeader
|
||||
icon={Sparkles}
|
||||
iconContainerClassName="bg-gradient-to-br from-violet-500 to-purple-600"
|
||||
title={__('AI suggestions need more data')}
|
||||
description={__(
|
||||
'Once you have at least :count transactions, you can generate rule suggestions from Settings → Automation rules.',
|
||||
{ count: state.min_transactions },
|
||||
)}
|
||||
/>
|
||||
<StepButton text={__('Continue')} onClick={onComplete} />
|
||||
</Centered>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.run?.status === 'failed') {
|
||||
return (
|
||||
<Centered>
|
||||
<StepHeader
|
||||
icon={Wand2}
|
||||
iconContainerClassName="bg-gradient-to-br from-violet-500 to-purple-600"
|
||||
title={__('We couldn’t generate suggestions')}
|
||||
description={__(
|
||||
'Something went wrong. You can try again or skip for now.',
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<StepButton
|
||||
text={__('Try again')}
|
||||
onClick={startGenerate}
|
||||
/>
|
||||
<Button variant="ghost" onClick={onComplete}>
|
||||
{__('Skip for now')}
|
||||
</Button>
|
||||
</div>
|
||||
</Centered>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.run?.status === 'empty' || state.suggestions.length === 0) {
|
||||
return (
|
||||
<Centered>
|
||||
<StepHeader
|
||||
icon={Sparkles}
|
||||
iconContainerClassName="bg-gradient-to-br from-violet-500 to-purple-600"
|
||||
title={__('No clear patterns yet')}
|
||||
description={__(
|
||||
'We couldn’t find confident rules to suggest right now. You can categorize your transactions in the next step.',
|
||||
)}
|
||||
/>
|
||||
<StepButton text={__('Continue')} onClick={onComplete} />
|
||||
</Centered>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedCount = state.suggestions.filter(
|
||||
(s) => drafts[s.id]?.include,
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="flex animate-in flex-col items-center pb-4 duration-500 fade-in slide-in-from-bottom-4">
|
||||
<StepHeader
|
||||
icon={Wand2}
|
||||
iconContainerClassName="bg-gradient-to-br from-violet-500 to-purple-600"
|
||||
title={__('Review your suggested rules')}
|
||||
description={__(
|
||||
'We found these patterns. Tweak anything you like, then create the rules — we’ll apply them to your transactions right away.',
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mb-5 w-full max-w-2xl space-y-3">
|
||||
{state.suggestions.map((suggestion) => (
|
||||
<AiSuggestionCard
|
||||
key={suggestion.id}
|
||||
suggestion={suggestion}
|
||||
draft={
|
||||
drafts[suggestion.id] ?? {
|
||||
include:
|
||||
suggestion.confidence >=
|
||||
state.auto_select_confidence,
|
||||
categoryId:
|
||||
suggestion.proposed_category?.id ?? null,
|
||||
values: suggestion.values.map((value) => ({
|
||||
field: value.match_field,
|
||||
operator: value.match_operator,
|
||||
token: value.match_token,
|
||||
})),
|
||||
}
|
||||
}
|
||||
categories={categories}
|
||||
onChange={(draft) =>
|
||||
setDrafts((prev) => ({
|
||||
...prev,
|
||||
[suggestion.id]: draft,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<StepButton
|
||||
text={
|
||||
selectedCount > 0
|
||||
? __('Create :count rules & apply', {
|
||||
count: selectedCount,
|
||||
})
|
||||
: __('Continue')
|
||||
}
|
||||
onClick={submit}
|
||||
loading={submitting}
|
||||
loadingText={__('Applying…')}
|
||||
/>
|
||||
<Button variant="ghost" onClick={onComplete}>
|
||||
{__('Skip for now')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Warns free users (who haven't linked a bank yet) that turning on AI
|
||||
* suggestions commits them to picking a paid plan at the end of onboarding,
|
||||
* mirroring the notice shown when choosing a connected account.
|
||||
*/
|
||||
function UpgradeNotice() {
|
||||
const { pricing, locale } = usePage<SharedData>().props;
|
||||
|
||||
const cheapestMonthlyPrice = useMemo(() => {
|
||||
const plans = Object.values(pricing.plans);
|
||||
if (plans.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.min(
|
||||
...plans.map((plan) =>
|
||||
plan.billing_period === 'year' ? plan.price / 12 : plan.price,
|
||||
),
|
||||
);
|
||||
}, [pricing.plans]);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md rounded-lg border border-emerald-100 bg-emerald-50 p-3 dark:border-emerald-900/50 dark:bg-emerald-900/20">
|
||||
<p className="text-center text-balance text-sm text-emerald-700 dark:text-emerald-300">
|
||||
{__(
|
||||
"AI suggestions are a Standard Plan feature. You'll choose a plan at the end of the onboarding.",
|
||||
)}
|
||||
</p>
|
||||
{cheapestMonthlyPrice !== null && (
|
||||
<p className="mt-1 text-center text-xs font-medium text-emerald-600 dark:text-emerald-400">
|
||||
{__('From')}{' '}
|
||||
{formatCurrency(
|
||||
cheapestMonthlyPrice * 100,
|
||||
pricing.currency,
|
||||
locale,
|
||||
)}
|
||||
{__('/month')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Centered({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex animate-in flex-col items-center gap-6 pb-4 duration-500 fade-in slide-in-from-bottom-4">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const GENERATING_MESSAGE_INTERVAL_MS = 3500;
|
||||
|
||||
/**
|
||||
* Cycles through reassuring status messages while a run is in flight. The
|
||||
* backend exposes no real progress, so the messages step forward on a timer and
|
||||
* hold on the last one rather than looping back to the start (which would read
|
||||
* as the process restarting).
|
||||
*/
|
||||
function GeneratingMessages() {
|
||||
const messages = [
|
||||
__('Analysing your transactions…'),
|
||||
__('Finding related groups…'),
|
||||
__('Finding the right categories…'),
|
||||
__('Grouping everything together…'),
|
||||
__('Polishing your suggestions…'),
|
||||
];
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setIndex((current) => Math.min(current + 1, messages.length - 1));
|
||||
}, GENERATING_MESSAGE_INTERVAL_MS);
|
||||
return () => clearInterval(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<Loader2 className="size-4 shrink-0 animate-spin text-violet-500" />
|
||||
<span
|
||||
key={index}
|
||||
className="animate-in duration-300 fade-in"
|
||||
aria-live="polite"
|
||||
>
|
||||
{messages[index]}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the collapsed {@link AiSuggestionCard} layout so the loading state
|
||||
* resembles the final UI: checkbox, summary line, match count, expand chevron.
|
||||
*/
|
||||
function SuggestionCardSkeleton() {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-xl border bg-card p-3">
|
||||
<Skeleton className="size-4 shrink-0 rounded" />
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<Skeleton className="h-4 w-32 max-w-[45%]" />
|
||||
<Skeleton className="h-4 w-20 max-w-[30%]" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-14 shrink-0" />
|
||||
<Skeleton className="size-4 shrink-0 rounded" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,64 @@
|
|||
import { act, renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useOnboardingState } from './use-onboarding-state';
|
||||
|
||||
describe('useOnboardingState', () => {
|
||||
describe('step URL sync', () => {
|
||||
beforeEach(() => {
|
||||
window.history.replaceState(null, '', '/onboarding');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.history.replaceState(null, '', '/onboarding');
|
||||
});
|
||||
|
||||
it('reflects the current step in the ?step= query param', () => {
|
||||
renderHook(() => useOnboardingState());
|
||||
|
||||
expect(
|
||||
new URLSearchParams(window.location.search).get('step'),
|
||||
).toBe('welcome');
|
||||
});
|
||||
|
||||
it('updates the ?step= query param when the step advances', () => {
|
||||
const { result } = renderHook(() => useOnboardingState());
|
||||
|
||||
act(() => {
|
||||
result.current.goNext();
|
||||
});
|
||||
|
||||
expect(
|
||||
new URLSearchParams(window.location.search).get('step'),
|
||||
).toBe('account-types');
|
||||
});
|
||||
|
||||
it('reflects a step reached via goToStep', () => {
|
||||
const { result } = renderHook(() => useOnboardingState());
|
||||
|
||||
act(() => {
|
||||
result.current.goToStep('import-balances');
|
||||
});
|
||||
|
||||
expect(
|
||||
new URLSearchParams(window.location.search).get('step'),
|
||||
).toBe('import-balances');
|
||||
});
|
||||
|
||||
it('preserves other query params when syncing the step', () => {
|
||||
window.history.replaceState(null, '', '/onboarding?ref=email');
|
||||
|
||||
const { result } = renderHook(() => useOnboardingState());
|
||||
|
||||
act(() => {
|
||||
result.current.goToStep('syncing');
|
||||
});
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
expect(params.get('step')).toBe('syncing');
|
||||
expect(params.get('ref')).toBe('email');
|
||||
});
|
||||
});
|
||||
|
||||
it('tracks when connected account setup has been selected', () => {
|
||||
const { result } = renderHook(() => useOnboardingState());
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export type OnboardingStep =
|
|||
| 'customize-categories'
|
||||
| 'smart-rules'
|
||||
| 'syncing'
|
||||
| 'ai-suggestions'
|
||||
| 'import-transactions'
|
||||
| 'import-balances'
|
||||
| 'categorize-transactions'
|
||||
|
|
@ -22,6 +23,7 @@ const PRIMARY_STEPS: OnboardingStep[] = [
|
|||
'category-types',
|
||||
'smart-rules',
|
||||
'syncing',
|
||||
'ai-suggestions',
|
||||
'categorize-transactions',
|
||||
'complete',
|
||||
];
|
||||
|
|
@ -82,6 +84,21 @@ export function useOnboardingState(options: UseOnboardingStateOptions = {}) {
|
|||
}
|
||||
}, [hasConnectedAccount]);
|
||||
|
||||
// Keep the ?step= query param in sync with the current step so a manual
|
||||
// refresh returns the user to the step they were on. Use replaceState to
|
||||
// avoid polluting browser history and preserve Inertia's stored page state.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.get('step') === currentStep) {
|
||||
return;
|
||||
}
|
||||
url.searchParams.set('step', currentStep);
|
||||
window.history.replaceState(window.history.state, '', url.toString());
|
||||
}, [currentStep]);
|
||||
|
||||
// Calculate step index for progress indicator
|
||||
// Sub-steps (import-transactions, import-balances) use the same index as 'create-account'
|
||||
const stepIndex = useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export default function OnboardingLayout({
|
|||
|
||||
<main
|
||||
className={cn(
|
||||
'flex flex-1 flex-col items-center justify-start px-4 pt-12 md:px-6',
|
||||
'flex flex-1 flex-col items-center justify-start px-4 pt-12 pb-12 md:px-6',
|
||||
align === 'center' && 'sm:justify-center',
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { StepAccountTypes } from '@/components/onboarding/step-account-types';
|
||||
import { StepAiSuggestions } from '@/components/onboarding/step-ai-suggestions';
|
||||
import { StepCategorizeTransactions } from '@/components/onboarding/step-categorize-transactions';
|
||||
import { StepCategoryTypes } from '@/components/onboarding/step-category-types';
|
||||
import { StepComplete } from '@/components/onboarding/step-complete';
|
||||
|
|
@ -57,6 +58,7 @@ const VALID_STEPS: OnboardingStep[] = [
|
|||
'customize-categories',
|
||||
'smart-rules',
|
||||
'syncing',
|
||||
'ai-suggestions',
|
||||
'categorize-transactions',
|
||||
'complete',
|
||||
];
|
||||
|
|
@ -217,6 +219,14 @@ export default function Onboarding({
|
|||
case 'syncing':
|
||||
return <StepSyncing onComplete={goNext} />;
|
||||
|
||||
case 'ai-suggestions':
|
||||
return (
|
||||
<StepAiSuggestions
|
||||
categories={categories}
|
||||
onComplete={goNext}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'import-transactions':
|
||||
return (
|
||||
<StepImportTransactions
|
||||
|
|
@ -261,6 +271,7 @@ export default function Onboarding({
|
|||
'customize-categories': __('Customize Categories'),
|
||||
'smart-rules': __('Smart Rules'),
|
||||
syncing: __('Syncing'),
|
||||
'ai-suggestions': __('AI Suggestions'),
|
||||
'import-transactions': __('Import Transactions'),
|
||||
'import-balances': __('Set Balance'),
|
||||
'categorize-transactions': __('Categorize Transactions'),
|
||||
|
|
|
|||
|
|
@ -360,7 +360,11 @@ function PricingSection({
|
|||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{selectedPlanData && (
|
||||
<FeaturesSection features={selectedPlanData.features} />
|
||||
<FeaturesSection
|
||||
features={selectedPlanData.features.filter(
|
||||
(feature) => feature !== 'Connect bank accounts',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
ShoppingBasketIcon,
|
||||
WineIcon,
|
||||
WrenchIcon,
|
||||
XIcon,
|
||||
} from 'lucide-react';
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
|
|
@ -1653,7 +1654,19 @@ function BudgetEditPreview() {
|
|||
);
|
||||
}
|
||||
|
||||
function FreePlanCard({ planFeatures }: { planFeatures: string[] }) {
|
||||
/**
|
||||
* Features reserved for paid plans. On the free plan card these are shown
|
||||
* dimmed with an X to set expectations before sign-up.
|
||||
*/
|
||||
const PRO_ONLY_FEATURES = [
|
||||
'Connect bank accounts',
|
||||
'AI Suggestions',
|
||||
'Priority support',
|
||||
];
|
||||
|
||||
function FreePlanCard({ features }: { features: string[] }) {
|
||||
const excluded = new Set(PRO_ONLY_FEATURES);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden rounded-2xl border border-[#e3e3e0] bg-[#FDFDFC] dark:border-[#3E3E3A] dark:bg-[#161615]">
|
||||
<div className="flex flex-1 flex-col p-6 pt-2 sm:pt-12">
|
||||
|
|
@ -1676,13 +1689,31 @@ function FreePlanCard({ planFeatures }: { planFeatures: string[] }) {
|
|||
|
||||
<div className="my-5 h-px bg-[#e3e3e0] dark:bg-[#3E3E3A]" />
|
||||
|
||||
<p className="mt-1 text-xs text-[#706f6c] opacity-0 dark:text-[#A1A09A]">
|
||||
{__('Create the account now, update at any moment')}
|
||||
</p>
|
||||
|
||||
<ul className="flex-1 space-y-2.5">
|
||||
{planFeatures.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-2.5">
|
||||
<CheckIcon className="size-4 shrink-0 text-[#1b1b18] dark:text-[#EDEDEC]" />
|
||||
<span className="text-sm">{__(feature)}</span>
|
||||
</li>
|
||||
))}
|
||||
{features.map((feature) => {
|
||||
const isExcluded = excluded.has(feature);
|
||||
|
||||
return (
|
||||
<li
|
||||
key={feature}
|
||||
className={cn(
|
||||
'flex items-center gap-2.5',
|
||||
isExcluded && 'opacity-40',
|
||||
)}
|
||||
>
|
||||
{isExcluded ? (
|
||||
<XIcon className="size-4 shrink-0 text-[#706f6c] dark:text-[#A1A09A]" />
|
||||
) : (
|
||||
<CheckIcon className="size-4 shrink-0 text-[#1b1b18] dark:text-[#EDEDEC]" />
|
||||
)}
|
||||
<span className="text-sm">{__(feature)}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<Link href="/register" className="mt-8">
|
||||
|
|
@ -1785,17 +1816,12 @@ function LandingPlanCard({
|
|||
<div className="my-5 h-px bg-[#e3e3e0] dark:bg-[#3E3E3A]" />
|
||||
|
||||
<ul className="flex-1 space-y-2.5">
|
||||
{[__('Connect bank accounts'), ...plan.features].map(
|
||||
(feature) => (
|
||||
<li
|
||||
key={feature}
|
||||
className="flex items-center gap-2.5"
|
||||
>
|
||||
<CheckIcon className="size-4 shrink-0 text-[#1b1b18] dark:text-[#EDEDEC]" />
|
||||
<span className="text-sm">{__(feature)}</span>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
{plan.features.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-2.5">
|
||||
<CheckIcon className="size-4 shrink-0 text-[#1b1b18] dark:text-[#EDEDEC]" />
|
||||
<span className="text-sm">{__(feature)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Link href="/register" className="mt-8">
|
||||
|
|
@ -2689,9 +2715,9 @@ export default function Welcome({
|
|||
)}
|
||||
>
|
||||
<FreePlanCard
|
||||
planFeatures={planEntries[0][1].features.filter(
|
||||
(f) => f !== 'Priority support',
|
||||
)}
|
||||
features={[
|
||||
...planEntries[0][1].features,
|
||||
]}
|
||||
/>
|
||||
{displayedPlanEntries.map(
|
||||
([key, plan]) => (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\AccountController;
|
||||
use App\Http\Controllers\Ai\AiConsentController;
|
||||
use App\Http\Controllers\Ai\RuleSuggestionController;
|
||||
use App\Http\Controllers\Auth\VerifyEmailController;
|
||||
use App\Http\Controllers\BudgetController;
|
||||
use App\Http\Controllers\CashflowController;
|
||||
|
|
@ -108,6 +110,16 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||
Route::post('transactions', [TransactionController::class, 'store'])->name('transactions.store');
|
||||
Route::patch('transactions/bulk', [TransactionController::class, 'bulkUpdate'])->name('transactions.bulk-update');
|
||||
Route::patch('transactions/{transaction}', [TransactionController::class, 'update'])->name('transactions.update');
|
||||
|
||||
// AI rule suggestions — accessible during onboarding (auto-apply) and after.
|
||||
Route::post('ai/consent', [AiConsentController::class, 'store'])->name('ai.consent.store');
|
||||
Route::delete('ai/consent', [AiConsentController::class, 'destroy'])->name('ai.consent.destroy');
|
||||
Route::prefix('ai/rule-suggestions')->name('ai.rule-suggestions.')->group(function () {
|
||||
Route::get('/', [RuleSuggestionController::class, 'show'])->name('show');
|
||||
Route::post('generate', [RuleSuggestionController::class, 'generate'])->name('generate');
|
||||
Route::post('preview', [RuleSuggestionController::class, 'preview'])->name('preview');
|
||||
Route::post('accept', [RuleSuggestionController::class, 'accept'])->name('accept');
|
||||
});
|
||||
});
|
||||
|
||||
Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(function () {
|
||||
|
|
|
|||
|
|
@ -550,6 +550,11 @@ it('completes entire onboarding flow with account creation, transaction import,
|
|||
->click('Continue')
|
||||
->wait(3); // syncing step reloads transactions — allow time for axios + router.reload
|
||||
|
||||
// AI Suggestions - decline the consent prompt to continue without generating
|
||||
$page->assertSee('Let AI organize your money')
|
||||
->click('No thanks')
|
||||
->wait(2);
|
||||
|
||||
// Categorize Transactions - 5 CSV transactions are loaded after the syncing step reloads
|
||||
$page->assertSee('Categorize Your Transactions')
|
||||
->click("Let's start")
|
||||
|
|
|
|||
|
|
@ -148,14 +148,16 @@ async function driveSabadell(page) {
|
|||
async function driveBbva(page) {
|
||||
await page.getByRole('button', { name: EB_CONTINUE }).click();
|
||||
await page.waitForTimeout(5000);
|
||||
const textboxes = await page.getByRole('textbox').all();
|
||||
await textboxes[0].fill(BBVA_USER);
|
||||
await textboxes[1].fill(BBVA_PASS);
|
||||
await page.getByRole('button', { name: 'Submit' }).first().click();
|
||||
// The BBVA mockup has no labels/placeholders and its language varies between
|
||||
// runs ("Submit"/"Enviar"), so target the stable input/button ids.
|
||||
await page.locator('#username').fill(BBVA_USER);
|
||||
await page.locator('#password').fill(BBVA_PASS);
|
||||
await page.locator('#mybutton').click();
|
||||
await page.waitForTimeout(6000);
|
||||
await page.getByRole('textbox', { name: 'SMS Code' }).fill(OTP);
|
||||
// SCA ("extra security measure") one-time-code step.
|
||||
await page.locator('#clave-acceso').fill(OTP);
|
||||
await page.waitForTimeout(300);
|
||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||
await page.locator('#submit').click();
|
||||
await page.waitForTimeout(10000);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\Category;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use App\Services\Ai\ApplyRuleSuggestions;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->account = Account::factory()->for($this->user)->create();
|
||||
$this->action = app(ApplyRuleSuggestions::class);
|
||||
|
||||
$this->makeTxn = function (array $attributes): Transaction {
|
||||
return Transaction::factory()->for($this->user)->create(array_merge([
|
||||
'account_id' => $this->account->id,
|
||||
'category_id' => null,
|
||||
'description_iv' => null,
|
||||
], $attributes));
|
||||
};
|
||||
|
||||
$this->group = fn (array $overrides = []): array => array_merge([
|
||||
'conditions' => [['field' => 'creditor_name', 'operator' => 'equals', 'token' => 'mercadona']],
|
||||
'proposed_category_id' => null,
|
||||
'new_category_name' => null,
|
||||
'new_category_direction' => null,
|
||||
'confidence' => 0.95,
|
||||
], $overrides);
|
||||
});
|
||||
|
||||
it('creates a rule and categorizes matching uncategorized transactions', function () {
|
||||
$groceries = Category::factory()->for($this->user)->create(['name' => 'Groceries', 'type' => 'expense']);
|
||||
|
||||
$transactions = collect(range(1, 5))->map(fn (int $i) => ($this->makeTxn)([
|
||||
'creditor_name' => 'MERCADONA',
|
||||
'description' => "MERCADONA {$i}",
|
||||
'amount' => -4000,
|
||||
]));
|
||||
|
||||
$result = $this->action->apply($this->user, [
|
||||
($this->group)(['proposed_category_id' => $groceries->id]),
|
||||
], applyToExisting: true);
|
||||
|
||||
expect($result)->toBe(['rules_created' => 1, 'transactions_categorized' => 5]);
|
||||
|
||||
$rule = AutomationRule::query()->where('user_id', $this->user->id)->first();
|
||||
expect($rule->action_category_id)->toBe($groceries->id)
|
||||
->and($rule->rules_json)->toBe(['==' => [['var' => 'creditor_name'], 'mercadona']]);
|
||||
|
||||
$transactions->each(fn (Transaction $t) => expect($t->fresh()->category_id)->toBe($groceries->id));
|
||||
});
|
||||
|
||||
it('merges values heading to the same category into one OR rule', function () {
|
||||
$online = Category::factory()->for($this->user)->create(['name' => 'Online services', 'type' => 'expense']);
|
||||
|
||||
collect(range(1, 2))->each(fn (int $i) => ($this->makeTxn)(['creditor_name' => null, 'description' => "LARAVEL FORGE {$i}", 'amount' => -1200]));
|
||||
collect(range(1, 3))->each(fn (int $i) => ($this->makeTxn)(['creditor_name' => null, 'description' => "DIGITALOCEAN {$i}", 'amount' => -500]));
|
||||
|
||||
$result = $this->action->apply($this->user, [
|
||||
($this->group)([
|
||||
'conditions' => [
|
||||
['field' => 'description', 'operator' => 'contains', 'token' => 'laravel forge'],
|
||||
['field' => 'description', 'operator' => 'contains', 'token' => 'digitalocean'],
|
||||
],
|
||||
'proposed_category_id' => $online->id,
|
||||
]),
|
||||
], applyToExisting: true);
|
||||
|
||||
expect($result)->toBe(['rules_created' => 1, 'transactions_categorized' => 5]);
|
||||
|
||||
$rule = AutomationRule::query()->where('user_id', $this->user->id)->first();
|
||||
expect($rule->rules_json)->toBe(['or' => [
|
||||
['in' => ['laravel forge', ['var' => 'description']]],
|
||||
['in' => ['digitalocean', ['var' => 'description']]],
|
||||
]]);
|
||||
});
|
||||
|
||||
it('creates a proposed new category before applying the rule', function () {
|
||||
collect(range(1, 4))->each(fn (int $i) => ($this->makeTxn)([
|
||||
'creditor_name' => null,
|
||||
'description' => "NETFLIX {$i}",
|
||||
'amount' => -1300,
|
||||
]));
|
||||
|
||||
$result = $this->action->apply($this->user, [
|
||||
($this->group)([
|
||||
'conditions' => [['field' => 'description', 'operator' => 'contains', 'token' => 'netflix']],
|
||||
'new_category_name' => 'Streaming',
|
||||
'new_category_direction' => 'outflow',
|
||||
'confidence' => 0.9,
|
||||
]),
|
||||
], applyToExisting: true);
|
||||
|
||||
$category = Category::query()->where('user_id', $this->user->id)->where('name', 'Streaming')->first();
|
||||
|
||||
expect($category)->not->toBeNull()
|
||||
->and($category->type->value)->toBe('expense')
|
||||
->and($category->cashflow_direction->value)->toBe('outflow')
|
||||
->and($result['transactions_categorized'])->toBe(4);
|
||||
});
|
||||
|
||||
it('creates the rule but does not categorize when applyToExisting is false', function () {
|
||||
$groceries = Category::factory()->for($this->user)->create(['type' => 'expense']);
|
||||
$txn = ($this->makeTxn)(['creditor_name' => 'MERCADONA', 'description' => 'MERCADONA', 'amount' => -4000]);
|
||||
|
||||
$result = $this->action->apply($this->user, [
|
||||
($this->group)(['proposed_category_id' => $groceries->id]),
|
||||
], applyToExisting: false);
|
||||
|
||||
expect($result['rules_created'])->toBe(1)
|
||||
->and($result['transactions_categorized'])->toBe(0)
|
||||
->and($txn->fresh()->category_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('lets the more specific rule win an overlapping transaction', function () {
|
||||
$groceries = Category::factory()->for($this->user)->create(['name' => 'Groceries', 'type' => 'expense']);
|
||||
$streaming = Category::factory()->for($this->user)->create(['name' => 'Streaming', 'type' => 'expense']);
|
||||
|
||||
// Broad "mercadona" matches 3 transactions; narrow "netflix" matches only the bundle.
|
||||
$bundle = ($this->makeTxn)(['creditor_name' => null, 'description' => 'MERCADONA NETFLIX BUNDLE', 'amount' => -4000]);
|
||||
collect(range(1, 2))->each(fn (int $i) => ($this->makeTxn)(['creditor_name' => null, 'description' => "MERCADONA {$i}", 'amount' => -4000]));
|
||||
|
||||
$broad = ($this->group)([
|
||||
'conditions' => [['field' => 'description', 'operator' => 'contains', 'token' => 'mercadona']],
|
||||
'proposed_category_id' => $groceries->id,
|
||||
'confidence' => 0.95,
|
||||
]);
|
||||
$narrow = ($this->group)([
|
||||
'conditions' => [['field' => 'description', 'operator' => 'contains', 'token' => 'netflix']],
|
||||
'proposed_category_id' => $streaming->id,
|
||||
'confidence' => 0.80,
|
||||
]);
|
||||
|
||||
$this->action->apply($this->user, [$broad, $narrow], applyToExisting: true);
|
||||
|
||||
expect($bundle->fresh()->category_id)->toBe($streaming->id);
|
||||
});
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Category;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use App\Services\Ai\RuleSuggestionAggregator;
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('ai_suggestions.min_group_count', 3);
|
||||
config()->set('ai_suggestions.max_groups_sent', 15);
|
||||
$this->aggregator = new RuleSuggestionAggregator;
|
||||
});
|
||||
|
||||
function makeTxn(User $user, Account $account, array $attributes): void
|
||||
{
|
||||
Transaction::factory()->for($user)->create(array_merge([
|
||||
'account_id' => $account->id,
|
||||
'category_id' => null,
|
||||
'description_iv' => null,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
it('groups by counterparty and description, filtering rare and encrypted', function () {
|
||||
$user = User::factory()->create();
|
||||
$account = Account::factory()->for($user)->create();
|
||||
|
||||
// Frequent counterparty group (4 txns).
|
||||
for ($i = 0; $i < 4; $i++) {
|
||||
makeTxn($user, $account, [
|
||||
'creditor_name' => 'MERCADONA',
|
||||
'description' => "COMPRA TARJ MERCADONA {$i}234",
|
||||
'amount' => -4210,
|
||||
]);
|
||||
}
|
||||
|
||||
// Frequent description-only group (3 txns, no counterparty).
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
makeTxn($user, $account, [
|
||||
'creditor_name' => null,
|
||||
'debtor_name' => null,
|
||||
'description' => "NETFLIX.COM AMSTERDAM {$i}99",
|
||||
'amount' => -1299,
|
||||
]);
|
||||
}
|
||||
|
||||
// Rare group (2 txns) — below the threshold.
|
||||
for ($i = 0; $i < 2; $i++) {
|
||||
makeTxn($user, $account, [
|
||||
'creditor_name' => 'RARE SHOP',
|
||||
'description' => 'RARE SHOP',
|
||||
'amount' => -500,
|
||||
]);
|
||||
}
|
||||
|
||||
// Encrypted transaction — must be ignored.
|
||||
Transaction::factory()->for($user)->create([
|
||||
'account_id' => $account->id,
|
||||
'category_id' => null,
|
||||
'description_iv' => str_repeat('a', 16),
|
||||
'creditor_name' => 'MERCADONA',
|
||||
'amount' => -1000,
|
||||
]);
|
||||
|
||||
$groups = collect($this->aggregator->groupsFor($user));
|
||||
|
||||
expect($groups)->toHaveCount(2);
|
||||
|
||||
$mercadona = $groups->firstWhere('key', 'mercadona');
|
||||
expect($mercadona['field'])->toBe('creditor_name')
|
||||
->and($mercadona['count'])->toBe(4)
|
||||
->and($mercadona['direction'])->toBe('outflow')
|
||||
->and($mercadona['avg_amount'])->toBe(-42.10);
|
||||
|
||||
$netflix = $groups->firstWhere('field', 'description');
|
||||
expect($netflix['count'])->toBe(3)
|
||||
->and($netflix['key'])->toContain('netflix');
|
||||
});
|
||||
|
||||
it('builds a closed category list with paths, direction and leaf flags', function () {
|
||||
$user = User::factory()->create();
|
||||
$food = Category::factory()->for($user)->create(['name' => 'Food']);
|
||||
$groceries = Category::factory()->childOf($food)->create(['name' => 'Groceries']);
|
||||
|
||||
$options = collect($this->aggregator->categoryOptions($user));
|
||||
|
||||
$groceriesOption = $options->firstWhere('id', $groceries->id);
|
||||
$foodOption = $options->firstWhere('id', $food->id);
|
||||
|
||||
expect($groceriesOption['path'])->toBe('Food > Groceries')
|
||||
->and($groceriesOption['is_leaf'])->toBeTrue()
|
||||
->and($foodOption['is_leaf'])->toBeFalse();
|
||||
});
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
use App\Services\Ai\RuleSuggestionConsolidator;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->consolidator = new RuleSuggestionConsolidator;
|
||||
|
||||
$this->make = fn (array $overrides = []): array => array_merge([
|
||||
'match_field' => 'description',
|
||||
'match_operator' => 'contains',
|
||||
'match_token' => 'endesa',
|
||||
'proposed_category_id' => 'cat-1',
|
||||
'new_category_name' => null,
|
||||
'new_category_direction' => null,
|
||||
], $overrides);
|
||||
});
|
||||
|
||||
it('drops a narrower token already covered by a broader one in the same category', function () {
|
||||
$result = $this->consolidator->consolidate([
|
||||
($this->make)(['match_token' => 'endesa']),
|
||||
($this->make)(['match_token' => 'endesa energia']),
|
||||
]);
|
||||
|
||||
expect($result)->toHaveCount(1)
|
||||
->and($result[0]['match_token'])->toBe('endesa');
|
||||
});
|
||||
|
||||
it('keeps overlapping tokens that target different categories', function () {
|
||||
$result = $this->consolidator->consolidate([
|
||||
($this->make)(['match_token' => 'endesa', 'proposed_category_id' => 'cat-1']),
|
||||
($this->make)(['match_token' => 'endesa energia', 'proposed_category_id' => 'cat-2']),
|
||||
]);
|
||||
|
||||
expect($result)->toHaveCount(2);
|
||||
});
|
||||
|
||||
it('does not prune across different match fields', function () {
|
||||
$result = $this->consolidator->consolidate([
|
||||
($this->make)(['match_field' => 'description', 'match_token' => 'endesa']),
|
||||
($this->make)(['match_field' => 'creditor_name', 'match_token' => 'endesa energia']),
|
||||
]);
|
||||
|
||||
expect($result)->toHaveCount(2);
|
||||
});
|
||||
|
||||
it('only prunes contains tokens, never equals', function () {
|
||||
$result = $this->consolidator->consolidate([
|
||||
($this->make)(['match_operator' => 'contains', 'match_token' => 'endesa']),
|
||||
($this->make)(['match_operator' => 'equals', 'match_token' => 'endesa energia']),
|
||||
]);
|
||||
|
||||
expect($result)->toHaveCount(2);
|
||||
});
|
||||
|
||||
it('groups new-category proposals by name and direction when pruning', function () {
|
||||
$result = $this->consolidator->consolidate([
|
||||
($this->make)(['match_token' => 'netflix', 'proposed_category_id' => null, 'new_category_name' => 'Streaming', 'new_category_direction' => 'outflow']),
|
||||
($this->make)(['match_token' => 'netflix hd', 'proposed_category_id' => null, 'new_category_name' => 'Streaming', 'new_category_direction' => 'outflow']),
|
||||
]);
|
||||
|
||||
expect($result)->toHaveCount(1)
|
||||
->and($result[0]['match_token'])->toBe('netflix');
|
||||
});
|
||||
|
||||
it('collapses a substring chain down to the broadest token', function () {
|
||||
$result = $this->consolidator->consolidate([
|
||||
($this->make)(['match_token' => 'endesa energia']),
|
||||
($this->make)(['match_token' => 'endesa']),
|
||||
($this->make)(['match_token' => 'endesa energia solar']),
|
||||
]);
|
||||
|
||||
expect($result)->toHaveCount(1)
|
||||
->and($result[0]['match_token'])->toBe('endesa');
|
||||
});
|
||||
|
|
@ -0,0 +1,286 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\SuggestionRunStatus;
|
||||
use App\Models\Account;
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\BankingConnection;
|
||||
use App\Models\Category;
|
||||
use App\Models\RuleSuggestion;
|
||||
use App\Models\SuggestionRun;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use App\Services\Ai\Contracts\RuleSuggestionGenerator;
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('ai_suggestions.eligibility_min_transactions', 50);
|
||||
config()->set('ai_suggestions.confidence_floor', 0.7);
|
||||
config()->set('ai_suggestions.overbroad_fraction', 0.4);
|
||||
config()->set('ai_suggestions.min_match_count', 1);
|
||||
|
||||
$this->user = User::factory()->notOnboarded()->create();
|
||||
$this->account = Account::factory()->for($this->user)->create();
|
||||
});
|
||||
|
||||
function seedTransactions(User $user, Account $account, int $mercadona = 6, int $filler = 44): void
|
||||
{
|
||||
for ($i = 0; $i < $mercadona; $i++) {
|
||||
Transaction::factory()->for($user)->create([
|
||||
'account_id' => $account->id, 'category_id' => null, 'description_iv' => null,
|
||||
'creditor_name' => 'MERCADONA', 'description' => "MERCADONA {$i}", 'amount' => -4000,
|
||||
]);
|
||||
}
|
||||
for ($i = 0; $i < $filler; $i++) {
|
||||
Transaction::factory()->for($user)->create([
|
||||
'account_id' => $account->id, 'category_id' => null, 'description_iv' => null,
|
||||
'creditor_name' => null, 'description' => "UNIQUE MERCHANT {$i}", 'amount' => -1000,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function fakeGeneratorReturning(string $categoryId): void
|
||||
{
|
||||
app()->instance(RuleSuggestionGenerator::class, new class($categoryId) implements RuleSuggestionGenerator
|
||||
{
|
||||
public function __construct(private string $categoryId) {}
|
||||
|
||||
public function generate(array $groups, array $categoryOptions): array
|
||||
{
|
||||
return [[
|
||||
'group_key' => 'mercadona',
|
||||
'match_field' => 'creditor_name',
|
||||
'match_operator' => 'equals',
|
||||
'match_token' => 'mercadona',
|
||||
'category_id' => $this->categoryId,
|
||||
'confidence' => 0.95,
|
||||
]];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('blocks generation without consent', function () {
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('ai.rule-suggestions.generate'))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('reports ineligible users with too few transactions', function () {
|
||||
$this->user->recordAiConsent();
|
||||
seedTransactions($this->user, $this->account, mercadona: 3, filler: 5); // 8 total
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('ai.rule-suggestions.generate'))
|
||||
->assertStatus(422)
|
||||
->assertJson(['eligible' => false, 'transaction_count' => 8]);
|
||||
});
|
||||
|
||||
it('generates, persists and returns suggestions for an eligible user', function () {
|
||||
$groceries = Category::factory()->for($this->user)->create(['name' => 'Groceries', 'type' => 'expense']);
|
||||
$this->user->recordAiConsent();
|
||||
seedTransactions($this->user, $this->account);
|
||||
fakeGeneratorReturning($groceries->id);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->postJson(route('ai.rule-suggestions.generate'))
|
||||
->assertOk()
|
||||
->assertJsonPath('run.status', SuggestionRunStatus::Completed->value)
|
||||
->assertJsonPath('run.suggestions_count', 1)
|
||||
->assertJsonPath('suggestions.0.values.0.match_token', 'mercadona');
|
||||
|
||||
expect($response->json('suggestions.0.proposed_category.id'))->toBe($groceries->id);
|
||||
});
|
||||
|
||||
it('groups pending suggestions that share a category into one card', function () {
|
||||
$groceries = Category::factory()->for($this->user)->create(['name' => 'Groceries', 'type' => 'expense']);
|
||||
$this->user->recordAiConsent();
|
||||
seedTransactions($this->user, $this->account); // 6 MERCADONA + 44 UNIQUE MERCHANT
|
||||
|
||||
$run = SuggestionRun::factory()->for($this->user)->create(['status' => SuggestionRunStatus::Completed]);
|
||||
RuleSuggestion::factory()->for($run, 'run')->create([
|
||||
'match_field' => 'creditor_name', 'match_operator' => 'equals', 'match_token' => 'mercadona',
|
||||
'proposed_category_id' => $groceries->id, 'group_size' => 6,
|
||||
]);
|
||||
RuleSuggestion::factory()->for($run, 'run')->create([
|
||||
'match_field' => 'description', 'match_operator' => 'contains', 'match_token' => 'unique merchant',
|
||||
'proposed_category_id' => $groceries->id, 'group_size' => 44,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->getJson(route('ai.rule-suggestions.show'))
|
||||
->assertOk();
|
||||
|
||||
expect($response->json('suggestions'))->toHaveCount(1)
|
||||
->and($response->json('suggestions.0.values'))->toHaveCount(2)
|
||||
->and($response->json('suggestions.0.group_size'))->toBe(50)
|
||||
->and($response->json('suggestions.0.proposed_category.id'))->toBe($groceries->id);
|
||||
});
|
||||
|
||||
it('hides suggestions matching fewer transactions than the configured minimum', function () {
|
||||
config()->set('ai_suggestions.min_match_count', 10);
|
||||
|
||||
$groceries = Category::factory()->for($this->user)->create(['name' => 'Groceries', 'type' => 'expense']);
|
||||
$shopping = Category::factory()->for($this->user)->create(['name' => 'Shopping', 'type' => 'expense']);
|
||||
$this->user->recordAiConsent();
|
||||
seedTransactions($this->user, $this->account); // 6 MERCADONA + 44 UNIQUE MERCHANT
|
||||
|
||||
$run = SuggestionRun::factory()->for($this->user)->create(['status' => SuggestionRunStatus::Completed]);
|
||||
RuleSuggestion::factory()->for($run, 'run')->create([
|
||||
'match_field' => 'creditor_name', 'match_operator' => 'equals', 'match_token' => 'mercadona',
|
||||
'proposed_category_id' => $groceries->id, 'group_size' => 6,
|
||||
]);
|
||||
RuleSuggestion::factory()->for($run, 'run')->create([
|
||||
'match_field' => 'description', 'match_operator' => 'contains', 'match_token' => 'unique merchant',
|
||||
'proposed_category_id' => $shopping->id, 'group_size' => 44,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->getJson(route('ai.rule-suggestions.show'))
|
||||
->assertOk();
|
||||
|
||||
// The MERCADONA card matches only 6 transactions (< 10) and is dropped.
|
||||
expect($response->json('suggestions'))->toHaveCount(1)
|
||||
->and($response->json('suggestions.0.group_size'))->toBe(44)
|
||||
->and($response->json('suggestions.0.proposed_category.id'))->toBe($shopping->id);
|
||||
});
|
||||
|
||||
it('shows every suggestion when the minimum match count is one', function () {
|
||||
config()->set('ai_suggestions.min_match_count', 1);
|
||||
|
||||
$groceries = Category::factory()->for($this->user)->create(['name' => 'Groceries', 'type' => 'expense']);
|
||||
$shopping = Category::factory()->for($this->user)->create(['name' => 'Shopping', 'type' => 'expense']);
|
||||
$this->user->recordAiConsent();
|
||||
seedTransactions($this->user, $this->account);
|
||||
|
||||
$run = SuggestionRun::factory()->for($this->user)->create(['status' => SuggestionRunStatus::Completed]);
|
||||
RuleSuggestion::factory()->for($run, 'run')->create([
|
||||
'match_field' => 'creditor_name', 'match_operator' => 'equals', 'match_token' => 'mercadona',
|
||||
'proposed_category_id' => $groceries->id, 'group_size' => 6,
|
||||
]);
|
||||
RuleSuggestion::factory()->for($run, 'run')->create([
|
||||
'match_field' => 'description', 'match_operator' => 'contains', 'match_token' => 'unique merchant',
|
||||
'proposed_category_id' => $shopping->id, 'group_size' => 44,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->getJson(route('ai.rule-suggestions.show'))
|
||||
->assertOk();
|
||||
|
||||
expect($response->json('suggestions'))->toHaveCount(2);
|
||||
});
|
||||
|
||||
it('reuses the latest run while throttled instead of generating again', function () {
|
||||
$this->user->recordAiConsent();
|
||||
seedTransactions($this->user, $this->account);
|
||||
SuggestionRun::factory()->for($this->user)->create(['status' => SuggestionRunStatus::Completed]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('ai.rule-suggestions.generate'))
|
||||
->assertOk()
|
||||
->assertJson(['throttled' => true]);
|
||||
|
||||
expect($this->user->suggestionRuns()->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('previews the transactions a group of conditions would match', function () {
|
||||
$this->user->recordAiConsent();
|
||||
seedTransactions($this->user, $this->account);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('ai.rule-suggestions.preview'), [
|
||||
'conditions' => [
|
||||
['match_field' => 'creditor_name', 'match_operator' => 'equals', 'match_token' => 'mercadona'],
|
||||
['match_field' => 'description', 'match_operator' => 'contains', 'match_token' => 'unique merchant 43'],
|
||||
],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJson(['match_count' => 7, 'total_uncategorized' => 50]);
|
||||
});
|
||||
|
||||
it('accepts suggestions and applies them immediately during onboarding', function () {
|
||||
$groceries = Category::factory()->for($this->user)->create(['name' => 'Groceries', 'type' => 'expense']);
|
||||
$this->user->recordAiConsent();
|
||||
seedTransactions($this->user, $this->account);
|
||||
fakeGeneratorReturning($groceries->id);
|
||||
|
||||
$generated = $this->actingAs($this->user)->postJson(route('ai.rule-suggestions.generate'))->json();
|
||||
$ids = collect($generated['suggestions'][0]['values'])->pluck('id')->all();
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('ai.rule-suggestions.accept'), [
|
||||
'suggestions' => [[
|
||||
'ids' => $ids,
|
||||
'values' => [[
|
||||
'match_field' => 'creditor_name',
|
||||
'match_operator' => 'equals',
|
||||
'match_token' => 'mercadona',
|
||||
]],
|
||||
'proposed_category_id' => $groceries->id,
|
||||
]],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJson([
|
||||
'summary' => ['rules_created' => 1, 'transactions_categorized' => 6],
|
||||
'applied_to_existing' => true,
|
||||
]);
|
||||
|
||||
expect(AutomationRule::query()->where('user_id', $this->user->id)->count())->toBe(1)
|
||||
->and(Transaction::query()->where('user_id', $this->user->id)->where('creditor_name', 'MERCADONA')->whereNotNull('category_id')->count())->toBe(6);
|
||||
});
|
||||
|
||||
it('does not flag an upgrade when subscriptions are disabled', function () {
|
||||
config()->set('subscriptions.enabled', false);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->getJson(route('ai.rule-suggestions.show'))
|
||||
->assertOk()
|
||||
->assertJson(['requires_upgrade' => false]);
|
||||
});
|
||||
|
||||
it('flags an upgrade for free users without a connected account', function () {
|
||||
config()->set('subscriptions.enabled', true);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->getJson(route('ai.rule-suggestions.show'))
|
||||
->assertOk()
|
||||
->assertJson(['requires_upgrade' => true]);
|
||||
});
|
||||
|
||||
it('does not flag an upgrade when the user already linked a bank', function () {
|
||||
config()->set('subscriptions.enabled', true);
|
||||
BankingConnection::factory()->for($this->user)->create();
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->getJson(route('ai.rule-suggestions.show'))
|
||||
->assertOk()
|
||||
->assertJson(['requires_upgrade' => false]);
|
||||
});
|
||||
|
||||
it('does not flag an upgrade for subscribed users', function () {
|
||||
config()->set('subscriptions.enabled', true);
|
||||
$this->user->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_test123',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_test123',
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->getJson(route('ai.rule-suggestions.show'))
|
||||
->assertOk()
|
||||
->assertJson(['requires_upgrade' => false]);
|
||||
});
|
||||
|
||||
it('records and revokes consent', function () {
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('ai.consent.store'))
|
||||
->assertOk()
|
||||
->assertJson(['consented' => true]);
|
||||
|
||||
expect($this->user->hasActiveAiConsent())->toBeTrue();
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->deleteJson(route('ai.consent.destroy'))
|
||||
->assertOk()
|
||||
->assertJson(['consented' => false]);
|
||||
|
||||
expect($this->user->fresh()->hasActiveAiConsent())->toBeFalse();
|
||||
});
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
<?php
|
||||
|
||||
use App\Ai\Agents\RuleSuggestionAgent;
|
||||
use App\Services\Ai\LaravelAiRuleSuggestionGenerator;
|
||||
|
||||
it('returns the structured suggestions produced by the model', function () {
|
||||
RuleSuggestionAgent::fake([
|
||||
[
|
||||
'suggestions' => [
|
||||
[
|
||||
'group_key' => 'mercadona',
|
||||
'match_field' => 'creditor_name',
|
||||
'match_operator' => 'equals',
|
||||
'match_token' => 'mercadona',
|
||||
'category_id' => 'cat-1',
|
||||
'confidence' => 0.96,
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$generator = new LaravelAiRuleSuggestionGenerator;
|
||||
|
||||
$suggestions = $generator->generate(
|
||||
groups: [['key' => 'mercadona', 'field' => 'creditor_name', 'count' => 14, 'avg_amount' => -42.1, 'direction' => 'outflow', 'samples' => ['mercadona compra']]],
|
||||
categoryOptions: [['id' => 'cat-1', 'name' => 'Groceries', 'path' => 'Food > Groceries', 'type' => 'expense', 'direction' => 'outflow', 'is_leaf' => true]],
|
||||
);
|
||||
|
||||
expect($suggestions)->toHaveCount(1)
|
||||
->and($suggestions[0]['match_token'])->toBe('mercadona')
|
||||
->and($suggestions[0]['category_id'])->toBe('cat-1');
|
||||
});
|
||||
|
||||
it('skips the model entirely when there are no groups', function () {
|
||||
$generator = new LaravelAiRuleSuggestionGenerator;
|
||||
|
||||
expect($generator->generate([], []))->toBe([]);
|
||||
});
|
||||
|
||||
function genSuggestion(string $key): array
|
||||
{
|
||||
return [
|
||||
'group_key' => $key,
|
||||
'match_field' => 'creditor_name',
|
||||
'match_operator' => 'equals',
|
||||
'match_token' => $key,
|
||||
'category_id' => 'cat-1',
|
||||
'confidence' => 0.9,
|
||||
];
|
||||
}
|
||||
|
||||
function benchGroup(string $key): array
|
||||
{
|
||||
return ['key' => $key, 'field' => 'creditor_name', 'count' => 3, 'avg_amount' => -10.0, 'direction' => 'outflow', 'samples' => [$key]];
|
||||
}
|
||||
|
||||
it('splits groups into batches and merges their suggestions', function () {
|
||||
config()->set('ai_suggestions.group_batch_size', 1);
|
||||
|
||||
RuleSuggestionAgent::fake([
|
||||
['suggestions' => [genSuggestion('alpha')]],
|
||||
['suggestions' => [genSuggestion('beta')]],
|
||||
]);
|
||||
|
||||
$generator = new LaravelAiRuleSuggestionGenerator;
|
||||
|
||||
$suggestions = $generator->generate(
|
||||
groups: [benchGroup('alpha'), benchGroup('beta')],
|
||||
categoryOptions: [['id' => 'cat-1', 'name' => 'X', 'path' => 'X', 'type' => 'expense', 'direction' => 'outflow', 'is_leaf' => true]],
|
||||
);
|
||||
|
||||
expect($suggestions)->toHaveCount(2)
|
||||
->and(array_column($suggestions, 'match_token'))->toBe(['alpha', 'beta']);
|
||||
});
|
||||
|
||||
it('keeps successful batches when one batch fails after retry', function () {
|
||||
config()->set('ai_suggestions.group_batch_size', 1);
|
||||
|
||||
RuleSuggestionAgent::fake(function (string $prompt) {
|
||||
if (str_contains($prompt, 'boomtoken')) {
|
||||
throw new RuntimeException('batch failed');
|
||||
}
|
||||
|
||||
return ['suggestions' => [genSuggestion('okkey')]];
|
||||
});
|
||||
|
||||
$generator = new LaravelAiRuleSuggestionGenerator;
|
||||
|
||||
$suggestions = $generator->generate(
|
||||
groups: [benchGroup('boomtoken'), benchGroup('goodkey')],
|
||||
categoryOptions: [['id' => 'cat-1', 'name' => 'X', 'path' => 'X', 'type' => 'expense', 'direction' => 'outflow', 'is_leaf' => true]],
|
||||
);
|
||||
|
||||
expect($suggestions)->toHaveCount(1)
|
||||
->and($suggestions[0]['match_token'])->toBe('okkey');
|
||||
});
|
||||
|
||||
it('rethrows when every batch fails', function () {
|
||||
config()->set('ai_suggestions.group_batch_size', 1);
|
||||
|
||||
RuleSuggestionAgent::fake(function () {
|
||||
throw new RuntimeException('all batches failed');
|
||||
});
|
||||
|
||||
$generator = new LaravelAiRuleSuggestionGenerator;
|
||||
|
||||
expect(fn () => $generator->generate(
|
||||
groups: [benchGroup('alpha'), benchGroup('beta')],
|
||||
categoryOptions: [['id' => 'cat-1', 'name' => 'X', 'path' => 'X', 'type' => 'expense', 'direction' => 'outflow', 'is_leaf' => true]],
|
||||
))->toThrow(RuntimeException::class);
|
||||
});
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Category;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use App\Services\Ai\RuleSuggestionGuard;
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('ai_suggestions.confidence_floor', 0.7);
|
||||
config()->set('ai_suggestions.overbroad_fraction', 0.4);
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
$this->account = Account::factory()->for($this->user)->create();
|
||||
$this->guard = app(RuleSuggestionGuard::class);
|
||||
|
||||
$make = function (string $description, ?string $creditor, int $amount): void {
|
||||
Transaction::factory()->for($this->user)->create([
|
||||
'account_id' => $this->account->id,
|
||||
'category_id' => null,
|
||||
'description_iv' => null,
|
||||
'creditor_name' => $creditor,
|
||||
'debtor_name' => null,
|
||||
'description' => $description,
|
||||
'amount' => $amount,
|
||||
]);
|
||||
};
|
||||
|
||||
// 6 Mercadona (outflow), 4 Netflix (outflow), 10 unique "various shop" (outflow) = 20 total.
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
$make("MERCADONA COMPRA {$i}", 'MERCADONA', -4000);
|
||||
}
|
||||
for ($i = 0; $i < 4; $i++) {
|
||||
$make("NETFLIX {$i}", null, -1300);
|
||||
}
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$make("VARIOUS SHOP {$i}", null, -1000);
|
||||
}
|
||||
|
||||
$this->groceries = Category::factory()->for($this->user)->create(['name' => 'Groceries', 'type' => 'expense']);
|
||||
$this->salary = Category::factory()->for($this->user)->create(['name' => 'Salary', 'type' => 'income']);
|
||||
|
||||
$this->categoryOptions = [
|
||||
['id' => $this->groceries->id, 'name' => 'Groceries', 'path' => 'Groceries', 'type' => 'expense', 'direction' => 'outflow', 'is_leaf' => true],
|
||||
['id' => $this->salary->id, 'name' => 'Salary', 'path' => 'Salary', 'type' => 'income', 'direction' => 'inflow', 'is_leaf' => true],
|
||||
];
|
||||
});
|
||||
|
||||
it('keeps a valid suggestion and computes group size + samples', function () {
|
||||
$result = $this->guard->validate($this->user, [
|
||||
['group_key' => 'mercadona', 'match_field' => 'creditor_name', 'match_operator' => 'equals', 'match_token' => 'MERCADONA', 'category_id' => $this->groceries->id, 'confidence' => 0.95],
|
||||
], $this->categoryOptions);
|
||||
|
||||
expect($result)->toHaveCount(1);
|
||||
expect($result[0]['match_token'])->toBe('mercadona')
|
||||
->and($result[0]['proposed_category_id'])->toBe($this->groceries->id)
|
||||
->and($result[0]['group_size'])->toBe(6)
|
||||
->and($result[0]['sample_descriptions'])->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('rejects low-confidence, over-broad, absent-token and disallowed-field suggestions', function () {
|
||||
$result = $this->guard->validate($this->user, [
|
||||
['group_key' => 'netflix', 'match_field' => 'description', 'match_operator' => 'contains', 'match_token' => 'netflix', 'category_id' => $this->groceries->id, 'confidence' => 0.5],
|
||||
['group_key' => 'shop', 'match_field' => 'description', 'match_operator' => 'contains', 'match_token' => 'shop', 'category_id' => $this->groceries->id, 'confidence' => 0.95],
|
||||
['group_key' => 'ghost', 'match_field' => 'description', 'match_operator' => 'contains', 'match_token' => 'zzzzz', 'category_id' => $this->groceries->id, 'confidence' => 0.95],
|
||||
['group_key' => 'notes', 'match_field' => 'notes', 'match_operator' => 'contains', 'match_token' => 'whatever', 'category_id' => $this->groceries->id, 'confidence' => 0.95],
|
||||
], $this->categoryOptions);
|
||||
|
||||
expect($result)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('rejects a token that only matches a single transaction', function () {
|
||||
$result = $this->guard->validate($this->user, [
|
||||
['group_key' => 'one-off', 'match_field' => 'description', 'match_operator' => 'contains', 'match_token' => 'mercadona compra 0', 'category_id' => $this->groceries->id, 'confidence' => 0.95],
|
||||
], $this->categoryOptions);
|
||||
|
||||
expect($result)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('rejects a suggestion whose category direction conflicts with the group', function () {
|
||||
$result = $this->guard->validate($this->user, [
|
||||
['group_key' => 'netflix', 'match_field' => 'description', 'match_operator' => 'contains', 'match_token' => 'netflix', 'category_id' => $this->salary->id, 'confidence' => 0.95],
|
||||
], $this->categoryOptions);
|
||||
|
||||
expect($result)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('accepts a new-category proposal when no existing category is given', function () {
|
||||
$result = $this->guard->validate($this->user, [
|
||||
['group_key' => 'netflix', 'match_field' => 'description', 'match_operator' => 'contains', 'match_token' => 'netflix', 'category_id' => '', 'new_category_name' => 'Streaming', 'confidence' => 0.9],
|
||||
], $this->categoryOptions);
|
||||
|
||||
expect($result)->toHaveCount(1);
|
||||
expect($result[0]['proposed_category_id'])->toBeNull()
|
||||
->and($result[0]['new_category_name'])->toBe('Streaming')
|
||||
->and($result[0]['new_category_direction'])->toBe('outflow')
|
||||
->and($result[0]['group_size'])->toBe(4);
|
||||
});
|
||||
|
||||
it('keeps only the highest-confidence suggestion per identical matcher', function () {
|
||||
$result = $this->guard->validate($this->user, [
|
||||
['group_key' => 'mercadona', 'match_field' => 'creditor_name', 'match_operator' => 'equals', 'match_token' => 'mercadona', 'category_id' => $this->groceries->id, 'confidence' => 0.80],
|
||||
['group_key' => 'mercadona', 'match_field' => 'creditor_name', 'match_operator' => 'equals', 'match_token' => 'mercadona', 'category_id' => $this->groceries->id, 'confidence' => 0.97],
|
||||
], $this->categoryOptions);
|
||||
|
||||
expect($result)->toHaveCount(1)
|
||||
->and($result[0]['confidence'])->toBe(0.97);
|
||||
});
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\SuggestionRunStatus;
|
||||
use App\Models\Account;
|
||||
use App\Models\Category;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use App\Services\Ai\Contracts\RuleSuggestionGenerator;
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('ai_suggestions.confidence_floor', 0.7);
|
||||
config()->set('ai_suggestions.overbroad_fraction', 0.4);
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
$this->account = Account::factory()->for($this->user)->create();
|
||||
$this->groceries = Category::factory()->for($this->user)->create(['name' => 'Groceries', 'type' => 'expense']);
|
||||
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
Transaction::factory()->for($this->user)->create([
|
||||
'account_id' => $this->account->id, 'category_id' => null, 'description_iv' => null,
|
||||
'creditor_name' => 'MERCADONA', 'description' => "MERCADONA {$i}", 'amount' => -4000,
|
||||
]);
|
||||
}
|
||||
|
||||
// Filler so the "mercadona" token matches well under the over-broad threshold.
|
||||
for ($i = 0; $i < 20; $i++) {
|
||||
Transaction::factory()->for($this->user)->create([
|
||||
'account_id' => $this->account->id, 'category_id' => null, 'description_iv' => null,
|
||||
'creditor_name' => null, 'description' => "UNIQUE MERCHANT {$i}", 'amount' => -1000,
|
||||
]);
|
||||
}
|
||||
|
||||
app()->instance(RuleSuggestionGenerator::class, new class($this->groceries->id) implements RuleSuggestionGenerator
|
||||
{
|
||||
public function __construct(private string $categoryId) {}
|
||||
|
||||
public function generate(array $groups, array $categoryOptions): array
|
||||
{
|
||||
return [[
|
||||
'group_key' => 'mercadona', 'match_field' => 'creditor_name', 'match_operator' => 'equals',
|
||||
'match_token' => 'mercadona', 'category_id' => $this->categoryId, 'confidence' => 0.95,
|
||||
]];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('prints the pipeline output for a user (dry run)', function () {
|
||||
$this->artisan('ai:suggest-rules', ['user' => $this->user->email])
|
||||
->expectsOutputToContain('mercadona')
|
||||
->expectsOutputToContain('survived the guards')
|
||||
->assertSuccessful();
|
||||
|
||||
expect($this->user->suggestionRuns()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('persists a run with --persist', function () {
|
||||
$this->artisan('ai:suggest-rules', ['user' => $this->user->id, '--persist' => true])
|
||||
->assertSuccessful();
|
||||
|
||||
$run = $this->user->suggestionRuns()->first();
|
||||
expect($run)->not->toBeNull()
|
||||
->and($run->status)->toBe(SuggestionRunStatus::Completed)
|
||||
->and($run->suggestions()->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('fails for an unknown user', function () {
|
||||
$this->artisan('ai:suggest-rules', ['user' => 'nobody@example.com'])
|
||||
->expectsOutputToContain('User not found.')
|
||||
->assertFailed();
|
||||
});
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
use App\Models\AiConsent;
|
||||
use App\Models\User;
|
||||
|
||||
it('records a consent and reports it as active', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
expect($user->hasActiveAiConsent())->toBeFalse();
|
||||
|
||||
$consent = $user->recordAiConsent();
|
||||
|
||||
expect($consent->scope)->toBe(AiConsent::SCOPE_FINANCE)
|
||||
->and($consent->version)->toBe((string) config('ai_suggestions.consent_version'))
|
||||
->and($consent->accepted_at)->not->toBeNull()
|
||||
->and($user->hasActiveAiConsent())->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not duplicate consent rows for the same version', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$user->recordAiConsent();
|
||||
$user->recordAiConsent();
|
||||
|
||||
expect($user->aiConsents()->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('revokes an active consent', function () {
|
||||
$user = User::factory()->create();
|
||||
$user->recordAiConsent();
|
||||
|
||||
$user->revokeAiConsent();
|
||||
|
||||
expect($user->hasActiveAiConsent())->toBeFalse()
|
||||
->and($user->aiConsents()->first()->revoked_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('treats consent from a previous version as inactive', function () {
|
||||
$user = User::factory()->create();
|
||||
AiConsent::factory()->for($user)->create(['version' => 'legacy-0']);
|
||||
|
||||
expect($user->hasActiveAiConsent())->toBeFalse();
|
||||
});
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\PlanFeature;
|
||||
use App\Models\User;
|
||||
|
||||
test('every current feature requires a pro plan', function (PlanFeature $feature) {
|
||||
expect($feature->requiresProPlan())->toBeTrue();
|
||||
})->with([
|
||||
[PlanFeature::ConnectedAccounts],
|
||||
[PlanFeature::AiSuggestions],
|
||||
]);
|
||||
|
||||
test('every feature is available when subscriptions are disabled', function () {
|
||||
config()->set('subscriptions.enabled', false);
|
||||
$user = User::factory()->create();
|
||||
|
||||
expect($user->canUseFeature(PlanFeature::ConnectedAccounts))->toBeTrue()
|
||||
->and($user->canUseFeature(PlanFeature::AiSuggestions))->toBeTrue();
|
||||
});
|
||||
|
||||
test('pro features are unavailable to free users when subscriptions are enabled', function () {
|
||||
config()->set('subscriptions.enabled', true);
|
||||
$user = User::factory()->create();
|
||||
|
||||
expect($user->canUseFeature(PlanFeature::ConnectedAccounts))->toBeFalse()
|
||||
->and($user->canUseFeature(PlanFeature::AiSuggestions))->toBeFalse();
|
||||
});
|
||||
|
||||
test('pro features are available to subscribed users', function () {
|
||||
config()->set('subscriptions.enabled', true);
|
||||
$user = User::factory()->create();
|
||||
$user->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_test123',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_test123',
|
||||
]);
|
||||
|
||||
expect($user->canUseFeature(PlanFeature::ConnectedAccounts))->toBeTrue()
|
||||
->and($user->canUseFeature(PlanFeature::AiSuggestions))->toBeTrue();
|
||||
});
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\RuleSuggestionStatus;
|
||||
use App\Enums\SuggestionRunStatus;
|
||||
use App\Models\RuleSuggestion;
|
||||
use App\Models\SuggestionRun;
|
||||
use App\Models\User;
|
||||
|
||||
it('persists a run with suggestions and casts fields', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$run = SuggestionRun::factory()->for($user)->create([
|
||||
'suggestions_count' => 2,
|
||||
]);
|
||||
|
||||
RuleSuggestion::factory()->count(2)->for($run, 'run')->create();
|
||||
|
||||
$run->refresh()->loadCount('suggestions');
|
||||
|
||||
expect($run->status)->toBe(SuggestionRunStatus::Completed)
|
||||
->and($run->status->countsTowardThrottle())->toBeTrue()
|
||||
->and($run->suggestions_count)->toBe(2)
|
||||
->and($run->suggestions)->toHaveCount(2)
|
||||
->and($run->suggestions->first()->status)->toBe(RuleSuggestionStatus::Pending)
|
||||
->and($run->suggestions->first()->confidence)->toBeFloat()
|
||||
->and($run->suggestions->first()->sample_descriptions)->toBeArray();
|
||||
});
|
||||
|
||||
it('flags a suggestion that proposes a new category', function () {
|
||||
$suggestion = RuleSuggestion::factory()->proposesNewCategory('Pet care')->create();
|
||||
|
||||
expect($suggestion->proposesNewCategory())->toBeTrue()
|
||||
->and($suggestion->new_category_name)->toBe('Pet care');
|
||||
});
|
||||
|
||||
it('does not count empty or failed runs toward the throttle', function () {
|
||||
expect(SuggestionRunStatus::Empty->countsTowardThrottle())->toBeFalse()
|
||||
->and(SuggestionRunStatus::Failed->countsTowardThrottle())->toBeFalse()
|
||||
->and(SuggestionRunStatus::Pending->countsTowardThrottle())->toBeFalse();
|
||||
});
|
||||
Loading…
Reference in New Issue