minor fixes

This commit is contained in:
2026-04-09 08:50:36 +02:00
parent 23d363a50c
commit a2457f4e49
75 changed files with 3848 additions and 387 deletions

93
docs/Discover/README.md Normal file
View File

@@ -0,0 +1,93 @@
# Discover Pages
This folder documents how each public discovery surface is assembled today.
It is intentionally page-oriented rather than architecture-oriented.
For the broader discovery engine, signal collection, and personalization background, also see `docs/discovery-personalization-engine.md`.
## Pages Covered
- `Trending``GET /discover/trending`
- `Rising``GET /discover/rising`
- `Fresh``GET /discover/fresh`
- `Top Rated``GET /discover/top-rated`
- `Most Downloaded``GET /discover/most-downloaded`
- `Today Downloads``GET /downloads/today`
- `On This Day``GET /discover/on-this-day`
- `For You``GET /discover/for-you` (auth only)
## Shared Request Pipeline
Most Discover pages follow this pattern:
1. Route enters `App\Http\Controllers\Web\DiscoverController`.
2. The controller calls `App\Services\ArtworkSearchService`.
3. `ArtworkSearchService` queries the `artworks` Meilisearch index through Laravel Scout.
4. The search result usually contains only search/index fields.
5. `DiscoverController::hydrateDiscoverSearchResults()` then reloads full `Artwork` rows from MySQL with relations (`user`, `profile`, `categories`) and converts them into the view model used by Blade.
6. Some pages can still pass through additional presentation layers such as `GridFiller`, depending on the controller action.
## Shared Visibility Rules
All search-backed pages use the same base visibility filter in `ArtworkSearchService`:
```text
is_public = true AND is_approved = true
```
That means an artwork must be:
- public
- approved
- present in the Meilisearch index
If the database row is correct but search is stale, the page can still miss the artwork until indexing catches up.
## Shared Cache Behavior
`ArtworkSearchService` uses application cache in front of Meilisearch.
- Default TTL: 300 seconds
- `Rising`: 120 seconds
- Category/content-type sort pages use per-sort TTLs, but those are outside this folder's scope
The page can therefore lag behind a real publish or stat change even when the underlying data is already correct.
## Shared Supporting Jobs
These jobs are active in the current Laravel 11 runtime scheduler (`routes/console.php`):
- `skinbase:flush-redis-stats` every 5 minutes
- `skinbase:recalculate-trending --period=24h` every 30 minutes
- `skinbase:recalculate-trending --period=7d --skip-index` every 30 minutes
- `skinbase:reset-windowed-stats --period=24h` daily at 03:30
- `skinbase:reset-windowed-stats --period=7d` weekly (Monday) at 03:30
- `nova:recalculate-rankings --sync-rank-scores` every 30 minutes
- `artworks:publish-scheduled` every minute
- `analytics:aggregate-discovery-feedback` daily at 03:25
- `RecBuildItemPairsFromFavouritesJob` every 4 hours
- `RecComputeSimilarByTagsJob` daily at 02:00
- `RecComputeSimilarByBehaviorJob` daily at 02:15
- `RecComputeSimilarHybridJob` daily at 02:30
## Important Scheduler Caveat
The codebase still contains some discovery-related schedules inside `app/Console/Kernel.php`, but the active Laravel 11 runtime schedule comes from `routes/console.php`.
The Rising pipeline depends on these active runtime jobs:
- `nova:metrics-snapshot-hourly` hourly
- `nova:recalculate-heat` every 15 minutes
If Rising stops moving while Trending changes, check `php artisan schedule:list` first and confirm both jobs are still active.
## File Map
- `trending.md`
- `rising.md`
- `fresh.md`
- `top-rated.md`
- `most-downloaded.md`
- `today-downloads.md`
- `on-this-day.md`
- `for-you.md`

174
docs/Discover/for-you.md Normal file
View File

