Aucune action automatique. Le serveur calcule des signaux sur les réponses d'une période, en tire un indice de 0 à 100,
appelle Claude seulement à la demande d'un admin, et n'exclut un joueur que sur décision manuelle.
Une ligne par réponse dans answers :
| Colonne | Origine |
|---|---|
elapsed_ms |
temps annoncé par l'app (déclaratif) |
created_at |
heure du serveur à l'enregistrement (default=now) |
correct |
décidé par le serveur |
question_id → questions.kind |
format, pour écarter le Chrono 30 s (rapid) |
answers_by_player charge toutes les réponses de la période (start ≤ created_at < end), jointes à questions,
triées par created_at, groupées par joueur.
| Paramètre | Règle |
|---|---|
from, to |
dates locales (ANALYTICS_TZ, défaut Europe/Paris) |
| Défaut | to = aujourd'hui, from = to − 27 jours (28 jours inclus) |
| Bornes UTC | day_start(from) à day_start(to + 1) |
| Limites | from > to → 422 ; plus de 366 jours → 422 |
Code : back/app/routers/admin.py (Period, period), back/app/services/cheat.py (answers_by_player), back/app/services/localtime.py (tz, day_start)
Calculés par signals(rows, hard) pour chaque joueur ayant au moins MIN_ANSWERS (30) réponses sur la période.
Sous-ensembles :
| Nom | Définition |
|---|---|
rows |
toutes les réponses de la période |
timed |
kind != "rapid" et elapsed_ms > 0 |
fast |
timed avec elapsed_ms < 900 (FAST_MS) |
slow_correct |
timed, justes, elapsed_ms > 20 000 (SLOW_MS) |
hard_rows |
rows dont la question est dure |
times |
created_at de rows, en UTC |
Question dure (hard_questions) : dans question_stats, plays ≥ 20 et correct < plays × 0,35. Statistiques
de toute la vie de la question (recalculées toutes les 10 minutes par le rollup), pas de la période.
pct(a, b) = round(100 × a / b, 1), ou 0,0 si b = 0.
| Signal | Formule |
|---|---|
answers |
len(rows) |
accuracy_pct |
pct(justes, rows) |
median_ms |
médiane des elapsed_ms de timed ; null si timed est vide |
fast_pct |
pct(fast, timed) |
fast_accuracy_pct |
pct(justes de fast, fast) |
slow_correct_pct |
pct(slow_correct, toutes les réponses justes) |
hard_answers |
len(hard_rows) |
hard_accuracy_pct |
pct(justes de hard_rows, hard_rows) |
time_cv |
cv(elapsed_ms de timed) |
gap_cv |
cv(écarts) ; écarts = secondes entre deux réponses consécutives, gardés seulement s'ils sont < 10 min |
max_per_hour |
max du nombre de réponses par heure pleine (created_at tronqué à l'heure) |
longest_session_min |
plus longue somme d'écarts consécutifs < 10 min (SESSION_GAP), en minutes entières ; un écart ≥ 10 min remet à 0 |
night_pct |
pct(réponses dont l'heure locale ∈ [2, 6[, rows) |
hours_played |
nombre d'heures locales distinctes (0 à 23) |
days_played |
nombre de dates locales distinctes |
Coefficient de variation : cv(v) = round(pstdev(v) / mean(v), 2). null si moins de 10 valeurs ou si la moyenne
vaut 0. Écart-type de population (statistics.pstdev).
Le Chrono 30 s (rapid) est exclu des temps (timed) mais compte dans le volume, la réussite, les questions dures,
les écarts, les sessions, les heures et la nuit.
Code : back/app/services/cheat.py (signals, cv, hard_questions, constantes MIN_ANSWERS, FAST_MS, SLOW_MS, HARD_MAX_ACCURACY, HARD_MIN_PLAYS, SESSION_GAP, NIGHT_HOURS), back/app/services/analytics.py (pct)
FLAGS, score)Indice = min(100, somme des points des signaux allumés). Total théorique : 140.
| Clé | Points | Condition |
|---|---|---|
fast_correct |
30 | fast_pct ≥ 20 et fast_accuracy_pct ≥ 80 |
hard_accuracy |
25 | hard_answers ≥ 15 et hard_accuracy_pct ≥ 85 |
slow_lookup |
15 | slow_correct_pct ≥ 30 et accuracy_pct ≥ 85 |
accuracy |
15 | answers ≥ 100 et accuracy_pct ≥ 95 |
regular_time |
15 | time_cv non nul et < 0,3 |
regular_rhythm |
10 | gap_cv non nul et < 0,3 |
burst |
15 | max_per_hour ≥ 400 |
marathon |
10 | longest_session_min ≥ 180 |
night |
5 | night_pct ≥ 30 |
Tri de la liste : indice décroissant, puis answers décroissant. Les joueurs exclus restent dans la liste avec
excluded: true.
baseline)Médiane (statistics.median) de chaque signal sur tous les joueurs analysés de la période, joueur regardé compris.
Les valeurs null sont ignorées. La liste des clés vient du premier joueur analysé : une clé à null chez lui est
absente de la baseline.
Code : back/app/services/cheat.py (FLAGS, score, baseline, suspects)
Déclenché seulement par POST /api/admin/cheat/{pseudo}/review?from=&to=.
review_player)suspects pour tous les joueurs de la période (même requête que la liste).ValueError → 422 « Pas d'avis possible ».ensure_ascii=False) avec période, joueur (ses signaux),indice_automatique, signaux_allumés (libellés), médiane_des_autres_joueurs (la baseline),joueurs_analysés, inscrit_le (date). Ni pseudo, ni messages.ask_model.None → 503 « Claude n'a pas répondu, réessaie dans un instant ». Rien n'estcheat_reviews, commit.| Paramètre | Valeur |
|---|---|
| Fonction | ask_model (partagée avec la modération des questions) |
| Modèle | CHEAT_MODEL, défaut claude-sonnet-5-5 |
thinking |
{"type": "adaptive"} (think=True) |
max_tokens |
8 000 |
Délai httpx |
120 s |
| En-têtes | x-api-key, anthropic-version: 2023-06-01, anthropic-workspace-id si configuré |
| Retour | concaténation des blocs text (les blocs de réflexion sont ignorés) ; None sans clé, HTTP ≥ 400, erreur réseau ou délai |
SYSTEM)Le modèle reçoit les statistiques d'un joueur et la médiane des autres. Il doit dire si elles évoquent :
Garde-fous : « Un très bon joueur existe aussi », ne pas conclure sur un seul signal, dire ce qui pourrait innocenter.
La consigne explique chaque clé de statistique (dont le repère « un humain est en général au-dessus de 0,5 » pour
time_cv et gap_cv).
Réponse attendue, uniquement en JSON, en français :
{"probability": 0-100, "verdict": "probable"|"possible"|"improbable", "summary": "2 ou 3 phrases", "reasons": [...]}.
parse_review)| Étape | Règle |
|---|---|
| Extraction | première { à dernière } (regex gourmande, DOTALL), json.loads |
probability |
obligatoire ; int(...), bornée à [0, 100] ; absente ou non convertible → échec |
verdict |
gardé s'il vaut probable, possible ou improbable ; sinon déduit : ≥ 70 probable, ≥ 35 possible, sinon improbable |
summary |
str, 1 200 caractères max |
reasons |
éléments non vides, 200 caractères chacun, 6 max |
cheat_reviews| Colonne | Contenu |
|---|---|
user_id |
joueur |
probability, verdict, summary, reasons |
réponse parsée |
signals |
les signaux du joueur seulement (ni baseline, ni indice, ni signaux allumés) |
period |
AAAA-MM-JJ/AAAA-MM-JJ |
model |
CHEAT_MODEL au moment de l'appel |
reviewer_pseudo |
admin qui a demandé |
created_at |
date |
Toutes les demandes sont gardées. La liste montre la plus récente par joueur (latest_reviews, toutes périodes
confondues).
Code : back/app/services/cheat.py (SYSTEM, review_player, parse_review, latest_reviews, review_out), back/app/services/moderation.py (ask_model), back/app/routers/admin.py (cheat_suspects, cheat_review), back/app/models.py (CheatReview)
Table leaderboard_exclusions : une ligne par joueur exclu (user_id en clé primaire), reason, by_pseudo,
created_at. Pas de période : l'exclusion vaut jusqu'à suppression de la ligne.
| Route | Fonction | Effet |
|---|---|---|
POST /api/admin/users/{pseudo}/exclusion {reason} |
exclude |
crée la ligne, ou met à jour reason et by_pseudo si elle existe (created_at inchangé) ; commit ; rebuild_leaderboards(season_id()) |
DELETE /api/admin/users/{pseudo}/exclusion |
restore |
supprime la ligne ; commit ; rebuild_leaderboards(season_id()) |
reason : 3 à 500 caractères (ExclusionIn), contrôlés avant le strip. Le joueur n'est pas notifié.
Les classements vivent dans des sorted sets Redis lb:{saison}:{thème|all}:{world|code pays}. La saison est le mois
UTC (season_id, format AAAA-MM).
| Moment | Comportement |
|---|---|
Réponse d'un joueur exclu (add_season_points) |
season_scores incrémenté en Postgres (thème et all) ; pas de ZINCRBY Redis |
Exclusion ou remise (rebuild_leaderboards) |
supprime les clés Redis de la saison en cours, puis les recharge depuis season_scores en sautant les joueurs exclus |
Lecture d'un classement (GET /leaderboards) |
si la clé Redis est vide, rebuild_leaderboards de la saison demandée, avec les exclusions actuelles |
Conséquences : un exclu n'a ni rang ni score Redis (my_rank nul, my_score 0, « non classé »). La zone
following filtre les 500 premiers du classement mondial, donc il en est absent aussi. XP et niveau ne sont pas
touchés. À la remise, ses season_scores du mois en cours, points marqués pendant l'exclusion compris, reviennent dans
Redis.
close_finished_seasons tourne au démarrage et à chaque rollup (toutes les 10 minutes). Pour chaque saison terminée
et non close, close_season (verrou par UPDATE … WHERE closed = false) :
| Étape | Source | Filtre des exclus |
|---|---|---|
Badges top 1 / 10 / 50 / 100 par thème et all |
Redis, 100 premiers | implicite (absents de Redis) ; repli Postgres si Redis est vide, avec NOT IN excluded |
| Badge top 50 France | Redis lb:{sid}:all:FR, 50 premiers |
implicite ; pas de repli Postgres |
| N°1 des abonnements | season_scores (all) |
NOT IN excluded |
| Bilan du mois (boîte + push à chaque classé) | Redis all:world ; repli Postgres |
implicite ; repli avec NOT IN excluded et score > 0 |
Les exclusions lues sont celles au moment de la clôture. Rien n'est recalculé ensuite.
Code : back/app/services/exclusions.py (exclude, restore, exclusion_of), back/app/services/progress.py (excluded_ids, add_season_points, rebuild_leaderboards, season_id, lb_key), back/app/services/badges.py (close_finished_seasons, close_season, month_report), back/app/routers/leaderboards.py (leaderboard), back/app/routers/admin.py (exclude_from_leaderboards, restore_to_leaderboards), back/app/models.py (LeaderboardExclusion)
| Variable | Défaut | Effet |
|---|---|---|
CHEAT_MODEL |
claude-sonnet-5-5 |
modèle de l'avis |
ANTHROPIC_API_KEY |
vide | vide : tout avis renvoie 503 |
ANTHROPIC_WORKSPACE_ID |
vide | en-tête anthropic-workspace-id |
ANALYTICS_TZ |
Europe/Paris | jours de la période, heures locales, nuit |
Tous les seuils des signaux et de l'indice sont des constantes de cheat.py, pas des réglages.
elapsed_ms = 0) : time_cv nul, le signalregular_time ne peut pas s'allumer. Idem gap_cv avec moins de 10 écarts de moins de 10 minutes.slow_correct_pct : le numérateur exclut le Chrono 30 s, pas le dénominateur. Un joueur qui joue beaucoup au"72.5") : int() échoue, l'avis est perdu (503). Un nombre 72.5 passe (72).rebuild_leaderboards vise le nouveau mois. Si laVérifiés dans le code le 14 octobre 2026.
| Sujet | Documentation | Code |
|---|---|---|
| Mois clos | back/CHEAT.md : « Remettre dans les classements le fait revenir à sa place » |
seule la saison en cours est reconstruite. Les mois clos pendant l'exclusion ne sont pas rattrapés : badges et bilan non rendus. Une exclusion décidée après une clôture ne retire pas les badges déjà donnés |
| Médiane « des autres joueurs » | back/CHEAT.md et clé médiane_des_autres_joueurs envoyée à Claude |
la baseline inclut le joueur regardé |
| Clés de la baseline | — | déduites du premier joueur analysé : median_ms, time_cv ou gap_cv peuvent manquer si ce joueur a null |
| Chiffres gardés avec l'avis | back/CHEAT.md : « gardé avec les chiffres envoyés » |
cheat_reviews.signals ne garde que les signaux du joueur, pas la baseline, l'indice ni les signaux allumés |
| Marathon | back/CHEAT.md : « une session de plus de 3 heures » |
longest_session_min ≥ 180 (3 h pile suffit) |
| Top 50 France | back/CHEAT.md : pas de badge de fin de mois pour un exclu |
vrai tant que Redis est à jour ; si Redis est vide à la clôture, le badge France n'est donné à personne (pas de repli Postgres) |
Docstring rebuild_leaderboards |
« sans les joueurs exclus de ce mois » | l'exclusion n'a pas de mois : ce sont les exclus du moment de l'appel |
Voir aussi : Classements, Modération des questions, Architecture.