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,182 @@
<?php
declare(strict_types=1);
namespace App\Services\AiBiography;
use Illuminate\Support\Facades\Log;
/**
* Coordinates prompt building, Vision gateway call, and result validation.
*
* v1.1 changes:
* Accepts quality tier so the prompt builder can choose the right template.
* One controlled retry on validation failure, using strict/conservative mode.
* Returns prompt_version and was_retried in the result.
* Logging improved to include retry reason and quality tier.
*
* Does NOT read or write to the database.
* Does NOT know about user-edit flags or storage decisions.
* Those responsibilities belong to AiBiographyService.
*/
class AiBiographyGenerator
{
public function __construct(
private readonly AiBiographyPromptBuilder $promptBuilder,
private readonly VisionLlmClient $llmClient,
private readonly AiBiographyValidator $validator,
) {
}
/**
* Generate a biography from a normalized input payload.
*
* On validation failure a single controlled retry is performed with a
* stricter/more conservative prompt. If the retry also fails, the result
* reports the final combined errors alongside was_retried=true.
*
* Returns:
* success bool
* text string|null
* errors list<string>
* model string|null
* prompt_version string
* was_retried bool
*
* @param array<string, mixed> $input from AiBiographyInputBuilder::build()
* @param string $qualityTier 'rich'|'medium'|'sparse'
* @return array{success: bool, text: string|null, errors: list<string>, model: string|null, prompt_version: string, was_retried: bool}
*/
public function generate(array $input, string $qualityTier = 'rich'): array
{
Log::info('AiBiographyGenerator: generation started', [
'user_id' => $input['user_id'] ?? null,
'quality_tier' => $qualityTier,
]);
$result = $this->attempt($input, $qualityTier, strict: false);
if ($result['success']) {
Log::info('AiBiographyGenerator: generation succeeded', [
'user_id' => $input['user_id'] ?? null,
'prompt_version' => $result['prompt_version'],
'quality_tier' => $qualityTier,
]);
return array_merge($result, ['was_retried' => false]);
}
// ── One retry with stricter prompt ───────────────────────────────────
Log::info('AiBiographyGenerator: first attempt failed; retrying with strict prompt', [
'user_id' => $input['user_id'] ?? null,
'quality_tier' => $qualityTier,
'first_errors' => $result['errors'],
]);
$retryResult = $this->attempt($input, $qualityTier, strict: true);
if ($retryResult['success']) {
Log::info('AiBiographyGenerator: retry succeeded', [
'user_id' => $input['user_id'] ?? null,
'prompt_version' => $retryResult['prompt_version'],
]);
return array_merge($retryResult, ['was_retried' => true]);
}
Log::warning('AiBiographyGenerator: retry also failed', [
'user_id' => $input['user_id'] ?? null,
'first_errors' => $result['errors'],
'retry_errors' => $retryResult['errors'],
]);
return array_merge($retryResult, ['was_retried' => true]);
}
// -------------------------------------------------------------------------
/**
* Single generation attempt.
*
* @return array{success: bool, text: string|null, errors: list<string>, model: string|null, prompt_version: string}
*/
private function attempt(array $input, string $qualityTier, bool $strict): array
{
$isSparse = $qualityTier === 'sparse';
$payload = $this->promptBuilder->build($input, strict: $strict, sparse: $isSparse);
$promptVersion = (string) ($payload['prompt_version'] ?? AiBiographyPromptBuilder::PROMPT_VERSION);
// Strip prompt_version before sending to the gateway (not a standard LLM field).
$gatewayPayload = array_diff_key($payload, ['prompt_version' => true]);
try {
$rawText = $this->llmClient->chat($gatewayPayload);
} catch (VisionLlmException $e) {
Log::warning('AiBiographyGenerator: gateway failure', [
'user_id' => $input['user_id'] ?? null,
'error' => $e->getMessage(),
'code' => $e->getCode(),
]);
return [
'success' => false,
'text' => null,
'errors' => [$e->getMessage()],
'model' => null,
'prompt_version' => $promptVersion,
];
}
$text = $this->normalizeOutput($rawText);
$errors = $this->validator->validate($text, $qualityTier);
if ($errors !== []) {
Log::info('AiBiographyGenerator: validation rejected generated text', [
'user_id' => $input['user_id'] ?? null,
'quality_tier' => $qualityTier,
'strict' => $strict,
'errors' => $errors,
'excerpt' => mb_substr($text, 0, 120),
'prompt_version' => $promptVersion,
]);
return [
'success' => false,
'text' => null,
'errors' => $errors,
'model' => null,
'prompt_version' => $promptVersion,
];
}
return [
'success' => true,
'text' => $text,
'errors' => [],
'model' => $this->llmClient->configuredModel(),
'prompt_version' => $promptVersion,
];
}
/**
* Strip model artifacts and normalize whitespace.
*/
private function normalizeOutput(string $rawText): string
{
// Strip chain-of-thought reasoning blocks emitted by some models (e.g. <think>...</think>).
$rawText = (string) preg_replace('/<think>.*?<\/think>/si', '', $rawText);
// Strip common markdown formatting the model may add despite instructions.
$rawText = (string) preg_replace('/\*\*([^*]+)\*\*/', '$1', $rawText); // **bold**
$rawText = (string) preg_replace('/\*([^*\n]+)\*/', '$1', $rawText); // *italic*
$rawText = (string) preg_replace('/^#{1,6}\s+/m', '', $rawText); // ## headings
$rawText = (string) preg_replace('/`([^`]*)`/', '$1', $rawText); // `code`
// Normalize: collapse multiple consecutive newlines into a single space.
$text = trim((string) preg_replace('/\n{2,}/', ' ', $rawText));
$text = trim((string) preg_replace('/\s{2,}/', ' ', $text));
return $text;
}
}

View File