@@ -0,0 +1,174 @@
# For You
## Route
- URL: `GET /discover/for-you`
- Auth required: yes
- Controller: `App\Http\Controllers\Web\DiscoverController::forYou()`
- Entry service: `App\Services\Recommendations\RecommendationFeedResolver`
## High-level flow
`For You` is not a simple search sort.
It is a recommendation pipeline with engine selection, caching, layered candidate generation, reranking, and cursor pagination.
The controller:
1. reads `limit` and `cursor`
2. calls `RecommendationFeedResolver::getFeed()`
3. converts feed items into the artwork card view model
4. returns HTML or JSON depending on request type
## Engine selection
`RecommendationFeedResolver` chooses between two implementations:
- V2: `App\Services\Recommendations\RecommendationServiceV2`
- V1: `App\Services\Recommendations\PersonalizedFeedService`
Selection is based on:
- `config('discovery.v2.enabled')`
- rollout percentage bucket for the current user
- explicit `algo_version` override
## Cache model
Both engines use `user_recommendation_cache`.
At request time:
1. Load cache row for `(user_id, algo_version)`
2. Check cache version and `expires_at`
3. If missing or stale, dispatch `RegenerateUserRecommendationCacheJob`
4. If the row is empty, build fallback recommendations inline for the current request
This means the page is usually cache-backed, but it does not hard-fail if the cache is cold.
## V2 pipeline
V2 is the richer layered engine.
### Candidate layers
The candidate pool is blended from:
- personalized layer
- social layer
- trending layer
- exploration layer
- vector layer (only when V3/vector support is enabled and configured)
Default target ratios from `config/discovery.php`:
- personalized: 50%
- social: 20%
- trending: 20%
- exploration: 10%
### Main V2 score
For each candidate row:
```text
score
= (base_score * weight_base)
+ session_boost
+ social_boost
+ trending_boost
+ exploration_boost
+ creator_boost
+ vector_boost
- negative_penalty
- repetition_penalty
```
Where:
- `session_boost` comes from merged session/profile signals
- `social_boost` comes from followed creators and artworks liked by followed creators
- `trending_boost` is built from `trending_score_1h`, `trending_score_24h`, `trending_score_7d`
- `exploration_boost` rewards fresh uploads, new creators, and unseen tags
- `creator_boost` uses creator follower count plus artwork momentum metrics
- `vector_boost` comes from visual similarity when vector mode is enabled
- `negative_penalty` reflects hidden artworks and disliked tags
- `repetition_penalty` suppresses creator and tag repetition inside one result page
### Trending contribution inside V2
V2 combines the artwork's stored trending columns like this:
```text
trendingBoost
= (trending_score_1h * weight_1h)
+ (trending_score_24h * weight_24h)
+ (trending_score_7d * weight_7d)
```
Then divides by 100 before merging into the final score.
### Negative signals
V2 reads `user_negative_signals` and applies:
- hidden artwork exclusion
- disliked tag penalty
### Supporting data sources
V2 reads from:
- `user_recommendation_cache`
- session/profile signal builders
- `artworks`
- `artwork_stats`
- `artwork_similarities`
- `artwork_embeddings` and vector service, when enabled
- `user_followers`
- `artwork_favourites`
- `user_negative_signals`
## V1 pipeline
V1 is simpler and category-affinity driven.
It reads `user_interest_profiles`, then scores candidates with a weighted blend:
```text
score = (w1 * affinity)
+ (w2 * recency)
+ (w3 * popularity)
+ (w4 * novelty)
```
Cold start falls back to a blend of popular artworks and `artwork_similarities` seeds.
## Background jobs and schedules
### Directly relevant
- `RegenerateUserRecommendationCacheJob`
- dispatched on demand when cache is missing or stale
### Support jobs for candidate quality
- `RecBuildItemPairsFromFavouritesJob` every 4 hours
- `RecComputeSimilarByTagsJob` daily at 02:00
- `RecComputeSimilarByBehaviorJob` daily at 02:15
- `RecComputeSimilarHybridJob` daily at 02:30
- `analytics:aggregate-discovery-feedback` daily at 03:25
These jobs do not directly render the page, but they improve the offline inputs and behavioral data used by the recommender.
## Cache behavior
- V1 cache TTL default: 60 minutes
- V2 cache TTL default: 15 minutes
Cursor pagination is offset-based under the hood.
## Notes
- `For You` is the most configuration-sensitive page in this set.
- What a given user sees can differ by rollout bucket, `algo_version`, cache state, and whether V2/V3 features are enabled.
- If you are debugging a single user's page, inspect `RecommendationFeedResolver::inspectDecision()` first.

