Studio: make grid checkbox rectangular and commit table changes
This commit is contained in:
180
app/Services/Recommendations/HybridSimilarArtworksService.php
Normal file
180
app/Services/Recommendations/HybridSimilarArtworksService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user