feat: ship creator journey v2 and profile updates

This commit is contained in:
2026-04-12 21:42:07 +02:00
parent a2457f4e49
commit d5cff21ea2
335 changed files with 20147 additions and 1545 deletions

View File

@@ -12,6 +12,8 @@ use RuntimeException;
final class AiArtworkVectorSearchService
{
private const MAX_SIMILAR_RESULTS = 120;
public function __construct(
private readonly VectorGatewayClient $client,
private readonly ArtworkVisionImageUrl $imageUrl,
@@ -28,7 +30,7 @@ final class AiArtworkVectorSearchService
*/
public function similarToArtwork(Artwork $artwork, int $limit = 12): array
{
$safeLimit = max(1, min(24, $limit));
$safeLimit = max(1, min(self::MAX_SIMILAR_RESULTS, $limit));
$cacheKey = sprintf('rec:artwork:%d:similar-ai:%d', $artwork->id, $safeLimit);
$ttl = max(60, (int) config('recommendations.ttl.similar_artworks', 30 * 60));
@@ -49,7 +51,7 @@ final class AiArtworkVectorSearchService
*/
public function searchByUploadedImage(UploadedFile $file, int $limit = 12): array
{
$safeLimit = max(1, min(24, $limit));
$safeLimit = max(1, min(self::MAX_SIMILAR_RESULTS, $limit));
$path = $file->store('ai-search/tmp', 'public');
if (! is_string($path) || $path === '') {

View File

@@ -21,9 +21,9 @@ final class VisionService
return (bool) config('vision.enabled', true);
}
public function buildImageUrl(string $hash): ?string
public function buildImageUrl(string $hash, ?string $variant = null): ?string
{
$variant = (string) config('vision.image_variant', 'md');
$variant = $variant ?? (string) config('vision.image_variant', 'md');
$variant = $variant !== '' ? $variant : 'md';
$clean = strtolower((string) preg_replace('/[^a-z0-9]/', '', $hash));
@@ -94,6 +94,45 @@ final class VisionService
];
}
/**
* @return array{assessment: array<string, mixed>, debug: array<string, mixed>}
*/
public function analyzeArtworkMaturityDetailed(Artwork $artwork, string $hash, ?string $variant = null): array
{
$imageUrl = $this->buildImageUrl($hash, $variant);
$ref = (string) Str::uuid();
if ($imageUrl === null) {
return [
'assessment' => [
'status' => 'failed',
'advisory' => 'Artwork maturity analysis could not start because no image URL was available.',
],
'debug' => [
'ref' => $ref,
'artwork_id' => (int) $artwork->id,
'hash' => $hash,
'image_url' => null,
'reason' => 'image_url_unavailable',
'calls' => [],
],
];
}
$call = $this->callMaturityDetailed($artwork, $imageUrl, $hash, $ref);
return [
'assessment' => $call['assessment'],
'debug' => [
'ref' => $ref,
'artwork_id' => (int) $artwork->id,
'hash' => $hash,
'image_url' => $imageUrl,
'calls' => [$call['debug']],
],
];
}
/**
* @return array{tags: array<int, array{name: string, slug: string, confidence: float|null, source: string, is_ai: true}>, vision_enabled: bool, source?: string, reason?: string}
*/
@@ -658,6 +697,177 @@ final class VisionService
return ['tags' => $this->extractTagList($response->json()), 'debug' => $debug];
}
/**
* @return array{assessment: array<string, mixed>, debug: array<string, mixed>}
*/
private function callMaturityDetailed(Artwork $artwork, string $imageUrl, string $hash, string $ref): array
{
$base = trim((string) config('vision.maturity.base_url', ''));
if ($base === '') {
return [
'assessment' => [
'status' => 'failed',
'advisory' => 'Vision maturity endpoint is not configured.',
],
'debug' => [
'service' => 'maturity',
'enabled' => false,
'reason' => 'base_url_missing',
],
];
}
$endpoint = (string) config('vision.maturity.endpoint', '/analyze/maturity');
$url = rtrim($base, '/') . '/' . ltrim($endpoint, '/');
$timeout = (int) config('vision.maturity.timeout_seconds', 20);
$connectTimeout = (int) config('vision.maturity.connect_timeout_seconds', 3);
$retries = (int) config('vision.maturity.retries', 1);
$delay = (int) config('vision.maturity.retry_delay_ms', 200);
$requestPayload = [
'url' => $imageUrl,
'image_url' => $imageUrl,
'artwork_id' => (int) $artwork->id,
'hash' => $hash,
];
$debug = [
'service' => 'maturity',
'endpoint' => $url,
'request' => $requestPayload,
];
try {
/** @var \Illuminate\Http\Client\Response $response */
$response = $this->requestWithVisionAuth('maturity', $ref)
->connectTimeout(max(1, $connectTimeout))
->timeout(max(1, $timeout))
->retry(max(0, $retries), max(0, $delay), throw: false)
->post($url, $requestPayload);
$debug['status'] = $response->status();
$debug['auth_header_sent'] = $this->visionApiKey('maturity') !== '';
$debug['response'] = $response->json() ?? $this->safeBody($response->body());
} catch (\Throwable $e) {
Log::warning('Vision maturity request failed', [
'ref' => $ref,
'artwork_id' => (int) $artwork->id,
'error' => $e->getMessage(),
]);
$debug['error'] = $e->getMessage();
return [
'assessment' => [
'status' => 'failed',
'advisory' => $e->getMessage(),
],
'debug' => $debug,
];
}
if ($response->ok()) {
return [
'assessment' => $this->parseMaturityAssessment($response->json()),
'debug' => $debug,
];
}
Log::warning('Vision maturity non-ok response', [
'ref' => $ref,
'artwork_id' => (int) $artwork->id,
'status' => $response->status(),
'body' => $this->safeBody($response->body()),
]);
$fallback = $this->callMaturityFileDetailed($artwork, $ref);
$debug['fallback_upload'] = $fallback['debug'];
if (($fallback['assessment']['status'] ?? null) === 'succeeded') {
return [
'assessment' => $fallback['assessment'],
'debug' => $debug,
];
}
return [
'assessment' => [
'status' => 'failed',
'advisory' => $this->buildFailureAdvisory($response->status(), $fallback['assessment']['advisory'] ?? null),
],
'debug' => $debug,
];
}
/**
* @return array{assessment: array<string, mixed>, debug: array<string, mixed>}
*/
private function callMaturityFileDetailed(Artwork $artwork, string $ref): array
{
$base = trim((string) config('vision.maturity.base_url', ''));
$endpoint = (string) config('vision.maturity.file_endpoint', '/analyze/maturity/file');
$url = rtrim($base, '/') . '/' . ltrim($endpoint, '/');
$debug = [
'endpoint' => $url,
];
if ($base === '') {
$debug['reason'] = 'base_url_missing';
return [
'assessment' => [
'status' => 'failed',
'advisory' => 'Vision maturity upload endpoint is not configured.',
],
'debug' => $debug,
];
}
$file = $this->fetchStoredArtworkBinary((int) $artwork->id);
if ($file === null) {
$debug['reason'] = 'file_unavailable';
return [
'assessment' => [
'status' => 'failed',
'advisory' => 'Artwork maturity analysis could not fall back to the upload endpoint because the stored file was unavailable.',
],
'debug' => $debug,
];
}
try {
/** @var \Illuminate\Http\Client\Response $response */
$response = $this->requestWithVisionAuth('maturity', $ref)
->attach('file', $file['contents'], $file['filename'])
->post($url, ['artwork_id' => (int) $artwork->id]);
$debug['status'] = $response->status();
$debug['response'] = $response->json() ?? $this->safeBody($response->body());
} catch (\Throwable $e) {
$debug['error'] = $e->getMessage();
return [
'assessment' => [
'status' => 'failed',
'advisory' => $e->getMessage(),
],
'debug' => $debug,
];
}
if (! $response->ok()) {
return [
'assessment' => [
'status' => 'failed',
'advisory' => 'Vision maturity upload endpoint returned HTTP ' . $response->status() . '.',
],
'debug' => $debug,
];
}
return [
'assessment' => $this->parseMaturityAssessment($response->json()),
'debug' => $debug,
];
}
private function shouldRunYolo(Artwork $artwork): bool
{
if (! (bool) config('vision.yolo.enabled', true)) {
@@ -696,12 +906,237 @@ final class VisionService
{
return match ($service) {
'gateway' => trim((string) config('vision.gateway.api_key', '')),
'maturity' => trim((string) config('vision.maturity.api_key', '')),
'clip' => trim((string) config('vision.clip.api_key', '')),
'yolo' => trim((string) config('vision.yolo.api_key', '')),
default => '',
};
}
/**
* @param mixed $json
* @return array<string, mixed>
*/
private function parseMaturityAssessment(mixed $json): array
{
if (! is_array($json)) {
return [
'status' => 'failed',
'advisory' => 'Vision maturity endpoint returned an invalid response.',
];
}
$label = $this->normalizeMaturityLabel(
$json['maturity_label']
?? $json['label']
?? data_get($json, 'data.maturity_label')
?? data_get($json, 'result.maturity_label')
);
$confidence = $this->normalizeFloat(
$json['confidence']
?? $json['score']
?? data_get($json, 'data.confidence')
?? data_get($json, 'result.confidence')
);
$thresholdUsed = $this->normalizeFloat(
$json['threshold_used']
?? $json['threshold']
?? data_get($json, 'data.threshold_used')
?? data_get($json, 'result.threshold_used')
);
$actionHint = $this->normalizeActionHint(
$json['action_hint']
?? data_get($json, 'data.action_hint')
?? data_get($json, 'result.action_hint')
);
$advisory = $this->normalizeText(
$json['advisory']
?? $json['message']
?? data_get($json, 'data.advisory')
?? data_get($json, 'result.advisory')
);
$status = $this->normalizeAssessmentStatus(
$json['status']
?? data_get($json, 'data.status')
?? data_get($json, 'result.status')
?? ($label !== null || $actionHint !== null ? 'succeeded' : 'failed')
);
$model = $this->normalizeText(
$json['model']
?? data_get($json, 'meta.model')
?? data_get($json, 'result.model')
);
$analysisTimeMs = $this->normalizeInt(
$json['analysis_time_ms']
?? data_get($json, 'meta.analysis_time_ms')
?? data_get($json, 'result.analysis_time_ms')
);
if ($status === 'succeeded' && $label === null && $actionHint === null) {
$status = 'failed';
$advisory = $advisory ?: 'Vision maturity endpoint did not return a maturity label or action hint.';
}
$labels = $this->extractMaturityLabels($json, $label);
return array_filter([
'status' => $status,
'maturity_label' => $label,
'confidence' => $confidence,
'model' => $model,
'threshold_used' => $thresholdUsed,
'analysis_time_ms' => $analysisTimeMs,
'action_hint' => $actionHint,
'advisory' => $advisory,
'labels' => $labels,
], static fn (mixed $value): bool => $value !== null);
}
/**
* @param mixed $json
* @return array<int, string>
*/
private function extractMaturityLabels(mixed $json, ?string $fallbackLabel): array
{
if (! is_array($json)) {
return $fallbackLabel !== null ? [$fallbackLabel] : [];
}
$raw = $json['labels']
?? $json['matched_labels']
?? $json['matched_terms']
?? data_get($json, 'data.labels')
?? data_get($json, 'result.labels')
?? [];
$labels = collect(is_array($raw) ? $raw : [$raw])
->map(function (mixed $item): ?string {
if (is_string($item)) {
$label = trim($item);
return $label !== '' ? $label : null;
}
if (! is_array($item)) {
return null;
}
$label = trim((string) ($item['label'] ?? $item['tag'] ?? $item['name'] ?? ''));
return $label !== '' ? $label : null;
})
->filter()
->values()
->all();
if ($labels === [] && $fallbackLabel !== null) {
$labels[] = $fallbackLabel;
}
return array_values(array_unique($labels));
}
/**
* @return array{filename: string, contents: string}|null
*/
private function fetchStoredArtworkBinary(int $artworkId): ?array
{
try {
$variant = (string) config('vision.image_variant', 'md');
$row = DB::table('artwork_files')
->where('artwork_id', $artworkId)
->where('variant', $variant)
->first();
if (! $row || empty($row->path)) {
return null;
}
$path = (string) $row->path;
$contents = Storage::disk((string) config('uploads.object_storage.disk', 's3'))->get($path);
if (! is_string($contents) || $contents === '') {
return null;
}
return [
'filename' => basename($path),
'contents' => $contents,
];
} catch (\Throwable) {
return null;
}
}
private function buildFailureAdvisory(int $status, ?string $fallback): string
{
if (is_string($fallback) && trim($fallback) !== '') {
return trim($fallback);
}
return 'Vision maturity endpoint returned HTTP ' . $status . '.';
}
private function normalizeMaturityLabel(mixed $value): ?string
{
if (! is_scalar($value)) {
return null;
}
return match (Str::lower(trim((string) $value))) {
'safe', 'clear', 'sfw' => 'safe',
'mature', 'adult', 'nsfw', 'explicit' => 'mature',
default => null,
};
}
private function normalizeActionHint(mixed $value): ?string
{
if (! is_scalar($value)) {
return null;
}
return match (Str::lower(trim((string) $value))) {
'allow', 'mark_safe', 'safe' => 'safe',
'review', 'queue', 'suspect' => 'review',
'flag_high', 'block', 'mature', 'mark_mature' => 'flag_high',
default => null,
};
}
private function normalizeAssessmentStatus(mixed $value): string
{
if (! is_scalar($value)) {
return 'failed';
}
return match (Str::lower(trim((string) $value))) {
'ok', 'success', 'succeeded', 'complete', 'completed' => 'succeeded',
'pending', 'queued', 'processing' => 'pending',
'skipped', 'not_requested' => 'skipped',
default => 'failed',
};
}
private function normalizeFloat(mixed $value): ?float
{
return is_numeric($value) ? round((float) $value, 4) : null;
}
private function normalizeInt(mixed $value): ?int
{
return is_numeric($value) ? (int) $value : null;
}
private function normalizeText(mixed $value): ?string
{
if (! is_scalar($value)) {
return null;
}
$normalized = trim((string) $value);
return $normalized !== '' ? $normalized : null;
}
/**
* @param mixed $json
* @return array<int, array{tag: string, confidence?: float|int|null}>