Save workspace changes
This commit is contained in:
182
app/Services/AiBiography/AiBiographyGenerator.php
Normal file
182
app/Services/AiBiography/AiBiographyGenerator.php
Normal 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;
|
||||
}
|
||||
}
|
||||
430
app/Services/AiBiography/AiBiographyInputBuilder.php
Normal file
430
app/Services/AiBiography/AiBiographyInputBuilder.php
Normal 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();
|
||||
}
|
||||
}
|
||||
276
app/Services/AiBiography/AiBiographyPromptBuilder.php
Normal file
276
app/Services/AiBiography/AiBiographyPromptBuilder.php
Normal 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);
|
||||
}
|
||||
}
|
||||
490
app/Services/AiBiography/AiBiographyService.php
Normal file
490
app/Services/AiBiography/AiBiographyService.php
Normal 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' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
241
app/Services/AiBiography/AiBiographyValidator.php
Normal file
241
app/Services/AiBiography/AiBiographyValidator.php
Normal 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;
|
||||
}
|
||||
}
|
||||
507
app/Services/AiBiography/VisionLlmClient.php
Normal file
507
app/Services/AiBiography/VisionLlmClient.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
14
app/Services/AiBiography/VisionLlmException.php
Normal file
14
app/Services/AiBiography/VisionLlmException.php
Normal 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
|
||||
{
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
162
app/Services/Artworks/ArtworkPublicationService.php
Normal file
162
app/Services/Artworks/ArtworkPublicationService.php
Normal 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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'] ?? []),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
255
app/Services/Vision/ArtworkLlmTagSuggestionService.php
Normal file
255
app/Services/Vision/ArtworkLlmTagSuggestionService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
1369
app/Services/Worlds/WorldService.php
Normal file
1369
app/Services/Worlds/WorldService.php
Normal file
File diff suppressed because it is too large
Load Diff
547
app/Services/Worlds/WorldSubmissionService.php
Normal file
547
app/Services/Worlds/WorldSubmissionService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user