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