88
docs/Discover/fresh.md Normal file
View File

@@ -0,0 +1,88 @@
# Fresh
## Route
- URL: `GET /discover/fresh`
- Controller: `App\Http\Controllers\Web\DiscoverController::fresh()`
- Service: `App\Services\ArtworkSearchService::discoverFresh()`
## What the page reads
Fresh is Meilisearch-backed, then hydrated from MySQL.
The page does not directly query `artworks` for ranking order.
It relies on the search index being up to date.
If the search-backed result comes back empty, the controller falls back to a direct MySQL query ordered by `published_at DESC, id DESC` so the page does not render blank while search is catching up.
## Search query
`discoverFresh()` uses:
- filter: `is_public = true AND is_approved = true`
- sort:
- `published_at_ts:desc`
`published_at_ts` is a numeric timestamp field stored in the search document specifically so same-day uploads can be ordered correctly by hour and minute.
## Why the dedicated timestamp field exists
Historically, the index sorted by a date-only `created_at` string, which meant all uploads on the same calendar day could collapse to the same sort value.
The current implementation uses `published_at_ts` to preserve intra-day ordering and avoid newer uploads being buried behind older uploads from the same date.
## Page behavior
Fresh now uses the raw newest-first search result without any curated blending or grid filler injection.
That means:
- page 1 is not mixed with spotlight content
- page 1 is not padded with older trending artworks
- deeper pages follow the same ordering model as page 1
If older artworks appear near the top, the likely causes are stale search documents or stale cache, not intentional feed mixing.
If the page renders completely empty even though recent public artworks exist, the DB fallback should populate it. A blank page after that points to a real data visibility problem, not just search freshness.
## Data sources
Ranking eligibility depends on:
- `is_public`
- `is_approved`
- presence in Meilisearch
- `published_at_ts` in the indexed document
Hydration reads full rows from MySQL after the search query returns IDs.
When the fallback path is used, the page is served directly from MySQL and does not require Meilisearch for that request.
## Relevant jobs and schedules
Fresh does not have a dedicated score calculation job.
It depends on publication and indexing freshness.
Relevant active schedules:
- `artworks:publish-scheduled` every minute
- `skinbase:flush-redis-stats` every 5 minutes (not for ordering, but for displayed stats freshness)
Index freshness depends on:
- normal Scout indexing from artwork updates
- scheduled-publication indexing after an artwork transitions from scheduled to published
- manual/full imports after search-document schema changes
## Cache behavior
- Cache key: `discover.fresh.{page}`
- TTL: 300 seconds
## Notes
- Fresh is the page most sensitive to stale indexing because it is supposed to surface the latest publish action immediately.
- If an artwork is public in MySQL but absent from Fresh, the usual causes are:
- it has not been indexed yet
- app cache has not expired yet
- the artwork is not actually public and approved at the same time

View File

@@ -0,0 +1,59 @@
# Most Downloaded
## Route
- URL: `GET /discover/most-downloaded`
- Controller: `App\Http\Controllers\Web\DiscoverController::mostDownloaded()`
- Service: `App\Services\ArtworkSearchService::discoverMostDownloaded()`
## What the page reads
This page is Meilisearch-ranked and then hydrated from MySQL.
## Search query
`discoverMostDownloaded()` uses:
- filter: `is_public = true AND is_approved = true`
- sort:
- `downloads:desc`
- `views:desc`
In the indexed document:
- `downloads` is sourced from `artwork_stats.downloads`
- `views` is sourced from `artwork_stats.views`
This is therefore an all-time leaderboard, not a rolling-window leaderboard.
## Where the download counts come from
Downloads are recorded in two places:
1. `artwork_downloads`
- full event log of each tracked download
2. `artwork_stats.downloads`
- all-time aggregate counter used by this page
The page sorts on the aggregate counter, not by counting the event log live.
## Background jobs and schedules
No dedicated page-specific cron exists.
Relevant active maintenance:
- `skinbase:flush-redis-stats` every 5 minutes
- pushes deferred Redis counters into MySQL
Window reset commands exist too, but they maintain `downloads_24h` and `downloads_7d` rather than the all-time `downloads` column used by this page.
## Cache behavior
- Cache key: `discover.most-downloaded.{page}`
- TTL: 300 seconds
## Notes
- This page is separate from `Today Downloads`.
- If you need "what is hot today by download activity," use the dedicated `/downloads/today` page instead.