@@ -0,0 +1,430 @@
<?php
declare(strict_types=1);
namespace App\Services\AiBiography;
use App\Models\ArtworkRelation;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Builds a normalized, public-safe input payload from creator data.
*
* Data sources: user record, user_profiles, creator_milestones, creator_eras,
* artworks (public only), artwork_features, artwork_relations.
*
* Privacy rules:
* Only public, approved, non-deleted artworks are used.
* No private milestones (is_public = false).
* No moderation, staff, or hidden data.
* No personal attributes (age, gender, location, religion, etc.).
*/
final class AiBiographyInputBuilder
{
/**
* Build and return the normalized input array for a creator.
*
* @return array<string, mixed>
*/
public function build(User $user): array
{
$userId = (int) $user->id;
$memberSinceYear = (int) $user->created_at->format('Y');
$yearsOnSkinbase = (int) now()->format('Y') - $memberSinceYear;
$uploadsCount = $this->publicUploadsCount($userId);
$featuredCount = $this->featuredCount($userId);
$downloadsCount = $this->totalDownloads($userId);
$topCategories = $this->topCategories($userId);
$topTags = $this->topTags($userId);
$bestWork = $this->bestPerformingWork($userId);
$mostProductiveYear = $this->mostProductiveYear($userId);
$evolutionCount = $this->evolutionCount($userId);
$activityStatus = $this->activityStatus($userId);
$milestones = $this->publicMilestoneSignals($userId);
$eras = $this->publicEras($userId);
return [
'user_id' => $userId,
'username' => (string) $user->username,
'member_since_year' => $memberSinceYear,
'years_on_skinbase' => max(0, $yearsOnSkinbase),
'uploads_count' => $uploadsCount,
'featured_count' => $featuredCount,
'downloads_count' => $downloadsCount,
'top_categories' => $topCategories,
'top_tags' => $topTags,
'best_performing_work' => $bestWork,
'most_productive_year' => $mostProductiveYear,
'evolution_count' => $evolutionCount,
'current_activity_status' => $activityStatus,
'milestones' => $milestones,
'eras' => $eras,
];
}
/**
* Compute a deterministic SHA-256 hash from the normalized input.
* Changing any meaningful field changes the hash, enabling stale detection.
*
* @param array<string, mixed> $input
*/
public function sourceHash(array $input): string
{
// Exclude fields that should not affect staleness:
// user_id / username: identity, not profile signal
// downloads_count: noisy micro-increments that change frequently without
// meaningfully altering what the biography should say
$excluded = ['user_id', 'username', 'downloads_count'];
$significant = array_diff_key($input, array_flip($excluded));
return hash('sha256', json_encode($significant, JSON_THROW_ON_ERROR));
}
/**
* Classify the creator's data richness for prompt and threshold decisions.
*
* rich long history, featured work, milestones/eras/evolution
* medium some uploads, limited signal depth
* sparse very little data; may not warrant generation at all
*
* @param array<string, mixed> $input from build()
*/
public function qualityTier(array $input): string
{
$uploads = (int) ($input['uploads_count'] ?? 0);
$featured = (int) ($input['featured_count'] ?? 0);
$years = (int) ($input['years_on_skinbase'] ?? 0);
$milestones = (array) ($input['milestones'] ?? []);
$eras = (array) ($input['eras'] ?? []);
$evolution = (int) ($input['evolution_count'] ?? 0);
$hasComeBack = ! empty($milestones['has_comeback']);
$hasStreak = (int) ($milestones['best_upload_streak_months'] ?? 0) >= 3;
$richSignals = ($featured >= 1 ? 1 : 0)
+ ($uploads >= 30 ? 1 : 0)
+ ($hasComeBack || $hasStreak ? 1 : 0)
+ (count($eras) >= 2 ? 1 : 0)
+ ($evolution >= 2 ? 1 : 0);
if ($uploads >= 20 && $years >= 2 && $richSignals >= 2) {
return 'rich';
}
if ($uploads >= 5 || $featured >= 1 || ($years >= 1 && $richSignals >= 1)) {
return 'medium';
}
return 'sparse';
}
/**
* Check whether the creator has enough public data to warrant biography generation.
*
* Returns false for brand-new or essentially empty profiles where any
* generated output would be generic or misleading.
*
* @param array<string, mixed> $input from build()
*/
public function meetsMinimumThreshold(array $input): bool
{
$uploads = (int) ($input['uploads_count'] ?? 0);
$featured = (int) ($input['featured_count'] ?? 0);
$categories = (array) ($input['top_categories'] ?? []);
$milestones = (array) ($input['milestones'] ?? []);
$years = (int) ($input['years_on_skinbase'] ?? 0);
return $uploads >= 3
|| $featured >= 1
|| ! empty($milestones['has_comeback'])
|| (int) ($milestones['best_upload_streak_months'] ?? 0) >= 3
|| (count($categories) >= 1 && $uploads >= 1 && $years >= 1);
}
// -------------------------------------------------------------------------
// Private helpers public data only
// -------------------------------------------------------------------------
private function publicUploadsCount(int $userId): int
{
return (int) DB::table('artworks')
->where('user_id', $userId)
->where('is_public', true)
->where('is_approved', true)
->whereNotNull('published_at')
->whereNull('deleted_at')
->count();
}
private function featuredCount(int $userId): int
{
if (! Schema::hasTable('artwork_features')) {
return 0;
}
return (int) DB::table('artwork_features')
->join('artworks', 'artworks.id', '=', 'artwork_features.artwork_id')
->where('artworks.user_id', $userId)
->whereNull('artworks.deleted_at')
->count();
}
private function totalDownloads(int $userId): int
{
if (! Schema::hasTable('artwork_stats')) {
return 0;
}
return (int) DB::table('artworks')
->join('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->where('artworks.user_id', $userId)
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNotNull('artworks.published_at')
->whereNull('artworks.deleted_at')
->sum('artwork_stats.downloads');
}
/**
* @return list<string>
*/
private function topCategories(int $userId): array
{
if (! Schema::hasTable('artwork_category') || ! Schema::hasTable('categories')) {
return [];
}
return DB::table('artwork_category')
->join('artworks', 'artworks.id', '=', 'artwork_category.artwork_id')
->join('categories', 'categories.id', '=', 'artwork_category.category_id')
->where('artworks.user_id', $userId)
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNotNull('artworks.published_at')
->whereNull('artworks.deleted_at')
->groupBy('categories.id', 'categories.name')
->orderByRaw('COUNT(*) DESC')
->orderBy('categories.name')
->limit(3)
->pluck('categories.name')
->map(fn ($n) => (string) $n)
->values()
->all();
}
/**
* @return list<string>
*/
private function topTags(int $userId): array
{
if (! Schema::hasTable('artwork_tag') || ! Schema::hasTable('tags')) {
return [];
}
return DB::table('artwork_tag')
->join('artworks', 'artworks.id', '=', 'artwork_tag.artwork_id')
->join('tags', 'tags.id', '=', 'artwork_tag.tag_id')
->where('artworks.user_id', $userId)
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNotNull('artworks.published_at')
->whereNull('artworks.deleted_at')
->groupBy('tags.id', 'tags.name')
->orderByRaw('COUNT(*) DESC')
->orderBy('tags.name')
->limit(5)
->pluck('tags.name')
->map(fn ($n) => (string) $n)
->values()
->all();
}
/**
* @return array{title: string, year: int}|null
*/
private function bestPerformingWork(int $userId): ?array
{
$query = DB::table('artworks')
->where('artworks.user_id', $userId)
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNotNull('artworks.published_at')
->whereNull('artworks.deleted_at')
->limit(1)
->select('artworks.title', 'artworks.published_at');
if (Schema::hasTable('artwork_stats')) {
$query
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->orderByRaw('(COALESCE(artwork_stats.downloads, 0) + COALESCE(artwork_stats.views, 0) + COALESCE(artwork_stats.favorites, 0)) DESC');
} else {
$query->orderByDesc('artworks.published_at');
}
$row = $query->first();
if ($row === null) {
return null;
}
return [
'title' => (string) $row->title,
'year' => (int) date('Y', strtotime((string) $row->published_at)),
];
}
private function mostProductiveYear(int $userId): ?int
{
// Use strftime for SQLite compatibility; MySQL also supports strftime via
// a compatibility shim, but we use a driver-agnostic expression here.
$driver = DB::getDriverName();
$yearExpr = $driver === 'sqlite'
? "strftime('%Y', published_at)"
: 'YEAR(published_at)';
$row = DB::table('artworks')
->where('user_id', $userId)
->where('is_public', true)
->where('is_approved', true)
->whereNotNull('published_at')
->whereNull('deleted_at')
->selectRaw("{$yearExpr} as yr, COUNT(*) as cnt")
->groupByRaw($yearExpr)
->orderByRaw('COUNT(*) DESC')
->limit(1)
->first();
return $row !== null ? (int) $row->yr : null;
}
private function evolutionCount(int $userId): int
{
if (! Schema::hasTable('artwork_relations')) {
return 0;
}
$evolutionTypes = [
ArtworkRelation::TYPE_REMASTER_OF,
ArtworkRelation::TYPE_REMAKE_OF,
ArtworkRelation::TYPE_REVISION_OF,
];
return (int) DB::table('artwork_relations')
->join('artworks as src', 'src.id', '=', 'artwork_relations.source_artwork_id')
->where('src.user_id', $userId)
->whereIn('artwork_relations.relation_type', $evolutionTypes)
->whereNull('src.deleted_at')
->count();
}
private function activityStatus(int $userId): string
{
$latestPublished = DB::table('artworks')
->where('user_id', $userId)
->where('is_public', true)
->where('is_approved', true)
->whereNotNull('published_at')
->whereNull('deleted_at')
->max('published_at');
if ($latestPublished === null) {
return 'inactive';
}
$daysSinceLast = now()->diffInDays(date('Y-m-d', strtotime((string) $latestPublished)));
if ($daysSinceLast <= 60) {
return 'active';
}
if ($daysSinceLast <= 365) {
return 'recently_active';
}
// Check for comeback: a gap > 180 days before the latest upload.
$previousPublished = DB::table('artworks')
->where('user_id', $userId)
->where('is_public', true)
->where('is_approved', true)
->whereNotNull('published_at')
->whereNull('deleted_at')
->where('published_at', '<', $latestPublished)
->max('published_at');
if ($previousPublished !== null) {
$gapDays = (int) (strtotime((string) $latestPublished) - strtotime((string) $previousPublished)) / 86400;
if ($gapDays >= 180) {
return 'returning';
}
}
return 'legacy';
}
/**
* @return array{has_comeback: bool, best_upload_streak_months: int}
*/
private function publicMilestoneSignals(int $userId): array
{
if (! Schema::hasTable('creator_milestones')) {
return ['has_comeback' => false, 'best_upload_streak_months' => 0];
}
$types = DB::table('creator_milestones')
->where('user_id', $userId)
->where('is_public', true)
->pluck('type')
->all();
$hasComeback = in_array('comeback_detected', $types, true);
$streakRow = DB::table('creator_milestones')
->where('user_id', $userId)
->where('is_public', true)
->whereIn('type', ['upload_streak_3', 'upload_streak_6', 'upload_streak_9', 'upload_streak_12'])
->orderByRaw('priority DESC')
->limit(1)
->first();
$bestStreakMonths = 0;
if ($streakRow !== null) {
$streakMap = [
'upload_streak_3' => 3,
'upload_streak_6' => 6,
'upload_streak_9' => 9,
'upload_streak_12' => 12,
];
$bestStreakMonths = $streakMap[$streakRow->type] ?? 0;
}
return [
'has_comeback' => $hasComeback,
'best_upload_streak_months' => $bestStreakMonths,
];
}
/**
* @return list<array{title: string, starts_at: string, ends_at: string|null}>
*/
private function publicEras(int $userId): array
{
if (! Schema::hasTable('creator_eras')) {
return [];
}
return DB::table('creator_eras')
->where('user_id', $userId)
->orderBy('starts_at')
->get(['title', 'starts_at', 'ends_at'])
->map(fn ($row): array => [
'title' => (string) $row->title,
'starts_at' => (string) $row->starts_at,
'ends_at' => $row->ends_at !== null ? (string) $row->ends_at : null,
])
->values()
->all();
}
}

View File

@@ -0,0 +1,276 @@
<?php
declare(strict_types=1);
namespace App\Services\AiBiography;
/**
* Builds the LLM prompt payload from a normalized creator input.
*
* v1.1 changes:
* PROMPT_VERSION constant tracks the active template family.
* Improved system prompt discourages formulaic openings and stat-dumps.
* Sparse profile branch uses a lighter, safer template.
* Strict mode is used on retry; produces a more conservative output.
*
* Prompt rules:
* Only include facts that are actually present in the input.
* Never instruct the model to invent details or speculate.
* Always require a single paragraph output with no markdown.
* Keep max_tokens tight to enforce the word cap.
*/
final class AiBiographyPromptBuilder
{
public const PROMPT_VERSION = 'v1.1';
private const MIN_WORDS = 30;
private const SYSTEM_PROMPT = <<<'PROMPT'
You are a concise writing assistant for Skinbase Nova, a digital art platform.
Write short creator biographies using only the facts provided. Use a polished, factual, and slightly editorial tone.
Rules:
- Use only the provided data. Do not invent achievements, personal details, visual style claims, or platform fame.
- Do not write bullet points, headings, or markdown.
- Output exactly one paragraph.
- Do not exceed 140 words.
- Avoid hype language: do not use "world-class", "iconic", "legendary", "renowned", "celebrated", "masterpiece", or "beloved".
- Do not speculate about personality, age, gender, politics, religion, or private life.
- Do not mention data points that are not provided or are zero/empty.
- Do not open with "has been part of Skinbase since" or similar formulaic phrases. Vary the opening.
- Mention only the 2 to 3 most meaningful signals. Do not list every available stat.
- Do not write "creator journey shows..." describe what the data reflects directly.
- Prefer natural narrative flow over data listing.
PROMPT;
private const SYSTEM_PROMPT_STRICT = <<<'PROMPT'
You are a cautious writing assistant for Skinbase Nova, a digital art platform.
Write a short, safe creator biography using only the facts provided. Be conservative.
Rules:
- Use only the provided facts. Do not invent or speculate.
- Output exactly one paragraph, no markdown, no headings, no bullets.
- Maximum 100 words.
- Mention only 1 or 2 standout facts. Do not list all available data.
- Avoid any superlatives, praise, or style claims.
- Do not mention missing or zero-value fields.
- Keep the tone neutral, simple, and factual.
PROMPT;
private const SYSTEM_PROMPT_SPARSE = <<<'PROMPT'
You are a cautious writing assistant for Skinbase Nova, a digital art platform.
Write a short, modest creator introduction using only the facts provided.
Rules:
- Use only the facts provided.
- Output exactly one paragraph, no markdown, no bullets.
- Write between 35 and 60 words.
- Minimum 30 words.
- Keep it simple. Mention member-since year and upload count if available.
- Add one category or another factual signal when available so the paragraph has enough substance.
- Do not invent anything. Do not praise. Do not speculate.
- If data is very limited, use two short factual sentences rather than a fragment.
PROMPT;
private const SYSTEM_PROMPT_SPARSE_STRICT = <<<'PROMPT'
You are a cautious writing assistant for Skinbase Nova, a digital art platform.
Write a short, modest creator introduction using only the facts provided. Be conservative and precise.
Rules:
- Use only the facts provided.
- Output exactly one paragraph, no markdown, no bullets.
- Write between 35 and 50 words.
- Minimum 30 words.
- Prefer two short factual sentences.
- Mention member-since year, upload count, and one category when available.
- Do not invent anything. Do not praise. Do not speculate.
PROMPT;
/**
* Build the full messages payload for the LLM.
*
* @param array<string, mixed> $input normalized creator input from AiBiographyInputBuilder
* @param bool $strict true on retry forces more conservative output
* @param bool $sparse true for sparse-profile creators
* @return array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool, prompt_version: string}
*/
public function build(array $input, bool $strict = false, bool $sparse = false): array
{
if ($sparse && $strict) {
$systemPrompt = self::SYSTEM_PROMPT_SPARSE_STRICT;
$userPrompt = $this->buildSparseUserPrompt($input, strict: true);
$maxTokens = 240;
$temperature = 0.2;
} elseif ($sparse) {
$systemPrompt = self::SYSTEM_PROMPT_SPARSE;
$userPrompt = $this->buildSparseUserPrompt($input, strict: false);
$maxTokens = 320;
$temperature = 0.3;
} elseif ($strict) {
$systemPrompt = self::SYSTEM_PROMPT_STRICT;
$userPrompt = $this->buildUserPrompt($input, strict: true);
$maxTokens = 450;
$temperature = 0.25;
} else {
$systemPrompt = self::SYSTEM_PROMPT;
$userPrompt = $this->buildUserPrompt($input, strict: false);
$maxTokens = 600;
$temperature = 0.45;
}
return [
'messages' => [
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $userPrompt],
],
'max_tokens' => $maxTokens,
'temperature' => $temperature,
'stream' => false,
'prompt_version' => self::PROMPT_VERSION,
];
}
private function buildUserPrompt(array $input, bool $strict): string
{
$wordTarget = $strict ? '60 to 100' : '70 to 130';
$lines = [
"Write a creator biography in {$wordTarget} words using only the facts below. Output one paragraph only.",
'',
];
$username = (string) ($input['username'] ?? '');
if ($username !== '') {
$lines[] = "- Creator: {$username}";
}
$memberYear = $input['member_since_year'] ?? null;
$years = $input['years_on_skinbase'] ?? null;
if ($memberYear !== null && (int) $memberYear > 0) {
$label = ((int) ($years ?? 0) > 1) ? ", {$years} years on the platform" : '';
$lines[] = "- Member since: {$memberYear}{$label}";
}
$uploads = $input['uploads_count'] ?? 0;
if ((int) $uploads > 0) {
$lines[] = "- Total public uploads: {$uploads}";
}
$featured = $input['featured_count'] ?? 0;
if ((int) $featured > 0) {
$lines[] = "- Featured artworks: {$featured}";
}
$downloads = $input['downloads_count'] ?? 0;
if ((int) $downloads > 5000) {
$lines[] = sprintf('- Total downloads: %s', number_format((int) $downloads));
}
$categories = $input['top_categories'] ?? [];
if ($categories !== []) {
$lines[] = '- Top categories: ' . implode(', ', array_slice((array) $categories, 0, 2));
}
// On strict retry, trim tags to keep prompt tight and reduce hallucination surface.
if (! $strict) {
$tags = $input['top_tags'] ?? [];
if ($tags !== []) {
$lines[] = '- Common themes: ' . implode(', ', array_slice((array) $tags, 0, 3));
}
}
$bestWork = $input['best_performing_work'] ?? null;
if (is_array($bestWork) && isset($bestWork['title'], $bestWork['year'])) {
$lines[] = "- Best-performing work: {$bestWork['title']} ({$bestWork['year']})";
}
$productiveYear = $input['most_productive_year'] ?? null;
if ($productiveYear !== null && (int) $productiveYear > 0) {
$lines[] = "- Most productive year: {$productiveYear}";
}
$status = $input['current_activity_status'] ?? null;
if ($status !== null && $status !== '') {
$statusLabels = [
'active' => 'currently active',
'recently_active' => 'recently active',
'returning' => 'returning creator',
'legacy' => 'long-standing creator',
'inactive' => null,
];
$statusLabel = $statusLabels[$status] ?? null;
if ($statusLabel !== null) {
$lines[] = "- Activity: {$statusLabel}";
}
}
$milestones = $input['milestones'] ?? [];
if (is_array($milestones)) {
if (! empty($milestones['has_comeback'])) {
$lines[] = '- Notable milestone: returned after a significant break';
}
$streak = (int) ($milestones['best_upload_streak_months'] ?? 0);
if ($streak >= 3 && ! $strict) {
$lines[] = "- Upload streak: {$streak} consecutive months";
}
}
// Include eras and evolution only when not on strict retry.
if (! $strict) {
$eras = $input['eras'] ?? [];
if (is_array($eras) && count($eras) >= 2) {
$eraTitles = array_column($eras, 'title');
$lines[] = '- Creator eras: ' . implode(' → ', $eraTitles);
}
$evolutionCount = $input['evolution_count'] ?? 0;
if ((int) $evolutionCount > 0) {
$lines[] = "- Remastered/evolved works: {$evolutionCount}";
}
}
$lines[] = '';
$lines[] = 'Avoid hype. Do not open with a formulaic phrase. Do not list every stat. Output one paragraph only. No markdown.';
return implode("\n", $lines);
}
private function buildSparseUserPrompt(array $input, bool $strict = false): string
{
$wordTarget = $strict ? '35 to 50' : '35 to 60';
$lines = [
"Write a brief, modest creator introduction in {$wordTarget} words using only these facts. Output one paragraph only.",
'',
];
$username = (string) ($input['username'] ?? '');
if ($username !== '') {
$lines[] = "- Creator: {$username}";
}
$memberYear = $input['member_since_year'] ?? null;
$years = $input['years_on_skinbase'] ?? null;
if ($memberYear !== null && (int) $memberYear > 0) {
$yearsLabel = ((int) ($years ?? 0) > 1) ? ", {$years} years on the platform" : '';
$lines[] = "- Member since: {$memberYear}{$yearsLabel}";
}
$uploads = $input['uploads_count'] ?? 0;
if ((int) $uploads > 0) {
$lines[] = "- Public uploads: {$uploads}";
}
$categories = $input['top_categories'] ?? [];
if ($categories !== []) {
$lines[] = '- Categories: ' . implode(', ', array_slice((array) $categories, 0, $strict ? 1 : 2));
}
$lines[] = '';
$lines[] = 'Keep it simple and factual. Write at least ' . self::MIN_WORDS . ' words. Prefer two short sentences if needed. No praise. No markdown.';
return implode("\n", $lines);
}
}

View File

