GET /feed?category=&cursor= renvoie une page de FEED_PAGE_SIZE questions (10). category vaut vide (Mix),
un identifiant de thème, ou following (questions des auteurs suivis). cursor est la position dans le fil (0, 10, 20…).
Code : back/app/routers/feed.py (feed), back/app/services/feed.py (select_page), back/app/services/skill.py, contrat back/FEED.md.
feed(category, cursor):
pool = questions published (filtrées par in_selection)
si pool vide → page vide, next_cursor = null
si cursor < taille(pool) → page = select_page(...) # questions neuves
si len(page) < 10 → compléter par la boucle # voir « Quand tout est joué »
next_cursor = cursor + 10
select_page:
1. état Redis feed:{user} (load_state)
2. sautées des 7 derniers jours (skipped_recently)
3. candidates (≤ 120, au hasard) (fetch_candidates)
4. note de chaque candidate (boucle dans select_page)
5. tirage de la page + rythme (draw_page)
6. sauvegarde de l'état Redis (save_state)
7. chargement complet des 10 retenues
fetch_candidates. Seulement 5 colonnes (id, category_id, kind, author_id, created_at).
| Filtre | Détail |
|---|---|
| Statut | status = 'published' |
| Sélection | in_selection : Mix = aucun filtre ; thème = category_id ; following = author_id dans les suivis |
| Non jouée | sous-requête NOT EXISTS answers(user, question) |
| Exclues | les 200 dernières servies (Redis served) et les sautées depuis 7 jours |
| Nombre | ORDER BY random() LIMIT FEED_CANDIDATES (120) |
Repli, dans l'ordre :
select_page renvoie [] et le routeur boucle.Sautées : événements feed_skip des 7 derniers jours (SKIP_DAYS), lus dans events.props.question_id.
note = w_thème × intérêt[thème]
+ w_diff × (1 − |difficulté − cible[thème]|)
+ w_frais × fraîcheur
+ w_social × social
+ uniform(0, 0.05)
| Terme | Poids par défaut | Valeur |
|---|---|---|
| Intérêt du thème | 0,45 | voir § 3 (plancher 0,05 ; ×3 pour un thème suivi, donc peut dépasser 1) |
| Écart à la cible | 0,35 | difficulté de la question (§ 5), cible du thème (§ 7) |
| Fraîcheur | 0,10 | 1 si créée depuis ≤ 14 j ; puis 1 − (âge − 14) / 76, plancher 0 (0 à 90 j) ; 0 si sautée récemment |
| Social | 0,10 | 1 si l'auteur est suivi ; 0,5 si aimée ou gardée par un joueur suivi ; sinon 0 |
| Bruit | — | uniforme entre 0 et 0,05 |
Poids : FEED_WEIGHTS="0.45,0.35,0.10,0.10" (ordre : thème, difficulté, fraîcheur, social). Une valeur mal formée
(pas 4 nombres) revient aux défauts (_weights).
interest_by_theme, hors phase de découverte :
brut[thème] = réponses (14 j) + 2 × j'aime + 2 × gardées + 3 × partages (14 j) − 0,5 × sauts (14 j)
intérêt[thème] = max(0.05, brut / max(brut))
si max(brut) ≤ 0 → intérêt = 1 partout
puis, pour chaque thème suivi : intérêt × 3 (FOLLOWED_THEME_WEIGHT)
answers jointe à questions.likes, saves (sans limite de date).events de type share / feed_skip, groupés par props.category.Pendant la découverte, intérêt = 1 partout (avant le ×3 des thèmes suivis).
Active tant que le joueur a moins de FEED_DISCOVERY_ANSWERS réponses (30, toutes réponses confondues).
discovery_rotation), curseur discovery gardé en Redis.
suivi, suivi, autre répété (≈ 2 suivis pour 1 autre).Après la découverte (Mix, plus de 2 thèmes) : à chaque place, avec probabilité FEED_DISCOVERY_SHARE (0,15), le tirage
se limite aux thèmes hors « favoris ». Favoris = les 2 thèmes au meilleur intérêt + les thèmes suivis.
Stockée dans question_stats.difficulty (0 à 1), recalculée par le rollup (analytics.rollup_questions, toutes les 10 min).
a_priori = indice de modération (1..5 → (d − 1) / 4) si présent, sinon 0,55
réussite = (bonnes + 6 × (1 − a_priori)) / (parties + 6)
difficulté = clamp(1 − réussite, 0, 1) # smoothed_difficulty
confiante = parties ≥ 20
| Constante | Valeur | Rôle |
|---|---|---|
PRIOR_DIFFICULTY |
0,55 | a priori sans indice, et valeur d'une question sans ligne question_stats |
PRIOR_PLAYS |
6 | parties fictives du lissage |
CONFIDENT_PLAYS |
20 | difficulty_confident (console seulement) |
EASY_MAX / HARD_MIN |
0,25 / 0,55 | paliers facile < 0,25, dure > 0,55, moyenne entre les deux |
question_reviews de source model ou auto avec difficulty non nul (moderation.difficulty_hint). Les questions importées (content.py) ont aussi un indice (revue auto).parties et bonnes comptent toutes les lignes answers, parties entre potes comprises. Les rejeux n'en créentskill.update_skill, appelé par POST /answers seulement si la question n'était pas déjà jouée. Colonnes
user_category_stats.skill (normalisée 0 à 1) et skill_n.
valeur = bonne ? 0,5 + difficulté : 0 # 0,5 à 1,5 ; temps écoulé = 0
r = 0,3 ^ (1 / (W − 1)) # W = FEED_SKILL_WINDOW = 30 ; la 30e réponse pèse 0,3
S(n) = (1 − r^min(n, W)) / (1 − r)
moy_prec = n > 0 ? skill / 0,7 × 0,9 : 0 # moyenne brute reconstruite
w_old = S(n) × r
moy = (valeur + moy_prec × w_old) / (1 + w_old)
skill = clamp(moy / 0,9 × 0,7, 0, 1)
skill_n = n + 1
Normalisation : une moyenne brute de 0,9 (bonnes réponses sur des questions à 0,40) donne 0,7.
Lue par skills_by_theme : un thème sans réponse (skill_n = 0) ou absent vaut 0,5.
cible = clamp(0,15 + 0,7 × skill, 0,15, 0,85) # target_difficulty
si boost[thème] : cible = min(0,85, cible + 0,2)
Pas de quota par palier : seule la note (§ 2) rapproche la page de la cible.
draw_page, une place après l'autre, jusqu'à 10 :
pool = candidates restantes autorisées par le rythme (allowed) ; si aucune ne l'est, toutes les restantes.wrong_streak ≥ 2) et c'est la première place : faciles (< 0,25) du thème au meilleur intérêt, sinon faciles de tout thème, sinon pas de changement.poids = exp((note − note_max) / T), T = max(0,05, FEED_TEMPERATURE) (0,3).boost et la question est dure (> 0,55), le boost est consommé.Règles de rythme (allowed), sur les 10 derniers thèmes et formats servis (Redis) :
| Règle | Constante |
|---|---|
| Pas 3 fois le même thème d'affilée | NO_REPEAT_THEME = 2 |
| Pas 3 QCM d'affilée | NO_REPEAT_QCM = 2 |
rapid ou order : aucun autre format long dans les 4 précédents |
LONG_FORMATS, LONG_EVERY = 5 |
En fil filtré (thème ou following), l'étape 3 ne s'applique pas. En fil thème, dès que les 2 derniers servis sont
de ce thème, la règle « même thème » écarte toutes les candidates : repli sur toutes les restantes (voir Écarts connus).
skill.record_result, appelé par POST /answers pour une question non jouée :
| Résultat | Effet |
|---|---|
| Bonne | wrong_streak = 0 ; correct_by_cat[thème] += 1 ; à 3 : boost[thème] = 1, compteur remis à 0 |
| Mauvaise ou temps écoulé | wrong_streak += 1 ; correct_by_cat[thème] = 0 |
draw_page remet wrong_streak à 0 dès qu'il a servi la facile. Le boost reste jusqu'à ce qu'une question dure du thème soit tirée.
Clé Redis feed:{user_id} (JSON, TTL 30 jours, réécrite à chaque page et chaque réponse) :
| Champ | Contenu |
|---|---|
served |
200 derniers identifiants servis |
themes, kinds |
10 derniers thèmes et formats servis |
wrong_streak |
erreurs d'affilée |
correct_by_cat |
bonnes d'affilée par thème |
boost |
thèmes à monter d'un cran |
discovery |
curseur du tour de rôle |
Dans feed (routeur), si select_page rend moins de 10 questions, ou si cursor ≥ taille du pool :
questions = pool trié par (position, created_at)
pour i de cursor + len(page) à cursor + 9 :
q = questions[i mod len]
lap = max(1, i // len)
id public = "{q.id}-l{lap}"
POST /answers retire le suffixe (questions.base_id). Une question déjà jouée n'est ni enregistrée ni créditée (voir Parcours d'une réponse).
Une question de la boucle encore jamais jouée rapporte normalement.
serialize.timer_for, à chaque sérialisation d'une question (fil, intro, GET /questions/{id}) :
si kind = rapid ou thème ∈ NO_SURPRISE_TIMER ({"logic"}) → timer_seconds = null
sinon, avec probabilité TIMED_RATIO (0,05) → timer_seconds = TIMED_SECONDS (10)
Le tirage n'est pas mémorisé : le serveur ne contrôle pas le chrono. L'app envoie un payload vide à la fin du temps.
La colonne questions.timer_seconds (QCM 15, vrai/faux 5, autres null) n'est pas envoyée ; elle ne sert qu'au bonus de
vitesse (voir Progression).
Logique dans l'app : app/lib/screens/feed/feed_cards.dart (FeedCards.afterAnswer). Le serveur donne
/config → feed_cards.ad_xp = { enabled, after }.
Affichée si toutes ces conditions sont vraies :
FEED_CARD_AD_XP_ENABLED (vrai) ;kind|session_id) ;sessionsOnDevice ≥ 2) ;session.adsAvailable : ADS_ENABLED, pas abonné, ads_watched < plafond du jour ;FEED_CARD_AD_XP_AFTER réponses dans la session (5) ;after − 1).Récompense côté serveur : services/ads.py (grant, emplacement daily) : XP_AD (500), ADS_PER_DAY (3),
plafond tous emplacements ADS_MAX_PER_DAY (6), XP hors journée de jeu (play=False), jamais de points.
| Variable | Défaut | Effet |
|---|---|---|
FEED_PAGE_SIZE |
10 | questions par page |
FEED_CANDIDATES |
120 | candidates tirées avant notation |
FEED_WEIGHTS |
0.45,0.35,0.10,0.10 |
poids thème, difficulté, fraîcheur, social |
FEED_TEMPERATURE |
0,3 | température du tirage (plancher 0,05) |
FEED_DISCOVERY_SHARE |
0,15 | part tirée hors favoris |
FEED_DISCOVERY_ANSWERS |
30 | réponses avant la fin de la découverte |
FEED_SKILL_WINDOW |
30 | fenêtre de la force par thème |
TIMED_RATIO |
0,05 | part de chronos surprise |
TIMED_SECONDS |
10 | durée du chrono surprise |
FEED_CARD_AD_XP_ENABLED |
vrai | carte pub |
FEED_CARD_AD_XP_AFTER |
5 | réponses avant la carte |
Constantes en dur (feed.py) : INTEREST_DAYS = 14, SKIP_DAYS = 7, FRESH_DAYS = 14, STALE_DAYS = 90,
FOLLOWED_THEME_WEIGHT = 3, 200 servies, TTL 30 jours (skill.SKILL_TTL_S).
share sans props.category (sheets.dart). Le terme « 3 × partages » vaut toujours 0.DELETE question_stats puis réinsertion.plays et correct.feed_skip sur un identifiant -l{n} ne correspond à aucune candidate.cursor ≥ taille du pool, select_page n'est plus appelé dans la session, même si des questions sont publiées entre-temps. Il faut repartir de cursor = 0.allowed les refuse toutes après 2 questions, le repli prend toutes les restantes, et les règles QCM et format long sautent avec.routers/answers.py appelle update_skill et record_result).