minor fixes
This commit is contained in:
93
docs/Discover/README.md
Normal file
93
docs/Discover/README.md
Normal 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
174
docs/Discover/for-you.md
Normal 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
88
docs/Discover/fresh.md
Normal 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
|
||||
59
docs/Discover/most-downloaded.md
Normal file
59
docs/Discover/most-downloaded.md
Normal 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.
|
||||
52
docs/Discover/on-this-day.md
Normal file
52
docs/Discover/on-this-day.md
Normal 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
115
docs/Discover/rising.md
Normal 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.
|
||||
63
docs/Discover/today-downloads.md
Normal file
63
docs/Discover/today-downloads.md
Normal 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`.
|
||||
58
docs/Discover/top-rated.md
Normal file
58
docs/Discover/top-rated.md
Normal 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
125
docs/Discover/trending.md
Normal 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.
|
||||
Reference in New Issue
Block a user