Studio: make grid checkbox rectangular and commit table changes

This commit is contained in:
2026-03-01 08:43:48 +01:00
parent 211dc58884
commit e3ca845a6d
89 changed files with 7323 additions and 475 deletions

View 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();
}
}

View File

@@ -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();
}
}

View File

@@ -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()}");
}
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}