@@ -0,0 +1,490 @@
<?php
declare(strict_types=1);
namespace App\Services\AiBiography;
use App\Models\CreatorAiBiography;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Orchestrates AI biography generation, storage, retrieval, and creator controls.
*
* v1.1 additions:
* Quality tier classification and minimum-threshold gating before generation.
* Sparse profiles below threshold are suppressed (or produce a safe fallback).
* All new metadata columns (prompt_version, input_quality_tier, generation_reason,
* needs_review, last_attempted_at, last_error_code, last_error_reason) are written.
* Stale detection for user-edited biographies: sets needs_review=true instead of
* silently overwriting, and stores a draft.
* Hidden biographies remain hidden unless explicitly shown again.
* adminInspect() returns full metadata for artisan/admin tooling.
*
* Public API:
* generate(User, reason): array generate and store a new biography
* regenerate(User, force, reason): array force-regenerate, respects user-edit lock
* updateText(User, string): void creator edits their biography
* hide(User): void creator hides their AI bio
* show(User): void creator re-enables their AI bio
* publicPayload(User): array|null public profile rendering payload
* creatorStatusPayload(User): array authenticated creator status (more fields)
* adminInspect(User): array full metadata for admin/artisan tooling
* isStale(User): bool source-hash staleness check
*/
final class AiBiographyService
{
public function __construct(
private readonly AiBiographyInputBuilder $inputBuilder,
private readonly AiBiographyGenerator $generator,
) {
}
// -------------------------------------------------------------------------
// Generation
// -------------------------------------------------------------------------
/**
* Generate a biography for the user.
*
* 1. Classify quality tier.
* 2. Check minimum-data threshold; suppress if below.
* 3. If existing active bio is user-edited, store draft + flag needs_review.
* 4. Otherwise generate and activate.
*
* @param string $reason why generation was triggered (CreatorAiBiography::REASON_*)
* @return array{success: bool, action: string, errors: list<string>}
*/
public function generate(User $user, string $reason = CreatorAiBiography::REASON_INITIAL_GENERATE): array
{
$input = $this->inputBuilder->build($user);
$sourceHash = $this->inputBuilder->sourceHash($input);
$qualityTier = $this->inputBuilder->qualityTier($input);
$existing = $this->activeRecord($user);
Log::info('AiBiographyService: generate requested', [
'user_id' => (int) $user->id,
'quality_tier' => $qualityTier,
'reason' => $reason,
]);
// ── Minimum threshold check ──────────────────────────────────────────
if (! $this->inputBuilder->meetsMinimumThreshold($input)) {
Log::info('AiBiographyService: suppressed — below minimum data threshold', [
'user_id' => (int) $user->id,
]);
return [
'success' => false,
'action' => 'suppressed_low_signal',
'errors' => ['Creator profile does not have enough public data for biography generation.'],
];
}
// ── User-edited protection ────────────────────────────────────────────
if ($existing !== null && $existing->is_user_edited) {
return $this->storeDraftForUserEdited($user, $input, $sourceHash, $qualityTier, $reason, $existing);
}
return $this->generateAndActivate($user, $input, $sourceHash, $qualityTier, $reason);
}
/**
* Force-regenerate, respecting user-edit lock unless $force=true.
*
* @param string $reason
* @return array{success: bool, action: string, errors: list<string>}
*/
public function regenerate(
User $user,
bool $force = false,
string $reason = CreatorAiBiography::REASON_MANUAL_REGENERATE,
): array {
$input = $this->inputBuilder->build($user);
$sourceHash = $this->inputBuilder->sourceHash($input);
$qualityTier = $this->inputBuilder->qualityTier($input);
$existing = $this->activeRecord($user);
// ── Minimum threshold check ──────────────────────────────────────────
if (! $this->inputBuilder->meetsMinimumThreshold($input)) {
Log::info('AiBiographyService: regenerate suppressed — below minimum data threshold', [
'user_id' => (int) $user->id,
]);
return [
'success' => false,
'action' => 'suppressed_low_signal',
'errors' => ['Creator profile does not have enough public data for biography generation.'],
];
}
if ($existing !== null && $existing->is_user_edited && ! $force) {
return [
'success' => false,
'action' => 'user_edited_locked',
'errors' => ['Existing biography is user-edited. Pass force=true to overwrite.'],
];
}
return $this->generateAndActivate($user, $input, $sourceHash, $qualityTier, $reason);
}
// -------------------------------------------------------------------------
// Creator controls
// -------------------------------------------------------------------------
public function updateText(User $user, string $text): void
{
$existing = $this->activeRecord($user);
if ($existing !== null) {
$existing->update([
'text' => $text,
'is_user_edited' => true,
'needs_review' => false,
'status' => CreatorAiBiography::STATUS_EDITED,
]);
return;
}
CreatorAiBiography::create([
'user_id' => (int) $user->id,
'text' => $text,
'source_hash' => null,
'model' => null,
'prompt_version' => null,
'input_quality_tier' => null,
'generation_reason' => null,
'status' => CreatorAiBiography::STATUS_EDITED,
'is_active' => true,
'is_hidden' => false,
'is_user_edited' => true,
'needs_review' => false,
'generated_at' => now(),
'approved_at' => now(),
'last_attempted_at' => null,
'last_error_code' => null,
'last_error_reason' => null,
]);
}
/**
* Hide the biography. Hidden state persists until explicitly shown.
*/
public function hide(User $user): void
{
$this->activeRecord($user)?->update(['is_hidden' => true]);
}
/**
* Show (un-hide) the biography. Requires explicit creator action.
*/
public function show(User $user): void
{
$this->activeRecord($user)?->update(['is_hidden' => false]);
}
// -------------------------------------------------------------------------
// Public rendering
// -------------------------------------------------------------------------
/**
* Return the public-facing payload for the profile API.
* Returns null if no visible biography exists.
*
* @return array{text: string, is_visible: bool, is_user_edited: bool, generated_at: string|null, status: string}|null
*/
public function publicPayload(User $user): ?array
{
$record = $this->activeRecord($user);
if ($record === null || ! $record->isVisible()) {
return null;
}
return [
'text' => (string) $record->text,
'is_visible' => true,
'is_user_edited' => (bool) $record->is_user_edited,
'generated_at' => $record->generated_at?->toIso8601String(),
'status' => (string) $record->status,
];
}
/**
* Return the authenticated creator's full status payload.
* Includes generation metadata not shown publicly.
*
* @return array<string, mixed>
*/
public function creatorStatusPayload(User $user): array
{
$record = $this->activeRecord($user);
if ($record === null) {
return [
'has_biography' => false,
'is_hidden' => false,
'is_user_edited' => false,
'needs_review' => false,
'status' => null,
'prompt_version' => null,
'input_quality_tier' => null,
'generation_reason' => null,
'generated_at' => null,
'last_attempted_at' => null,
'last_error_code' => null,
'last_error_reason' => null,
];
}
return [
'has_biography' => true,
'is_visible' => $record->isVisible(),
'is_hidden' => (bool) $record->is_hidden,
'is_user_edited' => (bool) $record->is_user_edited,
'needs_review' => (bool) $record->needs_review,
'status' => (string) $record->status,
'prompt_version' => $record->prompt_version,
'input_quality_tier' => $record->input_quality_tier,
'generation_reason' => $record->generation_reason,
'generated_at' => $record->generated_at?->toIso8601String(),
'last_attempted_at' => $record->last_attempted_at?->toIso8601String(),
'last_error_code' => $record->last_error_code,
'last_error_reason' => $record->last_error_reason,
];
}
/**
* Full metadata record for admin/artisan inspection.
* Includes normalized input payload.
*
* @return array<string, mixed>
*/
public function adminInspect(User $user): array
{
$record = $this->activeRecord($user);
$input = $this->inputBuilder->build($user);
return [
'record' => $record?->toArray(),
'input_payload' => $input,
'quality_tier' => $this->inputBuilder->qualityTier($input),
'meets_threshold' => $this->inputBuilder->meetsMinimumThreshold($input),
'source_hash_live' => $this->inputBuilder->sourceHash($input),
'is_stale' => $this->isStale($user),
];
}
// -------------------------------------------------------------------------
// Stale check
// -------------------------------------------------------------------------
public function isStale(User $user): bool
{
$record = $this->activeRecord($user);
if ($record === null) {
return true;
}
$input = $this->inputBuilder->build($user);
$sourceHash = $this->inputBuilder->sourceHash($input);
return $record->source_hash !== $sourceHash;
}
// -------------------------------------------------------------------------
// Internal helpers
// -------------------------------------------------------------------------
private function activeRecord(User $user): ?CreatorAiBiography
{
return CreatorAiBiography::query()
->where('user_id', (int) $user->id)
->where('is_active', true)
->latest()
->first();
}
/**
* @return array{success: bool, action: string, errors: list<string>}
*/
private function generateAndActivate(
User $user,
array $input,
string $sourceHash,
string $qualityTier,
string $reason,
): array {
$now = now();
$result = $this->generator->generate($input, $qualityTier);
// ── Record attempt regardless of outcome ─────────────────────────────
if (! $result['success']) {
// Update last-attempt metadata on the existing active record if present,
// or create a failed record for observability.
$existing = $this->activeRecord($user);
$failedAttrs = [
'last_attempted_at' => $now,
'last_error_code' => 'generation_failed',
'last_error_reason' => implode('; ', $result['errors']),
];
if ($existing !== null) {
$existing->update($failedAttrs);
} else {
CreatorAiBiography::create(array_merge([
'user_id' => (int) $user->id,
'text' => null,
'source_hash' => $sourceHash,
'model' => null,
'prompt_version' => $result['prompt_version'] ?? null,
'input_quality_tier' => $qualityTier,
'generation_reason' => $reason,
'status' => CreatorAiBiography::STATUS_FAILED,
'is_active' => false,
'is_hidden' => false,
'is_user_edited' => false,
'needs_review' => false,
'generated_at' => null,
'approved_at' => null,
], $failedAttrs));
}
Log::warning('AiBiographyService: generation failed', [
'user_id' => (int) $user->id,
'errors' => $result['errors'],
'retried' => $result['was_retried'] ?? false,
]);
return [
'success' => false,
'action' => 'generation_failed',
'errors' => $result['errors'],
];
}
DB::transaction(function () use ($user, $result, $sourceHash, $qualityTier, $reason, $now): void {
// Deactivate any previous active records.
CreatorAiBiography::query()
->where('user_id', (int) $user->id)
->where('is_active', true)
->update(['is_active' => false]);
CreatorAiBiography::create([
'user_id' => (int) $user->id,
'text' => $result['text'],
'source_hash' => $sourceHash,
'model' => $result['model'],
'prompt_version' => $result['prompt_version'],
'input_quality_tier' => $qualityTier,
'generation_reason' => $reason,
'status' => CreatorAiBiography::STATUS_GENERATED,
'is_active' => true,
'is_hidden' => false,
'is_user_edited' => false,
'needs_review' => false,
'generated_at' => $now,
'approved_at' => $now,
'last_attempted_at' => $now,
'last_error_code' => null,
'last_error_reason' => null,
]);
});
Log::info('AiBiographyService: biography generated and stored', [
'user_id' => (int) $user->id,
'model' => $result['model'],
'prompt_version' => $result['prompt_version'],
'quality_tier' => $qualityTier,
'was_retried' => $result['was_retried'] ?? false,
'reason' => $reason,
]);
return [
'success' => true,
'action' => 'generated',
'errors' => [],
];
}
/**
* Store a draft (non-active) without replacing the current user-edited biography.
* Marks the existing user-edited record as needs_review so the creator is notified.
*
* @return array{success: bool, action: string, errors: list<string>}
*/
private function storeDraftForUserEdited(
User $user,
array $input,
string $sourceHash,
string $qualityTier,
string $reason,
CreatorAiBiography $existingEdited,
): array {
$now = now();
$result = $this->generator->generate($input, $qualityTier);
if (! $result['success']) {
$existingEdited->update([
'last_attempted_at' => $now,
'last_error_code' => 'generation_failed',
'last_error_reason' => implode('; ', $result['errors']),
]);
Log::warning('AiBiographyService: draft generation failed for user-edited bio', [
'user_id' => (int) $user->id,
'errors' => $result['errors'],
]);
return [
'success' => false,
'action' => 'generation_failed',
'errors' => $result['errors'],
];
}
DB::transaction(function () use ($user, $result, $sourceHash, $qualityTier, $reason, $now, $existingEdited): void {
// Store the new generation as a non-active draft.
CreatorAiBiography::create([
'user_id' => (int) $user->id,
'text' => $result['text'],
'source_hash' => $sourceHash,
'model' => $result['model'],
'prompt_version' => $result['prompt_version'],
'input_quality_tier' => $qualityTier,
'generation_reason' => $reason,
'status' => CreatorAiBiography::STATUS_GENERATED,
'is_active' => false, // kept as draft; user-edited version remains active
'is_hidden' => false,
'is_user_edited' => false,
'needs_review' => false,
'generated_at' => $now,
'approved_at' => null,
'last_attempted_at' => $now,
'last_error_code' => null,
'last_error_reason' => null,
]);
// Flag the active user-edited record: a newer AI draft is available.
$existingEdited->update([
'needs_review' => true,
'last_attempted_at' => $now,
]);
});
Log::info('AiBiographyService: draft stored for user-edited biography', [
'user_id' => (int) $user->id,
'prompt_version' => $result['prompt_version'],
]);
return [
'success' => true,
'action' => 'draft_stored',
'errors' => [],
];
}
}

View File

@@ -0,0 +1,241 @@
<?php
declare(strict_types=1);
namespace App\Services\AiBiography;
/**
* Validates generated biography text before it is stored.
*
* v1.1 additions:
* Extended forbidden phrases (renowned, celebrated, iconic, etc.)
* Generic filler detection ("creator journey shows", "over the years" spam)
* Stat-dump detection (too many bare numbers in a short text)
* Repetitive phrase detection
* Sparse-profile mismatch check (rich-sounding bio for sparse creator)
*
* Rejects output that is:
* empty or too short to be useful
* too long (hard cap)
* not a single paragraph (multiple newlines separating blocks)
* contains markdown (headings, bullets, bold, italic, code)
* contains forbidden hype terms
* contains placeholder or apology patterns
* sounds too rich/boastful for a sparse creator profile
*/
final class AiBiographyValidator
{
private const MIN_WORDS = 20;
private const MAX_WORDS = 180;
/**
* Phrases that are always forbidden, regardless of tier.
* These indicate hallucinated praise, AI-apology patterns, or unsupported claims.
*/
private const FORBIDDEN_PHRASES = [
// Unsupported significance claims
'world-class',
'world class',
'iconic visionary',
'unmatched style',
'legendary',
'changed the platform',
'beloved by everyone',
'renowned for',
'masterpiece creator',
'masterclass',
'celebrated artist',
'celebrated creator',
'celebrated by',
'iconic creator',
'iconic artist',
'iconic work',
'platform legend',
'community favorite',
'widely recognized',
'highly regarded',
'critically acclaimed',
// AI apology / refusal patterns
'i cannot',
"i can't",
'i apologize',
'as an ai',
'as a language model',
'i do not have',
"i don't have",
'based on the information provided',
'unfortunately',
"i'm unable to",
'i am unable to',
// Vague over-praising filler
'truly remarkable',
'absolutely exceptional',
'without a doubt',
'undeniably talented',
];
/**
* Phrases that signal generic, formulaic filler when used more than once,
* or which are always a warning sign of lazy output.
* A single occurrence is allowed; repeated use is rejected.
*/
private const REPETITION_PHRASES = [
'creator journey',
'over the years',
'has been part of skinbase',
'has been a member',
'throughout the years',
'through the years',
'journey on skinbase',
];
/**
* Validate the generated biography.
*
* @param string $text the generated biography text
* @param string $qualityTier 'rich'|'medium'|'sparse' used for sparse mismatch check
* @return list<string> validation errors; empty list means valid
*/
public function validate(string $text, string $qualityTier = 'rich'): array
{
$errors = [];
$trimmed = trim($text);
if ($trimmed === '') {
$errors[] = 'Biography is empty.';
return $errors;
}
$wordCount = str_word_count($trimmed);
if ($wordCount < self::MIN_WORDS) {
$errors[] = "Biography is too short ({$wordCount} words, minimum " . self::MIN_WORDS . ').';
}
if ($wordCount > self::MAX_WORDS) {
$errors[] = "Biography is too long ({$wordCount} words, maximum " . self::MAX_WORDS . ').';
}
if ($this->containsMarkdown($trimmed)) {
$errors[] = 'Biography contains markdown or structural formatting.';
}
if ($this->hasMultipleParagraphs($trimmed)) {
$errors[] = 'Biography contains multiple paragraphs; must be a single paragraph.';
}
foreach (self::FORBIDDEN_PHRASES as $phrase) {
if (str_contains(mb_strtolower($trimmed), $phrase)) {
$errors[] = "Biography contains forbidden phrase: \"{$phrase}\".";
break;
}
}
$repetitionError = $this->checkRepetition($trimmed);
if ($repetitionError !== null) {
$errors[] = $repetitionError;
}
if ($qualityTier === 'sparse' && $this->soundsTooRichForSparseProfile($trimmed)) {
$errors[] = 'Biography sounds too claim-heavy for a sparse creator profile.';
}
return $errors;
}
public function isValid(string $text, string $qualityTier = 'rich'): bool
{
return $this->validate($text, $qualityTier) === [];
}
// -------------------------------------------------------------------------
private function containsMarkdown(string $text): bool
{
// Headings: #, ##, ###
if (preg_match('/^\s*#{1,6}\s/m', $text)) {
return true;
}
// Bullets: lines starting with -, *, or numbered list
if (preg_match('/^\s*[-*]\s/m', $text)) {
return true;
}
if (preg_match('/^\s*\d+\.\s/m', $text)) {
return true;
}
// Bold / italic markers
if (preg_match('/\*\*|__|\*[^*]|_[^_]/', $text)) {
return true;
}
// Code blocks or inline code
if (str_contains($text, '`') || str_contains($text, '```')) {
return true;
}
return false;
}
private function hasMultipleParagraphs(string $text): bool
{
// Two or more consecutive newlines indicate paragraph break.
return (bool) preg_match('/\n\s*\n/', $text);
}
/**
* Check whether any formulaic phrase appears more than once,
* which usually indicates a recycled or low-quality output.
*/
private function checkRepetition(string $text): ?string
{
$lower = mb_strtolower($text);
foreach (self::REPETITION_PHRASES as $phrase) {
// Count non-overlapping occurrences.
$count = substr_count($lower, $phrase);
if ($count >= 2) {
return "Biography repeats the phrase \"{$phrase}\" too many times.";
}
}
return null;
}
/**
* For sparse-profile biographies, reject text that sounds too achievement-heavy.
* These signals typically appear only in rich profiles and would be hallucinated
* or misleading when the creator has very little public history.
*/
private function soundsTooRichForSparseProfile(string $text): bool
{
$lower = mb_strtolower($text);
$richIndicators = [
'featured',
'best-performing',
'standout',
'milestone',
'comeback',
'evolution',
'remaster',
'era',
'streak',
'downloads',
'most productive',
];
$hitCount = 0;
foreach ($richIndicators as $indicator) {
if (str_contains($lower, $indicator)) {
$hitCount++;
}
}
// If a sparse profile biography references 3+ rich signals, it likely hallucinated them.
return $hitCount >= 3;
}
}

