Wire admin studio SSR and search infrastructure
This commit is contained in:
@@ -7,8 +7,9 @@ namespace App\Services\Vision;
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class AiArtworkVectorSearchService
|
||||
{
|
||||
@@ -35,12 +36,7 @@ final class AiArtworkVectorSearchService
|
||||
$ttl = max(60, (int) config('recommendations.ttl.similar_artworks', 30 * 60));
|
||||
|
||||
return Cache::remember($cacheKey, $ttl, function () use ($artwork, $safeLimit): array {
|
||||
$url = $this->imageUrl->fromArtwork($artwork);
|
||||
if ($url === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$matches = $this->client->searchByUrl($url, $safeLimit + 1);
|
||||
$matches = $this->searchMatchesForArtwork($artwork, $safeLimit + 1);
|
||||
|
||||
return $this->resolveMatches($matches, $safeLimit, $artwork->id);
|
||||
});
|
||||
@@ -52,27 +48,80 @@ final class AiArtworkVectorSearchService
|
||||
public function searchByUploadedImage(UploadedFile $file, int $limit = 12): array
|
||||
{
|
||||
$safeLimit = max(1, min(self::MAX_SIMILAR_RESULTS, $limit));
|
||||
$path = $file->store('ai-search/tmp', 'public');
|
||||
$matches = $this->client->searchByUploadedFile($file, $safeLimit);
|
||||
|
||||
if (! is_string($path) || $path === '') {
|
||||
throw new RuntimeException('Unable to persist uploaded image for vector search.');
|
||||
return $this->resolveMatches($matches, $safeLimit);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{contents: string, filename: string}|null
|
||||
*/
|
||||
private function downloadArtworkImage(Artwork $artwork, string $url): ?array
|
||||
{
|
||||
$response = Http::accept('*/*')
|
||||
->connectTimeout(5)
|
||||
->timeout(20)
|
||||
->retry(1, 200, throw: false)
|
||||
->get($url);
|
||||
|
||||
if (! $response->ok()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$publicBaseUrl = rtrim((string) config('filesystems.disks.public.url', ''), '/');
|
||||
if ($publicBaseUrl === '') {
|
||||
Storage::disk('public')->delete($path);
|
||||
throw new RuntimeException('Public disk URL is not configured for vector search uploads.');
|
||||
$contents = $response->body();
|
||||
if ($contents === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = $publicBaseUrl . '/' . ltrim($path, '/');
|
||||
$ext = strtolower(ltrim((string) ($artwork->thumb_ext ?: 'webp'), '.'));
|
||||
|
||||
return [
|
||||
'contents' => $contents,
|
||||
'filename' => sprintf('artwork-%d.%s', (int) $artwork->id, $ext !== '' ? $ext : 'webp'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id: int|string, score: float, metadata: array<string, mixed>}>
|
||||
*/
|
||||
private function searchMatchesForArtwork(Artwork $artwork, int $limit): array
|
||||
{
|
||||
$url = $this->imageUrl->fromArtwork($artwork);
|
||||
if ($url === null || $url === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$fileFailure = null;
|
||||
|
||||
try {
|
||||
$matches = $this->client->searchByUrl($url, $safeLimit);
|
||||
|
||||
return $this->resolveMatches($matches, $safeLimit);
|
||||
} finally {
|
||||
Storage::disk('public')->delete($path);
|
||||
$payload = $this->downloadArtworkImage($artwork, $url);
|
||||
if ($payload !== null) {
|
||||
return $this->client->searchByFileContents($payload['contents'], $payload['filename'], $limit);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$fileFailure = $e;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->client->searchByUrl($url, $limit);
|
||||
} catch (Throwable $e) {
|
||||
throw $this->normalizeSearchFailure($fileFailure, $e);
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeSearchFailure(?Throwable $fileFailure, Throwable $fallbackFailure): RuntimeException
|
||||
{
|
||||
if ($fileFailure === null) {
|
||||
return $fallbackFailure instanceof RuntimeException
|
||||
? $fallbackFailure
|
||||
: new RuntimeException($fallbackFailure->getMessage(), 0, $fallbackFailure);
|
||||
}
|
||||
|
||||
return new RuntimeException(sprintf(
|
||||
'Vector search failed via file endpoint (%s) and URL fallback (%s).',
|
||||
$fileFailure->getMessage(),
|
||||
$fallbackFailure->getMessage(),
|
||||
), 0, $fallbackFailure);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,7 +175,7 @@ final class AiArtworkVectorSearchService
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'thumb' => $artwork->thumbUrl('md'),
|
||||
'thumb' => $artwork->thumbUrl('sm'),
|
||||
'url' => '/art/' . $artwork->id . '/' . $artwork->slug,
|
||||
'author' => $artwork->user?->name ?? 'Artist',
|
||||
'author_avatar' => $artwork->user?->profile?->avatar_url,
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Services\Vision;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
final class ArtworkVectorIndexService
|
||||
@@ -44,7 +45,12 @@ final class ArtworkVectorIndexService
|
||||
{
|
||||
$payload = $this->payloadForArtwork($artwork);
|
||||
|
||||
$this->client->upsertByUrl($payload['url'], (int) $artwork->id, $payload['metadata']);
|
||||
$filePayload = $this->downloadArtworkImage($artwork, $payload['url']);
|
||||
if ($filePayload === null) {
|
||||
throw new RuntimeException('Unable to download artwork image bytes for vector upsert for artwork ' . (int) $artwork->id . '.');
|
||||
}
|
||||
|
||||
$this->client->upsertByFileContents($filePayload['contents'], $filePayload['filename'], (int) $artwork->id, $payload['metadata']);
|
||||
|
||||
$artwork->forceFill([
|
||||
'last_vector_indexed_at' => now(),
|
||||
@@ -52,4 +58,32 @@ final class ArtworkVectorIndexService
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{contents: string, filename: string}|null
|
||||
*/
|
||||
private function downloadArtworkImage(Artwork $artwork, string $url): ?array
|
||||
{
|
||||
$response = Http::accept('*/*')
|
||||
->connectTimeout(5)
|
||||
->timeout(20)
|
||||
->retry(1, 200, throw: false)
|
||||
->get($url);
|
||||
|
||||
if (! $response->ok()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$contents = $response->body();
|
||||
if ($contents === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ext = strtolower(ltrim((string) ($artwork->thumb_ext ?: 'webp'), '.'));
|
||||
|
||||
return [
|
||||
'contents' => $contents,
|
||||
'filename' => sprintf('artwork-%d.%s', (int) $artwork->id, $ext !== '' ? $ext : 'webp'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,12 @@ use App\Services\ThumbnailService;
|
||||
|
||||
final class ArtworkVisionImageUrl
|
||||
{
|
||||
public function fromArtwork(Artwork $artwork): ?string
|
||||
public function fromArtwork(Artwork $artwork, ?string $size = null): ?string
|
||||
{
|
||||
return $this->fromHash(
|
||||
(string) ($artwork->hash ?? ''),
|
||||
(string) ($artwork->thumb_ext ?: 'webp')
|
||||
(string) ($artwork->thumb_ext ?: 'webp'),
|
||||
$size ?? (string) config('vision.image_variant', 'md')
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Vision;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
@@ -38,6 +39,27 @@ final class VectorGatewayClient
|
||||
return is_array($json) ? $json : [];
|
||||
}
|
||||
|
||||
public function upsertByFileContents(string $contents, string $filename, int|string $id, array $metadata = []): array
|
||||
{
|
||||
$response = $this->request()
|
||||
->attach('file', $contents, $filename)
|
||||
->post(
|
||||
$this->url((string) config('vision.vector_gateway.upsert_file_endpoint', '/vectors/upsert/file')),
|
||||
[
|
||||
'id' => (string) $id,
|
||||
'metadata_json' => $metadata === [] ? null : json_encode($metadata, JSON_THROW_ON_ERROR),
|
||||
]
|
||||
);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException($this->failureMessage('Vector upsert', $response));
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
|
||||
return is_array($json) ? $json : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id: int|string, score: float, metadata: array<string, mixed>}>
|
||||
*/
|
||||
@@ -58,6 +80,45 @@ final class VectorGatewayClient
|
||||
return $this->extractMatches($response->json());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id: int|string, score: float, metadata: array<string, mixed>}>
|
||||
*/
|
||||
public function searchByFileContents(string $contents, string $filename, int $limit = 5): array
|
||||
{
|
||||
$response = $this->request()
|
||||
->attach('file', $contents, $filename)
|
||||
->post(
|
||||
$this->url((string) config('vision.vector_gateway.search_file_endpoint', '/vectors/search/file')),
|
||||
[
|
||||
'limit' => max(1, $limit),
|
||||
]
|
||||
);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException($this->failureMessage('Vector search', $response));
|
||||
}
|
||||
|
||||
return $this->extractMatches($response->json());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id: int|string, score: float, metadata: array<string, mixed>}>
|
||||
*/
|
||||
public function searchByUploadedFile(UploadedFile $file, int $limit = 5): array
|
||||
{
|
||||
$realPath = $file->getRealPath();
|
||||
if (! is_string($realPath) || $realPath === '') {
|
||||
throw new RuntimeException('Uploaded file has no readable temporary path for vector search.');
|
||||
}
|
||||
|
||||
$contents = file_get_contents($realPath);
|
||||
if (! is_string($contents) || $contents === '') {
|
||||
throw new RuntimeException('Unable to read uploaded image bytes for vector search.');
|
||||
}
|
||||
|
||||
return $this->searchByFileContents($contents, $file->getClientOriginalName() ?: 'search-image', $limit);
|
||||
}
|
||||
|
||||
public function deleteByIds(array $ids): array
|
||||
{
|
||||
$response = $this->postJson(
|
||||
|
||||
Reference in New Issue
Block a user