Files
SkinbaseNova/app/Console/Commands/GenerateAiBiographyCommand.php
2026-04-18 17:02:56 +02:00

430 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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();
}
}