json(['tags' => [], 'vision_enabled' => false]); } $artwork = Artwork::query()->findOrFail($id); $this->authorizeOrNotFound($request->user(), $artwork); $imageUrl = $this->buildImageUrl((string) $artwork->hash); if ($imageUrl === null) { return response()->json([ 'tags' => [], 'vision_enabled' => true, 'reason' => 'image_url_unavailable', ]); } $gatewayBase = trim((string) config('vision.gateway.base_url', config('vision.clip.base_url', ''))); if ($gatewayBase === '') { return response()->json([ 'tags' => [], 'vision_enabled' => true, 'reason' => 'gateway_not_configured', ]); } $url = rtrim($gatewayBase, '/') . '/analyze/all'; $limit = min(20, max(5, (int) ($request->query('limit', 10)))); $timeout = (int) config('vision.gateway.timeout_seconds', 10); $cTimeout = (int) config('vision.gateway.connect_timeout_seconds', 3); $ref = (string) Str::uuid(); try { /** @var \Illuminate\Http\Client\Response $response */ $response = Http::acceptJson() ->connectTimeout(max(1, $cTimeout)) ->timeout(max(1, $timeout)) ->withHeaders(['X-Request-ID' => $ref]) ->post($url, [ 'url' => $imageUrl, 'limit' => $limit, ]); if (! $response->ok()) { Log::warning('vision-suggest: non-ok response', [ 'ref' => $ref, 'artwork_id' => $id, 'status' => $response->status(), 'body' => Str::limit($response->body(), 400), ]); return response()->json([ 'tags' => [], 'vision_enabled' => true, 'reason' => 'gateway_error_' . $response->status(), ]); } $tags = $this->parseGatewayResponse($response->json()); return response()->json([ 'tags' => $tags, 'vision_enabled' => true, 'source' => 'gateway_sync', ]); } catch (\Throwable $e) { Log::warning('vision-suggest: request failed', [ 'ref' => $ref, 'artwork_id' => $id, 'error' => $e->getMessage(), ]); return response()->json([ 'tags' => [], 'vision_enabled' => true, 'reason' => 'gateway_exception', ]); } } // ── helpers ────────────────────────────────────────────────────────────── private function buildImageUrl(string $hash): ?string { $base = rtrim((string) config('cdn.files_url', ''), '/'); if ($base === '') { return null; } $clean = strtolower((string) preg_replace('/[^a-z0-9]/', '', $hash)); $clean = str_pad($clean, 6, '0'); $seg = [substr($clean, 0, 2) ?: '00', substr($clean, 2, 2) ?: '00', substr($clean, 4, 2) ?: '00']; $variant = (string) config('vision.image_variant', 'md'); return $base . '/img/' . implode('/', $seg) . '/' . $variant . '.webp'; } /** * Parse the /analyze/all gateway response. * * The gateway returns a unified object: * { clip: [{tag, confidence}], blip: ["caption1"], yolo: [{tag, confidence}] } * or a flat list of tags directly. * * @param mixed $json * @return array */ private function parseGatewayResponse(mixed $json): array { $raw = []; if (! is_array($json)) { return []; } // Unified gateway response if (isset($json['clip']) && is_array($json['clip'])) { foreach ($json['clip'] as $item) { $raw[] = ['tag' => $item['tag'] ?? $item['name'] ?? '', 'confidence' => $item['confidence'] ?? null, 'source' => 'clip']; } } if (isset($json['yolo']) && is_array($json['yolo'])) { foreach ($json['yolo'] as $item) { $raw[] = ['tag' => $item['tag'] ?? $item['label'] ?? $item['name'] ?? '', 'confidence' => $item['confidence'] ?? null, 'source' => 'yolo']; } } // Flat lists if ($raw === []) { $list = $json['tags'] ?? $json['data'] ?? $json; if (is_array($list)) { foreach ($list as $item) { if (is_array($item)) { $raw[] = ['tag' => $item['tag'] ?? $item['name'] ?? $item['label'] ?? '', 'confidence' => $item['confidence'] ?? null, 'source' => 'vision']; } elseif (is_string($item)) { $raw[] = ['tag' => $item, 'confidence' => null, 'source' => 'vision']; } } } } // Deduplicate by slug, keep highest confidence $bySlug = []; foreach ($raw as $r) { $slug = $this->normalizer->normalize((string) ($r['tag'] ?? '')); if ($slug === '') { continue; } $conf = isset($r['confidence']) && is_numeric($r['confidence']) ? (float) $r['confidence'] : null; if (! isset($bySlug[$slug]) || ($conf !== null && $conf > (float) ($bySlug[$slug]['confidence'] ?? 0))) { $bySlug[$slug] = [ 'name' => ucwords(str_replace(['-', '_'], ' ', $slug)), 'slug' => $slug, 'confidence' => $conf, 'source' => $r['source'] ?? 'vision', 'is_ai' => true, ]; } } // Sort by confidence desc $sorted = array_values($bySlug); usort($sorted, static fn ($a, $b) => ($b['confidence'] ?? 0) <=> ($a['confidence'] ?? 0)); return $sorted; } private function authorizeOrNotFound(mixed $user, Artwork $artwork): void { if (! $user) { abort(404); } if ((int) $artwork->user_id !== (int) $user->id) { abort(404); } } }