fix(gallery): fill tall portrait cards to full block width with object-cover crop

- ArtworkCard: add w-full to nova-card-media, use absolute inset-0 on img so
  object-cover fills the max-height capped box instead of collapsing the width
- MasonryGallery.css: add width:100% to media container, position img
  absolutely so top/bottom is cropped rather than leaving dark gaps
- Add React MasonryGallery + ArtworkCard components and entry point
- Add recommendation system: UserRecoProfile model/DTO/migration,
  SuggestedCreatorsController, SuggestedTagsController, Recommendation
  services, config/recommendations.php
- SimilarArtworksController, DiscoverController, HomepageService updates
- Update routes (api + web) and discover/for-you views
- Refresh favicon assets, update vite.config.js
This commit is contained in:
2026-02-27 13:34:08 +01:00
parent 09eadf9003
commit 67ef79766c
37 changed files with 3096 additions and 58 deletions

View File

@@ -7,6 +7,7 @@ namespace App\Services;
use App\Models\Artwork;
use App\Models\Tag;
use App\Services\ArtworkSearchService;
use App\Services\Recommendation\RecommendationService;
use App\Services\UserPreferenceService;
use App\Support\AvatarUrl;
use Illuminate\Support\Facades\Cache;
@@ -26,9 +27,10 @@ final class HomepageService
private const CACHE_TTL = 300; // 5 minutes
public function __construct(
private readonly ArtworkService $artworks,
private readonly ArtworkSearchService $search,
private readonly UserPreferenceService $prefs,
private readonly ArtworkService $artworks,
private readonly ArtworkSearchService $search,
private readonly UserPreferenceService $prefs,
private readonly RecommendationService $reco,
) {}
// ─────────────────────────────────────────────────────────────────────────
@@ -70,6 +72,7 @@ final class HomepageService
'is_logged_in' => true,
'user_data' => $this->getUserData($user),
'hero' => $this->getHeroArtwork(),
'for_you' => $this->getForYouPreview($user),
'from_following' => $this->getFollowingFeed($user, $prefs),
'trending' => $this->getTrending(),
'fresh' => $this->getFreshUploads(),
@@ -86,6 +89,22 @@ final class HomepageService
];
}
/**
* "For You" homepage preview: first 12 results from the Phase 1 personalised feed.
*
* Uses RecommendationService which handles Meilisearch retrieval, PHP reranking,
* diversity controls, and its own Redis cache layer.
*/
public function getForYouPreview(\App\Models\User $user, int $limit = 12): array
{
try {
return $this->reco->forYouPreview($user, $limit);
} catch (\Throwable $e) {
Log::warning('HomepageService::getForYouPreview failed', ['error' => $e->getMessage()]);
return [];
}
}
// ─────────────────────────────────────────────────────────────────────────
// Sections
// ─────────────────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,420 @@
<?php
declare(strict_types=1);
namespace App\Services\Recommendation;
use App\DTOs\UserRecoProfileDTO;
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Arr;
/**
* RecommendationService Phase 1 "For You" feed pipeline.
*
* Pipeline:
* 1. Load UserRecoProfileDTO (cached, built by UserPreferenceBuilder)
* 2. Generate 200 Meilisearch candidates filtered by user's top tags
* Exclude user's own artworks
* Exclude already-favourited artworks (PHP post-filter)
* 3. PHP reranking:
* score = (tag_overlap × w_tag_overlap)
* + (creator_affinity × w_creator_affinity)
* + (popularity_boost × w_popularity)
* + (freshness_boost × w_freshness)
* 4. Diversity controls:
* max N results per creator per page (config recommendations.max_per_creator)
* penalise repeated tag-pattern clusters
* 5. Cursor-based pagination, top 40 per page
* 6. Cold-start fallback (trending + fresh blend) when user has no signals
*
* Caching:
* key: for_you:{user_id}:{cursor_hash} TTL: config recommendations.ttl.for_you_feed
*/
final class RecommendationService
{
private const DEFAULT_PAGE_SIZE = 40;
private const COLD_START_LIMIT = 40;
public function __construct(
private readonly UserPreferenceBuilder $prefBuilder
) {}
// ─── Public API ───────────────────────────────────────────────────────────
/**
* Return a fully ranked, paginated "For You" feed for the given user.
*
* @return array{
* data: array<int, array<string, mixed>>,
* meta: array<string, mixed>
* }
*/
public function forYouFeed(User $user, int $limit = self::DEFAULT_PAGE_SIZE, ?string $cursor = null): array
{
$safeLimit = max(1, min(50, $limit));
$cursorHash = $cursor ? md5($cursor) : '0';
$cacheKey = "for_you:{$user->id}:{$cursorHash}";
$ttl = (int) config('recommendations.ttl.for_you_feed', 5 * 60);
return Cache::remember($cacheKey, $ttl, function () use ($user, $safeLimit, $cursor) {
return $this->build($user, $safeLimit, $cursor);
});
}
/**
* Convenience method for the homepage preview (first N items, no cursor).
*
* @return array<int, array<string, mixed>>
*/
public function forYouPreview(User $user, int $limit = 12): array
{
$result = $this->forYouFeed($user, $limit);
return $result['data'] ?? [];
}
// ─── Build pipeline ───────────────────────────────────────────────────────
/**
* @return array{data: array<int, array<string, mixed>>, meta: array<string, mixed>}
*/
private function build(User $user, int $limit, ?string $cursor): array
{
$profile = $this->prefBuilder->build($user);
$userId = (int) $user->id;
if (! $profile->hasSignals()) {
return $this->coldStart($userId, $limit, $cursor);
}
$poolSize = (int) config('recommendations.candidate_pool_size', 200);
$tagSlugs = array_slice($profile->topTagSlugs, 0, 10);
// ── 1. Meilisearch candidate retrieval ────────────────────────────────
$candidates = $this->fetchCandidates($tagSlugs, $userId, $poolSize);
if ($candidates->isEmpty()) {
return $this->coldStart($userId, $limit, $cursor);
}
// ── 2. Exclude already-favourited artworks ────────────────────────────
$favoritedIds = $this->getFavoritedIds((int) $user->id);
$candidates = $candidates->whereNotIn('id', $favoritedIds)->values();
// ── 3. Enrich: load tags + stats for all candidates (2 IN queries) ────
$candidates->load(['tags:id,slug', 'stats']);
// ── 4. PHP reranking ──────────────────────────────────────────────────
$scored = $this->rerank($candidates, $profile);
// ── 5. Diversity controls ─────────────────────────────────────────────
$diversified = $this->applyDiversity($scored);
// ── 6. Paginate ───────────────────────────────────────────────────────
return $this->paginate($diversified, $limit, $cursor, $profile);
}
// ─── Meilisearch retrieval ────────────────────────────────────────────────
/**
* @return Collection<int, Artwork>
*/
private function fetchCandidates(array $tagSlugs, int $userId, int $poolSize): Collection
{
$filterParts = [
'is_public = true',
'is_approved = true',
'author_id != ' . $userId,
];
if ($tagSlugs !== []) {
$tagFilter = implode(' OR ', array_map(
fn (string $t): string => 'tags = "' . addslashes($t) . '"',
$tagSlugs
));
$filterParts[] = '(' . $tagFilter . ')';
}
try {
$results = Artwork::search('')
->options([
'filter' => implode(' AND ', $filterParts),
'sort' => ['trending_score_7d:desc', 'created_at:desc'],
])
->paginate($poolSize, 'page', 1);
return $results->getCollection();
} catch (\Throwable $e) {
Log::warning('RecommendationService: Meilisearch unavailable, using DB fallback', [
'error' => $e->getMessage(),
]);
return $this->dbFallbackCandidates($userId, $tagSlugs, $poolSize);
}
}
/**
* DB fallback when Meilisearch is unavailable.
*
* @return Collection<int, Artwork>
*/
private function dbFallbackCandidates(int $userId, array $tagSlugs, int $poolSize): Collection
{
$query = Artwork::public()
->published()
->where('user_id', '!=', $userId)
->orderByDesc('trending_score_7d')
->orderByDesc('published_at')
->limit($poolSize);
if ($tagSlugs !== []) {
$query->whereHas('tags', fn ($q) => $q->whereIn('slug', $tagSlugs));
}
return $query->get();
}
// ─── Reranking ────────────────────────────────────────────────────────────
/**
* Score each candidate and return a sorted array of [score, artwork].
*
* @param Collection<int, Artwork> $candidates
* @return array<int, array{score: float, artwork: Artwork, tag_slugs: string[]}>
*/
private function rerank(Collection $candidates, UserRecoProfileDTO $profile): array
{
$weights = (array) config('recommendations.weights', []);
$wTag = (float) ($weights['tag_overlap'] ?? 0.40);
$wCre = (float) ($weights['creator_affinity'] ?? 0.25);
$wPop = (float) ($weights['popularity'] ?? 0.20);
$wFresh = (float) ($weights['freshness'] ?? 0.15);
$userTagSet = array_flip($profile->topTagSlugs); // slug → index (fast lookup)
$scored = [];
foreach ($candidates as $artwork) {
$artworkTagSlugs = $artwork->tags->pluck('slug')->all();
$artworkTagSet = array_flip($artworkTagSlugs);
// ── Tag overlap (Jaccard-like) ─────────────────────────────────────
$commonTags = count(array_intersect_key($userTagSet, $artworkTagSet));
$totalTags = max(1, count($userTagSet) + count($artworkTagSet) - $commonTags);
$tagOverlap = $commonTags / $totalTags;
// ── Creator affinity ──────────────────────────────────────────────
$creatorAffinity = $profile->followsCreator((int) $artwork->user_id) ? 1.0 : 0.0;
// ── Popularity boost (log-normalised views) ───────────────────────
$views = max(0, (int) ($artwork->stats?->views ?? 0));
$popularity = min(1.0, log(1 + $views) / 12.0);
// ── Freshness boost (exponential decay over 30 days) ─────────────
$publishedAt = $artwork->published_at ?? $artwork->created_at ?? now();
$ageDays = max(0.0, (float) $publishedAt->diffInSeconds(now()) / 86400);
$freshness = exp(-$ageDays / 30.0);
$score = ($wTag * $tagOverlap)
+ ($wCre * $creatorAffinity)
+ ($wPop * $popularity)
+ ($wFresh * $freshness);
$scored[] = [
'score' => $score,
'artwork' => $artwork,
'tag_slugs' => $artworkTagSlugs,
];
}
usort($scored, fn ($a, $b) => $b['score'] <=> $a['score']);
return $scored;
}
// ─── Diversity controls ───────────────────────────────────────────────────
/**
* Apply per-creator cap and tag variety enforcement.
*
* @param array<int, array{score: float, artwork: Artwork, tag_slugs: string[]}> $scored
* @return array<int, array{score: float, artwork: Artwork, tag_slugs: string[]}>
*/
private function applyDiversity(array $scored): array
{
$maxPerCreator = (int) config('recommendations.max_per_creator', 3);
$minUniqueTags = (int) config('recommendations.min_unique_tags', 5);
$creatorCount = [];
$seenTagSlugs = [];
$result = [];
$deferred = []; // items over per-creator cap (added back at end)
foreach ($scored as $item) {
$creatorId = (int) $item['artwork']->user_id;
if (($creatorCount[$creatorId] ?? 0) >= $maxPerCreator) {
$deferred[] = $item;
continue;
}
$result[] = $item;
$creatorCount[$creatorId] = ($creatorCount[$creatorId] ?? 0) + 1;
foreach ($item['tag_slugs'] as $slug) {
$seenTagSlugs[$slug] = true;
}
}
// Check tag variety in first 20 if insufficient, inject from deferred
if (count($seenTagSlugs) < $minUniqueTags && $deferred !== []) {
foreach ($deferred as $item) {
$newTags = array_diff($item['tag_slugs'], array_keys($seenTagSlugs));
if ($newTags !== []) {
$result[] = $item;
foreach ($newTags as $slug) {
$seenTagSlugs[$slug] = true;
}
if (count($seenTagSlugs) >= $minUniqueTags) {
break;
}
}
}
}
return $result;
}
// ─── Cold-start fallback ──────────────────────────────────────────────────
/**
* @return array{data: array<int, array<string, mixed>>, meta: array<string, mixed>}
*/
private function coldStart(int $userId, int $limit, ?string $cursor): array
{
$offset = $this->decodeCursor($cursor);
try {
$results = Artwork::search('')
->options([
'filter' => 'is_public = true AND is_approved = true AND author_id != ' . $userId,
'sort' => ['trending_score_7d:desc', 'created_at:desc'],
])
->paginate(self::COLD_START_LIMIT + $offset, 'page', 1);
$artworks = $results->getCollection()->slice($offset, $limit)->values();
} catch (\Throwable) {
$artworks = Artwork::public()
->published()
->where('user_id', '!=', $userId)
->orderByDesc('trending_score_7d')
->orderByDesc('published_at')
->skip($offset)
->limit($limit)
->get();
}
$nextOffset = $offset + $limit;
$hasMore = $artworks->count() >= $limit;
return [
'data' => $artworks->map(fn (Artwork $a): array => $this->serializeArtwork($a))->values()->all(),
'meta' => [
'source' => 'cold_start',
'cursor' => $this->encodeCursor($offset),
'next_cursor' => $hasMore ? $this->encodeCursor($nextOffset) : null,
'limit' => $limit,
],
];
}
// ─── Pagination ───────────────────────────────────────────────────────────
/**
* @param array<int, array{score: float, artwork: Artwork, tag_slugs: string[]}> $diversified
* @return array{data: array<int, array<string, mixed>>, meta: array<string, mixed>}
*/
private function paginate(array $diversified, int $limit, ?string $cursor, UserRecoProfileDTO $profile): array
{
$offset = $this->decodeCursor($cursor);
$pageItems = array_slice($diversified, $offset, $limit);
$total = count($diversified);
$nextOffset = $offset + $limit;
$data = array_map(
fn (array $item): array => array_merge(
$this->serializeArtwork($item['artwork']),
['score' => round((float) $item['score'], 5), 'source' => 'personalised']
),
$pageItems
);
return [
'data' => array_values($data),
'meta' => [
'source' => 'personalised',
'cursor' => $this->encodeCursor($offset),
'next_cursor' => $nextOffset < $total ? $this->encodeCursor($nextOffset) : null,
'limit' => $limit,
'total_candidates' => $total,
'has_signals' => true,
],
];
}
// ─── Helpers ──────────────────────────────────────────────────────────────
/** @return array<int, int> */
private function getFavoritedIds(int $userId): array
{
return DB::table('artwork_favourites')
->where('user_id', $userId)
->pluck('artwork_id')
->map(fn ($id) => (int) $id)
->all();
}
/** @return array<string, mixed> */
private function serializeArtwork(Artwork $artwork): array
{
return [
'id' => $artwork->id,
'title' => $artwork->title ?? 'Untitled',
'slug' => $artwork->slug ?? '',
'thumbnail_url' => $artwork->thumbUrl('md'),
'author' => $artwork->user?->name ?? 'Artist',
'author_id' => $artwork->user_id,
'url' => '/art/' . $artwork->id . '/' . ($artwork->slug ?? ''),
'width' => $artwork->width,
'height' => $artwork->height,
'published_at' => $artwork->published_at?->toIso8601String(),
];
}
private function decodeCursor(?string $cursor): int
{
if (! $cursor) {
return 0;
}
$decoded = base64_decode(strtr($cursor, '-_', '+/'), true);
if ($decoded === false) {
return 0;
}
$json = json_decode($decoded, true);
return max(0, (int) Arr::get((array) $json, 'offset', 0));
}
private function encodeCursor(int $offset): string
{
$payload = json_encode(['offset' => max(0, $offset)]);
return rtrim(strtr(base64_encode((string) $payload), '+/', '-_'), '=');
}
}

