Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View 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();
}
}