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