feat: ship creator journey v2 and profile updates
This commit is contained in:
@@ -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 === '') {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user