View File

@@ -0,0 +1,52 @@
# On This Day
## Route
- URL: `GET /discover/on-this-day`
- Controller: `App\Http\Controllers\Web\DiscoverController::onThisDay()`
## What the page reads
This page is a direct MySQL query over `artworks`.
It does not use Meilisearch.
## Query logic
The controller selects artworks that are:
- public
- published
- approved
- published on the same month/day as today
- from a previous year only
It then sorts them by `published_at DESC` and paginates 24 per page.
Equivalent logic:
```text
MONTH(published_at) = today.month
AND DAY(published_at) = today.day
AND YEAR(published_at) < today.year
```
## Data sources
- primary source: `artworks`
- supporting eager loads: `user`, `user.profile`, `categories`
## Cache behavior
- no explicit controller cache
## Background jobs and schedules
No dedicated cron drives this page.
It only depends on correct `published_at` values and the usual public/published scopes.
## Notes
- This is a calendar-history page, not a popularity or momentum page.
- The current implementation simply orders by newest qualifying `published_at`, not by views, downloads, or favourites.
- There are also legacy `TodayInHistoryController` variants elsewhere in the codebase, but `/discover/on-this-day` currently uses `DiscoverController::onThisDay()`.

115
docs/Discover/rising.md Normal file
View File

@@ -0,0 +1,115 @@
# Rising
## Route
- URL: `GET /discover/rising`
- Controller: `App\Http\Controllers\Web\DiscoverController::rising()`
- Service: `App\Services\ArtworkSearchService::discoverRising()`
- RSS feed: `GET /rss/discover/rising` via `App\Http\Controllers\RSS\DiscoverFeedController::rising()`
## What the page reads
Like most Discover surfaces, this page ranks via Meilisearch and then hydrates the result IDs from MySQL for presentation.
If the search-backed query throws or returns no items, the controller falls back to a direct MySQL query against `artworks` + `artwork_stats`.
If the page receives a non-empty result set but every item has zero `heat_score` and zero `engagement_velocity`, it switches to a low-signal fallback policy instead of pretending that the zero-heat order is meaningful.
The RSS Rising feed now follows the same low-signal policy and the same adaptive lookback window, so it does not drift to a stale zero-heat ordering when recent engagement is sparse.
Primary ranking fields:
- `heat_score`
- `engagement_velocity`
- `published_at_ts` as the final recency tie-breaker
## Search query
`discoverRising()` uses:
- filter: `is_public = true AND is_approved = true AND created_at >= cutoff`
- sort:
- `heat_score:desc`
- `engagement_velocity:desc`
- `published_at_ts:desc`
The cutoff comes from the same adaptive time-window service used by Trending.
## Rising formula
`heat_score` is produced by `App\Console\Commands\RecalculateHeatCommand`.
Current formula:
```text
raw_heat
= ((views_delta * 1)
+ (downloads_delta * 3)
+ (favourites_delta * 6)
+ (comments_delta * 8)
+ (shares_delta * 12)) / window_hours
age_factor
= 1 / (1 + hours_since_upload / 24)
heat_score
= raw_heat * age_factor
```
The heat command smooths deltas over a trailing lookback window, rather than relying only on the last single hour.
That matters on low-traffic periods, because a pure 1-hour delta often collapses to zero for almost every artwork.
An artwork still needs at least two snapshots inside that window for the smoothed heat delta to count. A single snapshot without an earlier baseline does not count as momentum.
The `views_1h`, `downloads_1h`, `favourites_1h`, `comments_1h`, and `shares_1h` columns are still stored from the previous-hour comparison for diagnostics and dashboards.
## Data sources
The page depends on:
- `artwork_metric_snapshots_hourly`
- `artwork_stats.heat_score`
- `artwork_stats.engagement_velocity`
- artwork publish timestamps
In zero-signal periods, the fallback policy also uses a 24-hour snapshot delta rollup from `artwork_metric_snapshots_hourly` and then falls back to `published_at DESC`.
`engagement_velocity` is not part of the heat command. It comes from the ranking engine and acts as a secondary momentum signal.
## Intended background jobs
The intended pipeline is:
1. `nova:metrics-snapshot-hourly`
- captures hourly totals into `artwork_metric_snapshots_hourly`
2. `nova:recalculate-heat`
- computes `heat_score` from snapshot deltas
3. Meilisearch picks up the updated score after indexing
## Runtime schedule
Rising depends on two active Laravel 11 runtime jobs in `routes/console.php`:
- `nova:metrics-snapshot-hourly`
- `nova:recalculate-heat`
If either one disappears from `php artisan schedule:list`, Rising will quickly drift toward stale or low-signal ordering.
## Active jobs that still affect Rising
- `nova:recalculate-rankings --sync-rank-scores` every 30 minutes updates `engagement_velocity`
- `skinbase:flush-redis-stats` every 5 minutes keeps all-time stats fresher
## Cache behavior
- Cache key: `discover.rising.{windowDays}d.{page}`
- TTL: 120 seconds
If Meilisearch sort settings are missing or the search result is empty, the controller falls back to the DB query instead of returning an empty page or a 500.
## Notes
- If Rising looks frozen while Trending moves, the first place to check is whether `nova:metrics-snapshot-hourly` and `nova:recalculate-heat` are actually being executed in production.
- The page no longer uses `GridFiller`, so it should not pull in unrelated older artworks when the real result set is thin.
- If all heat and velocity values are zero, Rising intentionally behaves like a low-signal discovery feed: recent activity in the last 24 hours first, then newest published artworks.

