Files
SkinbaseNova/app/Services/Recommendations/HybridSimilarArtworksService.php

181 lines
5.7 KiB
PHP

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