Save workspace changes
This commit is contained in:
429
app/Console/Commands/GenerateAiBiographyCommand.php
Normal file
429
app/Console/Commands/GenerateAiBiographyCommand.php
Normal file
@@ -0,0 +1,429 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\GenerateAiBiographyJob;
|
||||
use App\Models\CreatorAiBiography;
|
||||
use App\Models\User;
|
||||
use App\Services\AiBiography\AiBiographyInputBuilder;
|
||||
use App\Services\AiBiography\AiBiographyPromptBuilder;
|
||||
use App\Services\AiBiography\AiBiographyService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Generate AI biographies for one creator or refresh stale ones.
|
||||
*
|
||||
* Usage:
|
||||
* php artisan ai-biography:generate {user_id}
|
||||
* php artisan ai-biography:generate
|
||||
* php artisan ai-biography:generate --stale
|
||||
* php artisan ai-biography:generate --stale --limit=50 --queue
|
||||
*/
|
||||
final class GenerateAiBiographyCommand extends Command
|
||||
{
|
||||
protected $signature = 'ai-biography:generate
|
||||
{user_id? : The ID of a single creator to generate a biography for}
|
||||
{--stale : Refresh all biographies whose source hash has changed}
|
||||
{--all : Alias for generating only missing biographies ordered by latest public upload}
|
||||
{--provider= : Override the configured LLM provider for this run (vision_gateway|vision|gemini|home)}
|
||||
{--prompt : Output the initial prompt payload that would be sent for each processed creator}
|
||||
{--result : Output the generated biography text to the console after a successful inline run}
|
||||
{--skip-existing : Skip creators who already have an active AI biography}
|
||||
{--limit=100 : Maximum number of creators to process in batch mode}
|
||||
{--chunk=50 : Query chunk size for batch operations}
|
||||
{--force : Overwrite user-edited biographies}
|
||||
{--queue : Dispatch jobs to the queue instead of running inline}
|
||||
{--dry-run : List candidates without generating}';
|
||||
|
||||
protected $description = 'Generate missing AI biographies or refresh stale ones';
|
||||
|
||||
public function handle(
|
||||
AiBiographyService $biographies,
|
||||
AiBiographyInputBuilder $inputBuilder,
|
||||
AiBiographyPromptBuilder $promptBuilder,
|
||||
): int
|
||||
{
|
||||
if (! config('ai_biography.enabled', true)) {
|
||||
$this->warn('AI Biography is disabled (AI_BIOGRAPHY_ENABLED=false).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$provider = $this->resolveProviderOverride();
|
||||
|
||||
if ($provider === false) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (is_string($provider)) {
|
||||
config(['ai_biography.provider_override' => $provider]);
|
||||
config(['ai_biography.provider' => $provider]);
|
||||
$this->line("Using AI biography provider override: {$provider}");
|
||||
}
|
||||
|
||||
$userId = $this->argument('user_id');
|
||||
$stale = (bool) $this->option('stale');
|
||||
$all = (bool) $this->option('all');
|
||||
$prompt = (bool) $this->option('prompt');
|
||||
$result = (bool) $this->option('result');
|
||||
$skipExisting = (bool) $this->option('skip-existing');
|
||||
$force = (bool) $this->option('force');
|
||||
$queue = (bool) $this->option('queue');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$limit = max(1, (int) $this->option('limit'));
|
||||
$chunk = max(1, (int) $this->option('chunk'));
|
||||
|
||||
if ($userId !== null) {
|
||||
return $this->handleSingle((int) $userId, $biographies, $inputBuilder, $promptBuilder, $force, $queue, $dryRun, $provider ?: null, $prompt, $result, $skipExisting);
|
||||
}
|
||||
|
||||
if ($stale) {
|
||||
return $this->handleStale($biographies, $inputBuilder, $promptBuilder, $force, $queue, $dryRun, $limit, $chunk, $provider ?: null, $prompt, $result, $skipExisting);
|
||||
}
|
||||
|
||||
if ($all) {
|
||||
$this->line('`--all` is now an alias for the default missing-only batch mode.');
|
||||
}
|
||||
|
||||
return $this->handleMissing($biographies, $inputBuilder, $promptBuilder, $force, $queue, $dryRun, $limit, $provider ?: null, $prompt, $result, $skipExisting);
|
||||
}
|
||||
|
||||
private function resolveProviderOverride(): string|false|null
|
||||
{
|
||||
$rawProvider = $this->option('provider');
|
||||
|
||||
if ($rawProvider === null || trim((string) $rawProvider) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$provider = strtolower(trim((string) $rawProvider));
|
||||
|
||||
return match ($provider) {
|
||||
'vision_gateway', 'vision', 'local' => 'vision_gateway',
|
||||
'gemini' => 'gemini',
|
||||
'home', 'lmstudio', 'lm_studio' => 'home',
|
||||
default => $this->invalidProvider($provider),
|
||||
};
|
||||
}
|
||||
|
||||
private function invalidProvider(string $provider): false
|
||||
{
|
||||
$this->error("Invalid provider [{$provider}]. Supported values: vision_gateway, vision, gemini, home.");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function handleSingle(
|
||||
int $userId,
|
||||
AiBiographyService $biographies,
|
||||
AiBiographyInputBuilder $inputBuilder,
|
||||
AiBiographyPromptBuilder $promptBuilder,
|
||||
bool $force,
|
||||
bool $queue,
|
||||
bool $dryRun,
|
||||
?string $provider,
|
||||
bool $showPrompt,
|
||||
bool $showResult,
|
||||
bool $skipExisting,
|
||||
): int {
|
||||
$user = User::query()->where('id', $userId)->where('is_active', true)->whereNull('deleted_at')->first();
|
||||
|
||||
if ($user === null) {
|
||||
$this->error("User #{$userId} not found or inactive.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->line("Processing user #{$userId} ({$user->username})");
|
||||
|
||||
if ($skipExisting && $this->hasActiveBiography($userId)) {
|
||||
$this->info(' ↷ skipped_existing_active');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($showPrompt) {
|
||||
$this->outputPromptPreview($user, $inputBuilder, $promptBuilder);
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info('[dry-run] Would generate biography.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($queue) {
|
||||
if ($showResult) {
|
||||
$this->warn('[--result] is only available for inline runs. The job was queued, so no biography text is available yet.');
|
||||
}
|
||||
|
||||
GenerateAiBiographyJob::dispatch($userId, $force, $provider)->onQueue((string) config('ai_biography.queue', 'default'));
|
||||
$this->info("Queued biography generation for #{$userId}.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$result = $biographies->regenerate($user, $force);
|
||||
|
||||
if ($result['success']) {
|
||||
$this->info(" ✓ {$result['action']}");
|
||||
if ($showResult) {
|
||||
$this->outputGeneratedBiography($userId);
|
||||
}
|
||||
} else {
|
||||
$this->warn(" ✗ {$result['action']}: " . implode(', ', $result['errors']));
|
||||
}
|
||||
|
||||
return $result['success'] ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
|
||||
private function handleStale(
|
||||
AiBiographyService $biographies,
|
||||
AiBiographyInputBuilder $inputBuilder,
|
||||
AiBiographyPromptBuilder $promptBuilder,
|
||||
bool $force,
|
||||
bool $queue,
|
||||
bool $dryRun,
|
||||
int $limit,
|
||||
int $chunk,
|
||||
?string $provider,
|
||||
bool $showPrompt,
|
||||
bool $showResult,
|
||||
bool $skipExisting,
|
||||
): int {
|
||||
if ($skipExisting) {
|
||||
$this->warn('`--skip-existing` is ignored with `--stale` because stale refresh only applies to creators who already have an active AI biography.');
|
||||
}
|
||||
|
||||
$this->info("Scanning for stale AI biographies (limit={$limit})...");
|
||||
|
||||
$processed = 0;
|
||||
$queued = 0;
|
||||
$generated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
User::query()
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->whereExists(fn ($q) => $q->from('creator_ai_biographies')->whereColumn('creator_ai_biographies.user_id', 'users.id')->where('creator_ai_biographies.is_active', true))
|
||||
->chunkById($chunk, function ($users) use ($biographies, $inputBuilder, $promptBuilder, $force, $queue, $dryRun, $limit, $provider, $showPrompt, $showResult, &$processed, &$queued, &$generated, &$skipped): bool {
|
||||
foreach ($users as $user) {
|
||||
if ($processed >= $limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $biographies->isStale($user)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$processed++;
|
||||
$this->line(" [{$user->id}] {$user->username} – stale");
|
||||
|
||||
if ($showPrompt) {
|
||||
$this->outputPromptPreview($user, $inputBuilder, $promptBuilder, ' ');
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($queue) {
|
||||
if ($showResult) {
|
||||
$this->warn(" [--result] is only available for inline runs. User #{$user->id} was queued, so no biography text is available yet.");
|
||||
}
|
||||
|
||||
GenerateAiBiographyJob::dispatch((int) $user->id, $force, $provider)->onQueue((string) config('ai_biography.queue', 'default'));
|
||||
$queued++;
|
||||
} else {
|
||||
$result = $biographies->regenerate($user, $force);
|
||||
if ($result['success']) {
|
||||
$generated++;
|
||||
|
||||
if ($showResult) {
|
||||
$this->outputGeneratedBiography((int) $user->id, ' ');
|
||||
}
|
||||
} else {
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$this->info("Done. processed={$processed} queued={$queued} generated={$generated} skipped/dry={$skipped}");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function handleMissing(
|
||||
AiBiographyService $biographies,
|
||||
AiBiographyInputBuilder $inputBuilder,
|
||||
AiBiographyPromptBuilder $promptBuilder,
|
||||
bool $force,
|
||||
bool $queue,
|
||||
bool $dryRun,
|
||||
int $limit,
|
||||
?string $provider,
|
||||
bool $showPrompt,
|
||||
bool $showResult,
|
||||
bool $skipExisting,
|
||||
): int {
|
||||
$this->info("Generating missing AI biographies ordered by latest public upload (limit={$limit})...");
|
||||
|
||||
$processed = 0;
|
||||
$queued = 0;
|
||||
$generated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
$latestUploads = DB::table('artworks')
|
||||
->selectRaw('user_id, MAX(published_at) as latest_uploaded_at')
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNotNull('published_at')
|
||||
->whereNull('deleted_at')
|
||||
->groupBy('user_id');
|
||||
|
||||
$users = User::query()
|
||||
->leftJoinSub($latestUploads, 'latest_public_artwork', function ($join): void {
|
||||
$join->on('latest_public_artwork.user_id', '=', 'users.id');
|
||||
})
|
||||
->select('users.*')
|
||||
->where('users.is_active', true)
|
||||
->whereNull('users.deleted_at')
|
||||
->whereNotExists(fn ($q) => $q->from('creator_ai_biographies')->whereColumn('creator_ai_biographies.user_id', 'users.id')->where('creator_ai_biographies.is_active', true))
|
||||
->orderByDesc('latest_public_artwork.latest_uploaded_at')
|
||||
->orderByDesc('users.id')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
foreach ($users as $user) {
|
||||
$processed++;
|
||||
$this->line(" [{$user->id}] {$user->username}");
|
||||
|
||||
if ($showPrompt) {
|
||||
$this->outputPromptPreview($user, $inputBuilder, $promptBuilder, ' ');
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($queue) {
|
||||
if ($showResult) {
|
||||
$this->warn(" [--result] is only available for inline runs. User #{$user->id} was queued, so no biography text is available yet.");
|
||||
}
|
||||
|
||||
GenerateAiBiographyJob::dispatch((int) $user->id, $force, $provider)->onQueue((string) config('ai_biography.queue', 'default'));
|
||||
$queued++;
|
||||
} else {
|
||||
$result = $biographies->generate($user);
|
||||
if ($result['success']) {
|
||||
$generated++;
|
||||
|
||||
if ($showResult) {
|
||||
$this->outputGeneratedBiography((int) $user->id, ' ');
|
||||
}
|
||||
} else {
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Done. processed={$processed} queued={$queued} generated={$generated} skipped/dry={$skipped}");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function outputPromptPreview(
|
||||
User $user,
|
||||
AiBiographyInputBuilder $inputBuilder,
|
||||
AiBiographyPromptBuilder $promptBuilder,
|
||||
string $indent = ' ',
|
||||
): void {
|
||||
$input = $inputBuilder->build($user);
|
||||
$qualityTier = $inputBuilder->qualityTier($input);
|
||||
$meetsThreshold = $inputBuilder->meetsMinimumThreshold($input);
|
||||
|
||||
$this->line($indent . 'Prompt preview');
|
||||
$this->line($indent . ' Provider : ' . $this->resolvedProvider());
|
||||
$this->line($indent . ' Quality tier : ' . $qualityTier);
|
||||
$this->line($indent . ' Meets threshold: ' . ($meetsThreshold ? 'yes' : 'no'));
|
||||
|
||||
if (! $meetsThreshold) {
|
||||
$this->line($indent . ' No prompt will be sent because this profile is below the minimum generation threshold.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = $promptBuilder->build($input, strict: false, sparse: $qualityTier === 'sparse');
|
||||
$systemPrompt = (string) ($payload['messages'][0]['content'] ?? '');
|
||||
$userPrompt = (string) ($payload['messages'][1]['content'] ?? '');
|
||||
|
||||
$this->line($indent . ' Prompt version : ' . (string) ($payload['prompt_version'] ?? AiBiographyPromptBuilder::PROMPT_VERSION));
|
||||
$this->line($indent . ' Max tokens : ' . (string) ($payload['max_tokens'] ?? 'n/a'));
|
||||
$this->line($indent . ' Temperature : ' . (string) ($payload['temperature'] ?? 'n/a'));
|
||||
$this->line($indent . ' System prompt:');
|
||||
$this->writeIndentedBlock($systemPrompt, $indent . ' ');
|
||||
$this->line($indent . ' User prompt:');
|
||||
$this->writeIndentedBlock($userPrompt, $indent . ' ');
|
||||
}
|
||||
|
||||
private function writeIndentedBlock(string $text, string $indent): void
|
||||
{
|
||||
foreach (preg_split("/\r\n|\r|\n/", trim($text)) ?: [] as $line) {
|
||||
$this->line($indent . $line);
|
||||
}
|
||||
}
|
||||
|
||||
private function resolvedProvider(): string
|
||||
{
|
||||
$override = trim(strtolower((string) config('ai_biography.provider_override', '')));
|
||||
|
||||
if (in_array($override, ['together', 'vision_gateway', 'gemini', 'home'], true)) {
|
||||
return $override;
|
||||
}
|
||||
|
||||
if (trim((string) config('ai_biography.together.api_key', '')) !== ''
|
||||
&& trim((string) config('ai_biography.together.model', '')) !== '') {
|
||||
return 'together';
|
||||
}
|
||||
|
||||
$provider = trim(strtolower((string) config('ai_biography.provider', 'together')));
|
||||
|
||||
return in_array($provider, ['together', 'vision_gateway', 'gemini', 'home'], true) ? $provider : 'together';
|
||||
}
|
||||
|
||||
private function outputGeneratedBiography(int $userId, string $indent = ' '): void
|
||||
{
|
||||
$record = CreatorAiBiography::query()
|
||||
->where('user_id', $userId)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$text = trim((string) ($record?->text ?? ''));
|
||||
|
||||
if ($text === '') {
|
||||
$this->line($indent . 'Generated biography text: n/a');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->line($indent . 'Generated biography text:');
|
||||
foreach (preg_split('/\r\n|\r|\n/', $text) ?: [] as $line) {
|
||||
$this->line($indent . ' ' . $line);
|
||||
}
|
||||
}
|
||||
|
||||
private function hasActiveBiography(int $userId): bool
|
||||
{
|
||||
return CreatorAiBiography::query()
|
||||
->where('user_id', $userId)
|
||||
->where('is_active', true)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user