View File

@@ -0,0 +1,63 @@
# Today Downloads
## Route
- URL: `GET /downloads/today`
- Controller: `App\Http\Controllers\User\TodayDownloadsController::index()`
## Important scope note
This page is not inside `/discover/*`, but it is included here because it is discovery-adjacent and was requested together with the Discover surfaces.
## What the page reads
This page does not use Meilisearch.
It is a direct MySQL query over the download event log.
## Query logic
The controller:
1. Takes today's date
2. Reads `artwork_downloads`
3. Filters rows to `whereDate(created_at, today)`
4. Groups by `artwork_id`
5. Orders by `COUNT(*) DESC`
6. Eager-loads each artwork and related user/category data
Effectively:
```text
SELECT artwork_id, COUNT(*) AS num_downloads
FROM artwork_downloads
WHERE DATE(created_at) = today
GROUP BY artwork_id
ORDER BY num_downloads DESC
```
Only artworks that are currently public and published are allowed through `whereHas('artwork', ...)`.
## Data sources
- primary source: `artwork_downloads`
- supporting source: `artworks`, `users`, `user_profiles`, `categories`
Unlike `Most Downloaded`, this page does not trust the aggregate `artwork_stats.downloads` counter for ranking.
It re-counts today's actual events.
## Cache behavior
- no dedicated application cache in the controller
The page is as fresh as the underlying event log.
## Background jobs and schedules
No page-specific cron is needed because it reads the raw event log directly.
The only prerequisite is that downloads are being recorded correctly by the download endpoint.
## Notes
- This page is often the more operationally trustworthy answer to "what is being downloaded right now?"
- Because it counts raw rows, it is less sensitive to delayed aggregate-counter flushes than `Most Downloaded`.

View File

@@ -0,0 +1,58 @@
# Top Rated
## Route
- URL: `GET /discover/top-rated`
- Controller: `App\Http\Controllers\Web\DiscoverController::topRated()`
- Service: `App\Services\ArtworkSearchService::discoverTopRated()`
## What the page reads
This is a Meilisearch-ranked page with MySQL hydration after the fact.
## Search query
`discoverTopRated()` uses:
- filter: `is_public = true AND is_approved = true`
- sort:
- `likes:desc`
- `views:desc`
In the indexed document:
- `likes` is sourced from `artwork_stats.favorites`
- `views` is sourced from `artwork_stats.views`
So "Top Rated" really means highest favourite count, with views as a tie-breaker.
## Data sources
The page depends on:
- `artwork_stats.favorites`
- `artwork_stats.views`
- Scout/Meilisearch document freshness
It does not run a custom score formula beyond the sort order above.
## Background jobs and schedules
There is no dedicated top-rated cron.
The page depends on the freshness of the underlying stats.
Relevant active maintenance:
- `skinbase:flush-redis-stats` every 5 minutes for deferred stat deltas
Favorites themselves are typically updated in the normal request path rather than by a dedicated scheduled command.
## Cache behavior
- Cache key: `discover.top-rated.{page}`
- TTL: 300 seconds
## Notes
- Awards are not part of this page's ranking.
- If the business meaning should be "best overall" rather than "most favourited," this page would need a different sort field.

