Save workspace changes
This commit is contained in:
@@ -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),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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