Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Services\Recommendations;
use Illuminate\Support\Facades\DB;
final class FeedOfflineEvaluationService
{
/**
* @return array<string, mixed>
*/
public function evaluateAlgo(string $algoVersion, string $from, string $to): array
{
$row = DB::table('feed_daily_metrics')
->selectRaw('SUM(impressions) as impressions')
->selectRaw('SUM(clicks) as clicks')
->selectRaw('SUM(saves) as saves')
->selectRaw('SUM(dwell_0_5) as dwell_0_5')
->selectRaw('SUM(dwell_5_30) as dwell_5_30')
->selectRaw('SUM(dwell_30_120) as dwell_30_120')
->selectRaw('SUM(dwell_120_plus) as dwell_120_plus')
->where('algo_version', $algoVersion)
->whereBetween('metric_date', [$from, $to])
->first();
$impressions = (int) ($row->impressions ?? 0);
$clicks = (int) ($row->clicks ?? 0);
$saves = (int) ($row->saves ?? 0);
$dwell05 = (int) ($row->dwell_0_5 ?? 0);
$dwell530 = (int) ($row->dwell_5_30 ?? 0);
$dwell30120 = (int) ($row->dwell_30_120 ?? 0);
$dwell120Plus = (int) ($row->dwell_120_plus ?? 0);
$ctr = $impressions > 0 ? $clicks / $impressions : 0.0;
$saveRate = $clicks > 0 ? $saves / $clicks : 0.0;
$longDwellShare = $clicks > 0 ? ($dwell30120 + $dwell120Plus) / $clicks : 0.0;
$bounceRate = $clicks > 0 ? $dwell05 / $clicks : 0.0;
$objectiveWeights = (array) config('discovery.evaluation.objective_weights', []);
$wCtr = (float) ($objectiveWeights['ctr'] ?? 0.45);
$wSave = (float) ($objectiveWeights['save_rate'] ?? 0.35);
$wLong = (float) ($objectiveWeights['long_dwell_share'] ?? 0.25);
$wBouncePenalty = (float) ($objectiveWeights['bounce_rate_penalty'] ?? 0.15);
$saveRateInformational = (bool) config('discovery.evaluation.save_rate_informational', true);
if ($saveRateInformational) {
$wSave = 0.0;
}
$normalizationSum = $wCtr + $wSave + $wLong + $wBouncePenalty;
if ($normalizationSum > 0.0) {
$wCtr /= $normalizationSum;
$wSave /= $normalizationSum;
$wLong /= $normalizationSum;
$wBouncePenalty /= $normalizationSum;
}
$objectiveScore = ($wCtr * $ctr)
+ ($wSave * $saveRate)
+ ($wLong * $longDwellShare)
- ($wBouncePenalty * $bounceRate);
return [
'algo_version' => $algoVersion,
'from' => $from,
'to' => $to,
'impressions' => $impressions,
'clicks' => $clicks,
'saves' => $saves,
'ctr' => round($ctr, 6),
'save_rate' => round($saveRate, 6),
'long_dwell_share' => round($longDwellShare, 6),
'bounce_rate' => round($bounceRate, 6),
'dwell_buckets' => [
'0_5' => $dwell05,
'5_30' => $dwell530,
'30_120' => $dwell30120,
'120_plus' => $dwell120Plus,
],
'objective_score' => round($objectiveScore, 6),
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function evaluateAll(string $from, string $to): array
{
$algoVersions = DB::table('feed_daily_metrics')
->select('algo_version')
->whereBetween('metric_date', [$from, $to])
->distinct()
->orderBy('algo_version')
->pluck('algo_version')
->map(static fn (mixed $v): string => (string) $v)
->all();
$out = [];
foreach ($algoVersions as $algoVersion) {
$out[] = $this->evaluateAlgo($algoVersion, $from, $to);
}
usort($out, static fn (array $a, array $b): int => $b['objective_score'] <=> $a['objective_score']);
return $out;
}
/**
* @return array<string, mixed>
*/
public function compareBaselineCandidate(string $baselineAlgoVersion, string $candidateAlgoVersion, string $from, string $to): array
{
$baseline = $this->evaluateAlgo($baselineAlgoVersion, $from, $to);
$candidate = $this->evaluateAlgo($candidateAlgoVersion, $from, $to);
$deltaObjective = (float) $candidate['objective_score'] - (float) $baseline['objective_score'];
$objectiveLiftPct = (float) $baseline['objective_score'] !== 0.0
? ($deltaObjective / (float) $baseline['objective_score']) * 100.0
: null;
return [
'from' => $from,
'to' => $to,
'baseline' => $baseline,
'candidate' => $candidate,
'delta' => [
'objective_score' => round($deltaObjective, 6),
'objective_lift_pct' => $objectiveLiftPct !== null ? round($objectiveLiftPct, 4) : null,
'ctr' => round((float) $candidate['ctr'] - (float) $baseline['ctr'], 6),
'save_rate' => round((float) $candidate['save_rate'] - (float) $baseline['save_rate'], 6),
'long_dwell_share' => round((float) $candidate['long_dwell_share'] - (float) $baseline['long_dwell_share'], 6),
'bounce_rate' => round((float) $candidate['bounce_rate'] - (float) $baseline['bounce_rate'], 6),
],
];
}
}

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,664 @@
<?php
declare(strict_types=1);
namespace App\Services\Recommendations;
use App\Jobs\RegenerateUserRecommendationCacheJob;
use App\Models\Artwork;
use App\Models\UserInterestProfile;
use App\Models\UserRecommendationCache;
use App\Support\AvatarUrl;
use Carbon\CarbonImmutable;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
final class PersonalizedFeedService
{
public function getFeed(int $userId, int $limit = 24, ?string $cursor = null, ?string $algoVersion = null): array
{
$safeLimit = max(1, min(50, $limit));
$resolvedAlgoVersion = $this->resolveAlgoVersion($algoVersion, $userId);
$weightSet = $this->resolveRankingWeights($resolvedAlgoVersion);
$offset = $this->decodeCursorToOffset($cursor);
$cache = UserRecommendationCache::query()
->where('user_id', $userId)
->where('algo_version', $resolvedAlgoVersion)
->first();
$cacheItems = $this->extractCacheItems($cache);
$isFresh = $cache !== null && $cache->expires_at !== null && $cache->expires_at->isFuture();
$cacheStatus = 'hit';
if ($cache === null) {
$cacheStatus = 'miss';
} elseif (! $isFresh) {
$cacheStatus = 'stale';
}
if ($cache === null || ! $isFresh) {
RegenerateUserRecommendationCacheJob::dispatch($userId, $resolvedAlgoVersion)
->onQueue((string) config('discovery.queue', 'default'));
}
$items = $cacheItems;
if ($items === []) {
$items = $this->buildColdStartRecommendations($resolvedAlgoVersion, 240, 'fallback');
$cacheStatus = $cacheStatus . '-fallback';
}
return $this->buildFeedPageResponse(
items: $items,
offset: $offset,
limit: $safeLimit,
algoVersion: $resolvedAlgoVersion,
weightVersion: (string) $weightSet['version'],
cacheStatus: $cacheStatus,
generatedAt: $cache?->generated_at?->toIso8601String()
);
}
public function regenerateCacheForUser(int $userId, ?string $algoVersion = null): void
{
$resolvedAlgoVersion = $this->resolveAlgoVersion($algoVersion, $userId);
$cacheVersion = (string) config('discovery.cache_version', 'cache-v1');
$ttlMinutes = max(1, (int) config('discovery.cache_ttl_minutes', 60));
$items = $this->buildRecommendations($userId, $resolvedAlgoVersion, 240);
$generatedAt = now();
$expiresAt = now()->addMinutes($ttlMinutes);
UserRecommendationCache::query()->updateOrCreate(
[
'user_id' => $userId,
'algo_version' => $resolvedAlgoVersion,
],
[
'cache_version' => $cacheVersion,
'recommendations_json' => [
'items' => $items,
'algo_version' => $resolvedAlgoVersion,
'weight_version' => (string) $this->resolveRankingWeights($resolvedAlgoVersion)['version'],
'generated_at' => $generatedAt->toIso8601String(),
],
'generated_at' => $generatedAt,
'expires_at' => $expiresAt,
]
);
}
/**
* @return array<int, array{artwork_id:int,score:float,source:string}>
*/
public function buildRecommendations(int $userId, string $algoVersion, int $maxItems = 240): array
{
$profileVersion = (string) config('discovery.profile_version', 'profile-v1');
$profile = UserInterestProfile::query()
->where('user_id', $userId)
->where('profile_version', $profileVersion)
->where('algo_version', $algoVersion)
->first();
$normalized = $profile !== null ? (array) ($profile->normalized_scores_json ?? []) : [];
$personalized = $this->buildProfileBasedRecommendations($normalized, $maxItems, $algoVersion);
if ($personalized === []) {
return $this->buildColdStartRecommendations($algoVersion, $maxItems, 'cold_start');
}
$fallback = $this->buildColdStartRecommendations($algoVersion, $maxItems, 'fallback');
$combined = [];
foreach (array_merge($personalized, $fallback) as $item) {
$artworkId = (int) ($item['artwork_id'] ?? 0);
if ($artworkId <= 0) {
continue;
}
if (! isset($combined[$artworkId])) {
$combined[$artworkId] = [
'artwork_id' => $artworkId,
'score' => (float) ($item['score'] ?? 0.0),
'source' => (string) ($item['source'] ?? 'mixed'),
];
continue;
}
if ((float) $item['score'] > (float) $combined[$artworkId]['score']) {
$combined[$artworkId]['score'] = (float) $item['score'];
$combined[$artworkId]['source'] = (string) ($item['source'] ?? $combined[$artworkId]['source']);
}
}
$candidates = array_values($combined);
usort($candidates, static fn (array $a, array $b): int => $b['score'] <=> $a['score']);
return $this->applyDiversityGuard($candidates, $algoVersion, $maxItems);
}
/**
* @param array<string, mixed> $normalizedScores
* @return array<int, array{artwork_id:int,score:float,source:string}>
*/
private function buildProfileBasedRecommendations(array $normalizedScores, int $maxItems, string $algoVersion): array
{
$weightSet = $this->resolveRankingWeights($algoVersion);
$w1 = (float) $weightSet['w1'];
$w2 = (float) $weightSet['w2'];
$w3 = (float) $weightSet['w3'];
$w4 = (float) $weightSet['w4'];
$categoryAffinities = [];
foreach ($normalizedScores as $key => $score) {
if (! is_numeric($score)) {
continue;
}
if (! str_starts_with((string) $key, 'category:')) {
continue;
}
$categoryId = (int) str_replace('category:', '', (string) $key);
if ($categoryId <= 0) {
continue;
}
$categoryAffinities[$categoryId] = (float) $score;
}
if ($categoryAffinities === []) {
return [];
}
$rows = DB::table('artworks')
->join('artwork_category', 'artwork_category.artwork_id', '=', 'artworks.id')
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->whereIn('artwork_category.category_id', array_keys($categoryAffinities))
->whereNull('artworks.deleted_at')
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNotNull('artworks.published_at')
->where('artworks.published_at', '<=', now())
->orderByDesc('artworks.published_at')
->limit(max(200, $maxItems * 8))
->get([
'artworks.id',
'artworks.published_at',
'artwork_category.category_id',
DB::raw('COALESCE(artwork_stats.views, 0) as views'),
]);
$scored = [];
foreach ($rows as $row) {
$artworkId = (int) $row->id;
$categoryId = (int) $row->category_id;
$affinity = (float) ($categoryAffinities[$categoryId] ?? 0.0);
if ($affinity <= 0.0) {
continue;
}
$publishedAt = CarbonImmutable::parse((string) $row->published_at);
$ageDays = max(0.0, (float) $publishedAt->diffInSeconds(now()) / 86400);
$recency = exp(-$ageDays / 30.0);
$popularity = log(1 + max(0, (int) $row->views)) / 10.0;
$novelty = max(0.0, 1.0 - min(1.0, $popularity));
// Phase 8B blend with versioned weights (manual tuning, no auto-tuning yet).
$score = ($w1 * $affinity) + ($w2 * $recency) + ($w3 * $popularity) + ($w4 * $novelty);
if (! isset($scored[$artworkId]) || $score > $scored[$artworkId]['score']) {
$scored[$artworkId] = [
'artwork_id' => $artworkId,
'score' => $score,
'source' => 'personalized',
];
}
}
$candidates = array_values($scored);
usort($candidates, static fn (array $a, array $b): int => $b['score'] <=> $a['score']);
return $this->applyDiversityGuard($candidates, $algoVersion, $maxItems);
}
/**
* @return array<int, array{artwork_id:int,score:float,source:string}>
*/
private function buildColdStartRecommendations(string $algoVersion, int $maxItems, string $sourceLabel = 'cold_start'): array
{
$popularIds = DB::table('artworks')
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->whereNull('artworks.deleted_at')
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNotNull('artworks.published_at')
->where('artworks.published_at', '<=', now())
->orderByDesc('artwork_stats.views')
->orderByDesc('artwork_stats.downloads')
->orderByDesc('artworks.published_at')
->limit(max(40, $maxItems))
->pluck('artworks.id')
->map(static fn (mixed $id): int => (int) $id)
->all();
$seedIds = array_slice($popularIds, 0, 12);
$similarIds = [];
if ($seedIds !== []) {
$similarIds = DB::table('artwork_similarities')
->where('algo_version', $algoVersion)
->whereIn('artwork_id', $seedIds)
->orderBy('rank')
->orderByDesc('score')
->limit(max(80, $maxItems * 2))
->pluck('similar_artwork_id')
->map(static fn (mixed $id): int => (int) $id)
->all();
}
$candidates = [];
foreach ($popularIds as $index => $artworkId) {
$candidates[] = [
'artwork_id' => $artworkId,
'score' => max(0.0, 1.0 - ($index * 0.003)),
'source' => $sourceLabel,
];
}
foreach ($similarIds as $index => $artworkId) {
$candidates[] = [
'artwork_id' => $artworkId,
'score' => max(0.0, 0.75 - ($index * 0.002)),
'source' => $sourceLabel,
];
}
usort($candidates, static fn (array $a, array $b): int => $b['score'] <=> $a['score']);
return $this->applyDiversityGuard($candidates, $algoVersion, $maxItems);
}
/**
* @param array<int, array{artwork_id:int,score:float,source:string}> $candidates
* @return array<int, array{artwork_id:int,score:float,source:string}>
*/
private function applyDiversityGuard(array $candidates, string $algoVersion, int $maxItems): array
{
if ($candidates === []) {
return [];
}
$uniqueCandidates = [];
foreach ($candidates as $candidate) {
$artworkId = (int) ($candidate['artwork_id'] ?? 0);
if ($artworkId <= 0 || isset($uniqueCandidates[$artworkId])) {
continue;
}
$uniqueCandidates[$artworkId] = [
'artwork_id' => $artworkId,
'score' => (float) ($candidate['score'] ?? 0.0),
'source' => (string) ($candidate['source'] ?? 'mixed'),
];
}
$flattened = array_values($uniqueCandidates);
$candidateIds = array_map(static fn (array $item): int => (int) $item['artwork_id'], $flattened);
$nearDuplicatePairs = DB::table('artwork_similarities')
->where('algo_version', $algoVersion)
->where('score', '>=', 0.97)
->whereIn('artwork_id', $candidateIds)
->whereIn('similar_artwork_id', $candidateIds)
->get(['artwork_id', 'similar_artwork_id']);
$adjacency = [];
foreach ($nearDuplicatePairs as $pair) {
$left = (int) $pair->artwork_id;
$right = (int) $pair->similar_artwork_id;
if ($left === $right) {
continue;
}
$adjacency[$left][$right] = true;
$adjacency[$right][$left] = true;
}
$selected = [];
$selectedSet = [];
foreach ($flattened as $candidate) {
$id = (int) $candidate['artwork_id'];
$isNearDuplicate = false;
foreach ($selectedSet as $selectedId => $value) {
if (($adjacency[$id][$selectedId] ?? false) || ($adjacency[$selectedId][$id] ?? false)) {
$isNearDuplicate = true;
break;
}
}
if ($isNearDuplicate) {
continue;
}
$selected[] = [
'artwork_id' => $id,
'score' => round((float) $candidate['score'], 6),
'source' => (string) $candidate['source'],
];
$selectedSet[$id] = true;
if (count($selected) >= $maxItems) {
break;
}
}
return $selected;
}
/**
* @param array<int, array{artwork_id:int,score:float,source:string}> $items
*/
private function buildFeedPageResponse(
array $items,
int $offset,
int $limit,
string $algoVersion,
string $weightVersion,
string $cacheStatus,
?string $generatedAt
): array {
$safeOffset = max(0, $offset);
$pageItems = array_slice($items, $safeOffset, $limit);
$ids = array_values(array_unique(array_map(
static fn (array $item): int => (int) ($item['artwork_id'] ?? 0),
$pageItems
)));
/** @var Collection<int, Artwork> $artworks */
$artworks = Artwork::query()
->with([
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,headline,avatar_path,followers_count',
'categories:id,name,slug,content_type_id,parent_id,sort_order',
'categories.contentType:id,name,slug',
'tags:id,name,slug',
])
->whereIn('id', $ids)
->public()
->published()
->get()
->keyBy('id');
$responseItems = [];
foreach ($pageItems as $item) {
$artworkId = (int) ($item['artwork_id'] ?? 0);
$artwork = $artworks->get($artworkId);
if ($artwork === null) {
continue;
}
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
$primaryTag = $artwork->tags->sortBy('name')->first();
$source = (string) ($item['source'] ?? 'mixed');
$publisher = $this->mapPublisherPayload($artwork);
$isGroupPublisher = ($publisher['type'] ?? null) === 'group';
$responseItems[] = [
'id' => $artwork->id,
'slug' => $artwork->slug,
'title' => $artwork->title,
'thumbnail_url' => $artwork->thumb_url,
'thumbnail_srcset' => $artwork->thumb_srcset,
'author' => $isGroupPublisher ? ($publisher['name'] ?? 'Skinbase Group') : $artwork->user?->name,
'username' => $isGroupPublisher ? null : $artwork->user?->username,
'author_id' => $artwork->user?->id,
'avatar_url' => $isGroupPublisher
? ($publisher['avatar_url'] ?? null)
: AvatarUrl::forUser(
(int) ($artwork->user?->id ?? 0),
$artwork->user?->profile?->avatar_hash,
64
),
'published_as_type' => $artwork->publishedAsType(),
'publisher' => $publisher,
'content_type_name' => $primaryCategory?->contentType?->name ?? '',
'content_type_slug' => $primaryCategory?->contentType?->slug ?? '',
'category_name' => $primaryCategory?->name ?? '',
'category_slug' => $primaryCategory?->slug ?? '',
'width' => $artwork->width,
'height' => $artwork->height,
'published_at' => $artwork->published_at?->toIso8601String(),
'url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]),
'primary_tag' => $primaryTag !== null ? [
'id' => (int) $primaryTag->id,
'name' => (string) $primaryTag->name,
'slug' => (string) $primaryTag->slug,
] : null,
'tags' => $artwork->tags
->sortBy('name')
->take(3)
->map(static fn ($tag): array => [
'id' => (int) $tag->id,
'name' => (string) $tag->name,
'slug' => (string) $tag->slug,
])
->values()
->all(),
'score' => (float) ($item['score'] ?? 0.0),
'source' => $source,
'reason' => $this->buildRecommendationReason($artwork, $source),
'algo_version' => $algoVersion,
];
}
$nextOffset = $safeOffset + $limit;
$hasNext = $nextOffset < count($items);
return [
'data' => $responseItems,
'meta' => [
'algo_version' => $algoVersion,
'weight_version' => $weightVersion,
'cursor' => $this->encodeOffsetToCursor($safeOffset),
'next_cursor' => $hasNext ? $this->encodeOffsetToCursor($nextOffset) : null,
'limit' => $limit,
'cache_status' => $cacheStatus,
'generated_at' => $generatedAt,
'total_candidates' => count($items),
'engine' => 'v1',
],
];
}
private function buildRecommendationReason(Artwork $artwork, string $source): string
{
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
$categoryName = trim((string) ($primaryCategory?->name ?? ''));
return match ($source) {
'personalized' => $categoryName !== ''
? 'Matched to your interest in ' . $categoryName
: 'Matched to your recent interests',
'cold_start' => $categoryName !== ''
? 'Popular in ' . $categoryName . ' right now'
: 'Popular with the community right now',
'fallback' => $categoryName !== ''
? 'Trending in ' . $categoryName
: 'Trending across Skinbase',
default => 'Picked for you',
};
}
/**
* @return array<string, mixed>|null
*/
private function mapPublisherPayload(Artwork $artwork): ?array
{
if ($artwork->publishedAsType() !== Artwork::PUBLISHED_AS_GROUP) {
return null;
}
$group = $artwork->group;
if (! $group) {
return null;
}
return [
'id' => (int) $group->id,
'type' => 'group',
'name' => (string) $group->name,
'slug' => (string) $group->slug,
'headline' => (string) ($group->headline ?? ''),
'avatar_url' => $group->avatarUrl(),
'profile_url' => $group->publicUrl(),
'followers_count' => (int) ($group->followers_count ?? 0),
];
}
private function resolveAlgoVersion(?string $algoVersion = null, ?int $userId = null): string
{
if ($algoVersion !== null && $algoVersion !== '') {
return $algoVersion;
}
$forcedAlgoVersion = trim((string) config('discovery.rollout.force_algo_version', ''));
if ($forcedAlgoVersion !== '') {
return $forcedAlgoVersion;
}
$defaultAlgoVersion = (string) config('discovery.algo_version', 'clip-cosine-v1');
$rolloutEnabled = (bool) config('discovery.rollout.enabled', false);
if (! $rolloutEnabled || $userId === null || $userId <= 0) {
return $defaultAlgoVersion;
}
$baselineAlgoVersion = (string) config('discovery.rollout.baseline_algo_version', $defaultAlgoVersion);
$candidateAlgoVersion = (string) config('discovery.rollout.candidate_algo_version', $defaultAlgoVersion);
if ($candidateAlgoVersion === '' || $candidateAlgoVersion === $baselineAlgoVersion) {
return $baselineAlgoVersion;
}
$activeGate = (string) config('discovery.rollout.active_gate', 'g10');
$gates = (array) config('discovery.rollout.gates', []);
$gate = (array) ($gates[$activeGate] ?? []);
$rolloutPercentage = (int) ($gate['percentage'] ?? 0);
$rolloutPercentage = max(0, min(100, $rolloutPercentage));
if ($rolloutPercentage <= 0) {
return $baselineAlgoVersion;
}
if ($rolloutPercentage >= 100) {
return $candidateAlgoVersion;
}
$bucket = abs((int) crc32((string) $userId)) % 100;
return $bucket < $rolloutPercentage
? $candidateAlgoVersion
: $baselineAlgoVersion;
}
/**
* @return array{version:string,w1:float,w2:float,w3:float,w4:float}
*/
public function resolveRankingWeights(string $algoVersion): array
{
$defaults = (array) config('discovery.ranking.default_weights', []);
$byAlgo = (array) config('discovery.ranking.algo_weight_sets', []);
$override = (array) ($byAlgo[$algoVersion] ?? []);
$resolved = array_merge($defaults, $override);
$weights = [
'version' => (string) ($resolved['version'] ?? 'rank-w-v1'),
'w1' => max(0.0, (float) ($resolved['w1'] ?? 0.65)),
'w2' => max(0.0, (float) ($resolved['w2'] ?? 0.20)),
'w3' => max(0.0, (float) ($resolved['w3'] ?? 0.10)),
'w4' => max(0.0, (float) ($resolved['w4'] ?? 0.05)),
];
$sum = $weights['w1'] + $weights['w2'] + $weights['w3'] + $weights['w4'];
if ($sum > 0.0) {
$weights['w1'] /= $sum;
$weights['w2'] /= $sum;
$weights['w3'] /= $sum;
$weights['w4'] /= $sum;
}
return $weights;
}
private function decodeCursorToOffset(?string $cursor): int
{
if ($cursor === null || $cursor === '') {
return 0;
}
$decoded = base64_decode(strtr($cursor, '-_', '+/'), true);
if ($decoded === false) {
return 0;
}
$json = json_decode($decoded, true);
if (! is_array($json)) {
return 0;
}
return max(0, (int) Arr::get($json, 'offset', 0));
}
private function encodeOffsetToCursor(int $offset): string
{
$payload = json_encode(['offset' => max(0, $offset)]);
if (! is_string($payload)) {
return '';
}
return rtrim(strtr(base64_encode($payload), '+/', '-_'), '=');
}
/**
* @return array<int, array{artwork_id:int,score:float,source:string}>
*/
private function extractCacheItems(?UserRecommendationCache $cache): array
{
if ($cache === null) {
return [];
}
$raw = (array) ($cache->recommendations_json ?? []);
$items = $raw['items'] ?? null;
if (! is_array($items)) {
return [];
}
$typed = [];
foreach ($items as $item) {
if (! is_array($item)) {
continue;
}
$artworkId = (int) ($item['artwork_id'] ?? 0);
if ($artworkId <= 0) {
continue;
}
$typed[] = [
'artwork_id' => $artworkId,
'score' => (float) ($item['score'] ?? 0.0),
'source' => (string) ($item['source'] ?? 'mixed'),
];
}
return $typed;
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Services\Recommendations;
final class RecommendationFeedResolver
{
public function __construct(
private readonly PersonalizedFeedService $personalizedFeed,
private readonly \App\Services\Recommendations\RecommendationServiceV2 $v2Feed,
) {
}
public function getFeed(int $userId, int $limit = 24, ?string $cursor = null, ?string $algoVersion = null): array
{
if ($this->shouldUseV2($userId, $algoVersion)) {
return $this->v2Feed->getFeed($userId, $limit, $cursor, $algoVersion);
}
return $this->personalizedFeed->getFeed($userId, $limit, $cursor, $algoVersion);
}
public function regenerateCacheForUser(int $userId, ?string $algoVersion = null): void
{
if ($this->shouldUseV2($userId, $algoVersion)) {
$this->v2Feed->regenerateCacheForUser($userId, $algoVersion);
return;
}
$this->personalizedFeed->regenerateCacheForUser($userId, $algoVersion);
}
/**
* @return array<string, mixed>
*/
public function inspectDecision(int $userId, ?string $algoVersion = null): array
{
$requested = trim((string) ($algoVersion ?? ''));
$v2AlgoVersion = trim((string) config('discovery.v2.algo_version', 'clip-cosine-v2-adaptive'));
$v2Enabled = (bool) config('discovery.v2.enabled', false);
$rollout = max(0, min(100, (int) config('discovery.v2.rollout_percentage', 0)));
$bucket = abs((int) crc32((string) $userId)) % 100;
$forcedByAlgoVersion = $requested !== '' && $requested === $v2AlgoVersion;
$usesV2 = $forcedByAlgoVersion
|| ($v2Enabled && ($rollout >= 100 || ($rollout > 0 && $bucket < $rollout)));
$reason = match (true) {
$forcedByAlgoVersion => 'explicit_algo_override',
! $v2Enabled => 'v2_disabled',
$rollout >= 100 => 'full_rollout',
$rollout <= 0 => 'rollout_zero',
$bucket < $rollout => 'bucket_in_rollout',
default => 'bucket_outside_rollout',
};
return [
'user_id' => $userId,
'requested_algo_version' => $requested !== '' ? $requested : null,
'v2_algo_version' => $v2AlgoVersion,
'v2_enabled' => $v2Enabled,
'rollout_percentage' => $rollout,
'bucket' => $bucket,
'bucket_in_rollout' => $bucket < $rollout,
'forced_by_algo_version' => $forcedByAlgoVersion,
'uses_v2' => $usesV2,
'selected_engine' => $usesV2 ? 'v2' : 'v1',
'reason' => $reason,
];
}
private function shouldUseV2(int $userId, ?string $algoVersion = null): bool
{
return (bool) ($this->inspectDecision($userId, $algoVersion)['uses_v2'] ?? false);
}
}

View File

@@ -0,0 +1,278 @@
<?php
declare(strict_types=1);
namespace App\Services\Recommendations;
use App\Models\UserInterestProfile;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
final class SessionRecoService
{
/**
* @param array<string, mixed> $meta
*/
public function applyEvent(
int $userId,
string $eventType,
int $artworkId,
?int $categoryId,
string $occurredAt,
array $meta = []
): void {
if ($userId <= 0 || $artworkId <= 0) {
return;
}
$state = $this->readState($userId);
$weights = (array) config('discovery.v2.session.event_weights', []);
$fallbackWeights = (array) config('discovery.weights', []);
$eventWeight = (float) ($weights[$eventType] ?? $fallbackWeights[$eventType] ?? 1.0);
$timestamp = strtotime($occurredAt) ?: time();
$this->upsertSignal($state['signals'], 'artwork:' . $artworkId, $eventWeight, $timestamp);
if ($categoryId !== null && $categoryId > 0) {
$this->upsertSignal($state['signals'], 'category:' . $categoryId, $eventWeight, $timestamp);
}
foreach ($this->tagSlugsForArtwork($artworkId) as $tagSlug) {
$this->upsertSignal($state['signals'], 'tag:' . $tagSlug, $eventWeight, $timestamp);
}
$creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id');
if ($creatorId > 0) {
$this->upsertSignal($state['signals'], 'creator:' . $creatorId, $eventWeight, $timestamp);
}
$state['recent_artwork_ids'] = $this->prependUnique($state['recent_artwork_ids'], $artworkId);
if ($creatorId > 0) {
$state['recent_creator_ids'] = $this->prependUnique($state['recent_creator_ids'], $creatorId);
}
foreach ($this->tagSlugsForArtwork($artworkId) as $tagSlug) {
$state['recent_tag_slugs'] = $this->prependUnique($state['recent_tag_slugs'], $tagSlug);
}
if (in_array($eventType, ['view', 'click', 'favorite', 'download', 'dwell', 'scroll'], true)) {
$state['seen_artwork_ids'] = $this->prependUnique($state['seen_artwork_ids'], $artworkId, 200);
}
$state['updated_at'] = $timestamp;
$this->writeState($userId, $state);
}
/**
* @return array{
* merged_scores: array<string, float>,
* session_scores: array<string, float>,
* long_term_scores: array<string, float>,
* recent_artwork_ids: array<int, int>,
* recent_creator_ids: array<int, int>,
* recent_tag_slugs: array<int, string>,
* seen_artwork_ids: array<int, int>
* }
*/
public function mergedProfile(int $userId, string $algoVersion): array
{
$profileVersion = (string) config('discovery.profile_version', 'profile-v1');
$profile = UserInterestProfile::query()
->where('user_id', $userId)
->where('profile_version', $profileVersion)
->where('algo_version', $algoVersion)
->first();
$longTermScores = $this->normalizeScores((array) ($profile?->normalized_scores_json ?? []));
$state = $this->readState($userId);
$sessionScores = $this->materializeSessionScores($state['signals']);
$multiplier = max(0.0, (float) config('discovery.v2.session.merge_multiplier', 1.35));
$merged = $longTermScores;
foreach ($sessionScores as $key => $score) {
$merged[$key] = (float) ($merged[$key] ?? 0.0) + ($score * $multiplier);
}
return [
'merged_scores' => $this->normalizeScores($merged),
'session_scores' => $sessionScores,
'long_term_scores' => $longTermScores,
'recent_artwork_ids' => array_values(array_map('intval', $state['recent_artwork_ids'])),
'recent_creator_ids' => array_values(array_map('intval', $state['recent_creator_ids'])),
'recent_tag_slugs' => array_values(array_map('strval', $state['recent_tag_slugs'])),
'seen_artwork_ids' => array_values(array_map('intval', $state['seen_artwork_ids'])),
];
}
/**
* @return array<int, int>
*/
public function seenArtworkIds(int $userId): array
{
$state = $this->readState($userId);
return array_values(array_map('intval', $state['seen_artwork_ids']));
}
/**
* @return array<string, mixed>
*/
private function readState(int $userId): array
{
$key = $this->redisKey($userId);
try {
$raw = Redis::get($key);
} catch (\Throwable $e) {
Log::warning('SessionRecoService read failed', ['user_id' => $userId, 'error' => $e->getMessage()]);
return $this->emptyState();
}
if (! is_string($raw) || $raw === '') {
return $this->emptyState();
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? array_merge($this->emptyState(), $decoded) : $this->emptyState();
}
/**
* @param array<string, mixed> $state
*/
private function writeState(int $userId, array $state): void
{
$key = $this->redisKey($userId);
$ttlSeconds = max(60, (int) config('discovery.v2.session.ttl_seconds', 14400));
try {
Redis::setex($key, $ttlSeconds, (string) json_encode($state, JSON_UNESCAPED_SLASHES));
} catch (\Throwable $e) {
Log::warning('SessionRecoService write failed', ['user_id' => $userId, 'error' => $e->getMessage()]);
}
}
/**
* @param array<string, mixed> $signals
* @return array<string, float>
*/
private function materializeSessionScores(array $signals): array
{
$halfLifeHours = max(0.1, (float) config('discovery.v2.session.half_life_hours', 8));
$now = time();
$scores = [];
foreach ($signals as $key => $signal) {
if (! is_array($signal)) {
continue;
}
$score = (float) Arr::get($signal, 'score', 0.0);
$updatedAt = (int) Arr::get($signal, 'updated_at', $now);
$hoursElapsed = max(0.0, ($now - $updatedAt) / 3600);
$decay = exp(-log(2) * ($hoursElapsed / $halfLifeHours));
$decayedScore = $score * $decay;
if ($decayedScore > 0.000001) {
$scores[(string) $key] = $decayedScore;
}
}
return $this->normalizeScores($scores);
}
/**
* @param array<string, mixed> $signals
*/
private function upsertSignal(array &$signals, string $key, float $weight, int $timestamp): void
{
$maxItems = max(20, (int) config('discovery.v2.session.max_items', 120));
$current = (array) ($signals[$key] ?? []);
$signals[$key] = [
'score' => (float) ($current['score'] ?? 0.0) + $weight,
'updated_at' => $timestamp,
];
if (count($signals) <= $maxItems) {
return;
}
uasort($signals, static fn (array $left, array $right): int => ((int) ($right['updated_at'] ?? 0)) <=> ((int) ($left['updated_at'] ?? 0)));
$signals = array_slice($signals, 0, $maxItems, true);
}
/**
* @param array<int, int|string> $items
* @return array<int, int|string>
*/
private function prependUnique(array $items, int|string $value, int $maxItems = 40): array
{
$items = array_values(array_filter($items, static fn (mixed $item): bool => (string) $item !== (string) $value));
array_unshift($items, $value);
return array_slice($items, 0, $maxItems);
}
/**
* @return array<string, mixed>
*/
private function emptyState(): array
{
return [
'signals' => [],
'recent_artwork_ids' => [],
'recent_creator_ids' => [],
'recent_tag_slugs' => [],
'seen_artwork_ids' => [],
'updated_at' => null,
];
}
private function redisKey(int $userId): string
{
return 'session_reco:' . $userId;
}
/**
* @param array<string, mixed> $scores
* @return array<string, float>
*/
private function normalizeScores(array $scores): array
{
$typed = [];
foreach ($scores as $key => $score) {
if (is_numeric($score) && (float) $score > 0.0) {
$typed[(string) $key] = (float) $score;
}
}
$sum = array_sum($typed);
if ($sum <= 0.0) {
return [];
}
foreach ($typed as $key => $score) {
$typed[$key] = $score / $sum;
}
return $typed;
}
/**
* @return array<int, string>
*/
private function tagSlugsForArtwork(int $artworkId): array
{
return DB::table('artwork_tag')
->join('tags', 'tags.id', '=', 'artwork_tag.tag_id')
->where('artwork_tag.artwork_id', $artworkId)
->pluck('tags.slug')
->map(static fn (mixed $slug): string => (string) $slug)
->values()
->all();
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Services\Recommendations;
use App\Models\Artwork;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
final class SimilarArtworksService
{
/**
* @return Collection<int, Artwork>
*/
public function forArtwork(int $artworkId, int $limit = 12, ?string $algoVersion = null): Collection
{
$effectiveAlgo = $algoVersion ?: (string) config('recommendations.embedding.algo_version', 'clip-cosine-v1');
$ids = DB::table('artwork_similarities')
->where('artwork_id', $artworkId)
->where('algo_version', $effectiveAlgo)
->orderBy('rank')
->limit(max(1, min($limit, 50)))
->pluck('similar_artwork_id')
->map(static fn ($id) => (int) $id)
->all();
if ($ids === []) {
return collect();
}
$artworks = Artwork::query()
->whereIn('id', $ids)
->public()
->published()
->get();
$byId = $artworks->keyBy('id');
return collect($ids)
->map(static fn (int $id) => $byId->get($id))
->filter();
}
}

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Services\Recommendations;
use App\Models\UserInterestProfile;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\DB;
final class UserInterestProfileService
{
/**
* @param array<string, mixed> $eventMeta
*/
public function applyEvent(
int $userId,
string $eventType,
int $artworkId,
?int $categoryId,
CarbonInterface $occurredAt,
string $eventId,
string $algoVersion,
array $eventMeta = []
): void {
$profileVersion = (string) config('discovery.profile_version', 'profile-v1');
$halfLifeHours = (float) config('discovery.decay.half_life_hours', 72);
$weightMap = (array) config('discovery.weights', []);
$eventWeight = (float) ($weightMap[$eventType] ?? 1.0);
DB::transaction(function () use (
$userId,
$categoryId,
$artworkId,
$occurredAt,
$eventId,
$algoVersion,
$profileVersion,
$halfLifeHours,
$eventWeight,
$eventMeta
): void {
$profile = UserInterestProfile::query()
->where('user_id', $userId)
->where('profile_version', $profileVersion)
->where('algo_version', $algoVersion)
->lockForUpdate()
->first();
$rawScores = $profile !== null ? (array) ($profile->raw_scores_json ?? []) : [];
$lastEventAt = $profile?->last_event_at;
if ($lastEventAt !== null && $occurredAt->greaterThan($lastEventAt)) {
$hours = max(0.0, (float) $lastEventAt->diffInSeconds($occurredAt) / 3600);
$rawScores = $this->applyRecencyDecay($rawScores, $hours, $halfLifeHours);
}
$interestKey = $categoryId !== null
? sprintf('category:%d', $categoryId)
: sprintf('artwork:%d', $artworkId);
$rawScores[$interestKey] = (float) ($rawScores[$interestKey] ?? 0.0) + $eventWeight;
$rawScores = array_filter(
$rawScores,
static fn (mixed $value): bool => is_numeric($value) && (float) $value > 0.000001
);
$normalizedScores = $this->normalizeScores($rawScores);
$totalWeight = array_sum($rawScores);
$payload = [
'user_id' => $userId,
'profile_version' => $profileVersion,
'algo_version' => $algoVersion,
'raw_scores_json' => $rawScores,
'normalized_scores_json' => $normalizedScores,
'total_weight' => $totalWeight,
'event_count' => $profile !== null ? ((int) $profile->event_count + 1) : 1,
'last_event_at' => $lastEventAt === null || $occurredAt->greaterThan($lastEventAt)
? $occurredAt
: $lastEventAt,
'half_life_hours' => $halfLifeHours,
'updated_from_event_id' => $eventId,
'updated_at' => now(),
];
if ($profile === null) {
$payload['created_at'] = now();
UserInterestProfile::query()->create($payload);
return;
}
$profile->fill($payload);
$profile->save();
}, 3);
}
/**
* @param array<string, mixed> $scores
* @return array<string, float>
*/
public function applyRecencyDecay(array $scores, float $hoursElapsed, float $halfLifeHours): array
{
if ($hoursElapsed <= 0 || $halfLifeHours <= 0) {
return $this->castToFloatScores($scores);
}
$decayFactor = exp(-log(2) * ($hoursElapsed / $halfLifeHours));
$output = [];
foreach ($scores as $key => $score) {
if (! is_numeric($score)) {
continue;
}
$decayed = (float) $score * $decayFactor;
if ($decayed > 0.000001) {
$output[(string) $key] = $decayed;
}
}
return $output;
}
/**
* @param array<string, mixed> $scores
* @return array<string, float>
*/
public function normalizeScores(array $scores): array
{
$typedScores = $this->castToFloatScores($scores);
$sum = array_sum($typedScores);
if ($sum <= 0.0) {
return [];
}
$normalized = [];
foreach ($typedScores as $key => $score) {
$normalized[$key] = $score / $sum;
}
return $normalized;
}
/**
* @param array<string, mixed> $scores
* @return array<string, float>
*/
private function castToFloatScores(array $scores): array
{
$output = [];
foreach ($scores as $key => $score) {
if (is_numeric($score) && (float) $score > 0.0) {
$output[(string) $key] = (float) $score;
}
}
return $output;
}
}

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