View File

@@ -0,0 +1,507 @@
<?php
declare(strict_types=1);
namespace App\Services\AiBiography;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Thin client for the Skinbase Vision LLM gateway.
*
* Uses the existing Vision gateway infrastructure (VISION_GATEWAY_URL / VISION_GATEWAY_API_KEY).
* Prefers /ai/chat; falls back to /v1/chat/completions if configured.
*
* Error codes handled:
* 401 invalid API key
* 413 oversized request
* 422 invalid payload
* 503 LLM unavailable
* 504 timeout / upstream
*/
final class VisionLlmClient
{
public function isConfigured(): bool
{
return match ($this->provider()) {
'together' => $this->togetherApiKey() !== '' && $this->togetherModel() !== '',
'gemini' => $this->geminiBaseUrl() !== '' && $this->geminiApiKey() !== '' && $this->geminiModel() !== '',
'home' => $this->homeBaseUrl() !== '' && $this->homeModel() !== '',
default => $this->baseUrl() !== '' && $this->apiKey() !== '',
};
}
public function configuredModel(): string
{
return match ($this->provider()) {
'together' => $this->togetherModel(),
'gemini' => $this->geminiModel(),
'home' => $this->homeModel(),
default => trim((string) config('ai_biography.llm_model', 'vision-gateway')),
};
}
/**
* Send a chat completion payload to the Vision gateway.
*
* @param array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool} $payload
* @return string The generated text content.
*
* @throws VisionLlmException On structured gateway failure.
*/
public function chat(array $payload): string
{
if (! $this->isConfigured()) {
throw new VisionLlmException(
match ($this->provider()) { 'together' => 'Together.ai is not configured. Set TOGETHER_API_KEY and optionally AI_BIOGRAPHY_TOGETHER_MODEL.', 'gemini' => 'Gemini API is not configured. Set GEMINI_API_KEY and optionally AI_BIOGRAPHY_GEMINI_MODEL.',
'home' => 'Home LM Studio is not configured. Set AI_BIOGRAPHY_HOME_BASE_URL and AI_BIOGRAPHY_HOME_MODEL.',
default => 'Vision LLM gateway is not configured. Set VISION_GATEWAY_URL and VISION_GATEWAY_API_KEY.',
},
0
);
}
return match ($this->provider()) {
'together' => $this->chatWithTogether($payload),
'gemini' => $this->chatWithGemini($payload),
'home' => $this->chatWithHome($payload),
default => $this->chatWithVisionGateway($payload),
};
}
/**
* @param array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool} $payload
*/
private function chatWithTogether(array $payload): string
{
$response = $this->sendTogetherRequest($this->togetherEndpoint(), $this->toTogetherPayload($payload));
$this->assertSuccessful($response, 'together');
return $this->extractContent($response);
}
/**
* @param array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool} $payload
*/
private function chatWithVisionGateway(array $payload): string
{
$endpoint = $this->primaryEndpoint();
$response = $this->sendRequest($endpoint, $payload);
// If primary endpoint returned a 404, fall back to the OpenAI-compatible path.
if ($response->status() === 404) {
$fallbackEndpoint = $this->fallbackEndpoint();
if ($fallbackEndpoint !== $endpoint) {
Log::debug('VisionLlmClient: primary endpoint returned 404, trying fallback', [
'primary' => $endpoint,
'fallback' => $fallbackEndpoint,
]);
$response = $this->sendRequest($fallbackEndpoint, $payload);
}
}
$this->assertSuccessful($response, 'vision_gateway');
return $this->extractContent($response);
}
/**
* @param array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool} $payload
*/
private function chatWithGemini(array $payload): string
{
$response = $this->sendGeminiRequest($this->geminiEndpoint(), $this->toGeminiPayload($payload));
$this->assertSuccessful($response, 'gemini');
return $this->extractGeminiContent($response);
}
/**
* @param array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool} $payload
*/
private function chatWithHome(array $payload): string
{
$response = $this->sendHomeRequest($this->homeEndpoint(), $this->toHomePayload($payload));
$this->assertSuccessful($response, 'home');
return $this->extractContent($response);
}
// -------------------------------------------------------------------------
private function sendRequest(string $url, array $payload): Response
{
try {
return $this->buildRequest()->post($url, $payload);
} catch (\Illuminate\Http\Client\ConnectionException $e) {
throw new VisionLlmException(
'Vision LLM gateway connection failed: ' . $e->getMessage(),
504,
$e
);
}
}
private function sendTogetherRequest(string $url, array $payload): Response
{
try {
return $this->buildTogetherRequest()->post($url, $payload);
} catch (\Illuminate\Http\Client\ConnectionException $e) {
throw new VisionLlmException(
'Together.ai connection failed: ' . $e->getMessage(),
504,
$e
);
}
}
private function sendGeminiRequest(string $url, array $payload): Response
{
try {
return $this->buildGeminiRequest()->post($url, $payload);
} catch (\Illuminate\Http\Client\ConnectionException $e) {
throw new VisionLlmException(
'Gemini API connection failed: ' . $e->getMessage(),
504,
$e
);
}
}
private function sendHomeRequest(string $url, array $payload): Response
{
try {
return $this->buildHomeRequest()->post($url, $payload);
} catch (\Illuminate\Http\Client\ConnectionException $e) {
throw new VisionLlmException(
'Home LM Studio connection failed: ' . $e->getMessage(),
504,
$e
);
}
}
private function assertSuccessful(Response $response, string $provider): void
{
if ($response->successful()) {
return;
}
$status = $response->status();
$body = mb_substr(trim($response->body()), 0, 300);
$label = match ($provider) {
'together' => 'Together.ai',
'gemini' => 'Gemini API',
'home' => 'Home LM Studio',
default => 'Vision LLM gateway',
};
$message = match ($status) {
401, 403 => "{$label}: invalid or unauthorized API key ({$status}).",
413 => "{$label}: request payload too large (413).",
422 => "{$label}: invalid payload (422). {$body}",
429 => "{$label}: rate limit or quota exceeded (429).",
503 => "{$label}: LLM service unavailable (503).",
504 => "{$label}: upstream timeout (504).",
default => "{$label}: unexpected HTTP {$status}. {$body}",
};
Log::warning('VisionLlmClient: gateway error', [
'provider' => $provider,
'status' => $status,
'excerpt' => $body,
]);
throw new VisionLlmException($message, $status);
}
private function extractContent(Response $response): string
{
$json = $response->json();
// Standard OpenAI-compatible shape: choices[0].message.content
if (isset($json['choices'][0]['message']['content'])) {
return trim((string) $json['choices'][0]['message']['content']);
}
// Simple shape: { "content": "..." }
if (isset($json['content']) && is_string($json['content'])) {
return trim($json['content']);
}
// Shape: { "text": "..." }
if (isset($json['text']) && is_string($json['text'])) {
return trim($json['text']);
}
throw new VisionLlmException(
'Vision LLM gateway: unrecognized response shape. Could not extract generated text.',
0
);
}
private function extractGeminiContent(Response $response): string
{
$json = $response->json();
$parts = $json['candidates'][0]['content']['parts'] ?? null;
if (! is_array($parts) || $parts === []) {
throw new VisionLlmException(
'Gemini API: unrecognized response shape. Could not extract generated text.',
0
);
}
$text = collect($parts)
->map(fn ($part) => is_array($part) ? trim((string) ($part['text'] ?? '')) : '')
->filter(fn (string $value): bool => $value !== '')
->implode("\n");
if ($text === '') {
throw new VisionLlmException(
'Gemini API: response did not contain text content.',
0
);
}
return $text;
}
private function buildRequest(): PendingRequest
{
return Http::acceptJson()
->contentType('application/json')
->withHeaders(['X-API-Key' => $this->apiKey()])
->connectTimeout(max(1, (int) config('vision.gateway.connect_timeout_seconds', 3)))
->timeout(max(5, (int) config('ai_biography.llm_timeout_seconds', 30)));
}
private function buildTogetherRequest(): PendingRequest
{
return Http::acceptJson()
->contentType('application/json')
->withToken($this->togetherApiKey())
->connectTimeout(max(1, (int) config('ai_biography.together.connect_timeout_seconds', 5)))
->timeout(max(5, (int) config('ai_biography.together.timeout_seconds', config('ai_biography.llm_timeout_seconds', 90))));
}
private function buildGeminiRequest(): PendingRequest
{
return Http::acceptJson()
->contentType('application/json')
->withHeaders(['X-goog-api-key' => $this->geminiApiKey()])
->connectTimeout(max(1, (int) config('vision.gateway.connect_timeout_seconds', 3)))
->timeout(max(5, (int) config('ai_biography.llm_timeout_seconds', 30)));
}
private function buildHomeRequest(): PendingRequest
{
$request = Http::acceptJson()
->contentType('application/json')
->connectTimeout(max(1, (int) config('ai_biography.home.connect_timeout_seconds', 3)))
->timeout(max(5, (int) config('ai_biography.home.timeout_seconds', config('ai_biography.llm_timeout_seconds', 30))));
if (! (bool) config('ai_biography.home.verify_ssl', true)) {
$request = $request->withoutVerifying();
}
if ($this->homeApiKey() !== '') {
$request = $request->withToken($this->homeApiKey());
}
return $request;
}
private function baseUrl(): string
{
return rtrim((string) config('vision.gateway.base_url', ''), '/');
}
private function apiKey(): string
{
return trim((string) config('vision.gateway.api_key', ''));
}
private function primaryEndpoint(): string
{
$path = ltrim((string) config('ai_biography.llm_endpoint', '/ai/chat'), '/');
return $this->baseUrl() . '/' . $path;
}
private function fallbackEndpoint(): string
{
$path = ltrim((string) config('ai_biography.llm_fallback_endpoint', '/v1/chat/completions'), '/');
return $this->baseUrl() . '/' . $path;
}
private function provider(): string
{
$override = trim(strtolower((string) config('ai_biography.provider_override', '')));
if (in_array($override, ['together', 'vision_gateway', 'gemini', 'home'], true)) {
return $override;
}
if ($this->togetherApiKey() !== '' && $this->togetherModel() !== '') {
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 geminiBaseUrl(): string
{
return rtrim((string) config('ai_biography.gemini.base_url', 'https://generativelanguage.googleapis.com'), '/');
}
private function geminiApiKey(): string
{
return trim((string) config('ai_biography.gemini.api_key', ''));
}
private function geminiModel(): string
{
return trim((string) config('ai_biography.gemini.model', 'gemini-flash-latest'));
}
private function geminiEndpoint(): string
{
return $this->geminiBaseUrl() . '/v1beta/models/' . rawurlencode($this->geminiModel()) . ':generateContent';
}
private function togetherApiKey(): string
{
return trim((string) config('ai_biography.together.api_key', ''));
}
private function togetherModel(): string
{
return trim((string) config('ai_biography.together.model', 'google/gemma-3n-E4B-it'));
}
private function togetherEndpoint(): string
{
$base = rtrim((string) config('ai_biography.together.base_url', 'https://api.together.xyz'), '/');
$path = ltrim((string) config('ai_biography.together.endpoint', '/v1/chat/completions'), '/');
return $base . '/' . $path;
}
/**
* @param array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool} $payload
* @return array<string, mixed>
*/
private function toTogetherPayload(array $payload): array
{
return [
'model' => $this->togetherModel(),
'messages' => array_values((array) ($payload['messages'] ?? [])),
'max_tokens' => max(1, (int) ($payload['max_tokens'] ?? 256)),
'temperature' => (float) ($payload['temperature'] ?? 0.3),
'stream' => false,
];
}
private function homeBaseUrl(): string
{
return rtrim((string) config('ai_biography.home.base_url', 'http://home.klevze.si:8200'), '/');
}
private function homeApiKey(): string
{
return trim((string) config('ai_biography.home.api_key', ''));
}
private function homeModel(): string
{
return trim((string) config('ai_biography.home.model', 'qwen/qwen3.5-9b'));
}
private function homeEndpoint(): string
{
$path = ltrim((string) config('ai_biography.home.endpoint', '/v1/chat/completions'), '/');
return $this->homeBaseUrl() . '/' . $path;
}
/**
* @param array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool} $payload
* @return array<string, mixed>
*/
private function toGeminiPayload(array $payload): array
{
$systemParts = [];
$contents = [];
foreach ((array) ($payload['messages'] ?? []) as $message) {
$role = strtolower((string) ($message['role'] ?? 'user'));
$content = trim((string) ($message['content'] ?? ''));
if ($content === '') {
continue;
}
if ($role === 'system') {
$systemParts[] = ['text' => $content];
continue;
}
$contents[] = [
'role' => $role === 'assistant' ? 'model' : 'user',
'parts' => [
['text' => $content],
],
];
}
if ($contents === [] && $systemParts !== []) {
$contents[] = [
'role' => 'user',
'parts' => $systemParts,
];
$systemParts = [];
}
$geminiPayload = [
'contents' => $contents,
'generationConfig' => [
'temperature' => (float) ($payload['temperature'] ?? 0.3),
'maxOutputTokens' => max(1, (int) ($payload['max_tokens'] ?? 256)),
],
];
if ($systemParts !== []) {
$geminiPayload['systemInstruction'] = [
'parts' => $systemParts,
];
}
return $geminiPayload;
}
/**
* @param array{messages: list<array{role: string, content: string}>, max_tokens: int, temperature: float, stream: bool} $payload
* @return array<string, mixed>
*/
private function toHomePayload(array $payload): array
{
return [
'model' => $this->homeModel(),
'messages' => array_values((array) ($payload['messages'] ?? [])),
'max_tokens' => max(1, (int) ($payload['max_tokens'] ?? 256)),
'temperature' => (float) ($payload['temperature'] ?? 0.3),
'stream' => false,
];
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Services\AiBiography;
use RuntimeException;
/**
* Thrown when the Vision LLM gateway returns a structured failure.
*/
final class VisionLlmException extends RuntimeException
{
}

View File

@@ -8,6 +8,7 @@ use App\Models\Artwork;
use App\Models\Tag;
use App\Services\EarlyGrowth\AdaptiveTimeWindow;
use App\Services\Maturity\ArtworkMaturityService;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\LengthAwarePaginator as PaginationLengthAwarePaginator;
use Illuminate\Support\Collection;
@@ -81,11 +82,28 @@ final class ArtworkSearchService
$options['sort'] = $sort;
}
$options = $this->viewerAwareOptions($options);
return Artwork::search($q ?: '')
->options($options)
$results = Artwork::search($q ?: '')
->options($this->viewerAwareOptions($options))
->paginate($perPage);
if (! $this->shouldFallbackToViewerVisibilityFiltering($results)) {
return $results;
}
$page = max(1, (int) request()->get('page', 1));
$candidateCount = $this->determineSearchCandidatePoolSize($perPage, $page);
$fallbackResults = Artwork::search($q ?: '')
->options($options)
->paginate($candidateCount, 'page', 1);
$visibleItems = $this->filterSearchCollectionByCatalogVisibility($fallbackResults->getCollection());
$offset = max(0, ($page - 1) * $perPage);
$slice = $visibleItems->slice($offset, $perPage)->values();
$visibleTotal = (int) ($fallbackResults->total() <= $candidateCount
? $visibleItems->count()
: $fallbackResults->total());
return $this->makeModelPaginator($slice, $visibleTotal, $perPage, $page);
}
public function searchWithThumbnailPreference(array $options, int $perPage, bool $excludeMissing = false, ?int $page = null): LengthAwarePaginator
@@ -96,21 +114,18 @@ final class ArtworkSearchService
->options($this->viewerAwareOptions($options))
->paginate($candidateCount, 'page', 1);
$ordered = $this->rerankSearchCollectionByThumbnailHealth($results->getCollection(), $excludeMissing);
if ($this->shouldFallbackToViewerVisibilityFiltering($results)) {
$results = Artwork::search('')
->options($options)
->paginate($candidateCount, 'page', 1);
}
$visibleItems = $this->filterSearchCollectionByCatalogVisibility($results->getCollection());
$ordered = $this->rerankSearchCollectionByThumbnailHealth($visibleItems, $excludeMissing);
$offset = max(0, ($page - 1) * $perPage);
$slice = $ordered->slice($offset, $perPage)->values();
return new PaginationLengthAwarePaginator(
$slice->all(),
(int) $results->total(),
$perPage,
$page,
[
'path' => request()->url(),
'query' => request()->query(),
'pageName' => 'page',
]
);
return $this->makeModelPaginator($slice, (int) $results->total(), $perPage, $page);
}
/**
@@ -165,15 +180,14 @@ final class ArtworkSearchService
*/
public function byCategory(string $cat, int $perPage = 24, array $filters = []): LengthAwarePaginator
{
$cacheKey = "search.cat.{$cat}.{$this->viewerCacheSegment()}.page." . request()->get('page', 1);
$page = (int) request()->get('page', 1);
$cacheKey = "search.cat.catalog-visible.v2.{$cat}.{$this->viewerCacheSegment()}.page." . $page;
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($cat, $perPage) {
return Artwork::search('')
->options($this->viewerAwareOptions([
'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($cat) . '"',
'sort' => ['created_at:desc'],
]))
->paginate($perPage);
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($cat, $perPage, $page) {
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($cat) . '"',
'sort' => ['created_at:desc'],
], $perPage, false, $page);
});
}
@@ -214,15 +228,13 @@ final class ArtworkSearchService
$sort = array_key_exists($sort, self::CATEGORY_SORT_FIELDS) ? $sort : 'trending';
$page = (int) request()->get('page', 1);
$ttl = self::CATEGORY_SORT_TTL[$sort] ?? self::CACHE_TTL;
$cacheKey = "category.{$categorySlug}.{$sort}.{$this->viewerCacheSegment()}.{$page}";
$cacheKey = "category.catalog-visible.v2.{$categorySlug}.{$sort}.{$this->viewerCacheSegment()}.{$page}";
return Cache::remember($cacheKey, $ttl, function () use ($categorySlug, $sort, $perPage) {
return Artwork::search('')
->options($this->viewerAwareOptions([
'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($categorySlug) . '"',
'sort' => self::CATEGORY_SORT_FIELDS[$sort],
]))
->paginate($perPage);
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($categorySlug) . '"',
'sort' => self::CATEGORY_SORT_FIELDS[$sort],
], $perPage, false, (int) request()->get('page', 1));
});
}
@@ -237,15 +249,13 @@ final class ArtworkSearchService
$sort = array_key_exists($sort, self::CATEGORY_SORT_FIELDS) ? $sort : 'trending';
$page = (int) request()->get('page', 1);
$ttl = self::CATEGORY_SORT_TTL[$sort] ?? self::CACHE_TTL;
$cacheKey = "content_type.{$contentTypeSlug}.{$sort}.{$this->viewerCacheSegment()}.{$page}";
$cacheKey = "content_type.catalog-visible.v2.{$contentTypeSlug}.{$sort}.{$this->viewerCacheSegment()}.{$page}";
return Cache::remember($cacheKey, $ttl, function () use ($contentTypeSlug, $sort, $perPage) {
return Artwork::search('')
->options($this->viewerAwareOptions([
'filter' => self::BASE_FILTER . ' AND content_type = "' . addslashes($contentTypeSlug) . '"',
'sort' => self::CATEGORY_SORT_FIELDS[$sort],
]))
->paginate($perPage);
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER . ' AND content_type = "' . addslashes($contentTypeSlug) . '"',
'sort' => self::CATEGORY_SORT_FIELDS[$sort],
], $perPage, false, (int) request()->get('page', 1));
});
}
@@ -431,6 +441,15 @@ final class ArtworkSearchService
return $options;
}
private function shouldFallbackToViewerVisibilityFiltering(LengthAwarePaginator $results): bool
{
if ($results->total() > 0) {
return false;
}
return $this->maturity->viewerPreferences(request()->user())['visibility'] === ArtworkMaturityService::VIEW_HIDE;
}
private function viewerCacheSegment(): string
{
return 'visibility-' . $this->maturity->viewerPreferences(request()->user())['visibility'];
@@ -513,6 +532,37 @@ final class ArtworkSearchService
->values();
}
private function filterSearchCollectionByCatalogVisibility(Collection $items): Collection
{
if ($items->isEmpty()) {
return $items;
}
$ids = $items
->pluck('id')
->filter(fn ($id) => is_numeric($id) && (int) $id > 0)
->map(fn ($id) => (int) $id)
->values();
if ($ids->isEmpty()) {
return $items->values();
}
$visibilityQuery = Artwork::query()
->catalogVisible()
->whereIn('id', $ids);
$visibleIds = $this->maturity
->applyViewerFilter($visibilityQuery, request()->user())
->pluck('id')
->map(fn ($id) => (int) $id)
->flip();
return $items
->filter(fn ($item) => $visibleIds->has((int) ($item->id ?? 0)))
->values();
}
private function determineSearchCandidatePoolSize(int $perPage, int $page): int
{
return min(
@@ -521,6 +571,23 @@ final class ArtworkSearchService
);
}
private function makeModelPaginator(Collection $items, int $total, int $perPage, int $page): LengthAwarePaginator
{
$paginator = new PaginationLengthAwarePaginator(
[],
$total,
$perPage,
$page,
[
'path' => request()->url(),
'query' => request()->query(),
'pageName' => 'page',
]
);
return $paginator->setCollection(new EloquentCollection($items->all()));
}
private function emptyPaginator(int $perPage): LengthAwarePaginator
{
return new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage);

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Services\Artworks;
use App\Models\ActivityEvent;
use App\Models\Artwork;
use App\Services\Activity\UserActivityService;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class ArtworkPublicationService
{
public function publishNow(Artwork $artwork, ?Carbon $publishedAt = null): Artwork
{
$publishedAt ??= now()->utc();
$artwork->forceFill([
'artwork_status' => 'published',
'publish_at' => null,
'artwork_timezone' => null,
'published_at' => $publishedAt,
'is_public' => $artwork->visibility !== Artwork::VISIBILITY_PRIVATE,
])->save();
$this->syncSearch($artwork);
$this->recordActivity($artwork);
return $artwork;
}
public function publishIfDue(Artwork $artwork, ?Carbon $now = null): Artwork
{
$now ??= now()->utc();
if (! $this->isDue($artwork, $now)) {
return $artwork;
}
DB::transaction(function () use (&$artwork, $now): void {
$locked = Artwork::query()
->lockForUpdate()
->find($artwork->id);
if (! $locked || ! $this->isDue($locked, $now)) {
if ($locked) {
$artwork = $locked;
}
return;
}
$artwork = $this->publishNow($locked, $now);
});
return $artwork->fresh() ?? $artwork;
}
public function publishDueScheduled(int $limit = 100, ?Carbon $now = null): array
{
$now ??= now()->utc();
$candidates = Artwork::query()
->where('artwork_status', 'scheduled')
->where('publish_at', '<=', $now)
->where('is_approved', true)
->orderBy('publish_at')
->limit($limit)
->get(['id', 'user_id', 'title', 'publish_at', 'artwork_status']);
$published = collect();
foreach ($candidates as $candidate) {
$result = null;
DB::transaction(function () use ($candidate, $now, &$result): void {
$locked = Artwork::query()
->lockForUpdate()
->where('id', $candidate->id)
->where('artwork_status', 'scheduled')
->first();
if (! $locked || ! $this->isDue($locked, $now)) {
return;
}
$result = $this->publishNow($locked, $now);
});
if ($result instanceof Artwork) {
$published->push($result);
}
}
return [
'candidates' => $candidates,
'published' => $published,
];
}
public function publishDueScheduledForUser(int $userId, int $limit = 100, ?Carbon $now = null): void
{
$now ??= now()->utc();
$candidateIds = Artwork::query()
->where('user_id', $userId)
->where('artwork_status', 'scheduled')
->where('publish_at', '<=', $now)
->where('is_approved', true)
->orderBy('publish_at')
->limit($limit)
->pluck('id');
foreach ($candidateIds as $candidateId) {
$artwork = Artwork::query()->find((int) $candidateId);
if ($artwork) {
$this->publishIfDue($artwork, $now);
}
}
}
private function isDue(Artwork $artwork, Carbon $now): bool
{
return $artwork->artwork_status === 'scheduled'
&& $artwork->is_approved
&& $artwork->publish_at !== null
&& $artwork->publish_at->lte($now);
}
private function syncSearch(Artwork $artwork): void
{
if (! method_exists($artwork, 'searchable')) {
return;
}
try {
$artwork->searchable();
} catch (\Throwable $e) {
Log::warning("PublishScheduled: scout reindex failed for #{$artwork->id}: {$e->getMessage()}");
}
}
private function recordActivity(Artwork $artwork): void
{
try {
ActivityEvent::record(
actorId: (int) $artwork->user_id,
type: ActivityEvent::TYPE_UPLOAD,
targetType: ActivityEvent::TARGET_ARTWORK,
targetId: (int) $artwork->id,
);
} catch (\Throwable) {
}
try {
app(UserActivityService::class)->logUpload((int) $artwork->user_id, (int) $artwork->id);
} catch (\Throwable) {
}
}
}