View File

@@ -0,0 +1,307 @@
<?php
declare(strict_types=1);
namespace App\Services\Recommendation;
use App\DTOs\UserRecoProfileDTO;
use App\Models\User;
use App\Models\UserRecoProfile;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* UserPreferenceBuilder
*
* Builds (and caches) a UserRecoProfileDTO for a given user by aggregating
* signals from their interaction history:
*
* Signal Weight (default)
* ───────────────────────── ────────────────
* Award given (gold/silver) +5
* Artwork favourited +3
* Reaction given +2
* Creator followed (tags) +2 for their tags
* View recorded +1
*
* The result is persisted to `user_reco_profiles` (6-hour TTL by default) so
* subsequent requests in the same window skip all DB queries.
*
* Usage:
* $builder = app(UserPreferenceBuilder::class);
* $dto = $builder->build($user); // cached
* $dto = $builder->buildFresh($user); // force rebuild
*/
class UserPreferenceBuilder
{
// Redis / file cache key (short-lived insurance layer on top of DB row)
private const REDIS_TTL = 300; // 5 minutes — warm cache after DB write
public function __construct() {}
// ─── Public API ───────────────────────────────────────────────────────────
/**
* Return a cached profile DTO, rebuilding from DB if stale or absent.
*/
public function build(User $user): UserRecoProfileDTO
{
$cacheKey = $this->cacheKey($user->id);
// 1. Redis warm layer
/** @var array<string,mixed>|null $cached */
$cached = Cache::get($cacheKey);
if ($cached !== null) {
return UserRecoProfileDTO::fromArray($cached);
}
// 2. Persistent DB row
$row = UserRecoProfile::find($user->id);
if ($row !== null && $row->isFresh()) {
$dto = $row->toDTO();
Cache::put($cacheKey, $dto->toArray(), self::REDIS_TTL);
return $dto;
}
// 3. Rebuild
return $this->buildFresh($user);
}
/**
* Force a full rebuild from source tables, persist and cache the result.
*/
public function buildFresh(User $user): UserRecoProfileDTO
{
try {
$dto = $this->compute($user);
$this->persist($user->id, $dto);
Cache::put($this->cacheKey($user->id), $dto->toArray(), self::REDIS_TTL);
return $dto;
} catch (\Throwable $e) {
Log::warning('UserPreferenceBuilder: failed to build profile', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
return new UserRecoProfileDTO();
}
}
/**
* Invalidate only the Redis warm layer (DB row stays intact until TTL).
*/
public function invalidate(int $userId): void
{
Cache::forget($this->cacheKey($userId));
}
// ─── Computation ──────────────────────────────────────────────────────────
private function compute(User $user): UserRecoProfileDTO
{
$sigWeights = (array) config('recommendations.signal_weights', []);
$wAward = (float) ($sigWeights['award'] ?? 5.0);
$wFav = (float) ($sigWeights['favorite'] ?? 3.0);
$wFollow = (float) ($sigWeights['follow'] ?? 2.0);
$tagLimit = (int) config('recommendations.profile.top_tags_limit', 20);
$catLimit = (int) config('recommendations.profile.top_categories_limit', 5);
$creatorLimit = (int) config('recommendations.profile.strong_creators_limit', 50);
// ── 1. Tag scores from favourited artworks ────────────────────────────
$tagRaw = $this->tagScoresFromFavourites($user->id, $wFav);
// ── 2. Tag scores from awards given ──────────────────────────────────
foreach ($this->tagScoresFromAwards($user->id, $wAward) as $slug => $score) {
$tagRaw[$slug] = ($tagRaw[$slug] ?? 0.0) + $score;
}
// ── 3. Creator IDs from follows (top N) ───────────────────────────────
$followedIds = $this->followedCreatorIds($user->id, $creatorLimit);
// ── 4. Tag scores lifted from followed creators' recent works ─────────
if ($followedIds !== []) {
foreach ($this->tagScoresFromFollowedCreators($followedIds, $wFollow) as $slug => $score) {
$tagRaw[$slug] = ($tagRaw[$slug] ?? 0.0) + $score;
}
}
// ── 5. Category scores from favourited artworks ───────────────────────
$catRaw = $this->categoryScoresFromFavourites($user->id, $wFav);
// Sort descending and take top N
arsort($tagRaw);
arsort($catRaw);
$topTagSlugs = array_keys(array_slice($tagRaw, 0, $tagLimit));
$topCatSlugs = array_keys(array_slice($catRaw, 0, $catLimit));
$tagWeights = $this->normalise($tagRaw);
$catWeights = $this->normalise($catRaw);
return new UserRecoProfileDTO(
topTagSlugs: $topTagSlugs,
topCategorySlugs: $topCatSlugs,
strongCreatorIds: $followedIds,
tagWeights: $tagWeights,
categoryWeights: $catWeights,
);
}
// ─── Signal collectors ────────────────────────────────────────────────────
/**
* @return array<string, float> slug raw score
*/
private function tagScoresFromFavourites(int $userId, float $weight): array
{
$rows = DB::table('artwork_favourites as af')
->join('artwork_tag as at', 'at.artwork_id', '=', 'af.artwork_id')
->join('tags as t', 't.id', '=', 'at.tag_id')
->where('af.user_id', $userId)
->where('t.is_active', true)
->selectRaw('t.slug, COUNT(*) as cnt')
->groupBy('t.id', 't.slug')
->get();
$scores = [];
foreach ($rows as $row) {
$scores[(string) $row->slug] = (float) $row->cnt * $weight;
}
return $scores;
}
/**
* @return array<string, float>
*/
private function tagScoresFromAwards(int $userId, float $weight): array
{
$rows = DB::table('artwork_awards as aa')
->join('artwork_tag as at', 'at.artwork_id', '=', 'aa.artwork_id')
->join('tags as t', 't.id', '=', 'at.tag_id')
->where('aa.user_id', $userId)
->where('t.is_active', true)
->selectRaw('t.slug, SUM(aa.weight) as total_weight')
->groupBy('t.id', 't.slug')
->get();
$scores = [];
foreach ($rows as $row) {
$scores[(string) $row->slug] = (float) $row->total_weight * $weight;
}
return $scores;
}
/**
* @param array<int, int> $creatorIds
* @return array<string, float>
*/
private function tagScoresFromFollowedCreators(array $creatorIds, float $weight): array
{
if ($creatorIds === []) {
return [];
}
// Sample recent artworks to avoid full scan
$rows = DB::table('artworks as a')
->join('artwork_tag as at', 'at.artwork_id', '=', 'a.id')
->join('tags as t', 't.id', '=', 'at.tag_id')
->whereIn('a.user_id', $creatorIds)
->where('a.is_public', true)
->where('a.is_approved', true)
->where('t.is_active', true)
->whereNull('a.deleted_at')
->orderByDesc('a.published_at')
->limit(500)
->selectRaw('t.slug, COUNT(*) as cnt')
->groupBy('t.id', 't.slug')
->get();
$scores = [];
foreach ($rows as $row) {
$scores[(string) $row->slug] = (float) $row->cnt * $weight;
}
return $scores;
}
/**
* @return array<string, float>
*/
private function categoryScoresFromFavourites(int $userId, float $weight): array
{
$rows = DB::table('artwork_favourites as af')
->join('artwork_category as ac', 'ac.artwork_id', '=', 'af.artwork_id')
->join('categories as c', 'c.id', '=', 'ac.category_id')
->where('af.user_id', $userId)
->whereNull('c.deleted_at')
->selectRaw('c.slug, COUNT(*) as cnt')
->groupBy('c.id', 'c.slug')
->get();
$scores = [];
foreach ($rows as $row) {
$scores[(string) $row->slug] = (float) $row->cnt * $weight;
}
return $scores;
}
/**
* @return array<int, int>
*/
private function followedCreatorIds(int $userId, int $limit): array
{
return DB::table('user_followers')
->where('follower_id', $userId)
->orderByDesc('created_at')
->limit($limit)
->pluck('user_id')
->map(fn ($id) => (int) $id)
->all();
}
// ─── Helpers ──────────────────────────────────────────────────────────────
/**
* Normalise a raw score map to sum 1.0.
*
* @param array<string, float> $raw
* @return array<string, float>
*/
private function normalise(array $raw): array
{
$sum = array_sum($raw);
if ($sum <= 0.0) {
return $raw;
}
return array_map(fn (float $v): float => round($v / $sum, 6), $raw);
}
private function cacheKey(int $userId): string
{
return "user_reco_profile:{$userId}";
}
// ─── Persistence ──────────────────────────────────────────────────────────
private function persist(int $userId, UserRecoProfileDTO $dto): void
{
$data = $dto->toArray();
UserRecoProfile::query()->updateOrCreate(
['user_id' => $userId],
[
'top_tags_json' => $data['top_tags'],
'top_categories_json' => $data['top_categories'],
'followed_creator_ids_json' => $data['strong_creators'],
'tag_weights_json' => $data['tag_weights'],
'category_weights_json' => $data['category_weights'],
'disliked_tag_ids_json' => $data['disliked_tags'],
]
);
}
}