Wire admin studio SSR and search infrastructure

This commit is contained in:
2026-05-01 11:46:06 +02:00
parent 257b0dbef6
commit 18cea8b0f0
329 changed files with 197465 additions and 2741 deletions

View File

@@ -46,33 +46,22 @@ final class RecBuildItemPairsFromFavouritesJob implements ShouldQueue
->pluck('cnt', 'artwork_id')
->all();
// ── Accumulate co-occurrence counts across all users ──
$coOccurrenceCounts = [];
// ── Rebuild weights from scratch to avoid cross-run accumulation ──
DB::table('rec_item_pairs')->delete();
DB::table('artwork_favourites')
->select('user_id')
->groupBy('user_id')
->orderBy('user_id')
->chunk($this->userBatchSize, function ($userRows) use ($favCap, &$coOccurrenceCounts) {
->chunk($this->userBatchSize, function ($userRows) use ($favCap) {
$userIds = [];
foreach ($userRows as $row) {
$pairs = $this->pairsForUser((int) $row->user_id, $favCap);
foreach ($pairs as $pair) {
$key = $pair[0] . ':' . $pair[1];
$coOccurrenceCounts[$key] = ($coOccurrenceCounts[$key] ?? 0) + 1;
}
$userIds[] = (int) $row->user_id;
}
$this->flushPairCountChunk($this->pairCountsForUsers($userIds, $favCap));
});
// ── Normalize to cosine-like scores and flush ──
$normalized = [];
foreach ($coOccurrenceCounts as $key => $count) {
[$a, $b] = explode(':', $key);
$likesA = $this->artworkLikeCounts[(int) $a] ?? 1;
$likesB = $this->artworkLikeCounts[(int) $b] ?? 1;
$normalized[$key] = $count / sqrt($likesA * $likesB);
}
$this->flushPairs($normalized);
}
/** @var array<int, int> artwork_id => total favourite count */
@@ -93,6 +82,56 @@ final class RecBuildItemPairsFromFavouritesJob implements ShouldQueue
->map(fn ($id) => (int) $id)
->all();
return $this->pairsForArtworkIds($artworkIds);
}
/**
* Collect chunk-local pair counts using one capped favourites query for the chunk.
*
* @param list<int> $userIds
* @return array<string, int>
*/
private function pairCountsForUsers(array $userIds, int $cap): array
{
if ($userIds === []) {
return [];
}
$rankedFavourites = DB::query()
->fromSub(
DB::table('artwork_favourites')
->selectRaw('user_id, artwork_id, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC, artwork_id DESC) as favourite_rank')
->whereIn('user_id', $userIds),
'ranked_favourites'
)
->where('favourite_rank', '<=', $cap)
->orderBy('user_id')
->orderBy('favourite_rank')
->get(['user_id', 'artwork_id']);
$artworksByUser = [];
foreach ($rankedFavourites as $row) {
$artworksByUser[(int) $row->user_id][] = (int) $row->artwork_id;
}
$pairCounts = [];
foreach ($artworksByUser as $artworkIds) {
foreach ($this->pairsForArtworkIds($artworkIds) as [$a, $b]) {
$key = $this->pairKey($a, $b);
$pairCounts[$key] = ($pairCounts[$key] ?? 0) + 1;
}
}
return $pairCounts;
}
/**
* @param list<int> $artworkIds
* @return list<array{0: int, 1: int}>
*/
private function pairsForArtworkIds(array $artworkIds): array
{
$count = count($artworkIds);
if ($count < 2) {
return [];
@@ -112,28 +151,50 @@ final class RecBuildItemPairsFromFavouritesJob implements ShouldQueue
}
/**
* Upsert normalized pair weights into rec_item_pairs.
* Upsert one chunk of pair counts into rec_item_pairs.
*
* Uses Laravel's DB-agnostic upsert (works on MySQL, Postgres, SQLite).
*
* @param array<string, float> $upserts key = "a:b", value = cosine-normalized weight
* @param array<string, int> $pairCounts key = "a:b", value = chunk-local co-occurrence count
*/
private function flushPairs(array $upserts): void
private function flushPairCountChunk(array $pairCounts): void
{
if ($upserts === []) {
if ($pairCounts === []) {
return;
}
$now = now();
foreach (array_chunk($upserts, 500, preserve_keys: true) as $chunk) {
foreach (array_chunk($pairCounts, 500, preserve_keys: true) as $chunk) {
$pairIds = [];
$aIds = [];
$bIds = [];
foreach ($chunk as $key => $count) {
[$a, $b] = $this->pairIdsFromKey($key);
$pairIds[$key] = [$a, $b];
$aIds[] = $a;
$bIds[] = $b;
}
$existingWeights = DB::table('rec_item_pairs')
->whereIn('a_artwork_id', array_values(array_unique($aIds)))
->whereIn('b_artwork_id', array_values(array_unique($bIds)))
->get(['a_artwork_id', 'b_artwork_id', 'weight'])
->mapWithKeys(fn ($row): array => [
$this->pairKey((int) $row->a_artwork_id, (int) $row->b_artwork_id) => (float) $row->weight,
])
->all();
$rows = [];
foreach ($chunk as $key => $weight) {
[$a, $b] = explode(':', $key);
foreach ($chunk as $key => $count) {
[$a, $b] = $pairIds[$key];
$likesA = $this->artworkLikeCounts[$a] ?? 1;
$likesB = $this->artworkLikeCounts[$b] ?? 1;
$deltaWeight = $count / sqrt($likesA * $likesB);
$rows[] = [
'a_artwork_id' => (int) $a,
'b_artwork_id' => (int) $b,
'weight' => $weight,
'a_artwork_id' => $a,
'b_artwork_id' => $b,
'weight' => ($existingWeights[$key] ?? 0.0) + $deltaWeight,
'updated_at' => $now,
];
}
@@ -145,4 +206,19 @@ final class RecBuildItemPairsFromFavouritesJob implements ShouldQueue
);
}
}
private function pairKey(int $a, int $b): string
{
return $a . ':' . $b;
}
/**
* @return array{0: int, 1: int}
*/
private function pairIdsFromKey(string $key): array
{
[$a, $b] = explode(':', $key, 2);
return [(int) $a, (int) $b];
}
}