View File

@@ -12,6 +12,7 @@ use App\Services\EarlyGrowth\EarlyGrowth;
use App\Services\EarlyGrowth\GridFiller;
use App\Services\Recommendations\RecommendationFeedResolver;
use App\Services\UserPreferenceService;
use App\Services\Worlds\WorldService;
use App\Support\AvatarUrl;
use App\Models\Collection as CollectionModel;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
@@ -55,6 +56,7 @@ final class HomepageService
private readonly CollectionSurfaceService $collectionSurfaces,
private readonly GroupDiscoveryService $groupDiscovery,
private readonly LeaderboardService $leaderboards,
private readonly WorldService $worlds,
) {}
// ─────────────────────────────────────────────────────────────────────────
@@ -126,6 +128,7 @@ final class HomepageService
'collections_trending' => $this->getTrendingCollections(),
'collections_editorial' => $this->getEditorialCollections(),
'collections_community' => $this->getCommunityCollections(),
'world_spotlight' => $this->worlds->homepageSpotlight(),
'groups' => $this->getHomepageGroups(),
'tags' => $this->getPopularTags(),
'creators' => $this->getCreatorSpotlight(),
@@ -180,6 +183,7 @@ final class HomepageService
'collections_trending' => $this->getTrendingCollections(),
'collections_editorial' => $this->getEditorialCollections(),
'collections_community' => $this->getCommunityCollections(),
'world_spotlight' => $this->worlds->homepageSpotlight($user),
'groups' => $this->getHomepageGroups($user),
'by_tags' => $this->getByTags($prefs['top_tags'] ?? []),
'by_categories' => $this->getByCategories($prefs['top_categories'] ?? []),

View File

