Studio: make grid checkbox rectangular and commit table changes
This commit is contained in:
@@ -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()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user