195 lines
5.7 KiB
PHP
195 lines
5.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Jobs;
|
|
|
|
use App\Models\Artwork;
|
|
use App\Models\ArtworkEmbedding;
|
|
use App\Services\Vision\ArtworkEmbeddingClient;
|
|
use App\Services\Vision\ArtworkVisionImageUrl;
|
|
use App\Services\Vision\ArtworkVectorIndexService;
|
|
use App\Services\Vision\VectorService;
|
|
use Illuminate\Bus\Queueable;
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
use Illuminate\Foundation\Bus\Dispatchable;
|
|
use Illuminate\Queue\InteractsWithQueue;
|
|
use Illuminate\Queue\SerializesModels;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Redis;
|
|
|
|
final class GenerateArtworkEmbeddingJob implements ShouldQueue
|
|
{
|
|
use Dispatchable;
|
|
use InteractsWithQueue;
|
|
use Queueable;
|
|
use SerializesModels;
|
|
|
|
public int $tries = 3;
|
|
|
|
public int $timeout = 20;
|
|
|
|
public function __construct(
|
|
private readonly int $artworkId,
|
|
private readonly ?string $sourceHash = null,
|
|
private readonly bool $force = false,
|
|
) {
|
|
$queue = (string) config('recommendations.queue', config('vision.queue', 'default'));
|
|
if ($queue !== '') {
|
|
$this->onQueue($queue);
|
|
}
|
|
}
|
|
|
|
public function backoff(): array
|
|
{
|
|
return [2, 10, 30];
|
|
}
|
|
|
|
public function handle(
|
|
ArtworkEmbeddingClient $client,
|
|
ArtworkVisionImageUrl $imageUrlBuilder,
|
|
VectorService|ArtworkVectorIndexService $vectors,
|
|
): void
|
|
{
|
|
if (! (bool) config('recommendations.embedding.enabled', true)) {
|
|
return;
|
|
}
|
|
|
|
$artwork = Artwork::query()
|
|
->with(['categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name')])
|
|
->find($this->artworkId);
|
|
if (! $artwork) {
|
|
return;
|
|
}
|
|
|
|
$sourceHash = strtolower((string) preg_replace('/[^a-z0-9]/', '', (string) ($this->sourceHash ?? $artwork->hash ?? '')));
|
|
if ($sourceHash === '') {
|
|
return;
|
|
}
|
|
|
|
$model = (string) config('recommendations.embedding.model', 'clip');
|
|
$modelVersion = (string) config('recommendations.embedding.model_version', 'v1');
|
|
$algoVersion = (string) config('recommendations.embedding.algo_version', 'clip-cosine-v1');
|
|
|
|
if (! $this->force) {
|
|
$existing = ArtworkEmbedding::query()
|
|
->where('artwork_id', $artwork->id)
|
|
->where('model', $model)
|
|
->where('model_version', $modelVersion)
|
|
->first();
|
|
|
|
if ($existing && (string) ($existing->source_hash ?? '') === $sourceHash) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
$lockKey = $this->lockKey($artwork->id, $model, $modelVersion);
|
|
if (! $this->acquireLock($lockKey)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$imageUrl = $imageUrlBuilder->fromHash($sourceHash, (string) ($artwork->thumb_ext ?: 'webp'));
|
|
if ($imageUrl === null) {
|
|
return;
|
|
}
|
|
|
|
$vector = $client->embed($imageUrl, (int) $artwork->id, $sourceHash);
|
|
if ($vector === []) {
|
|
return;
|
|
}
|
|
|
|
$normalized = $this->normalize($vector);
|
|
|
|
ArtworkEmbedding::query()->updateOrCreate(
|
|
[
|
|
'artwork_id' => (int) $artwork->id,
|
|
'model' => $model,
|
|
'model_version' => $modelVersion,
|
|
],
|
|
[
|
|
'algo_version' => $algoVersion,
|
|
'dim' => count($normalized),
|
|
'embedding_json' => json_encode($normalized, JSON_THROW_ON_ERROR),
|
|
'source_hash' => $sourceHash,
|
|
'is_normalized' => true,
|
|
'generated_at' => now(),
|
|
'meta' => [
|
|
'source' => 'clip',
|
|
'image_variant' => (string) config('vision.image_variant', 'md'),
|
|
],
|
|
]
|
|
);
|
|
|
|
$this->upsertVectorIndex($vectors, $artwork);
|
|
} finally {
|
|
$this->releaseLock($lockKey);
|
|
}
|
|
}
|
|
|
|
private function upsertVectorIndex(
|
|
VectorService|ArtworkVectorIndexService $vectors,
|
|
Artwork $artwork
|
|
): void
|
|
{
|
|
if (! $vectors->isConfigured()) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$vectors->upsertArtwork($artwork);
|
|
} catch (\Throwable $e) {
|
|
Log::warning('GenerateArtworkEmbeddingJob vector upsert failed', [
|
|
'artwork_id' => (int) $artwork->id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<int, float> $vector
|
|
* @return array<int, float>
|
|
*/
|
|
private function normalize(array $vector): array
|
|
{
|
|
$sumSquares = 0.0;
|
|
foreach ($vector as $value) {
|
|
$sumSquares += ($value * $value);
|
|
}
|
|
|
|
if ($sumSquares <= 0.0) {
|
|
return $vector;
|
|
}
|
|
|
|
$norm = sqrt($sumSquares);
|
|
return array_map(static fn (float $value): float => $value / $norm, $vector);
|
|
}
|
|
|
|
private function lockKey(int $artworkId, string $model, string $version): string
|
|
{
|
|
return 'artwork-embedding:lock:' . $artworkId . ':' . $model . ':' . $version;
|
|
}
|
|
|
|
private function acquireLock(string $key): bool
|
|
{
|
|
try {
|
|
$didSet = Redis::setnx($key, 1);
|
|
if ($didSet) {
|
|
Redis::expire($key, 1800);
|
|
}
|
|
return (bool) $didSet;
|
|
} catch (\Throwable) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
private function releaseLock(string $key): void
|
|
{
|
|
try {
|
|
Redis::del($key);
|
|
} catch (\Throwable) {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|