Optimize academy

This commit is contained in:
2026-06-09 13:16:01 +02:00
parent f89ee937c0
commit 5af95f6533
109 changed files with 6862 additions and 719 deletions

View File

@@ -344,7 +344,18 @@ final class AcademyAccessService
$previewImage = $this->promptPreviewImagePayload((string) ($prompt->preview_image ?? ''));
$documentation = $this->promptDocumentationPayload($prompt->documentation);
$placeholders = $this->promptPlaceholdersPayload((array) ($prompt->placeholders ?? []));
$allFilledExamples = $this->promptFilledExamplesPayload((array) ($prompt->filled_examples ?? []));
$filledExamplesTotal = count($allFilledExamples);
$hasFullFilledExamplesAccess = (bool) (($viewer?->hasAcademyProAccess() ?? false) || ($viewer?->hasStaffAccess() ?? false));
$hasPartialFilledExamplesAccess = (bool) ($viewer?->hasAcademyCreatorAccess() ?? false);
$visibleFilledExamples = match (true) {
! $includeFull => [],
$hasFullFilledExamplesAccess => $allFilledExamples,
$hasPartialFilledExamplesAccess => array_slice($allFilledExamples, 0, 2),
default => [],
};
$hasPlaceholderInputs = $this->promptHasPlaceholderInputs((string) $prompt->prompt, $placeholders);
$hasFilledExamples = $allFilledExamples !== [];
$hasHelperPrompts = $this->promptHelperPromptsPayload((array) ($prompt->helper_prompts ?? [])) !== [];
$hasPromptVariants = $this->promptVariantsPayload((array) ($prompt->prompt_variants ?? [])) !== [];
$helperPrompts = $authorized && $includeFull
@@ -367,6 +378,12 @@ final class AcademyAccessService
'documentation' => $documentation,
'placeholders' => $placeholders,
'has_placeholder_inputs' => $hasPlaceholderInputs,
'filled_examples' => $visibleFilledExamples,
'has_filled_examples' => $hasFilledExamples,
'filled_examples_total' => $filledExamplesTotal,
'can_access_filled_examples' => ($hasFullFilledExamplesAccess || $hasPartialFilledExamplesAccess) && $includeFull,
'has_more_filled_examples' => $filledExamplesTotal > count($visibleFilledExamples),
'has_full_filled_examples_access' => $hasFullFilledExamplesAccess,
'has_helper_prompts' => $hasHelperPrompts,
'has_prompt_variants' => $hasPromptVariants,
'helper_prompts' => $helperPrompts,
@@ -396,6 +413,47 @@ final class AcademyAccessService
];
}
/**
* @param array<int, mixed> $filledExamples
* @return array<int, array<string, mixed>>
*/
private function promptFilledExamplesPayload(array $filledExamples): array
{
return collect($filledExamples)
->filter(static fn ($example): bool => is_array($example))
->map(function (array $example): array {
return [
'title' => $this->nullableTrimmedString($example['title'] ?? null),
'description' => $this->nullableTrimmedString($example['description'] ?? null),
'placeholder_values' => collect(is_array($example['placeholder_values'] ?? null) ? $example['placeholder_values'] : [])
->mapWithKeys(function ($value, $key): array {
$normalizedKey = trim((string) $key);
if ($normalizedKey === '') {
return [];
}
return [$normalizedKey => $value];
})
->all(),
'prompt' => trim((string) ($example['prompt'] ?? '')),
'negative_prompt' => $this->nullableTrimmedString($example['negative_prompt'] ?? null),
];
})
->filter(function (array $example): bool {
return collect([
$example['title'] ?? null,
$example['description'] ?? null,
$example['prompt'] ?? null,
$example['negative_prompt'] ?? null,
$example['placeholder_values'] ?? null,
])->contains(fn ($item): bool => $item !== null && $item !== '' && $item !== []);
})
->take(5)
->values()
->all();
}
/**
* @param mixed $documentation
* @return array<string, mixed>

View File

@@ -5,8 +5,11 @@ declare(strict_types=1);
namespace App\Services\Academy;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use RuntimeException;
use Stripe\Exception\InvalidRequestException;
use Stripe\StripeClient;
final class AcademyBillingPlanService
{
@@ -64,6 +67,7 @@ final class AcademyBillingPlanService
$plan['stripe_price_id'] = trim((string) ($plan['stripe_price_id'] ?? ''));
$plan['configured'] = $plan['stripe_price_id'] !== '';
$plan['price_id_valid'] = $this->isValidPriceId($plan['stripe_price_id']);
$plan['remote_price_exists'] = $this->remotePriceExists($plan['stripe_price_id']);
$plan['price_display'] = $plan['amount'] !== '' ? $plan['amount'].' '.$plan['currency'] : null;
return $plan;
@@ -145,4 +149,86 @@ final class AcademyBillingPlanService
return preg_match('/^price_[A-Za-z0-9]+$/', $priceId) === 1;
}
}
public function remotePriceExists(?string $priceId): ?bool
{
$priceId = trim((string) $priceId);
if ($priceId === '') {
return false;
}
// Avoid calling Stripe in local/testing environments — assume exists there.
if (app()->environment(['local', 'testing'])) {
return true;
}
$cacheKey = 'academy.remote_price_exists:'.md5($priceId);
return Cache::remember($cacheKey, 300, function () use ($priceId): ?bool {
try {
$secret = $this->stripeSecret();
if ($secret === null) {
return null;
}
$client = new StripeClient($secret);
$price = $client->prices->retrieve($priceId, []);
// If Stripe returned an object with an id, it exists. Also ensure product exists where possible.
if (is_object($price) && ! empty($price->id)) {
return true;
}
return false;
} catch (InvalidRequestException $e) {
report($e);
return false;
} catch (\Throwable $e) {
report($e);
// Auth, network, or transient Stripe failures should not make
// public pricing look fully misconfigured.
return null;
}
});
}
private function stripeSecret(): ?string
{
foreach ([config('cashier.secret'), env('STRIPE_SECRET')] as $candidate) {
if (! is_string($candidate)) {
continue;
}
$candidate = trim($candidate);
if ($candidate !== '') {
return $candidate;
}
}
return null;
}
/**
* @return array<int, string>
*/
public function missingRemotePriceIds(?string $planKey = null): array
{
if ($planKey !== null) {
$plan = $this->plan($planKey);
return $plan !== null && $this->remotePriceExists($plan['stripe_price_id'] ?? '') === false
? [$this->normalizePlanKey($planKey)]
: [];
}
return collect(array_keys($this->plans()))
->filter(fn (string $key): bool => $this->remotePriceExists($this->plan($key)['stripe_price_id'] ?? '') === false)
->values()
->all();
}
}

