Skip to content

Algorithme de recommandation des Éclaireurs (EE)

Objectif

À un lycéen qui a terminé son questionnaire, on propose une liste d'Éclaireurs classés par pertinence, qu'il peut contacter. La liste est recalculée à chaque requête — il n'y a ni table de précalcul, ni invalidation de cache à gérer : quand le lycéen met à jour son questionnaire, la reco est automatiquement à jour à la requête suivante.

Comportement par page

Page Route front Endpoint Randomisation Pagination
Accueil (carousel) /accueil GET /eclaireurs/recommandes?limit=10&seed=<aléatoire> Oui — seed aléatoire à chaque chargement de page Non (carousel 10 EE)
Tous les EE (défaut) /eclaireurs GET /users/eclaireurs (hors algo reco) Non — tri createdAt DESC Oui (9/page desktop)
Tous les EE (recherche) /eclaireurs?search=... GET /eclaireurs/search (Elasticsearch texte) Non Non
EE d'une piste /eclaireurs/:pisteSlug GET /eclaireurs/recommandes?pisteSlug=...&limit=100 Non — seed = lyceenId (stable) Oui (100/page)
Vue piste (carousel) /pistes/:slug GET /eclaireurs/recommandes?pisteSlug=...&limit=10 Non — seed = lyceenId (stable) Non (carousel 10 EE)

Critères

# Critère (côté lycéen) Condition (côté EE) Coef
1 isBoursierSecondaire ∈ {oui, ne-sait-pas} OU boostBudget ∈ {oui, unPeu} l'EE a au moins une sourceOfFinanceStudies dans {boursePrivee, crous} ×1.5
2 pisteSlug fourni en paramètre (vue "EE recommandés dans une piste") parcoursEclaireurs.pisteEtude = pisteSlug filtre dur
3 lastActivityAt >= now - 1M (connecté/actif dans le mois) ×2
4 unansweredConversationCount = N (messages non lus) ×0.5ᴺ, cap N=4
Randomisation seedée ×[0.9,1.2]

Non-cumul du critère #1 : un lycéen boursier et qui a répondu "oui" à la question budget exprime la même intention économique. On n'applique qu'une seule fois le ×1.5, sinon on double-compte le même signal et on déséquilibre vis-à-vis d'un lycéen qui n'en a coché qu'un.

Non-cumul sur sourceOfFinanceStudies : si un EE a à la fois crous et boursePrivee, il reçoit ×1.5 une fois, pas ×2.25. On récompense le profil (EE qui sait parler financement boursé), pas le nombre de cases cochées.

Malus de réactivité (#4) : unansweredConversationCount est le nombre de conversations où l'EE n'a pas encore répondu au lycéen. Ce nombre N est transformé en pénalité exponentielle ×0.5ᴺ, de façon à pénaliser davantage les EE qui laissent plusieurs convs sans réponse. Exemples :

  • N=0 : ×1.0 — pas de malus
  • N=1 : ×0.5 — score ÷2
  • N=2 : ×0.25 — score ÷4
  • … jusqu'au cap N≥4 : ×0.0625 — score ÷16 (plancher)

Le cap à N=4 (0.5⁴ = 0.0625) empêche une exclusion de facto : un EE très peu réactif descend en bas du classement mais reste visible, et peut remonter dès qu'il répond.

Décisions Elasticsearch

score_mode: multiply et boost_mode: replace

  • score_mode: multiply → les coefs des functions se multiplient entre eux. Ça rend les effets indépendants combinables proprement.
  • boost_mode: replace → le score final = produit des fonctions uniquement (le score de la query bool filter vaut toujours 1, on le remplace plutôt que de le multiplier — multiply donnerait le même résultat mais replace exprime mieux l'intention).

Randomisation maîtrisée

  • Seed = lyceenId (pages piste, vue piste) → stable entre visites, différente entre lycéens. La pagination ne remontre pas d'EE déjà vus.
  • Seed aléatoire (accueil) → chaque chargement de la home donne un ordre différent, ce qui évite que les mêmes EE soient toujours en tête du carousel.
  • weight: 1.2 → la randomisation module le score dans [0.9, 1.2] environ, insuffisant pour faire remonter un EE sans coef au-dessus d'un EE avec coef bourse (×1.5). La randomisation départage des égalités, elle ne bouscule pas les signaux forts.