Studio: make grid checkbox rectangular and commit table changes
This commit is contained in:
180
app/Services/Recommendations/HybridSimilarArtworksService.php
Normal file
180
app/Services/Recommendations/HybridSimilarArtworksService.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Recommendations;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\RecArtworkRec;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Runtime service for the Similar Artworks hybrid recommender (spec §8).
|
||||
*
|
||||
* Flow:
|
||||
* 1. Try precomputed similar_hybrid list
|
||||
* 2. Else similar_visual (if enabled)
|
||||
* 3. Else similar_tags
|
||||
* 4. Else similar_behavior
|
||||
* 5. Else trending fallback in the same category/content_type
|
||||
*
|
||||
* Lists are cached in Redis/cache with a configurable TTL.
|
||||
* Hydration fetches artworks in one query, preserving stored order.
|
||||
* An author-cap diversity filter is applied at runtime as a final check.
|
||||
*/
|
||||
final class HybridSimilarArtworksService
|
||||
{
|
||||
private const FALLBACK_ORDER = [
|
||||
'similar_hybrid',
|
||||
'similar_visual',
|
||||
'similar_tags',
|
||||
'similar_behavior',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get similar artworks for the given artwork.
|
||||
*
|
||||
* @param string|null $type null|'similar'='hybrid fallback', 'visual', 'tags', 'behavior'
|
||||
* @return Collection<int, Artwork>
|
||||
*/
|
||||
public function forArtwork(int $artworkId, int $limit = 12, ?string $type = null): Collection
|
||||
{
|
||||
$modelVersion = (string) config('recommendations.similarity.model_version', 'sim_v1');
|
||||
$cacheTtl = (int) config('recommendations.similarity.cache_ttl', 6 * 3600);
|
||||
$maxPerAuthor = (int) config('recommendations.similarity.max_per_author', 2);
|
||||
$vectorEnabled = (bool) config('recommendations.similarity.vector_enabled', false);
|
||||
|
||||
$typeSuffix = $type && $type !== 'similar' ? ":{$type}" : '';
|
||||
$cacheKey = "rec:artwork:{$artworkId}:similar:{$modelVersion}{$typeSuffix}";
|
||||
|
||||
$ids = Cache::remember($cacheKey, $cacheTtl, function () use (
|
||||
$artworkId, $modelVersion, $vectorEnabled, $type
|
||||
): array {
|
||||
return $this->resolveIds($artworkId, $modelVersion, $vectorEnabled, $type);
|
||||
});
|
||||
|
||||
if ($ids === []) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
// Take requested limit + buffer for author-diversity filtering
|
||||
$idSlice = array_slice($ids, 0, $limit * 3);
|
||||
|
||||
$artworks = Artwork::query()
|
||||
->whereIn('id', $idSlice)
|
||||
->public()
|
||||
->published()
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
// Preserve precomputed order + apply author cap
|
||||
$authorCounts = [];
|
||||
$result = [];
|
||||
|
||||
foreach ($idSlice as $id) {
|
||||
/** @var Artwork|null $artwork */
|
||||
$artwork = $artworks->get($id);
|
||||
if (! $artwork) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$authorId = $artwork->user_id;
|
||||
$authorCounts[$authorId] = ($authorCounts[$authorId] ?? 0) + 1;
|
||||
if ($authorCounts[$authorId] > $maxPerAuthor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[] = $artwork;
|
||||
if (count($result) >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return collect($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the precomputed ID list, falling through rec types.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function resolveIds(int $artworkId, string $modelVersion, bool $vectorEnabled, ?string $type = null): array
|
||||
{
|
||||
// If a specific type was requested, try only that type + trending fallback
|
||||
if ($type && $type !== 'similar') {
|
||||
$recType = match ($type) {
|
||||
'visual' => 'similar_visual',
|
||||
'tags' => 'similar_tags',
|
||||
'behavior' => 'similar_behavior',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($recType) {
|
||||
$rec = RecArtworkRec::query()
|
||||
->where('artwork_id', $artworkId)
|
||||
->where('rec_type', $recType)
|
||||
->where('model_version', $modelVersion)
|
||||
->first();
|
||||
|
||||
if ($rec && is_array($rec->recs) && $rec->recs !== []) {
|
||||
return array_map('intval', $rec->recs);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->trendingFallback($artworkId);
|
||||
}
|
||||
|
||||
// Default: hybrid fallback chain
|
||||
$tryTypes = $vectorEnabled
|
||||
? self::FALLBACK_ORDER
|
||||
: array_filter(self::FALLBACK_ORDER, fn (string $t) => $t !== 'similar_visual');
|
||||
|
||||
foreach ($tryTypes as $recType) {
|
||||
$rec = RecArtworkRec::query()
|
||||
->where('artwork_id', $artworkId)
|
||||
->where('rec_type', $recType)
|
||||
->where('model_version', $modelVersion)
|
||||
->first();
|
||||
|
||||
if ($rec && is_array($rec->recs) && $rec->recs !== []) {
|
||||
return array_map('intval', $rec->recs);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Trending fallback (category-scoped) ────────────────────────────────
|
||||
return $this->trendingFallback($artworkId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trending fallback: fetch recent popular artworks in the same category.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function trendingFallback(int $artworkId): array
|
||||
{
|
||||
$catIds = DB::table('artwork_category')
|
||||
->where('artwork_id', $artworkId)
|
||||
->pluck('category_id')
|
||||
->all();
|
||||
|
||||
$query = Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->where('id', '!=', $artworkId);
|
||||
|
||||
if ($catIds !== []) {
|
||||
$query->whereHas('categories', function ($q) use ($catIds) {
|
||||
$q->whereIn('categories.id', $catIds);
|
||||
});
|
||||
}
|
||||
|
||||
return $query
|
||||
->orderByDesc('published_at')
|
||||
->limit(30)
|
||||
->pluck('id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Recommendations\VectorSimilarity;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* pgvector adapter — uses the artwork_embeddings table with cosine similarity.
|
||||
*
|
||||
* Requires PostgreSQL with the pgvector extension installed.
|
||||
* Schema: artwork_embeddings (artwork_id PK, model, dims, embedding vector(N), ...)
|
||||
*
|
||||
* Spec §9 Option A.
|
||||
*/
|
||||
final class PgvectorAdapter implements VectorAdapterInterface
|
||||
{
|
||||
public function querySimilar(int $artworkId, int $topK = 100): array
|
||||
{
|
||||
// Fetch reference embedding
|
||||
$ref = DB::table('artwork_embeddings')
|
||||
->where('artwork_id', $artworkId)
|
||||
->select('embedding_json')
|
||||
->first();
|
||||
|
||||
if (! $ref || ! $ref->embedding_json) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$embedding = json_decode($ref->embedding_json, true);
|
||||
if (! is_array($embedding) || $embedding === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// pgvector cosine distance operator: <=>
|
||||
// Score = 1 - distance (higher = more similar)
|
||||
$vecLiteral = '[' . implode(',', array_map('floatval', $embedding)) . ']';
|
||||
|
||||
try {
|
||||
$rows = DB::select(
|
||||
"SELECT artwork_id, 1 - (embedding_json::vector <=> ?::vector) AS score
|
||||
FROM artwork_embeddings
|
||||
WHERE artwork_id != ?
|
||||
ORDER BY embedding_json::vector <=> ?::vector
|
||||
LIMIT ?",
|
||||
[$vecLiteral, $artworkId, $vecLiteral, $topK]
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("[PgvectorAdapter] Query failed: {$e->getMessage()}");
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(fn ($row) => [
|
||||
'artwork_id' => (int) $row->artwork_id,
|
||||
'score' => (float) $row->score,
|
||||
], $rows);
|
||||
}
|
||||
|
||||
public function upsertEmbedding(int $artworkId, array $embedding, array $metadata = []): void
|
||||
{
|
||||
$json = json_encode($embedding);
|
||||
|
||||
DB::table('artwork_embeddings')->updateOrInsert(
|
||||
['artwork_id' => $artworkId],
|
||||
[
|
||||
'embedding_json' => $json,
|
||||
'model' => $metadata['model'] ?? 'clip',
|
||||
'model_version' => $metadata['model_version'] ?? 'v1',
|
||||
'dim' => count($embedding),
|
||||
'is_normalized' => $metadata['is_normalized'] ?? true,
|
||||
'generated_at' => now(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function deleteEmbedding(int $artworkId): void
|
||||
{
|
||||
DB::table('artwork_embeddings')
|
||||
->where('artwork_id', $artworkId)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Recommendations\VectorSimilarity;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Managed vector DB adapter (Pinecone-style REST API).
|
||||
*
|
||||
* Spec §9 Option B.
|
||||
*
|
||||
* Configuration:
|
||||
* recommendations.similarity.pinecone.api_key
|
||||
* recommendations.similarity.pinecone.index_host
|
||||
* recommendations.similarity.pinecone.index_name
|
||||
* recommendations.similarity.pinecone.namespace
|
||||
* recommendations.similarity.pinecone.top_k
|
||||
*/
|
||||
final class PineconeAdapter implements VectorAdapterInterface
|
||||
{
|
||||
private function apiKey(): string
|
||||
{
|
||||
return (string) config('recommendations.similarity.pinecone.api_key', '');
|
||||
}
|
||||
|
||||
private function host(): string
|
||||
{
|
||||
return rtrim((string) config('recommendations.similarity.pinecone.index_host', ''), '/');
|
||||
}
|
||||
|
||||
private function namespace(): string
|
||||
{
|
||||
return (string) config('recommendations.similarity.pinecone.namespace', '');
|
||||
}
|
||||
|
||||
public function querySimilar(int $artworkId, int $topK = 100): array
|
||||
{
|
||||
$configTopK = (int) config('recommendations.similarity.pinecone.top_k', 100);
|
||||
$effectiveTopK = min($topK, $configTopK);
|
||||
|
||||
$vectorId = "artwork:{$artworkId}";
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'Api-Key' => $this->apiKey(),
|
||||
'Content-Type' => 'application/json',
|
||||
])->timeout(10)->post("{$this->host()}/query", array_filter([
|
||||
'id' => $vectorId,
|
||||
'topK' => $effectiveTopK + 1, // +1 to exclude self
|
||||
'includeMetadata' => true,
|
||||
'namespace' => $this->namespace() ?: null,
|
||||
'filter' => [
|
||||
'is_active' => ['$eq' => true],
|
||||
],
|
||||
]));
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::warning("[PineconeAdapter] Query failed: HTTP {$response->status()}");
|
||||
return [];
|
||||
}
|
||||
|
||||
$matches = $response->json('matches', []);
|
||||
|
||||
$results = [];
|
||||
foreach ($matches as $match) {
|
||||
$matchId = $match['id'] ?? '';
|
||||
// Extract artwork ID from "artwork:123" format
|
||||
if (! str_starts_with($matchId, 'artwork:')) {
|
||||
continue;
|
||||
}
|
||||
$matchArtworkId = (int) substr($matchId, 8);
|
||||
if ($matchArtworkId === $artworkId) {
|
||||
continue; // skip self
|
||||
}
|
||||
|
||||
$results[] = [
|
||||
'artwork_id' => $matchArtworkId,
|
||||
'score' => (float) ($match['score'] ?? 0.0),
|
||||
];
|
||||
}
|
||||
|
||||
return $results;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("[PineconeAdapter] Query exception: {$e->getMessage()}");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public function upsertEmbedding(int $artworkId, array $embedding, array $metadata = []): void
|
||||
{
|
||||
$vectorId = "artwork:{$artworkId}";
|
||||
|
||||
// Spec §9B: metadata should include category_id, content_type, author_id, is_active, nsfw
|
||||
$pineconeMetadata = array_merge([
|
||||
'is_active' => true,
|
||||
'category_id' => $metadata['category_id'] ?? null,
|
||||
'content_type' => $metadata['content_type'] ?? null,
|
||||
'author_id' => $metadata['author_id'] ?? null,
|
||||
'nsfw' => $metadata['nsfw'] ?? false,
|
||||
], array_diff_key($metadata, array_flip([
|
||||
'category_id', 'content_type', 'author_id', 'nsfw', 'is_active',
|
||||
])));
|
||||
|
||||
// Remove null values (Pinecone doesn't accept nulls in metadata)
|
||||
$pineconeMetadata = array_filter($pineconeMetadata, fn ($v) => $v !== null);
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'Api-Key' => $this->apiKey(),
|
||||
'Content-Type' => 'application/json',
|
||||
])->timeout(10)->post("{$this->host()}/vectors/upsert", array_filter([
|
||||
'vectors' => [
|
||||
[
|
||||
'id' => $vectorId,
|
||||
'values' => array_map('floatval', $embedding),
|
||||
'metadata' => $pineconeMetadata,
|
||||
],
|
||||
],
|
||||
'namespace' => $this->namespace() ?: null,
|
||||
]));
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::warning("[PineconeAdapter] Upsert failed for artwork {$artworkId}: HTTP {$response->status()}");
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("[PineconeAdapter] Upsert exception for artwork {$artworkId}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteEmbedding(int $artworkId): void
|
||||
{
|
||||
$vectorId = "artwork:{$artworkId}";
|
||||
|
||||
try {
|
||||
Http::withHeaders([
|
||||
'Api-Key' => $this->apiKey(),
|
||||
'Content-Type' => 'application/json',
|
||||
])->timeout(10)->post("{$this->host()}/vectors/delete", array_filter([
|
||||
'ids' => [$vectorId],
|
||||
'namespace' => $this->namespace() ?: null,
|
||||
]));
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("[PineconeAdapter] Delete exception for artwork {$artworkId}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Recommendations\VectorSimilarity;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Factory to resolve the configured VectorAdapterInterface implementation.
|
||||
*/
|
||||
final class VectorAdapterFactory
|
||||
{
|
||||
/**
|
||||
* @return VectorAdapterInterface|null null when vector similarity is disabled
|
||||
*/
|
||||
public static function make(): ?VectorAdapterInterface
|
||||
{
|
||||
if (! (bool) config('recommendations.similarity.vector_enabled', false)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$adapter = (string) config('recommendations.similarity.vector_adapter', 'pgvector');
|
||||
|
||||
return match ($adapter) {
|
||||
'pgvector' => new PgvectorAdapter(),
|
||||
'pinecone' => new PineconeAdapter(),
|
||||
default => self::fallback($adapter),
|
||||
};
|
||||
}
|
||||
|
||||
private static function fallback(string $adapter): PgvectorAdapter
|
||||
{
|
||||
Log::warning("[VectorAdapterFactory] Unknown adapter '{$adapter}', falling back to pgvector.");
|
||||
return new PgvectorAdapter();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Recommendations\VectorSimilarity;
|
||||
|
||||
/**
|
||||
* Contract for vector-similarity adapters (pgvector, Pinecone, etc.).
|
||||
*
|
||||
* Each adapter can query nearest-neighbor artworks for a given artwork ID
|
||||
* and return an ordered list of (artwork_id, score) pairs.
|
||||
*/
|
||||
interface VectorAdapterInterface
|
||||
{
|
||||
/**
|
||||
* Find the most visually similar artworks.
|
||||
*
|
||||
* @param int $artworkId Source artwork
|
||||
* @param int $topK Max neighbors to return
|
||||
* @return list<array{artwork_id: int, score: float}> Ordered by score descending
|
||||
*/
|
||||
public function querySimilar(int $artworkId, int $topK = 100): array;
|
||||
|
||||
/**
|
||||
* Upsert an artwork embedding into the vector store.
|
||||
*
|
||||
* @param int $artworkId
|
||||
* @param array $embedding Raw float vector
|
||||
* @param array $metadata Optional metadata (category, author, etc.)
|
||||
*/
|
||||
public function upsertEmbedding(int $artworkId, array $embedding, array $metadata = []): void;
|
||||
|
||||
/**
|
||||
* Delete an artwork embedding from the vector store.
|
||||
*/
|
||||
public function deleteEmbedding(int $artworkId): void;
|
||||
}
|
||||
Reference in New Issue
Block a user