View File

@@ -15,6 +15,7 @@ use App\Models\User;
use App\Services\Artworks\ArtworkDraftService;
use App\Services\Artworks\ArtworkPublicationService;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\Studio\StudioAiAssistService;
use App\Services\TagService;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
@@ -189,7 +190,9 @@ final class UploadQueueService
$item = $this->itemQuery()->findOrFail($itemId);
$item->forceFill([
'processing_stage' => UploadBatchItem::STAGE_MATURITY_CHECK,
'processing_stage' => $this->uploadMaturityEnabled()
? UploadBatchItem::STAGE_MATURITY_CHECK
: UploadBatchItem::STAGE_FINALIZED,
'error_code' => null,
'error_message' => null,
'processed_at' => now(),
@@ -290,7 +293,7 @@ final class UploadQueueService
'apply_category' => $this->applyCategory($item, (int) ($params['category_id'] ?? 0)),
'apply_tags' => $this->applyTags($item, (array) ($params['tags'] ?? [])),
'set_visibility' => $this->setVisibility($item, (string) ($params['visibility'] ?? '')),
'generate_ai' => $this->retryProcessing($item),
'generate_ai' => $this->requestAiGeneration($item),
default => throw ValidationException::withMessages([
'action' => ['Unsupported upload queue action.'],
]),
@@ -341,16 +344,40 @@ final class UploadQueueService
$item->forceFill([
'status' => UploadBatchItem::STATUS_PROCESSING,
'processing_stage' => UploadBatchItem::STAGE_MATURITY_CHECK,
'processing_stage' => $this->uploadMaturityEnabled()
? UploadBatchItem::STAGE_MATURITY_CHECK
: UploadBatchItem::STAGE_FINALIZED,
'error_code' => null,
'error_message' => null,
'is_ready_to_publish' => false,
])->save();
AutoTagArtworkJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit();
DetectArtworkMaturityJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit();
GenerateArtworkEmbeddingJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit();
AnalyzeArtworkAiAssistJob::dispatch((int) $artwork->id, true)->afterCommit();
if ((bool) config('vision.auto_tagging.enabled', false)) {
AutoTagArtworkJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit();
}
if ($this->uploadMaturityEnabled()) {
DetectArtworkMaturityJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit();
}
if ((bool) config('vision.upload.embeddings.enabled', true)) {
GenerateArtworkEmbeddingJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit();
}
if ((bool) config('vision.upload.ai_assist.enabled', false)) {
AnalyzeArtworkAiAssistJob::dispatch((int) $artwork->id, true)->afterCommit();
}
return $this->refreshItem((int) $item->id);
}
private function requestAiGeneration(UploadBatchItem $item): UploadBatchItem
{
$artwork = $item->artwork;
if (! $artwork || trim((string) ($artwork->hash ?? '')) === '' || trim((string) ($artwork->file_path ?? '')) === '') {
throw ValidationException::withMessages([
'item' => ['This item cannot generate AI suggestions safely. Re-upload the original file instead.'],
]);
}
app(StudioAiAssistService::class)->queueAnalysis($artwork, true);
return $this->refreshItem((int) $item->id);
}
@@ -543,13 +570,13 @@ final class UploadQueueService
$maturityStatus = Str::lower((string) ($artwork?->maturity_status ?? ArtworkMaturityService::STATUS_CLEAR));
$maturityAiStatus = Str::lower((string) ($artwork?->maturity_ai_status ?? ArtworkMaturityService::AI_STATUS_NOT_REQUESTED));
$aiStatus = Str::lower((string) ($artwork?->ai_status ?? ''));
$visionEnabled = (bool) config('vision.enabled', true);
$uploadMaturityEnabled = $this->uploadMaturityEnabled();
$maturityPending = $visionEnabled && in_array($maturityAiStatus, [
$maturityPending = $uploadMaturityEnabled && in_array($maturityAiStatus, [
ArtworkMaturityService::AI_STATUS_PENDING,
ArtworkMaturityService::AI_STATUS_NOT_REQUESTED,
], true);
$maturityFailed = $visionEnabled && $maturityAiStatus === ArtworkMaturityService::AI_STATUS_FAILED;
$maturityFailed = $uploadMaturityEnabled && $maturityAiStatus === ArtworkMaturityService::AI_STATUS_FAILED;
$needsReview = $maturityStatus === ArtworkMaturityService::STATUS_SUSPECTED || $maturityFailed;
$needsMetadata = ! $hasTitle || ! $hasCategory;
$blockingUploadFailure = ! $hasProcessedMedia && ($this->nullableString($item->error_code) !== null || $this->nullableText($item->error_message) !== null);
@@ -634,9 +661,9 @@ final class UploadQueueService
}
if ($maturityStatus === ArtworkMaturityService::STATUS_SUSPECTED) {
$missing[] = 'Needs maturity review';
} elseif ((bool) config('vision.enabled', true) && in_array($maturityAiStatus, [ArtworkMaturityService::AI_STATUS_PENDING, ArtworkMaturityService::AI_STATUS_NOT_REQUESTED], true)) {
} elseif ($this->uploadMaturityEnabled() && in_array($maturityAiStatus, [ArtworkMaturityService::AI_STATUS_PENDING, ArtworkMaturityService::AI_STATUS_NOT_REQUESTED], true)) {
$missing[] = 'Maturity analysis pending';
} elseif ((bool) config('vision.enabled', true) && $maturityAiStatus === ArtworkMaturityService::AI_STATUS_FAILED) {
} elseif ($this->uploadMaturityEnabled() && $maturityAiStatus === ArtworkMaturityService::AI_STATUS_FAILED) {
$missing[] = 'Maturity check failed';
}
@@ -690,6 +717,12 @@ final class UploadQueueService
return (int) round((collect($checks)->filter()->count() / count($checks)) * 100);
}
private function uploadMaturityEnabled(): bool
{
return (bool) config('vision.enabled', true)
&& (bool) config('vision.upload.maturity.enabled', false);
}
private function normalizeDefaults(array $defaults): array
{
$visibility = (string) ($defaults['visibility'] ?? Artwork::VISIBILITY_PUBLIC);