Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions app/Console/Commands/AutoCategorisePlugins.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Jobs\SuggestPluginCategories;
use App\Models\Plugin;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Throwable;

final class AutoCategorisePlugins extends Command
{
protected $signature = 'plugins:auto-categorise
{--dry-run : Ask Jev and list its suggestions without changing any plugin\'s categories}';

protected $description = 'Ask Jev which categories every plugin belongs in, then put each plugin in the ones it suggests';

/**
* Plugins Jev has already been asked about aren't asked again, so a run that
* stops part way, or follows a dry run, picks up where the last one left off.
*/
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');

$plugins = Plugin::query()->orderBy('name')->get();

if ($plugins->isEmpty()) {
$this->info('There are no plugins to categorise.');

return self::SUCCESS;
}

/** @var Collection<int, Plugin> $categorised */
$categorised = new Collection;
/** @var Collection<int, Plugin> $unsure */
$unsure = new Collection;
/** @var Collection<int, array{0: Plugin, 1: string}> $failed */
$failed = new Collection;

$this->withProgressBar($plugins, function (Plugin $plugin) use ($dryRun, $categorised, $unsure, $failed): void {
if ($plugin->category_suggestions === null) {
try {
(new SuggestPluginCategories($plugin))->handle();
} catch (Throwable $exception) {
$failed->push([$plugin, $exception->getMessage()]);

return;
}
}

if ($plugin->suggestedCategories()->isEmpty()) {
$unsure->push($plugin);

return;
}

if (! $dryRun) {
$plugin->applySuggestedCategories();
}

$categorised->push($plugin);
});

$this->newLine(2);

if ($categorised->isNotEmpty()) {
$this->table(
['Plugin', $dryRun ? 'Jev Suggests' : 'Categories'],
$categorised->map(fn (Plugin $plugin): array => [
$plugin->name,
$plugin->suggestedCategories()->map->label()->join(', '),
])->all()
);
}

if ($unsure->isNotEmpty()) {
$this->newLine();
$this->warn('Jev isn\'t confident about any category for these, so they were left as they are:');
$this->table(['Plugin'], $unsure->map(fn (Plugin $plugin): array => [$plugin->name])->all());
}

if ($failed->isNotEmpty()) {
$this->newLine();
$this->error('Jev couldn\'t be asked about these. Run the command again to retry them:');
$this->table(
['Plugin', 'Error'],
$failed->map(fn (array $row): array => [$row[0]->name, Str::limit($row[1], 100)])->all()
);
}

$this->newLine();
$this->info(sprintf('%d categorised, %d left as they are, %d failed.', $categorised->count(), $unsure->count(), $failed->count()));

if ($dryRun) {
$this->warn('Dry run, so no categories were changed. Run it again without --dry-run to apply these. Jev won\'t be asked again.');
}

return $failed->isEmpty() ? self::SUCCESS : self::FAILURE;
}
}
4 changes: 2 additions & 2 deletions app/Console/Commands/BackfillPluginCategories.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public function handle(PluginCategoryClassifier $classifier): int
{
$dryRun = (bool) $this->option('dry-run');

$plugins = Plugin::query()->whereNull('category')->get();
$plugins = Plugin::query()->uncategorized()->get();

if ($plugins->isEmpty()) {
$this->info('No plugins need a category backfilled.');
Expand All @@ -46,7 +46,7 @@ public function handle(PluginCategoryClassifier $classifier): int
$matched->push([$plugin, $category]);

if (! $dryRun) {
$plugin->update(['category' => $category]);
$plugin->update(['categories' => [$category]]);
}
}

Expand Down
16 changes: 16 additions & 0 deletions app/Enums/PluginCategory.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,20 @@ public function label(): string
self::System => 'System',
};
}