@@ -12,10 +12,13 @@ use App\Models\Story;
use App\Models\StoryLike;
use App\Models\StoryView;
use App\Models\User;
use App\Models\World;
use App\Models\WorldSubmission;
use Carbon\CarbonImmutable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class LeaderboardService
{
@@ -68,6 +71,16 @@ class LeaderboardService
return $this->persistRows(Leaderboard::TYPE_GROUP, $normalizedPeriod, $rows, self::ENTITY_STORE_LIMIT);
}
public function calculateWorldLeaderboard(string $period): int
{
$normalizedPeriod = $this->normalizePeriod($period);
$rows = $normalizedPeriod === Leaderboard::PERIOD_ALL_TIME
? $this->allTimeWorldRows()
: $this->windowedWorldRows($this->periodStart($normalizedPeriod));
return $this->persistRows(Leaderboard::TYPE_WORLD, $normalizedPeriod, $rows, self::ENTITY_STORE_LIMIT);
}
public function refreshAll(): array
{
$results = [];
@@ -77,6 +90,7 @@ class LeaderboardService
Leaderboard::TYPE_ARTWORK,
Leaderboard::TYPE_GROUP,
Leaderboard::TYPE_STORY,
Leaderboard::TYPE_WORLD,
] as $type) {
foreach ($this->periods() as $period) {
$results[$type][$period] = match ($type) {
@@ -84,6 +98,7 @@ class LeaderboardService
Leaderboard::TYPE_ARTWORK => $this->calculateArtworkLeaderboard($period),
Leaderboard::TYPE_GROUP => $this->calculateGroupLeaderboard($period),
Leaderboard::TYPE_STORY => $this->calculateStoryLeaderboard($period),
Leaderboard::TYPE_WORLD => $this->calculateWorldLeaderboard($period),
};
}
}
@@ -121,6 +136,7 @@ class LeaderboardService
Leaderboard::TYPE_ARTWORK => $this->artworkEntities($items->pluck('entity_id')->all()),
Leaderboard::TYPE_GROUP => $this->groupEntities($items->pluck('entity_id')->all()),
Leaderboard::TYPE_STORY => $this->storyEntities($items->pluck('entity_id')->all()),
Leaderboard::TYPE_WORLD => $this->worldEntities($items->pluck('entity_id')->all()),
};
return [
@@ -205,6 +221,7 @@ class LeaderboardService
'artwork', 'artworks' => Leaderboard::TYPE_ARTWORK,
'group', 'groups' => Leaderboard::TYPE_GROUP,
'story', 'stories' => Leaderboard::TYPE_STORY,
'world', 'worlds' => Leaderboard::TYPE_WORLD,
default => Leaderboard::TYPE_CREATOR,
};
}
@@ -228,6 +245,7 @@ class LeaderboardService
Leaderboard::TYPE_ARTWORK => $this->calculateArtworkLeaderboard($period),
Leaderboard::TYPE_GROUP => $this->calculateGroupLeaderboard($period),
Leaderboard::TYPE_STORY => $this->calculateStoryLeaderboard($period),
Leaderboard::TYPE_WORLD => $this->calculateWorldLeaderboard($period),
};
}
@@ -585,6 +603,128 @@ class LeaderboardService
->values();
}
private function allTimeWorldRows(): Collection
{
if (! $this->worldTablesExist()) {
return collect();
}
return World::query()
->from('worlds')
->withCount([
'worldRelations as relations_count',
'worldRelations as featured_relations_count' => fn ($query) => $query->where('is_featured', true),
'worldSubmissions as approved_submissions_count' => fn ($query) => $query->where('status', WorldSubmission::STATUS_LIVE),
'worldSubmissions as featured_submissions_count' => fn ($query) => $query->where('status', WorldSubmission::STATUS_LIVE)->where('is_featured', true),
])
->publiclyVisible()
->get()
->map(fn (World $world): array => [
'entity_id' => (int) $world->id,
'score' => $this->scoreWorld(
(int) ($world->relations_count ?? 0),
(int) ($world->featured_relations_count ?? 0),
(int) ($world->approved_submissions_count ?? 0),
(int) ($world->featured_submissions_count ?? 0),
$world->isCurrent(),
(bool) $world->is_featured,
false,
),
])
->filter(fn (array $row): bool => $row['score'] > 0)
->values();
}
private function windowedWorldRows(CarbonImmutable $start): Collection
{
if (! $this->worldTablesExist()) {
return collect();
}
$relations = DB::table('world_relations')
->select('world_id', DB::raw('COUNT(*) as relations_count'))
->where('created_at', '>=', $start)
->groupBy('world_id');
$featuredRelations = DB::table('world_relations')
->select('world_id', DB::raw('COUNT(*) as featured_relations_count'))
->where('created_at', '>=', $start)
->where('is_featured', true)
->groupBy('world_id');
$approvedSubmissions = DB::table('world_submissions')
->select('world_id', DB::raw('COUNT(*) as approved_submissions_count'))
->where('status', WorldSubmission::STATUS_LIVE)
->where(function ($query) use ($start): void {
$query->where('reviewed_at', '>=', $start)
->orWhere(function ($fallback) use ($start): void {
$fallback->whereNull('reviewed_at')
->where('created_at', '>=', $start);
});
})
->groupBy('world_id');
$featuredSubmissions = DB::table('world_submissions')
->select('world_id', DB::raw('COUNT(*) as featured_submissions_count'))
->where('status', WorldSubmission::STATUS_LIVE)
->where('is_featured', true)
->where(function ($query) use ($start): void {
$query->where('reviewed_at', '>=', $start)
->orWhere(function ($fallback) use ($start): void {
$fallback->whereNull('reviewed_at')
->where('created_at', '>=', $start);
});
})
->groupBy('world_id');
return World::query()
->from('worlds')
->leftJoinSub($relations, 'relations', 'relations.world_id', '=', 'worlds.id')
->leftJoinSub($featuredRelations, 'featured_relations', 'featured_relations.world_id', '=', 'worlds.id')
->leftJoinSub($approvedSubmissions, 'approved_submissions', 'approved_submissions.world_id', '=', 'worlds.id')
->leftJoinSub($featuredSubmissions, 'featured_submissions', 'featured_submissions.world_id', '=', 'worlds.id')
->publiclyVisible()
->select([
'worlds.id',
'worlds.status',
'worlds.starts_at',
'worlds.ends_at',
'worlds.published_at',
'worlds.is_featured',
DB::raw('COALESCE(relations.relations_count, 0) as relations_count'),
DB::raw('COALESCE(featured_relations.featured_relations_count, 0) as featured_relations_count'),
DB::raw('COALESCE(approved_submissions.approved_submissions_count, 0) as approved_submissions_count'),
DB::raw('COALESCE(featured_submissions.featured_submissions_count, 0) as featured_submissions_count'),
DB::raw("CASE WHEN worlds.published_at >= '" . $start->toDateTimeString() . "' OR worlds.starts_at >= '" . $start->toDateTimeString() . "' THEN 1 ELSE 0 END as recent_launch_bonus"),
])
->get()
->map(function ($row): array {
$world = new World();
$world->forceFill([
'status' => (string) $row->status,
'starts_at' => $row->starts_at,
'ends_at' => $row->ends_at,
'published_at' => $row->published_at,
'is_featured' => (bool) $row->is_featured,
]);
return [
'entity_id' => (int) $row->id,
'score' => $this->scoreWorld(
(int) $row->relations_count,
(int) $row->featured_relations_count,
(int) $row->approved_submissions_count,
(int) $row->featured_submissions_count,
$world->isCurrent(),
(bool) $row->is_featured,
(bool) $row->recent_launch_bonus,
),
];
})
->filter(fn (array $row): bool => $row['score'] > 0)
->values();
}
private function windowedGroupRows(CarbonImmutable $start): Collection
{
$follows = DB::table('group_follows')
@@ -819,4 +959,95 @@ class LeaderboardService
})
->all();
}
private function worldEntities(array $ids): array
{
if (! $this->worldTablesExist()) {
return [];
}
return World::query()
->withCount([
'worldRelations as relations_count',
'worldSubmissions as approved_submissions_count' => fn ($query) => $query->where('status', WorldSubmission::STATUS_LIVE),
'worldSubmissions as featured_submissions_count' => fn ($query) => $query->where('status', WorldSubmission::STATUS_LIVE)->where('is_featured', true),
])
->whereIn('id', $ids)
->publiclyVisible()
->get()
->mapWithKeys(function (World $world): array {
return [
(int) $world->id => [
'id' => (int) $world->id,
'type' => Leaderboard::TYPE_WORLD,
'name' => (string) $world->title,
'summary' => (string) ($world->summary ?: $world->tagline ?: ''),
'url' => $world->publicUrl(),
'image' => $world->coverUrl(),
'timeframe_label' => $this->worldTimeframeLabel($world),
'badge_label' => (string) ($world->badge_label ?? ''),
'phase' => $world->isCurrent() ? 'active' : ((string) $world->status === World::STATUS_ARCHIVED ? 'archive' : 'published'),
'icon_name' => (string) ($world->icon_name ?: 'fa-solid fa-globe'),
'theme_label' => $this->worldThemeLabel($world),
'relations_count' => (int) ($world->relations_count ?? 0),
'approved_submissions_count' => (int) ($world->approved_submissions_count ?? 0),
'featured_submissions_count' => (int) ($world->featured_submissions_count ?? 0),
'is_featured' => (bool) $world->is_featured,
],
];
})
->all();
}
private function scoreWorld(
int $relationsCount,
int $featuredRelationsCount,
int $approvedSubmissionsCount,
int $featuredSubmissionsCount,
bool $isCurrent,
bool $isFeatured,
bool $isRecentLaunch,
): int {
return ($relationsCount * 22)
+ ($featuredRelationsCount * 10)
+ ($approvedSubmissionsCount * 16)
+ ($featuredSubmissionsCount * 26)
+ ($isCurrent ? 48 : 0)
+ ($isFeatured ? 70 : 0)
+ ($isRecentLaunch ? 24 : 0);
}
private function worldTablesExist(): bool
{
return Schema::hasTable('worlds')
&& Schema::hasTable('world_relations')
&& Schema::hasTable('world_submissions');
}
private function worldThemeLabel(World $world): string
{
return match ((string) $world->type) {
World::TYPE_EVENT => 'Event',
World::TYPE_CAMPAIGN => 'Campaign',
World::TYPE_TRIBUTE => 'Tribute',
default => 'Seasonal',
};
}
private function worldTimeframeLabel(World $world): ?string
{
if ($world->starts_at && $world->ends_at) {
return $world->starts_at->format('M j, Y') . ' - ' . $world->ends_at->format('M j, Y');
}
if ($world->starts_at) {
return 'Starts ' . $world->starts_at->format('M j, Y');
}
if ($world->ends_at) {
return 'Ends ' . $world->ends_at->format('M j, Y');
}
return null;
}
}

View File