125
docs/Discover/trending.md Normal file
View File

@@ -0,0 +1,125 @@
# Trending
## Route
- URL: `GET /discover/trending`
- Controller: `App\Http\Controllers\Web\DiscoverController::trending()`
- Service: `App\Services\ArtworkSearchService::discoverTrending()`
## What the page actually reads
Primary ranking comes from Meilisearch, not directly from MySQL.
The controller flow is:
1. Query Meilisearch through `ArtworkSearchService`
2. If the search-backed query throws or returns no items, fall back to a direct MySQL query against `artworks` + `artwork_stats`
3. Hydrate the returned IDs back into full `Artwork` Eloquent models when needed
4. Render `resources/views/web/discover/index.blade.php`
So the page is effectively:
- ranking source: Meilisearch
- fallback ranking source: MySQL + `artwork_stats`
- display hydration source: MySQL
## Search query
`discoverTrending()` uses:
- filter: `is_public = true AND is_approved = true AND created_at >= cutoff`
- sort:
- `ranking_score:desc`
- `engagement_velocity:desc`
- `views:desc`
`created_at` here is the search index field, not necessarily the raw DB column semantics.
## Time window
The cutoff is not hardcoded to 30 days in all cases.
`App\Services\EarlyGrowth\AdaptiveTimeWindow` chooses the effective look-back window:
- 7 days when uploads/day is healthy
- 30 days in moderate activity
- 90 days in low activity
That widening only changes which artworks are eligible for the query.
It does not rewrite timestamps.
## Ranking formula
The page does not sort by `trending_score_7d` anymore.
It sorts by `ranking_score`, which is produced by `App\Services\Ranking\ArtworkRankingService`.
Current V2 ranking formula:
```text
base_score
= (views_all * 0.2)
+ (downloads_all * 1.5)
+ (favourites_all * 2.5)
+ (comments_count * 3.0)
+ (shares_count * 4.0)
authority_multiplier
= 1 + ((log10(1 + author_followers_count)
+ (author_favourites_received / 1000)) * 0.05)
decay_factor
= 1 / (1 + age_hours / 48)
velocity_boost
= ((views_24h * 1.0)
+ (favourites_24h * 3.0)
+ (comments_24h * 4.0)
+ (shares_24h * 5.0)) * 0.5
ranking_score
= (base_score * authority_multiplier * decay_factor) + velocity_boost
```
`engagement_velocity` is the raw `velocity_boost` term stored separately in `artwork_stats`.
## Where the numbers come from
The page depends mainly on:
- `artwork_stats.ranking_score`
- `artwork_stats.engagement_velocity`
- `artwork_stats.views`
- `artwork_stats.downloads`
- `artwork_stats.favorites`
- `artwork_stats.comments_count`
- `artwork_stats.shares_count`
- author-level follower and favourites-received signals
Those values are copied into the search document by `Artwork::toSearchableArray()`.
## Active jobs and schedules
Relevant active schedules:
- `nova:recalculate-rankings --sync-rank-scores` every 30 minutes
- `skinbase:flush-redis-stats` every 5 minutes
Indirectly relevant:
- `artworks:publish-scheduled` every minute
- Meilisearch indexing jobs dispatched after score recalculation
## Cache behavior
- Cache key: `discover.trending.{windowDays}d.{page}`
- TTL: 300 seconds
That means a ranking update can be correct in Meilisearch but still hidden by app cache for up to 5 minutes.
If Meilisearch sort settings are missing or the index returns no results, the controller falls back to a DB query instead of rendering a 500 or an empty page.
## Notes
- The view text still says "most-viewed artworks on Skinbase over the past 7 days," but the current implementation is really a `ranking_score` page with a dynamic eligibility window.
- The page is only as fresh as both the index and the app cache.
- The page no longer uses `GridFiller`, so it should not inject older out-of-window artworks just to pad page 1.