/**
* What a plugin in this category is for, so Jev can tell whether a plugin fits it.
*/
public function description(): string
{
return match ($this) {
self::Media => 'cameras, photos, video, audio, barcode and QR code scanning, or speech',
self::Security => 'biometrics like Face ID and fingerprints, authentication, encryption, or secure storage like the keychain',
self::Connectivity => 'Bluetooth, NFC, Wi-Fi, networking, websockets, beacons or deep links',
self::Notifications => 'push or local notifications, alerts or reminders',
self::Payments => 'in-app purchases, subscriptions, payment providers like Stripe and PayPal, wallets, or ads',
self::Analytics => 'usage analytics, crash reporting, tracking, metrics or telemetry',
self::System => 'device and operating system features like device info, battery, sensors, haptics, the clipboard, keyboard, screen orientation and brightness, the status bar, splash screen, app icon, widgets, background tasks, the file picker, contacts, calendar, geolocation, maps or health data',
};
}
}
72 changes: 72 additions & 0 deletions app/Filament/Resources/PluginResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

namespace App\Filament\Resources;

use App\Enums\PluginCategory;
use App\Enums\PluginStatus;
use App\Enums\PluginTier;
use App\Enums\PluginType;
use App\Filament\Resources\PluginResource\Pages;
use App\Filament\Resources\PluginResource\RelationManagers;
use App\Jobs\ReviewPluginRepository;
use App\Jobs\SuggestPluginCategories;
use App\Jobs\SyncPlugin;
use App\Models\Plugin;
use App\Models\PluginLicense;
Expand All @@ -20,12 +22,14 @@
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\HtmlString;
use Throwable;

class PluginResource extends Resource
{
Expand Down Expand Up @@ -128,6 +132,74 @@ public static function form(Schema $schema): Schema
->helperText('Show a badge indicating this plugin runs in the Jump preview app (i.e. it ships no custom native code)'),
]),

Schemas\Components\Section::make('Categories')
->key('categories-section')
->inlineLabel()
->columns(1)
->schema([
Forms\Components\CheckboxList::make('categories')
->options(PluginCategory::class)
->columns(4),

Forms\Components\Placeholder::make('category_suggestions_display')
->label('Jev suggests')
->content(fn (?Plugin $record): string => match (true) {
$record?->category_suggestions === null => 'Not asked yet',
$record->suggestedCategories()->isEmpty() => 'No category is a confident fit',
default => $record->suggestedCategories()
->map(fn (PluginCategory $category): string => sprintf('%s (%d%%)', $category->label(), round($record->category_suggestions[$category->value] * 100)))
->join(', '),
}),
])
->headerActions([
Action::make('suggestCategories')
->label(fn (?Plugin $record): string => $record?->category_suggestions === null ? 'Ask Jev' : 'Ask Jev again')
->icon('heroicon-o-sparkles')
->color('gray')
->action(function (Plugin $record): void {
try {
(new SuggestPluginCategories($record))->handle();
} catch (Throwable $exception) {
report($exception);

Notification::make()
->title('Jev couldn\'t suggest categories')
->body($exception->getMessage())
->danger()
->send();

return;
}

$suggestions = $record->suggestedCategories();

Notification::make()
->title($suggestions->isEmpty()
? 'Jev isn\'t confident about any category'
: 'Jev suggests '.$suggestions->map->label()->join(', ', ' and '))
->status($suggestions->isEmpty() ? 'warning' : 'success')
->send();
}),

Action::make('applySuggestedCategories')
->label('Apply suggestions')
->icon('heroicon-o-check')
->color('success')
->visible(fn (?Plugin $record): bool => (bool) $record?->hasUnappliedCategorySuggestions())
->action(function (Plugin $record, Set $set): void {
$record->applySuggestedCategories();

$set('categories', $record->categories->map->value->all());

Notification::make()
->title('Categories applied')
->body('This plugin is now in '.$record->categories->map->label()->join(', ', ' and ').'.')
->success()
->send();
}),
])
->visible(fn (?Plugin $record) => $record !== null),

