mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
dashboard redesign
This commit is contained in:
1 parent
cc916e472a
commit
8aaf4fc3d7
23 files changed
+2326
-1044
No files matched your search
+64
-1
@@ -159,14 +159,77 @@ pub struct PublicProfileOut {
|
||||
pub following: Vec<UserOut>,
|
||||
}
|
||||
|
||||
/// The user's most-rated category.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct FavoriteCategory {
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
/// Aggregate stats over the current user's own ratings, surfaced on the dashboard.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct DashboardStats {
|
||||
/// Mean of the scores the user has given (`None` when they have no ratings).
|
||||
pub average_score: Option<f64>,
|
||||
/// Count of the user's ratings per score, keyed `"1".."10"` (always all 10 keys).
|
||||
pub score_distribution: std::collections::BTreeMap<String, i64>,
|
||||
/// The category the user has rated most (`None` when they have no ratings).
|
||||
pub favorite_category: Option<FavoriteCategory>,
|
||||
/// Ratings the user created in the current calendar month (UTC).
|
||||
pub ratings_this_month: i64,
|
||||
/// Number of distinct categories the user has rated in.
|
||||
pub category_count: i64,
|
||||
}
|
||||
|
||||
/// Custom payload of the `users/dashboard` action.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct DashboardOut {
|
||||
pub user: UserOut,
|
||||
pub rated_count: i64,
|
||||
pub missing_count: i64,
|
||||
pub missing_flavors: Vec<FlavorOut>,
|
||||
pub my_ratings: Vec<RatingOut>,
|
||||
pub stats: DashboardStats,
|
||||
}
|
||||
|
||||
/// One recommended flavor (card payload + the recommendation metadata). Mirrors the
|
||||
/// fields the frontend flavor card reads, plus the prediction + reason.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct RecommendationOut {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub image_url: Option<String>,
|
||||
pub category_name: String,
|
||||
pub category_slug: String,
|
||||
pub average_rating: Option<f64>,
|
||||
pub is_legacy: bool,
|
||||
pub is_available: bool,
|
||||
/// Predicted score for this user (CF) or the flavor's damped community score
|
||||
/// (popularity fallback).
|
||||
pub predicted_score: f64,
|
||||
/// "N tasters like you" count (CF) or the flavor's rating count (popularity).
|
||||
pub contributing_neighbours: i64,
|
||||
/// `"cf"` or `"popular"` — frontend picks the reason copy. Names are never
|
||||
/// surfaced (privacy decision).
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// Item-based "people who liked this flavor also liked…" card, served on
|
||||
/// `GET /api/flavors/{id}/similar/`. Flavor card fields plus the similarity metadata.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct SimilarFlavorOut {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub image_url: Option<String>,
|
||||
pub category_name: String,
|
||||
pub category_slug: String,
|
||||
pub average_rating: Option<f64>,
|
||||
pub is_legacy: bool,
|
||||
pub is_available: bool,
|
||||
/// Adjusted-cosine similarity to the target flavor, in (0, 1].
|
||||
pub similarity: f64,
|
||||
/// Users who rated both this flavor and the target (the confidence count).
|
||||
pub co_raters: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
|
||||
@@ -17,6 +17,7 @@ mod openapi;
|
||||
mod pagination;
|
||||
mod password;
|
||||
mod password_policy;
|
||||
mod recommend;
|
||||
mod routes;
|
||||
mod search;
|
||||
mod service;
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
//! Flavor recommendations: "tasters like you also liked X".
|
||||
//!
|
||||
//! User-based collaborative filtering (CF) with a Bayesian-popularity fallback for
|
||||
//! cold-start users. Kept pure (no DB) so the scoring math is unit-testable; the
|
||||
//! route handler loads the rating matrix and calls [`recommend`].
|
||||
//!
|
||||
//! At the current scale (tens of users, hundreds of ratings) this runs in-process
|
||||
//! per request in well under a millisecond. If the dataset grows by orders of
|
||||
//! magnitude, move the call behind a scheduled job that precomputes per-user results
|
||||
//! — the math here is unchanged.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// One cell of the user×flavor rating matrix.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct RatingInput {
|
||||
pub user_id: i32,
|
||||
pub flavor_id: i32,
|
||||
pub score: f64,
|
||||
}
|
||||
|
||||
/// Where a recommendation came from — drives the frontend copy ("cf" → "tasters like
|
||||
/// you", "popular" → "popular in the community").
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RecSource {
|
||||
/// Collaborative filtering over similar users.
|
||||
Cf,
|
||||
/// Cold-start / fallback Bayesian-popularity ranking.
|
||||
Popular,
|
||||
}
|
||||
|
||||
impl RecSource {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
RecSource::Cf => "cf",
|
||||
RecSource::Popular => "popular",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Recommendation {
|
||||
pub flavor_id: i32,
|
||||
pub predicted_score: f64,
|
||||
/// Number of similar users whose ratings backed this (CF), or the flavor's total
|
||||
/// rating count (popularity). Surfaced as the "N tasters" reason.
|
||||
pub contributing_neighbours: i64,
|
||||
pub source: RecSource,
|
||||
}
|
||||
|
||||
// --- Tunables ----------------------------------------------------------------
|
||||
/// Below this many of their own ratings a user is "cold" → popularity fallback.
|
||||
const MIN_PROFILE: usize = 5;
|
||||
/// A neighbour must share at least this many co-rated flavors to be trusted.
|
||||
const MIN_OVERLAP: usize = 3;
|
||||
/// Significance-weighting cap: similarity is scaled by `min(overlap, K)/K`.
|
||||
const SIG_K: f64 = 5.0;
|
||||
/// A CF candidate needs at least this many contributing neighbours.
|
||||
const MIN_NEIGHBOURS: i64 = 2;
|
||||
/// Bayesian prior strength for the popularity fallback.
|
||||
const BAYES_C: f64 = 5.0;
|
||||
|
||||
/// Recommend up to `limit` flavors for `target`, given the full rating matrix.
|
||||
pub fn recommend(target: i32, ratings: &[RatingInput], limit: usize) -> Vec<Recommendation> {
|
||||
if limit == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// user -> (flavor -> score)
|
||||
let mut by_user: HashMap<i32, HashMap<i32, f64>> = HashMap::new();
|
||||
for r in ratings {
|
||||
by_user
|
||||
.entry(r.user_id)
|
||||
.or_default()
|
||||
.insert(r.flavor_id, r.score);
|
||||
}
|
||||
|
||||
let target_ratings = by_user.get(&target).cloned().unwrap_or_default();
|
||||
let target_rated: HashSet<i32> = target_ratings.keys().copied().collect();
|
||||
|
||||
// Cold start: too thin a profile to trust similarity → popularity.
|
||||
if target_ratings.len() < MIN_PROFILE {
|
||||
return popularity(&target_rated, ratings, limit);
|
||||
}
|
||||
|
||||
let means: HashMap<i32, f64> = by_user
|
||||
.iter()
|
||||
.map(|(&u, rs)| (u, mean(rs.values().copied())))
|
||||
.collect();
|
||||
let target_mean = *means.get(&target).unwrap_or(&0.0);
|
||||
|
||||
// Similarity (positive Pearson, significance-weighted) to each other user.
|
||||
let mut sims: HashMap<i32, f64> = HashMap::new();
|
||||
for (&v, v_ratings) in &by_user {
|
||||
if v == target {
|
||||
continue;
|
||||
}
|
||||
let v_mean = *means.get(&v).unwrap_or(&0.0);
|
||||
let common: Vec<i32> = target_ratings
|
||||
.keys()
|
||||
.filter(|f| v_ratings.contains_key(f))
|
||||
.copied()
|
||||
.collect();
|
||||
if common.len() < MIN_OVERLAP {
|
||||
continue;
|
||||
}
|
||||
let (mut num, mut du, mut dv) = (0.0, 0.0, 0.0);
|
||||
for f in &common {
|
||||
let a = target_ratings[f] - target_mean;
|
||||
let b = v_ratings[f] - v_mean;
|
||||
num += a * b;
|
||||
du += a * a;
|
||||
dv += b * b;
|
||||
}
|
||||
let den = du.sqrt() * dv.sqrt();
|
||||
if den == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let mut sim = num / den;
|
||||
if sim <= 0.0 {
|
||||
continue; // only positively-correlated tasters
|
||||
}
|
||||
sim *= (common.len().min(SIG_K as usize) as f64) / SIG_K;
|
||||
sims.insert(v, sim);
|
||||
}
|
||||
|
||||
// Predict a score for every flavor the target hasn't rated, from its neighbours.
|
||||
let mut acc: HashMap<i32, (f64, f64, i64)> = HashMap::new(); // flavor -> (num, |sim| sum, count)
|
||||
for (&v, &sim) in &sims {
|
||||
let v_mean = *means.get(&v).unwrap_or(&0.0);
|
||||
for (&f, &score) in &by_user[&v] {
|
||||
if target_rated.contains(&f) {
|
||||
continue;
|
||||
}
|
||||
let e = acc.entry(f).or_insert((0.0, 0.0, 0));
|
||||
e.0 += sim * (score - v_mean);
|
||||
e.1 += sim.abs();
|
||||
e.2 += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut recs: Vec<Recommendation> = acc
|
||||
.into_iter()
|
||||
.filter(|(_, (_, den, count))| *count >= MIN_NEIGHBOURS && *den > 0.0)
|
||||
.map(|(flavor_id, (num, den, count))| Recommendation {
|
||||
flavor_id,
|
||||
predicted_score: (target_mean + num / den).clamp(1.0, 10.0),
|
||||
contributing_neighbours: count,
|
||||
source: RecSource::Cf,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// No confident CF candidates → fall back to popularity.
|
||||
if recs.is_empty() {
|
||||
return popularity(&target_rated, ratings, limit);
|
||||
}
|
||||
|
||||
sort_recs(&mut recs);
|
||||
recs.truncate(limit);
|
||||
recs
|
||||
}
|
||||
|
||||
/// Bayesian-average popularity ranking over flavors the target hasn't rated. Damps
|
||||
/// flavors with few ratings toward the global mean so one 10/10 can't top the chart.
|
||||
fn popularity(
|
||||
target_rated: &HashSet<i32>,
|
||||
ratings: &[RatingInput],
|
||||
limit: usize,
|
||||
) -> Vec<Recommendation> {
|
||||
let global_mean = mean(ratings.iter().map(|r| r.score));
|
||||
let mut agg: HashMap<i32, (f64, i64)> = HashMap::new(); // flavor -> (sum, count)
|
||||
for r in ratings {
|
||||
if target_rated.contains(&r.flavor_id) {
|
||||
continue;
|
||||
}
|
||||
let e = agg.entry(r.flavor_id).or_insert((0.0, 0));
|
||||
e.0 += r.score;
|
||||
e.1 += 1;
|
||||
}
|
||||
|
||||
let mut recs: Vec<Recommendation> = agg
|
||||
.into_iter()
|
||||
.map(|(flavor_id, (sum, count))| Recommendation {
|
||||
flavor_id,
|
||||
predicted_score: (BAYES_C * global_mean + sum) / (BAYES_C + count as f64),
|
||||
contributing_neighbours: count,
|
||||
source: RecSource::Popular,
|
||||
})
|
||||
.collect();
|
||||
sort_recs(&mut recs);
|
||||
recs.truncate(limit);
|
||||
recs
|
||||
}
|
||||
|
||||
/// Deterministic ordering: predicted score desc, then more neighbours, then flavor id.
|
||||
fn sort_recs(recs: &mut [Recommendation]) {
|
||||
recs.sort_by(|a, b| {
|
||||
b.predicted_score
|
||||
.partial_cmp(&a.predicted_score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then(b.contributing_neighbours.cmp(&a.contributing_neighbours))
|
||||
.then(a.flavor_id.cmp(&b.flavor_id))
|
||||
});
|
||||
}
|
||||
|
||||
fn mean(values: impl Iterator<Item = f64>) -> f64 {
|
||||
let (sum, n) = values.fold((0.0, 0i64), |(s, n), v| (s + v, n + 1));
|
||||
if n == 0 {
|
||||
0.0
|
||||
} else {
|
||||
sum / n as f64
|
||||
}
|
||||
}
|
||||
|
||||
// --- Item-based CF: "people who liked this flavor also liked…" ---------------
|
||||
/// A flavor co-rated needs at least this many shared raters with the target to be
|
||||
/// trusted — similarity over 1 shared user is noise.
|
||||
const MIN_CORATERS: usize = 2;
|
||||
|
||||
/// One flavor similar to a target flavor.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SimilarFlavor {
|
||||
pub flavor_id: i32,
|
||||
/// Adjusted-cosine similarity in (0, 1]. Higher = more alike.
|
||||
pub similarity: f64,
|
||||
/// How many users rated both this flavor and the target (the confidence count).
|
||||
pub co_raters: i64,
|
||||
}
|
||||
|
||||
/// Flavors most similar to `target_flavor`, by **adjusted cosine** over the user
|
||||
/// rating vectors (each user's ratings mean-centred to cancel rater bias). Pure (no
|
||||
/// DB) and computed on demand; at current scale this is sub-millisecond.
|
||||
///
|
||||
/// Returns only positively-correlated flavors sharing at least [`MIN_CORATERS`]
|
||||
/// raters with the target, ranked by similarity. Empty if the target has too few
|
||||
/// raters to compare against.
|
||||
pub fn similar_flavors(
|
||||
target_flavor: i32,
|
||||
ratings: &[RatingInput],
|
||||
limit: usize,
|
||||
) -> Vec<SimilarFlavor> {
|
||||
if limit == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// user -> mean of their ratings (for mean-centring)
|
||||
let mut by_user: HashMap<i32, Vec<f64>> = HashMap::new();
|
||||
for r in ratings {
|
||||
by_user.entry(r.user_id).or_default().push(r.score);
|
||||
}
|
||||
let user_mean: HashMap<i32, f64> = by_user
|
||||
.iter()
|
||||
.map(|(&u, vs)| (u, mean(vs.iter().copied())))
|
||||
.collect();
|
||||
|
||||
// flavor -> (user -> mean-centred score)
|
||||
let mut by_flavor: HashMap<i32, HashMap<i32, f64>> = HashMap::new();
|
||||
for r in ratings {
|
||||
let centred = r.score - user_mean.get(&r.user_id).copied().unwrap_or(0.0);
|
||||
by_flavor
|
||||
.entry(r.flavor_id)
|
||||
.or_default()
|
||||
.insert(r.user_id, centred);
|
||||
}
|
||||
|
||||
let Some(target_vec) = by_flavor.get(&target_flavor) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut sims: Vec<SimilarFlavor> = Vec::new();
|
||||
for (&f, f_vec) in &by_flavor {
|
||||
if f == target_flavor {
|
||||
continue;
|
||||
}
|
||||
// co-rated users
|
||||
let (mut num, mut dt, mut df, mut co) = (0.0, 0.0, 0.0, 0i64);
|
||||
for (&u, &a) in target_vec {
|
||||
if let Some(&b) = f_vec.get(&u) {
|
||||
num += a * b;
|
||||
dt += a * a;
|
||||
df += b * b;
|
||||
co += 1;
|
||||
}
|
||||
}
|
||||
if (co as usize) < MIN_CORATERS {
|
||||
continue;
|
||||
}
|
||||
let den = dt.sqrt() * df.sqrt();
|
||||
if den == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let sim = num / den;
|
||||
if sim <= 0.0 {
|
||||
continue; // only positively-correlated flavors
|
||||
}
|
||||
sims.push(SimilarFlavor {
|
||||
flavor_id: f,
|
||||
similarity: sim,
|
||||
co_raters: co,
|
||||
});
|
||||
}
|
||||
|
||||
// Deterministic: similarity desc, then more co-raters, then flavor id.
|
||||
sims.sort_by(|a, b| {
|
||||
b.similarity
|
||||
.partial_cmp(&a.similarity)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then(b.co_raters.cmp(&a.co_raters))
|
||||
.then(a.flavor_id.cmp(&b.flavor_id))
|
||||
});
|
||||
sims.truncate(limit);
|
||||
sims
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn r(user_id: i32, flavor_id: i32, score: f64) -> RatingInput {
|
||||
RatingInput {
|
||||
user_id,
|
||||
flavor_id,
|
||||
score,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cold_start_user_gets_popularity() {
|
||||
// Target (1) has only 1 rating → cold. Others rate flavors 10/20.
|
||||
let ratings = vec![
|
||||
r(1, 100, 9.0),
|
||||
r(2, 10, 9.0),
|
||||
r(2, 20, 4.0),
|
||||
r(3, 10, 8.0),
|
||||
r(3, 20, 5.0),
|
||||
];
|
||||
let recs = recommend(1, &ratings, 10);
|
||||
assert!(!recs.is_empty());
|
||||
assert!(recs.iter().all(|x| x.source == RecSource::Popular));
|
||||
// Flavor 10 (avg ~8.5) should outrank flavor 20 (avg ~4.5).
|
||||
assert_eq!(recs[0].flavor_id, 10);
|
||||
// Already-rated flavor 100 is excluded.
|
||||
assert!(recs.iter().all(|x| x.flavor_id != 100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cf_recommends_what_similar_tasters_liked() {
|
||||
// Target (1) has a full profile (>= MIN_PROFILE ratings). Users 2 & 4 share
|
||||
// its taste and both loved flavor 99, which the target hasn't tried. User 3
|
||||
// is an opposite-taste decoy who rated 99 low — must be filtered out.
|
||||
let mut ratings = vec![
|
||||
// target's profile
|
||||
r(1, 1, 9.0),
|
||||
r(1, 2, 8.0),
|
||||
r(1, 3, 7.0),
|
||||
r(1, 4, 9.0),
|
||||
r(1, 5, 6.0),
|
||||
// similar taster 2 (agrees, plus loves 99)
|
||||
r(2, 1, 9.0),
|
||||
r(2, 2, 8.0),
|
||||
r(2, 3, 7.0),
|
||||
r(2, 4, 9.0),
|
||||
r(2, 99, 10.0),
|
||||
// similar taster 4 (>= MIN_OVERLAP, also loves 99) → 2nd neighbour for 99
|
||||
r(4, 1, 9.0),
|
||||
r(4, 2, 8.0),
|
||||
r(4, 3, 7.0),
|
||||
r(4, 99, 10.0),
|
||||
];
|
||||
// opposite-taste user rates 99 low — negative correlation, must be filtered
|
||||
ratings.push(r(3, 1, 1.0));
|
||||
ratings.push(r(3, 2, 2.0));
|
||||
ratings.push(r(3, 3, 3.0));
|
||||
ratings.push(r(3, 99, 1.0));
|
||||
|
||||
let recs = recommend(1, &ratings, 10);
|
||||
assert!(!recs.is_empty(), "expected CF recommendations");
|
||||
assert!(recs.iter().any(|x| x.source == RecSource::Cf));
|
||||
// The standout shared pick (99) should be recommended.
|
||||
let top = &recs[0];
|
||||
assert_eq!(top.flavor_id, 99);
|
||||
assert!(top.contributing_neighbours >= MIN_NEIGHBOURS);
|
||||
assert!(top.predicted_score > 7.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excludes_already_rated_and_respects_limit() {
|
||||
let ratings = vec![
|
||||
r(1, 1, 9.0),
|
||||
r(1, 2, 8.0),
|
||||
r(1, 3, 7.0),
|
||||
r(1, 4, 6.0),
|
||||
r(1, 5, 5.0),
|
||||
r(2, 1, 9.0),
|
||||
r(2, 2, 8.0),
|
||||
r(2, 3, 7.0),
|
||||
r(2, 6, 9.0),
|
||||
r(2, 7, 8.0),
|
||||
r(3, 1, 9.0),
|
||||
r(3, 2, 8.0),
|
||||
r(3, 3, 7.0),
|
||||
r(3, 6, 9.0),
|
||||
r(3, 7, 8.0),
|
||||
];
|
||||
let recs = recommend(1, &ratings, 1);
|
||||
assert_eq!(recs.len(), 1);
|
||||
// never recommend a flavor the target already rated
|
||||
assert!(recs.iter().all(|x| ![1, 2, 3, 4, 5].contains(&x.flavor_id)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn similar_flavors_finds_co_liked() {
|
||||
// Flavors 1 & 2 move together (users who like one like the other); flavor 3
|
||||
// moves opposite. Target = flavor 1 → expect 2 ranked above (or instead of) 3.
|
||||
let ratings = vec![
|
||||
r(10, 1, 9.0),
|
||||
r(10, 2, 9.0),
|
||||
r(10, 3, 2.0),
|
||||
r(11, 1, 8.0),
|
||||
r(11, 2, 8.0),
|
||||
r(11, 3, 3.0),
|
||||
r(12, 1, 3.0),
|
||||
r(12, 2, 2.0),
|
||||
r(12, 3, 9.0),
|
||||
];
|
||||
let sims = similar_flavors(1, &ratings, 10);
|
||||
assert!(!sims.is_empty(), "expected similar flavors");
|
||||
assert_eq!(sims[0].flavor_id, 2, "flavor 2 moves with flavor 1");
|
||||
assert!(sims[0].co_raters >= MIN_CORATERS as i64);
|
||||
// The anti-correlated flavor 3 is filtered out (negative similarity).
|
||||
assert!(sims.iter().all(|s| s.flavor_id != 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn similar_flavors_needs_min_coraters() {
|
||||
// Only one shared rater between flavor 1 and 2 → below MIN_CORATERS → empty.
|
||||
let ratings = vec![r(10, 1, 9.0), r(10, 2, 9.0), r(11, 1, 8.0), r(12, 2, 7.0)];
|
||||
assert!(similar_flavors(1, &ratings, 10).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn similar_flavors_unknown_target_is_empty() {
|
||||
let ratings = vec![r(10, 1, 9.0), r(11, 1, 8.0)];
|
||||
assert!(similar_flavors(999, &ratings, 10).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -9,17 +9,21 @@ use sea_orm::{
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::auth::{AuthUser, OptionalUser};
|
||||
use crate::dto::{FlavorOut, SearchHit};
|
||||
use crate::dto::{FlavorOut, SearchHit, SimilarFlavorOut};
|
||||
use crate::entities::prelude::*;
|
||||
use crate::entities::{category, flavor, rating};
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::media::image_field_url;
|
||||
use crate::pagination::{parse_page, Paginated, DEFAULT_PAGE_SIZE};
|
||||
use crate::recommend::{similar_flavors, RatingInput};
|
||||
use crate::search::{extract_category_slug, query_words, score_relevance};
|
||||
use crate::service::build_flavors;
|
||||
use crate::state::AppState;
|
||||
use crate::web::RequestCtx;
|
||||
|
||||
/// Max "similar flavors" returned by `/flavors/{id}/similar/`.
|
||||
const SIMILAR_LIMIT: usize = 8;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/flavors/", get(list))
|
||||
@@ -28,6 +32,7 @@ pub fn router() -> Router<AppState> {
|
||||
.route("/flavors/newest/", get(newest))
|
||||
.route("/flavors/followed_top/", get(followed_top))
|
||||
.route("/flavors/{id}/", get(retrieve))
|
||||
.route("/flavors/{id}/similar/", get(similar))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -122,6 +127,69 @@ async fn retrieve(
|
||||
dtos.pop().map(Json).ok_or(ApiError::NotFound)
|
||||
}
|
||||
|
||||
/// GET /api/flavors/{id}/similar/
|
||||
///
|
||||
/// Item-based collaborative filtering: "people who liked this flavor also liked…".
|
||||
/// Adjusted-cosine over the rating matrix (see `crate::recommend::similar_flavors`).
|
||||
/// Empty (not 404) when the catalog has too few co-ratings to compare.
|
||||
async fn similar(
|
||||
State(state): State<AppState>,
|
||||
ctx: RequestCtx,
|
||||
OptionalUser(viewer): OptionalUser,
|
||||
Path(id): Path<i32>,
|
||||
) -> ApiResult<Json<Vec<SimilarFlavorOut>>> {
|
||||
// Target must exist.
|
||||
Flavor::find_by_id(id)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
|
||||
// Whole rating matrix (hundreds of rows at current scale) → in-process scoring.
|
||||
let all = Rating::find().all(&state.db).await?;
|
||||
let inputs: Vec<RatingInput> = all
|
||||
.iter()
|
||||
.map(|r| RatingInput {
|
||||
user_id: r.user_id,
|
||||
flavor_id: r.flavor_id,
|
||||
score: r.score as f64,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let sims = similar_flavors(id, &inputs, SIMILAR_LIMIT);
|
||||
if sims.is_empty() {
|
||||
return Ok(Json(Vec::new()));
|
||||
}
|
||||
|
||||
// Resolve ids to card payloads, then re-zip in similarity-ranked order.
|
||||
let sim_ids: Vec<i32> = sims.iter().map(|s| s.flavor_id).collect();
|
||||
let models = Flavor::find()
|
||||
.filter(flavor::Column::Id.is_in(sim_ids))
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
let flavors = build_flavors(&state, &ctx, models, viewer, &[], false, false).await?;
|
||||
let by_id: std::collections::HashMap<i32, _> = flavors.into_iter().map(|f| (f.id, f)).collect();
|
||||
|
||||
let out: Vec<SimilarFlavorOut> = sims
|
||||
.into_iter()
|
||||
.filter_map(|s| {
|
||||
by_id.get(&s.flavor_id).map(|f| SimilarFlavorOut {
|
||||
id: f.id,
|
||||
name: f.name.clone(),
|
||||
image_url: f.image_url.clone(),
|
||||
category_name: f.category_name.clone(),
|
||||
category_slug: f.category_slug.clone(),
|
||||
average_rating: f.average_rating,
|
||||
is_legacy: f.is_legacy,
|
||||
is_available: f.is_available,
|
||||
similarity: s.similarity,
|
||||
co_raters: s.co_raters,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(out))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CategoryParam {
|
||||
category: Option<String>,
|
||||
|
||||
+206
-44
@@ -16,6 +16,7 @@ use axum::extract::{ConnectInfo, Multipart, Path, Query, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::routing::{get, patch, post};
|
||||
use axum::{Json, Router};
|
||||
use chrono::Datelike;
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, ConnectionTrait, DbBackend, EntityTrait, PaginatorTrait,
|
||||
@@ -26,11 +27,15 @@ use serde_json::{json, Value};
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
use crate::auth::{AuthUser, OptionalUser};
|
||||
use crate::dto::{DashboardOut, ProfileCommentOut, PublicProfileOut, UserOut};
|
||||
use crate::dto::{
|
||||
DashboardOut, DashboardStats, FavoriteCategory, ProfileCommentOut, PublicProfileOut,
|
||||
RecommendationOut, UserOut,
|
||||
};
|
||||
use crate::entities::prelude::*;
|
||||
use crate::entities::{flavor, notification, profile_comment, rating, user, user_ip};
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::pagination::{parse_page, Paginated, DEFAULT_PAGE_SIZE};
|
||||
use crate::recommend::{recommend, RatingInput};
|
||||
use crate::service::{
|
||||
build_flavors, build_profile_comments, build_ratings, build_user, build_users,
|
||||
};
|
||||
@@ -91,6 +96,7 @@ pub fn router() -> Router<AppState> {
|
||||
post(confirm_account_deletion),
|
||||
)
|
||||
.route("/users/dashboard/", get(dashboard))
|
||||
.route("/users/recommendations/", get(recommendations))
|
||||
.route("/users/{id}/", get(retrieve))
|
||||
.route("/users/{id}/follow/", post(follow))
|
||||
.route("/users/{id}/unfollow/", post(unfollow))
|
||||
@@ -1315,7 +1321,6 @@ async fn dashboard(
|
||||
AuthUser(uid): AuthUser,
|
||||
) -> ApiResult<Json<DashboardOut>> {
|
||||
let me = load_user(&state, uid).await?;
|
||||
let followed = following_ids(&state, uid).await?;
|
||||
|
||||
let my_ratings_models = Rating::find()
|
||||
.filter(rating::Column::UserId.eq(uid))
|
||||
@@ -1325,10 +1330,24 @@ async fn dashboard(
|
||||
let rated_count = my_ratings_models.len() as i64;
|
||||
let rated_flavor_ids: std::collections::HashSet<i32> =
|
||||
my_ratings_models.iter().map(|r| r.flavor_id).collect();
|
||||
let my_ratings = build_ratings(&state, &ctx, my_ratings_models).await?;
|
||||
|
||||
// Missing flavors: not rated by me, excluding the "Packs and other" category,
|
||||
// ordered by category name then flavor name.
|
||||
// Ratings created in the current UTC month — computed from the models while we
|
||||
// still have proper datetimes (RatingOut.created_at is a formatted string).
|
||||
let now = chrono::Utc::now().naive_utc();
|
||||
let (this_year, this_month) = (now.year(), now.month());
|
||||
let ratings_this_month = my_ratings_models
|
||||
.iter()
|
||||
.filter(|r| r.created_at.year() == this_year && r.created_at.month() == this_month)
|
||||
.count() as i64;
|
||||
|
||||
let my_ratings = build_ratings(&state, &ctx, my_ratings_models).await?;
|
||||
let stats = dashboard_stats(&my_ratings, ratings_this_month);
|
||||
|
||||
// "To try" count: flavors the user hasn't rated, excluding the "Packs and other"
|
||||
// category. We no longer ship the full unrated flavor list — the dashboard's old
|
||||
// "explore" grid is gone (browsing lives on the main flavor list, discovery on the
|
||||
// recommendations endpoint), which drops a large per-request payload + the
|
||||
// per-flavor aggregate pass. Only the count is needed now, so load ids alone.
|
||||
let packs = crate::entities::category::Entity::find()
|
||||
.filter(crate::entities::category::Column::Name.eq("Packs and other"))
|
||||
.one(&state.db)
|
||||
@@ -1337,52 +1356,195 @@ async fn dashboard(
|
||||
if let Some(p) = packs {
|
||||
q = q.filter(flavor::Column::CategoryId.ne(p.id));
|
||||
}
|
||||
let mut all_flavors = q.all(&state.db).await?;
|
||||
all_flavors.retain(|f| !rated_flavor_ids.contains(&f.id));
|
||||
let missing_count = q
|
||||
.all(&state.db)
|
||||
.await?
|
||||
.iter()
|
||||
.filter(|f| !rated_flavor_ids.contains(&f.id))
|
||||
.count() as i64;
|
||||
|
||||
// Order by category name then flavor name (resolve category names first).
|
||||
let cat_ids: Vec<i32> = all_flavors.iter().map(|f| f.category_id).collect();
|
||||
let cat_names: std::collections::HashMap<i32, String> =
|
||||
crate::entities::category::Entity::find()
|
||||
.filter(crate::entities::category::Column::Id.is_in(cat_ids))
|
||||
.all(&state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|c| (c.id, c.name))
|
||||
.collect();
|
||||
all_flavors.sort_by(|a, b| {
|
||||
let ca = cat_names
|
||||
.get(&a.category_id)
|
||||
.map(String::as_str)
|
||||
.unwrap_or("");
|
||||
let cb = cat_names
|
||||
.get(&b.category_id)
|
||||
.map(String::as_str)
|
||||
.unwrap_or("");
|
||||
ca.cmp(cb).then_with(|| a.name.cmp(&b.name))
|
||||
});
|
||||
|
||||
let missing_count = all_flavors.len() as i64;
|
||||
// The dashboard "explore" grid sorts these by community / followed average
|
||||
// but never reads the nested ratings, and this query loads *every* unrated
|
||||
// flavor — so skip building a RatingOut per rating (large payload win).
|
||||
let missing_flavors = build_flavors(
|
||||
&state,
|
||||
&ctx,
|
||||
all_flavors,
|
||||
Some(uid),
|
||||
&followed,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
let user = build_user(&state, &ctx, me, Some(uid)).await?;
|
||||
|
||||
Ok(Json(DashboardOut {
|
||||
user,
|
||||
rated_count,
|
||||
missing_count,
|
||||
missing_flavors,
|
||||
my_ratings,
|
||||
stats,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Max recommendations returned by `/users/recommendations/`.
|
||||
const RECOMMENDATION_LIMIT: usize = 12;
|
||||
|
||||
/// GET /api/users/recommendations/
|
||||
///
|
||||
/// "Tasters like you also liked…" — user-based collaborative filtering with a
|
||||
/// Bayesian-popularity cold-start fallback (see `crate::recommend`).
|
||||
async fn recommendations(
|
||||
State(state): State<AppState>,
|
||||
ctx: RequestCtx,
|
||||
AuthUser(uid): AuthUser,
|
||||
) -> ApiResult<Json<Vec<RecommendationOut>>> {
|
||||
// Whole rating matrix (hundreds of rows at current scale) → in-process scoring.
|
||||
let all = Rating::find().all(&state.db).await?;
|
||||
let inputs: Vec<RatingInput> = all
|
||||
.iter()
|
||||
.map(|r| RatingInput {
|
||||
user_id: r.user_id,
|
||||
flavor_id: r.flavor_id,
|
||||
score: r.score as f64,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let recs = recommend(uid, &inputs, RECOMMENDATION_LIMIT);
|
||||
if recs.is_empty() {
|
||||
return Ok(Json(Vec::new()));
|
||||
}
|
||||
|
||||
// Resolve the recommended flavor ids to card payloads, then re-zip with the
|
||||
// recommendation metadata in the engine's ranked order.
|
||||
let rec_ids: Vec<i32> = recs.iter().map(|r| r.flavor_id).collect();
|
||||
let models = Flavor::find()
|
||||
.filter(flavor::Column::Id.is_in(rec_ids.clone()))
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
let followed = following_ids(&state, uid).await?;
|
||||
let flavors = build_flavors(&state, &ctx, models, Some(uid), &followed, false, false).await?;
|
||||
let by_id: std::collections::HashMap<i32, _> = flavors.into_iter().map(|f| (f.id, f)).collect();
|
||||
|
||||
let out: Vec<RecommendationOut> = recs
|
||||
.into_iter()
|
||||
.filter_map(|rec| {
|
||||
by_id.get(&rec.flavor_id).map(|f| RecommendationOut {
|
||||
id: f.id,
|
||||
name: f.name.clone(),
|
||||
image_url: f.image_url.clone(),
|
||||
category_name: f.category_name.clone(),
|
||||
category_slug: f.category_slug.clone(),
|
||||
average_rating: f.average_rating,
|
||||
is_legacy: f.is_legacy,
|
||||
is_available: f.is_available,
|
||||
predicted_score: rec.predicted_score,
|
||||
contributing_neighbours: rec.contributing_neighbours,
|
||||
reason: rec.source.as_str().to_string(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(out))
|
||||
}
|
||||
|
||||
/// Aggregate the dashboard stats block from the user's own ratings. `this_month` is
|
||||
/// passed in because it's computed from the rating models (which carry real
|
||||
/// datetimes) before they're turned into the string-timestamped `RatingOut`s.
|
||||
fn dashboard_stats(my_ratings: &[crate::dto::RatingOut], this_month: i64) -> DashboardStats {
|
||||
let mut score_distribution: std::collections::BTreeMap<String, i64> =
|
||||
(1..=10).map(|s| (s.to_string(), 0)).collect();
|
||||
let mut sum: i64 = 0;
|
||||
// category slug -> (display name, count)
|
||||
let mut cat_counts: std::collections::HashMap<String, (String, i64)> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for r in my_ratings {
|
||||
if (1..=10).contains(&r.score) {
|
||||
*score_distribution.get_mut(&r.score.to_string()).unwrap() += 1;
|
||||
}
|
||||
sum += r.score as i64;
|
||||
let entry = cat_counts
|
||||
.entry(r.category_slug.clone())
|
||||
.or_insert((r.category_name.clone(), 0));
|
||||
entry.1 += 1;
|
||||
}
|
||||
|
||||
let n = my_ratings.len() as i64;
|
||||
let average_score = (n > 0).then(|| sum as f64 / n as f64);
|
||||
// Highest count wins; ties broken by slug so the result is deterministic
|
||||
// (HashMap iteration order isn't).
|
||||
let favorite_category = cat_counts
|
||||
.into_iter()
|
||||
.max_by(|(slug_a, (_, count_a)), (slug_b, (_, count_b))| {
|
||||
count_a.cmp(count_b).then_with(|| slug_b.cmp(slug_a))
|
||||
})
|
||||
.map(|(slug, (name, count))| FavoriteCategory { name, slug, count });
|
||||
|
||||
let category_count = my_ratings
|
||||
.iter()
|
||||
.map(|r| r.category_slug.as_str())
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.len() as i64;
|
||||
|
||||
DashboardStats {
|
||||
average_score,
|
||||
score_distribution,
|
||||
favorite_category,
|
||||
ratings_this_month: this_month,
|
||||
category_count,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dashboard_stats_tests {
|
||||
use super::*;
|
||||
use crate::dto::RatingOut;
|
||||
|
||||
fn rating(score: i32, cat_slug: &str, cat_name: &str) -> RatingOut {
|
||||
RatingOut {
|
||||
id: 0,
|
||||
user: String::new(),
|
||||
user_id: 0,
|
||||
user_avatar: None,
|
||||
flavor: 0,
|
||||
flavor_name: String::new(),
|
||||
flavor_image: None,
|
||||
category_name: cat_name.into(),
|
||||
category_slug: cat_slug.into(),
|
||||
is_available: true,
|
||||
is_legacy: false,
|
||||
score,
|
||||
comment: None,
|
||||
created_at: String::new(),
|
||||
replies: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_ratings_yield_zeroed_stats() {
|
||||
let s = dashboard_stats(&[], 0);
|
||||
assert_eq!(s.average_score, None);
|
||||
assert!(s.favorite_category.is_none());
|
||||
assert_eq!(s.category_count, 0);
|
||||
assert_eq!(s.ratings_this_month, 0);
|
||||
// Distribution still carries all 10 keys, all zero.
|
||||
assert_eq!(s.score_distribution.len(), 10);
|
||||
assert!(s.score_distribution.values().all(|&v| v == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregates_average_distribution_and_favorite() {
|
||||
let ratings = vec![
|
||||
rating(10, "fruity", "Fruity"),
|
||||
rating(8, "fruity", "Fruity"),
|
||||
rating(6, "minty", "Minty"),
|
||||
];
|
||||
let s = dashboard_stats(&ratings, 2);
|
||||
assert_eq!(s.average_score, Some(8.0));
|
||||
assert_eq!(s.score_distribution["10"], 1);
|
||||
assert_eq!(s.score_distribution["8"], 1);
|
||||
assert_eq!(s.score_distribution["6"], 1);
|
||||
assert_eq!(s.score_distribution["1"], 0);
|
||||
assert_eq!(s.category_count, 2);
|
||||
assert_eq!(s.ratings_this_month, 2);
|
||||
let fav = s.favorite_category.unwrap();
|
||||
assert_eq!(fav.slug, "fruity");
|
||||
assert_eq!(fav.count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn favorite_category_tie_breaks_by_slug_deterministically() {
|
||||
// Equal counts (1 each) → smaller slug ("apple") wins, stable across runs.
|
||||
let ratings = vec![rating(5, "banana", "Banana"), rating(5, "apple", "Apple")];
|
||||
let fav = dashboard_stats(&ratings, 0).favorite_category.unwrap();
|
||||
assert_eq!(fav.slug, "apple");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user