181 lines
5.7 KiB
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();
|
|
}
|
|
}
|