Schemas\Components\Section::make('Review Checks')
->inlineLabel()
->columns(1)
Expand Down
115 changes: 115 additions & 0 deletions app/Jobs/SuggestPluginCategories.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?php

namespace App\Jobs;

use App\Enums\PluginCategory;
use App\Models\Plugin;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Str;
use Laravel\Ai\Classification;
use Laravel\Ai\Classification\Boolean;
use Laravel\Ai\Contracts\Question;
use Laravel\Ai\Responses\Data\Answer;
use Laravel\Ai\Responses\Data\BooleanAnswer;

/**
* Asks Jev which marketplace categories a plugin belongs in, and keeps the ones
* it's confident about as suggestions for an admin to apply.
*/
class SuggestPluginCategories implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

/**
* How likely Jev has to think it is that a plugin belongs in a category before it's suggested.
*/
public const float THRESHOLD = 0.5;

public const int MAX_SUGGESTIONS = 3;

/**
* The start of a README says what the plugin is for. The rest is mostly
* installation steps and API reference, so Jev only reads this much of it.
*/
public const int README_CHARACTER_LIMIT = 5000;

public const string INSTRUCTIONS = 'Should this plugin be listed in the %s category of the NativePHP plugin marketplace? A plugin can be listed in more than one category.';

public int $tries = 3;

public int $backoff = 60;

public bool $deleteWhenMissingModels = true;

public function __construct(public Plugin $plugin) {}

public function handle(): void
{
$answers = Classification::of($this->state())
->questions($this->questions())
->classify()
->collect();

$this->plugin->update([
'category_suggestions' => $answers
->only(array_column(PluginCategory::cases(), 'value'))
->filter(fn (Answer $answer): bool => $answer instanceof BooleanAnswer && $answer->isTrue(self::THRESHOLD))
->map(fn (BooleanAnswer $answer): float => round($answer->probability, 4))
->sortDesc()
->take(self::MAX_SUGGESTIONS)
->all(),
]);
}

/**
* @return array<string, string>
*/
protected function state(): array
{
return array_filter([
'title' => $this->plugin->display_name ?? $this->plugin->name,
'description' => $this->plugin->description,
'readme' => $this->readme(),
]);
}

/**
* The README as plain text, without the "#" link the site adds to every
* heading. Tags are spaced out before they're stripped so that text from
* neighbouring elements doesn't run together.
*/
protected function readme(): ?string
{
if (blank($this->plugin->readme_html)) {
return null;
}

$html = preg_replace('/<a\b[^>]*\bheading-anchor\b[^>]*>.*?<\/a>/s', '', $this->plugin->readme_html);

$text = html_entity_decode(strip_tags(str_replace('<', ' <', $html)), ENT_QUOTES | ENT_HTML5);

return Str::limit(Str::squish($text), self::README_CHARACTER_LIMIT);
}

/**
* One yes/no question per category, so a plugin that fits several isn't
* forced to split Jev's confidence between them.
*
* @return array<string, Question>
*/
protected function questions(): array
{
return collect(PluginCategory::cases())
->mapWithKeys(fn (PluginCategory $category): array => [
$category->value => new Boolean(
sprintf(self::INSTRUCTIONS, $category->label()),
['true' => "The plugin is for {$category->description()}."],
),
])
->all();
}
}
3 changes: 3 additions & 0 deletions app/Livewire/Customer/Plugins/Create.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use App\Enums\PluginStatus;
use App\Features\AllowPaidPlugins;
use App\Jobs\SuggestPluginCategories;
use App\Models\Plugin;
use App\Services\GitHubUserService;
use App\Services\PluginSyncService;
Expand Down Expand Up @@ -203,6 +204,8 @@ function ($attribute, $value, $fail): void {
return;
}

SuggestPluginCategories::dispatch($plugin);

[$vendor, $package] = explode('/', $plugin->name);

$this->redirect(
Expand Down
Loading
Loading