@@ -58,12 +58,13 @@ final class ArtworkMaturityService
*/
public function viewerPreferences(?User $viewer): array
{
$guestMode = $this->normalizeVisibilityPreference((string) config('maturity.viewer.guest_mode', self::VIEW_HIDE));
$defaultMode = $this->normalizeVisibilityPreference((string) config('maturity.viewer.default_mode', self::VIEW_BLUR));
$defaultWarnOnDetail = (bool) config('maturity.viewer.default_warn_on_detail', true);
if (! $viewer) {
return [
'visibility' => $defaultMode,
'visibility' => $guestMode,
'warn_on_detail' => $defaultWarnOnDetail,
'is_guest' => true,
];

View File

@@ -12,6 +12,7 @@ use App\Models\ContentType;
use App\Services\TagNormalizer;
use App\Services\TagService;
use App\Services\Vision\AiArtworkVectorSearchService;
use App\Services\Vision\ArtworkLlmTagSuggestionService;
use App\Services\Vision\VisionService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -26,11 +27,12 @@ final class StudioAiAssistService
private readonly AiArtworkVectorSearchService $similarity,
private readonly TagService $tagService,
private readonly TagNormalizer $tagNormalizer,
private readonly ArtworkLlmTagSuggestionService $llmTagSuggestions,
private readonly StudioAiAssistEventService $eventService,
) {
}
public function queueAnalysis(Artwork $artwork, bool $force = false, ?string $intent = null): ArtworkAiAssist
public function queueAnalysis(Artwork $artwork, bool $force = false, ?string $intent = null, ?string $provider = null): ArtworkAiAssist
{
$assist = $this->assistRecord($artwork);
$mode = $assist->mode ?: $this->builder->detectMode($artwork->loadMissing(['tags', 'categories.contentType']), []);
@@ -42,26 +44,26 @@ final class StudioAiAssistService
])->save();
$artwork->forceFill(['ai_status' => ArtworkAiAssist::STATUS_QUEUED])->saveQuietly();
$meta = ['force' => $force, 'direct' => false, 'intent' => $intent];
$meta = ['force' => $force, 'direct' => false, 'intent' => $intent, 'provider' => $provider];
$this->appendAction($assist, 'analysis_requested', $meta);
$this->eventService->record($artwork, 'analysis_requested', $meta, $assist);
AnalyzeArtworkAiAssistJob::dispatch($artwork->id, $force)->afterCommit();
AnalyzeArtworkAiAssistJob::dispatch($artwork->id, $force, $intent, $provider)->afterCommit();
return $assist->fresh();
}
public function analyzeDirect(Artwork $artwork, bool $force = false, ?string $intent = null): ArtworkAiAssist
public function analyzeDirect(Artwork $artwork, bool $force = false, ?string $intent = null, ?string $provider = null): ArtworkAiAssist
{
$assist = $this->assistRecord($artwork);
$meta = ['force' => $force, 'direct' => true, 'intent' => $intent];
$meta = ['force' => $force, 'direct' => true, 'intent' => $intent, 'provider' => $provider];
$this->appendAction($assist, 'analysis_requested', $meta);
$this->eventService->record($artwork, 'analysis_requested', $meta, $assist);
return $this->analyze($artwork, $force, $intent);
return $this->analyze($artwork, $force, $intent, $provider);
}
public function analyze(Artwork $artwork, bool $force = false, ?string $intent = null): ArtworkAiAssist
public function analyze(Artwork $artwork, bool $force = false, ?string $intent = null, ?string $provider = null): ArtworkAiAssist
{
$artwork->loadMissing(['tags', 'categories.contentType', 'user']);
@@ -99,7 +101,9 @@ final class StudioAiAssistService
$titleSuggestions = $this->builder->buildTitleSuggestions($artwork, $analysis, $mode);
$descriptionSuggestions = $this->builder->buildDescriptionSuggestions($artwork, $analysis, $mode);
$tagSuggestions = $this->builder->buildTagSuggestions($artwork, $analysis, $mode);
$fallbackTagSuggestions = $this->builder->buildTagSuggestions($artwork, $analysis, $mode);
$llmTagGeneration = $this->llmTagSuggestions->suggestForArtwork($artwork, 10, 15, $provider);
$tagSuggestions = $this->mergeTagSuggestions($llmTagGeneration, $fallbackTagSuggestions);
$similarCandidates = $this->buildSimilarCandidates($artwork);
$assist->forceFill([
@@ -115,6 +119,7 @@ final class StudioAiAssistService
'artwork_id' => (int) $artwork->id,
'hash' => $hash,
'intent' => $intent,
'provider' => $provider,
'force' => $force,
'current_title' => (string) ($artwork->title ?? ''),
'current_description' => (string) ($artwork->description ?? ''),
@@ -122,6 +127,7 @@ final class StudioAiAssistService
],
'vision_debug' => $visionDebug,
'analysis' => $analysis,
'tag_generation' => $llmTagGeneration,
'generated_at' => \now()->toIso8601String(),
'force' => $force,
],
@@ -134,6 +140,7 @@ final class StudioAiAssistService
'force' => $force,
'mode' => $mode,
'intent' => $intent,
'provider' => $llmTagGeneration['provider'] ?? $provider,
'title_suggestion_count' => count($titleSuggestions),
'description_suggestion_count' => count($descriptionSuggestions),
'tag_suggestion_count' => count($tagSuggestions),
@@ -326,11 +333,54 @@ final class StudioAiAssistService
'request' => $assist->raw_response_json['request'] ?? null,
'vision_debug' => $assist->raw_response_json['vision_debug'] ?? null,
'analysis' => $assist->raw_response_json['analysis'] ?? null,
'tag_generation' => $assist->raw_response_json['tag_generation'] ?? null,
'generated_at' => $assist->raw_response_json['generated_at'] ?? null,
] : null,
];
}
/**
* @param array{tags: list<string>, model: ?string, endpoint: ?string, image_url: ?string, variant: string, raw_content?: string, reason?: string, error?: string} $llmResult
* @param array<int, array{tag: string, confidence: float|null}> $fallback
* @return array<int, array{tag: string, confidence: float|null, source: string}>
*/
private function mergeTagSuggestions(array $llmResult, array $fallback, int $min = 10, int $max = 15): array
{
$rows = collect();
foreach (array_values($llmResult['tags'] ?? []) as $index => $tag) {
$rows->push([
'tag' => $tag,
'confidence' => round(max(0.55, 0.94 - ($index * 0.03)), 2),
'source' => 'llm',
]);
}
foreach ($fallback as $row) {
$rows->push([
'tag' => (string) ($row['tag'] ?? ''),
'confidence' => isset($row['confidence']) && is_numeric($row['confidence']) ? (float) $row['confidence'] : null,
'source' => 'vision',
]);
}
$merged = $rows
->filter(fn (array $row): bool => trim((string) ($row['tag'] ?? '')) !== '')
->unique('tag')
->take($max)
->values();
if ($merged->count() < $min && count($fallback) > $merged->count()) {
$merged = $rows
->filter(fn (array $row): bool => trim((string) ($row['tag'] ?? '')) !== '')
->unique('tag')
->take(max($min, min($max, $rows->count())))
->values();
}
return $merged->all();
}
private function assistRecord(Artwork $artwork): ArtworkAiAssist
{
return ArtworkAiAssist::query()->firstOrCreate(

View File

@@ -77,10 +77,7 @@ final class TrendingService
Artwork::query()
->select('id')
->where('is_public', true)
->where('is_approved', true)
->whereNull('deleted_at')
->whereNotNull('published_at')
->catalogVisible()
->where('published_at', '>=', $cutoff)
->orderBy('id')
->chunkById($chunkSize, function ($artworks) use ($column, $viewCol, $dlCol, $favCol, $commentCol, $shareCol, $wFavorite, $wComment, $wShare, $wView, &$updated): void {
@@ -137,9 +134,7 @@ final class TrendingService
Artwork::query()
->select('id')
->where('is_public', true)
->where('is_approved', true)
->whereNull('deleted_at')
->catalogVisible()
->where('published_at', '>=', $cutoff)
->chunkById($chunkSize, function ($artworks): void {
foreach ($artworks as $artwork) {

View File

@@ -0,0 +1,255 @@
<?php
declare(strict_types=1);
namespace App\Services\Vision;
use App\Models\Artwork;
use App\Services\TagNormalizer;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
final class ArtworkLlmTagSuggestionService
{
private const SYSTEM_PROMPT = <<<'PROMPT'
You are a precise visual-art tagging engine for Skinbase.
Analyze the provided artwork thumbnail and generate search-friendly tags that help users discover the work in a gallery.
Rules:
- Use only what is clearly visible or strongly implied by the image.
- Prefer concrete visual concepts over vague opinions.
- Do not include artist names, brands, platform names, or watermarks.
- Do not write sentences, explanations, numbering, or markdown.
- Return concise gallery-style tags only.
- Favor subject, setting, style, mood, palette, medium, and composition when visible.
- Avoid filler tags like "art", "image", "beautiful", "cool", or "design".
- Avoid duplicates and near-duplicates.
PROMPT;
private const USER_PROMPT = <<<'PROMPT'
Analyze this artwork thumbnail and return ONLY a valid JSON array of lowercase strings.
Requirements:
- Return between 10 and 15 tags.
- Each tag must be 1 to 3 words.
- Use only letters, numbers, spaces, and hyphens.
- No markdown, no explanations, no extra text.
- Order tags from most useful to least useful.
Focus on:
1. main subject or scene
2. style or genre
3. mood or atmosphere
4. dominant colours
5. medium or technique
6. notable composition or visual elements
Good example:
["fantasy portrait","digital painting","female warrior","blue tones","dramatic lighting","glowing eyes","detailed armor","cinematic mood","character art","moody background"]
Bad example:
["art","beautiful image","masterpiece","cool fantasy woman"]
PROMPT;
public function __construct(
private readonly TagNormalizer $normalizer,
private readonly ArtworkVisionImageUrl $imageUrl,
) {
}
/**
* @return array{provider: string, tags: list<string>, model: ?string, endpoint: ?string, image_url: ?string, variant: string, raw_content?: string, reason?: string, error?: string}
*/
public function suggestForArtwork(Artwork $artwork, int $minTags = 10, int $maxTags = 15, ?string $providerOverride = null): array
{
$provider = $this->resolveProvider($providerOverride);
$variant = 'md';
$imageUrl = $this->imageUrl->fromHash((string) ($artwork->hash ?? ''), (string) ($artwork->thumb_ext ?: 'webp'), $variant);
if ($imageUrl === null) {
return [
'provider' => $provider,
'tags' => [],
'model' => null,
'endpoint' => null,
'image_url' => null,
'variant' => $variant,
'reason' => 'image_url_unavailable',
];
}
$configuration = $this->providerConfiguration($provider);
$baseUrl = rtrim((string) ($configuration['base_url'] ?? ''), '/');
$endpointPath = (string) ($configuration['endpoint'] ?? '/v1/chat/completions');
$model = trim((string) ($configuration['model'] ?? ''));
if ($baseUrl === '' || $model === '') {
return [
'provider' => $provider,
'tags' => [],
'model' => $model !== '' ? $model : null,
'endpoint' => $baseUrl !== '' ? $baseUrl . '/' . ltrim($endpointPath, '/') : null,
'image_url' => $imageUrl,
'variant' => $variant,
'reason' => $provider . '_not_configured',
];
}
$endpoint = $baseUrl . '/' . ltrim($endpointPath, '/');
$safeMin = min(15, max(1, $minTags));
$safeMax = min(15, max($safeMin, $maxTags));
$payload = [
'model' => $model,
'temperature' => (float) config('vision.lm_studio.temperature', 0.3),
'max_tokens' => (int) config('vision.lm_studio.max_tokens', 300),
'messages' => [
[
'role' => 'system',
'content' => self::SYSTEM_PROMPT,
],
[
'role' => 'user',
'content' => [
['type' => 'image_url', 'image_url' => ['url' => $imageUrl]],
['type' => 'text', 'text' => str_replace(['10 and 15', '10 to 15'], ["{$safeMin} and {$safeMax}", "{$safeMin} to {$safeMax}"], self::USER_PROMPT)],
],
],
],
];
try {
$response = $this->buildRequest($provider, $configuration)
->post($endpoint, $payload);
if (! $response->ok()) {
return [
'provider' => $provider,
'tags' => [],
'model' => $model,
'endpoint' => $endpoint,
'image_url' => $imageUrl,
'variant' => $variant,
'reason' => 'http_' . $response->status(),
'error' => substr($response->body(), 0, 500),
];
}
$body = $response->json();
$content = is_array($body)
? (string) (($body['choices'][0]['message']['content'] ?? ''))
: '';
$tags = $this->parseTags($content, $safeMax);
return [
'provider' => $provider,
'tags' => $tags,
'model' => $model,
'endpoint' => $endpoint,
'image_url' => $imageUrl,
'variant' => $variant,
'raw_content' => $content,
];
} catch (\Throwable $exception) {
return [
'provider' => $provider,
'tags' => [],
'model' => $model,
'endpoint' => $endpoint,
'image_url' => $imageUrl,
'variant' => $variant,
'reason' => 'request_failed',
'error' => $exception->getMessage(),
];
}
}
/**
* @return array{base_url: string, endpoint: string, model: string, timeout: int, connect_timeout: int, api_key?: string}
*/
private function providerConfiguration(string $provider): array
{
return match ($provider) {
'together' => [
'base_url' => (string) config('vision.together.base_url', 'https://api.together.xyz'),
'endpoint' => (string) config('vision.together.endpoint', '/v1/chat/completions'),
'model' => (string) config('vision.together.model', 'meta-llama/Llama-3.2-11B-Vision-Instruct-Turbo'),
'timeout' => (int) config('vision.together.timeout', 90),
'connect_timeout' => (int) config('vision.together.connect_timeout', 5),
'api_key' => (string) config('vision.together.api_key', ''),
],
default => [
'base_url' => (string) config('vision.lm_studio.base_url', ''),
'endpoint' => '/v1/chat/completions',
'model' => (string) config('vision.lm_studio.model', ''),
'timeout' => (int) config('vision.lm_studio.timeout', 60),
'connect_timeout' => (int) config('vision.lm_studio.connect_timeout', 5),
],
};
}
/**
* @param array{base_url: string, endpoint: string, model: string, timeout: int, connect_timeout: int, api_key?: string} $configuration
*/
private function buildRequest(string $provider, array $configuration): PendingRequest
{
$request = Http::acceptJson()
->asJson()
->timeout(max(1, (int) ($configuration['timeout'] ?? 60)))
->connectTimeout(max(1, (int) ($configuration['connect_timeout'] ?? 5)));
if ($provider === 'together') {
$apiKey = trim((string) ($configuration['api_key'] ?? ''));
if ($apiKey !== '') {
$request = $request->withToken($apiKey);
}
}
return $request;
}
private function resolveProvider(?string $providerOverride = null): string
{
$candidate = trim(strtolower((string) ($providerOverride ?? config('vision.tag_suggestions.provider', 'lm_studio'))));
return match ($candidate) {
'together', 'together_ai' => 'together',
'lm-studio', 'local', 'home' => 'lm_studio',
default => 'lm_studio',
};
}
/**
* @return list<string>
*/
private function parseTags(string $content, int $maxTags): array
{
$trimmed = trim($content);
$trimmed = preg_replace('/^```(?:json)?\s*/i', '', $trimmed) ?? $trimmed;
$trimmed = preg_replace('/\s*```$/', '', $trimmed) ?? $trimmed;
if (! preg_match('/(\[.*?\])/s', $trimmed, $matches)) {
return [];
}
$decoded = json_decode($matches[1], true);
if (! is_array($decoded)) {
return [];
}
$tags = [];
foreach ($decoded as $item) {
if (! is_string($item)) {
continue;
}
$normalized = $this->normalizer->normalize($item);
if ($normalized === '') {
continue;
}
$tags[] = $normalized;
}
return array_slice(array_values(array_unique($tags)), 0, $maxTags);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,547 @@
<?php
declare(strict_types=1);
namespace App\Services\Worlds;
use App\Http\Resources\ArtworkListResource;
use App\Models\Artwork;
use App\Models\User;
use App\Models\World;
use App\Models\WorldSubmission;
use App\Services\Maturity\ArtworkMaturityService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
final class WorldSubmissionService
{
public function __construct(private readonly ArtworkMaturityService $maturity)
{
}
public function eligibleWorldOptions(?User $viewer = null): array
{
return $this->eligibleWorldsQuery()
->get()
->map(fn (World $world): array => $this->mapCreatorWorldOption($world, null, true))
->all();
}
public function artworkSubmissionOptions(Artwork $artwork, User $viewer): array
{
$artwork->loadMissing(['worldSubmissions.world', 'worldSubmissions.reviewer']);
$existing = $artwork->worldSubmissions
->filter(fn (WorldSubmission $submission): bool => $submission->world !== null)
->keyBy(fn (WorldSubmission $submission): int => (int) $submission->world_id);
$eligibleWorlds = $this->eligibleWorldsQuery()->get()->keyBy(fn (World $world): int => (int) $world->id);
$worlds = $eligibleWorlds;
$missingWorldIds = $existing->keys()
->map(fn ($id): int => (int) $id)
->reject(fn (int $id): bool => $eligibleWorlds->has($id))
->values();
if ($missingWorldIds->isNotEmpty()) {
World::query()
->whereIn('id', $missingWorldIds->all())
->get()
->each(fn (World $world) => $worlds->put((int) $world->id, $world));
}
return $worlds
->sortBy([
fn (World $world): int => $existing->has((int) $world->id) ? 0 : 1,
fn (World $world): int => $world->starts_at?->getTimestamp() ?? PHP_INT_MAX,
fn (World $world): string => Str::lower((string) $world->title),
])
->values()
->map(function (World $world) use ($existing): array {
$submission = $existing->get((int) $world->id);
return $this->mapCreatorWorldOption($world, $submission, $this->isEligibleWorld($world));
})
->all();
}
public function syncForArtwork(Artwork $artwork, User $actor, array $entries): void
{
$artwork->loadMissing('worldSubmissions');
$normalizedEntries = collect($entries)
->map(function (array $entry): ?array {
$worldId = (int) ($entry['world_id'] ?? 0);
if ($worldId < 1) {
return null;
}
return [
'world_id' => $worldId,
'note' => Str::limit(trim((string) ($entry['note'] ?? '')), 1000, ''),
];
})
->filter()
->unique('world_id')
->values();
$existing = $artwork->worldSubmissions->keyBy(fn (WorldSubmission $submission): int => (int) $submission->world_id);
$selectedWorldIds = $normalizedEntries->pluck('world_id')->map(fn ($id): int => (int) $id)->all();
$allWorldIds = array_values(array_unique(array_merge($selectedWorldIds, $existing->keys()->map(fn ($id): int => (int) $id)->all())));
$worlds = World::query()
->whereIn('id', $allWorldIds)
->get()
->keyBy(fn (World $world): int => (int) $world->id);
$errors = [];
foreach ($normalizedEntries as $index => $entry) {
$world = $worlds->get((int) $entry['world_id']);
$submission = $existing->get((int) $entry['world_id']);
if (! $world) {
$errors["world_submissions.{$index}.world_id"] = 'Selected world no longer exists.';
continue;
}
if (! $this->isEligibleWorld($world)) {
$errors["world_submissions.{$index}.world_id"] = 'That world is not currently accepting community submissions.';
continue;
}
if ($submission && $submission->isBlockingResubmission()) {
$errors["world_submissions.{$index}.world_id"] = 'This artwork is blocked from that world until a moderator clears the block.';
continue;
}
if ($submission && (string) $submission->status === WorldSubmission::STATUS_REMOVED && ! (bool) $world->allow_readd_after_removal) {
$errors["world_submissions.{$index}.world_id"] = 'That world does not allow re-adding removed artworks right now.';
}
}
if ($errors !== []) {
throw ValidationException::withMessages($errors);
}
DB::transaction(function () use ($normalizedEntries, $artwork, $actor, $existing, $worlds, $selectedWorldIds): void {
foreach ($normalizedEntries as $entry) {
$worldId = (int) $entry['world_id'];
$submission = $existing->get($worldId);
$world = $worlds->get($worldId);
$note = ($world?->submission_note_enabled ?? true) ? ($entry['note'] !== '' ? $entry['note'] : null) : null;
$startingStatus = $world?->submissionStartsAsLive()
? WorldSubmission::STATUS_LIVE
: WorldSubmission::STATUS_PENDING;
$reviewedAt = $startingStatus === WorldSubmission::STATUS_LIVE ? now() : null;
if ($submission && $submission->isBlockingResubmission()) {
continue;
}
if ($submission) {
$payload = [
'mode_snapshot' => $world?->participation_mode,
'note' => $note,
];
if ((string) $submission->status === WorldSubmission::STATUS_REMOVED) {
$payload = array_merge($payload, [
'status' => $startingStatus,
'is_featured' => false,
'reviewer_note' => null,
'moderation_reason' => null,
'reviewed_by_user_id' => null,
'reviewed_at' => $reviewedAt,
'removed_at' => null,
'blocked_at' => null,
'featured_at' => null,
]);
}
$submission->forceFill($payload)->save();
continue;
}
WorldSubmission::query()->create([
'world_id' => $worldId,
'artwork_id' => (int) $artwork->id,
'submitted_by_user_id' => (int) $actor->id,
'status' => $startingStatus,
'is_featured' => false,
'mode_snapshot' => $world?->participation_mode,
'note' => $note,
'reviewed_at' => $reviewedAt,
]);
}
$existing->each(function (WorldSubmission $submission, int $worldId) use ($selectedWorldIds): void {
if (in_array((string) $submission->status, [WorldSubmission::STATUS_LIVE, WorldSubmission::STATUS_REMOVED, WorldSubmission::STATUS_BLOCKED], true)) {
return;
}
if (! in_array($worldId, $selectedWorldIds, true)) {
$submission->delete();
}
});
});
}
public function transition(WorldSubmission $submission, User $reviewer, string $status, ?string $reviewerNote = null): WorldSubmission
{
$payload = [
'status' => $status,
'reviewer_note' => $this->nullableText($reviewerNote),
'moderation_reason' => $this->nullableText($reviewerNote),
];
if ($status === WorldSubmission::STATUS_PENDING) {
$payload['reviewer_note'] = null;
$payload['moderation_reason'] = null;
$payload['reviewed_by_user_id'] = null;
$payload['reviewed_at'] = null;
$payload['removed_at'] = null;
$payload['blocked_at'] = null;
} else {
$payload['reviewed_by_user_id'] = (int) $reviewer->id;
$payload['reviewed_at'] = now();
$payload['removed_at'] = $status === WorldSubmission::STATUS_REMOVED ? now() : null;
$payload['blocked_at'] = $status === WorldSubmission::STATUS_BLOCKED ? now() : null;
}
if ($status !== WorldSubmission::STATUS_LIVE) {
$payload['is_featured'] = false;
$payload['featured_at'] = null;
}
$submission->forceFill($payload)->save();
return $submission->fresh(['artwork.user.profile', 'artwork.stats', 'artwork.categories', 'submittedBy.profile', 'reviewer.profile']);
}
public function setFeatured(WorldSubmission $submission, User $reviewer, bool $featured, ?string $reviewerNote = null): WorldSubmission
{
$payload = [
'is_featured' => $featured,
'featured_at' => $featured ? now() : null,
'reviewed_by_user_id' => (int) $reviewer->id,
'reviewed_at' => now(),
];
if ($reviewerNote !== null) {
$payload['reviewer_note'] = $this->nullableText($reviewerNote);
$payload['moderation_reason'] = $this->nullableText($reviewerNote);
}
if ((string) $submission->status !== WorldSubmission::STATUS_LIVE) {
$payload['status'] = WorldSubmission::STATUS_LIVE;
$payload['removed_at'] = null;
$payload['blocked_at'] = null;
}
$submission->forceFill($payload)->save();
return $submission->fresh(['artwork.user.profile', 'artwork.stats', 'artwork.categories', 'submittedBy.profile', 'reviewer.profile']);
}
public function studioReviewQueue(World $world): array
{
$world->loadMissing([
'worldSubmissions.artwork.user.profile',
'worldSubmissions.artwork.stats',
'worldSubmissions.artwork.categories',
'worldSubmissions.submittedBy.profile',
'worldSubmissions.reviewer.profile',
]);
$items = $world->worldSubmissions
->sortBy([
fn (WorldSubmission $submission): int => match ((string) $submission->status) {
WorldSubmission::STATUS_PENDING => 0,
WorldSubmission::STATUS_LIVE => $submission->is_featured ? 1 : 2,
WorldSubmission::STATUS_REMOVED => 3,
WorldSubmission::STATUS_BLOCKED => 4,
default => 4,
},
fn (WorldSubmission $submission): int => -1 * ($submission->reviewed_at?->getTimestamp() ?? $submission->created_at?->getTimestamp() ?? 0),
])
->values();
return [
'counts' => [
'pending' => $items->where('status', WorldSubmission::STATUS_PENDING)->count(),
'live' => $items->where('status', WorldSubmission::STATUS_LIVE)->count(),
'removed' => $items->where('status', WorldSubmission::STATUS_REMOVED)->count(),
'blocked' => $items->where('status', WorldSubmission::STATUS_BLOCKED)->count(),
'featured' => $items->where('is_featured', true)->count(),
],
'items' => $items->map(fn (WorldSubmission $submission): array => $this->mapStudioSubmission($submission))->all(),
];
}
public function publicSectionPayload(World $world, ?User $viewer = null): ?array
{
if (! $world->community_section_enabled) {
return null;
}
$query = Artwork::query()
->select('artworks.*', 'world_submissions.status as world_submission_status', 'world_submissions.is_featured as world_submission_is_featured', 'world_submissions.note as world_submission_note', 'world_submissions.reviewed_at as world_submission_reviewed_at')
->join('world_submissions', function ($join) use ($world): void {
$join->on('world_submissions.artwork_id', '=', 'artworks.id')
->where('world_submissions.world_id', '=', $world->id)
->where('world_submissions.status', '=', WorldSubmission::STATUS_LIVE);
})
->with(['user.profile', 'categories.contentType', 'stats'])
->catalogVisible();
$this->maturity->applyViewerFilter($query, $viewer);
$items = $query
->orderByRaw('CASE WHEN world_submissions.is_featured = 1 THEN 0 ELSE 1 END')
->orderByDesc('world_submissions.reviewed_at')
->limit(24)
->get()
->map(fn (Artwork $artwork): array => $this->mapPublicSubmissionArtwork($artwork))
->all();
if ($items === []) {
return null;
}
return [
'title' => 'Community submissions',
'description' => 'Artworks submitted by creators and selected for this world outside the editorial curated-relation system.',
'items' => $items,
];
}
private function eligibleWorldsQuery(): Builder
{
return World::query()
->published()
->where('accepts_submissions', true)
->whereIn('participation_mode', [World::PARTICIPATION_MODE_MANUAL_APPROVAL, World::PARTICIPATION_MODE_AUTO_ADD])
->where(function (Builder $builder): void {
$builder->whereNull('submission_starts_at')
->orWhere('submission_starts_at', '<=', now());
})
->where(function (Builder $builder): void {
$builder->whereNull('submission_ends_at')
->orWhere('submission_ends_at', '>=', now());
})
->orderBy('submission_ends_at')
->orderBy('starts_at')
->orderBy('title');
}
private function isEligibleWorld(World $world): bool
{
return $world->isAcceptingSubmissions();
}
private function mapCreatorWorldOption(World $world, ?WorldSubmission $submission, bool $eligible): array
{
$status = $submission ? (string) $submission->status : null;
$selected = match ($status) {
WorldSubmission::STATUS_PENDING,
WorldSubmission::STATUS_LIVE => true,
default => false,
};
$locked = match ($status) {
WorldSubmission::STATUS_BLOCKED => true,
WorldSubmission::STATUS_PENDING => ! $eligible,
WorldSubmission::STATUS_REMOVED => ! $eligible || ! (bool) $world->allow_readd_after_removal,
default => false,
};
$lockedReason = $locked
? match ($status) {
WorldSubmission::STATUS_BLOCKED => 'This artwork is blocked from this world until a moderator clears the block.',
WorldSubmission::STATUS_PENDING => 'This world is no longer accepting submission changes right now.',
WorldSubmission::STATUS_REMOVED => (bool) $world->allow_readd_after_removal
? 'This world is not currently open for re-adding removed artworks.'
: 'Removed artworks cannot be re-added to this world right now.',
default => 'This world is locked.',
}
: null;
return [
'id' => (int) $world->id,
'title' => (string) $world->title,
'slug' => (string) $world->slug,
'tagline' => (string) ($world->tagline ?? ''),
'summary' => (string) ($world->summary ?? ''),
'cover_url' => $world->coverUrl(),
'timeframe_label' => $this->timeframeLabel($world),
'submission_window_label' => $this->submissionWindowLabel($world),
'submission_guidelines' => (string) ($world->submission_guidelines ?? ''),
'participation_mode' => (string) ($world->participation_mode ?: World::PARTICIPATION_MODE_CLOSED),
'participation_mode_label' => $this->participationModeLabel((string) ($world->participation_mode ?: World::PARTICIPATION_MODE_CLOSED)),
'submission_note_enabled' => (bool) $world->submission_note_enabled,
'is_accepting_submissions' => $eligible,
'selected' => $selected,
'selection_locked' => $locked,
'selection_locked_reason' => $lockedReason,
'note' => (string) ($submission?->note ?? ''),
'status' => $status,
'status_label' => $status ? $this->statusLabel($status, (bool) ($submission?->is_featured ?? false)) : null,
'reviewer_note' => (string) ($submission?->moderation_reason ?: $submission?->reviewer_note ?? ''),
'is_featured' => (bool) ($submission?->is_featured ?? false),
'submitted_at' => $submission?->created_at?->toIso8601String(),
'reviewed_at' => $submission?->reviewed_at?->toIso8601String(),
'can_resubmit' => $eligible && (bool) $world->allow_readd_after_removal && $status === WorldSubmission::STATUS_REMOVED,
];
}
private function mapStudioSubmission(WorldSubmission $submission): array
{
$artwork = $submission->artwork;
$views = (int) ($artwork?->stats?->views ?? 0);
return [
'id' => (int) $submission->id,
'status' => (string) $submission->status,
'status_label' => $this->statusLabel((string) $submission->status, (bool) $submission->is_featured),
'is_featured' => (bool) $submission->is_featured,
'note' => (string) ($submission->note ?? ''),
'reviewer_note' => (string) ($submission->moderation_reason ?: $submission->reviewer_note ?? ''),
'submitted_at' => $submission->created_at?->toIso8601String(),
'reviewed_at' => $submission->reviewed_at?->toIso8601String(),
'removed_at' => $submission->removed_at?->toIso8601String(),
'blocked_at' => $submission->blocked_at?->toIso8601String(),
'featured_at' => $submission->featured_at?->toIso8601String(),
'submitted_by' => $submission->submittedBy ? [
'id' => (int) $submission->submittedBy->id,
'name' => (string) ($submission->submittedBy->name ?: $submission->submittedBy->username ?: 'Unknown creator'),
'username' => (string) ($submission->submittedBy->username ?? ''),
] : null,
'reviewed_by' => $submission->reviewer ? [
'id' => (int) $submission->reviewer->id,
'name' => (string) ($submission->reviewer->name ?: $submission->reviewer->username ?: 'Moderator'),
] : null,
'artwork' => $artwork ? [
'id' => (int) $artwork->id,
'title' => (string) ($artwork->title ?: 'Untitled artwork'),
'slug' => (string) ($artwork->slug ?? ''),
'url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug ?: Str::slug((string) $artwork->title)]),
'edit_url' => route('studio.artworks.edit', ['id' => $artwork->id]),
'thumbnail_url' => $artwork->thumbUrl('md'),
'creator_name' => (string) ($artwork->user?->name ?: $artwork->user?->username ?: ''),
'meta' => array_values(array_filter([
$artwork->categories->first()?->name,
$views > 0 ? number_format($views) . ' views' : null,
$artwork->visibility ? Str::headline((string) $artwork->visibility) : null,
])),
] : null,
'actions' => [
'approve' => route('studio.worlds.submissions.approve', ['world' => $submission->world_id, 'submission' => $submission->id]),
'remove' => route('studio.worlds.submissions.remove', ['world' => $submission->world_id, 'submission' => $submission->id]),
'block' => route('studio.worlds.submissions.block', ['world' => $submission->world_id, 'submission' => $submission->id]),
'unblock' => route('studio.worlds.submissions.unblock', ['world' => $submission->world_id, 'submission' => $submission->id]),
'restore' => route('studio.worlds.submissions.restore', ['world' => $submission->world_id, 'submission' => $submission->id]),
'feature' => route('studio.worlds.submissions.feature', ['world' => $submission->world_id, 'submission' => $submission->id]),
'unfeature' => route('studio.worlds.submissions.unfeature', ['world' => $submission->world_id, 'submission' => $submission->id]),
'pending' => route('studio.worlds.submissions.pending', ['world' => $submission->world_id, 'submission' => $submission->id]),
],
];
}
private function mapPublicSubmissionArtwork(Artwork $artwork): array
{
$resource = ArtworkListResource::make($artwork)->toArray(request());
$views = (int) ($artwork->stats?->views ?? 0);
$status = (string) ($artwork->world_submission_status ?? WorldSubmission::STATUS_LIVE);
$isFeatured = (bool) ($artwork->world_submission_is_featured ?? false);
return [
'id' => (int) $artwork->id,
'title' => (string) ($resource['title'] ?? $artwork->title ?? 'Untitled artwork'),
'subtitle' => (string) ($resource['author']['name'] ?? ''),
'description' => Str::limit(trim(strip_tags((string) ($artwork->description ?? ''))), 120),
'url' => (string) ($resource['urls']['canonical'] ?? route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug ?: Str::slug((string) $artwork->title)])),
'image' => $resource['thumbnail_url'] ?? $artwork->thumbUrl('md'),
'status' => $status,
'status_label' => $this->statusLabel($status, $isFeatured),
'context_label' => $isFeatured ? 'Community featured' : 'Community submission',
'meta' => array_values(array_filter([
$resource['category']['name'] ?? null,
$views > 0 ? number_format($views) . ' views' : null,
])),
];
}
private function statusLabel(string $status, bool $isFeatured = false): string
{
if ($status === WorldSubmission::STATUS_LIVE && $isFeatured) {
return 'Featured';
}
return match ($status) {
WorldSubmission::STATUS_PENDING => 'Pending',
WorldSubmission::STATUS_LIVE => 'Live',
WorldSubmission::STATUS_REMOVED => 'Removed',
WorldSubmission::STATUS_BLOCKED => 'Blocked',
default => Str::headline($status),
};
}
private function participationModeLabel(string $mode): string
{
return match ($mode) {
World::PARTICIPATION_MODE_MANUAL_APPROVAL => 'Manual approval',
World::PARTICIPATION_MODE_AUTO_ADD => 'Auto add',
World::PARTICIPATION_MODE_CLOSED => 'Closed',
default => Str::headline($mode),
};
}
private function timeframeLabel(World $world): string
{
if ($world->starts_at && $world->ends_at) {
return $world->starts_at->format('M j') . ' - ' . $world->ends_at->format('M j, Y');
}
if ($world->starts_at) {
return 'Starts ' . $world->starts_at->format('M j, Y');
}
if ($world->ends_at) {
return 'Until ' . $world->ends_at->format('M j, Y');
}
return 'Open-ended world';
}
private function submissionWindowLabel(World $world): string
{
$start = $world->submission_starts_at;
$end = $world->submission_ends_at;
if ($start && $end) {
return $start->format('M j') . ' - ' . $end->format('M j, Y');
}
if ($start) {
return 'Opens ' . $start->format('M j, Y');
}
if ($end) {
return 'Open until ' . $end->format('M j, Y');
}
return 'Open submissions';
}
private function nullableText(?string $value): ?string
{
$value = trim((string) $value);
return $value !== '' ? $value : null;
}
}