Une question créée passe par : validation du schéma, limite quotidienne, insertion, contrôle de doublon, réponse HTTP,
puis filtre Claude en tâche de fond. Ensuite, trois mécanismes peuvent la remettre en file : signalements, réussite
trop basse, revérification.
| Table | Rôle | Colonnes utiles |
|---|---|---|
questions |
la question | status (pending | published | rejected, défaut published), reports (compteur historique), difficulty, content, answer |
question_reviews |
historique des décisions, jamais effacé | source (model | human | auto), verdict (ok | doute | refus), confidence, reason, suggestion, issues (JSON), reviewer_pseudo, model, difficulty (1-5), suggested_category |
question_reports |
signalements des joueurs | weight, reason, comment (≤ 200), resolved_at, outcome (confirmed | dismissed) ; index unique partiel uq_question_reports_open sur (question_id, user_id) quand resolved_at IS NULL |
question_stats |
parties et réussite, recalculées par le rollup | plays, correct, difficulty |
Seul published est servi (fil, recherche, parties). Une question non publiée n'est visible que par son auteur et les
admins (load_question, sinon 404).
Codes issues acceptés : wrong_answer, ambiguous, options_not_wrong, language, offensive, personal_data,
duplicate, low_accuracy, reported, wrong_theme.
Code : back/app/models.py (Question, QuestionReview, QuestionReport), back/app/services/questions.py (load_question), back/app/services/moderation.py (ISSUES)
POST /questionsOrdre exact dans create_question :
NewQuestionIn), sinon 422.is_team = pseudo @slidz ou is_admin.content / answer (build_question), position = max + 1, timer_seconds selon le format.flush) : published pour l'équipe, pending sinon.rejected + revue, commit, puis 422.c_create (bump_challenge), commit.review_in_background ajouté aux BackgroundTasks. La réponse 201 part avec review = doute « Vérification en cours ».L'équipe ne passe ni par la limite, ni par le doublon, ni par Claude. Aucune revue n'est créée pour ses questions.
NewQuestionIn)| Champ / format | Règle serveur |
|---|---|
question |
1 à 300 caractères |
explanation |
≤ 500 caractères ; vrai/faux : ≥ 10 caractères après strip |
kind |
qcm (défaut), true_false, estimate, order |
| QCM | options : 2 à 6 ; 0 ≤ correct_index < len(options) |
| Vrai/faux | truth obligatoire |
| Estimation | value, min, max entiers, min < value < max ; unit ≤ 20 ; tolerance ≥ 0, défaut round((max − min) × 0,05) ; refus si tolerance > (max − min) // 10 |
| Ordre | exactement 4 items {label ≤ 80, detail ≤ 40}, labels distincts (minuscules, strip) |
Ordre : les éléments arrivent dans le bon ordre. order_question les mélange avec random.Random(seed=texte) et
remélange tant que le résultat est l'ordre d'origine. answer.order garde l'inverse de la permutation.
Chronos (TIMERS) : QCM 15 s, vrai/faux 5 s, estimation et ordre sans chrono.
| Joueur | Réglage | Défaut |
|---|---|---|
| Gratuit | QUESTIONS_PER_DAY |
10 |
Slidz+ (user.subscribed) |
QUESTIONS_PER_DAY_PLUS |
30 |
| Équipe | — | pas de limite |
Compte : COUNT(questions) de l'auteur avec created_at ≥ player_midnight(user) (minuit dans le fuseau du joueur,
converti en UTC). Tous statuts confondus : les doublons refusés et les questions refusées comptent.
Code : back/app/routers/creation.py (create_question, build_question, created_today), back/app/schemas.py (NewQuestionIn, OrderItemIn), back/app/services/questions.py (TIMERS, default_tolerance, max_tolerance, order_question), back/app/services/players.py (is_team), back/app/clock.py (player_midnight)
| Étape | Fonction | Détail |
|---|---|---|
| Texte | dedup_text |
l'énoncé ; pour order, énoncé + les 4 options triées alphabétiquement |
| Normalisation | normalize |
NFKD, suppression des diacritiques, minuscules, tout ce qui n'est pas [a-z0-9] devient un espace, strip |
| Mots porteurs | content_words |
mots de la clé, sauf STOPWORDS et mots d'une lettre ; les nombres sont toujours gardés |
STOPWORDS : articles, prépositions, pronoms et interrogatifs courants (« le », « de », « quelle », « combien »,
« pour », « entre »…), 67 mots distincts, liste en dur.
same_question)Deux textes sont la même question si :
Aucun seuil de ressemblance n'entre dans la décision.
find_duplicate)| Base | Candidats |
|---|---|
| PostgreSQL | similarity(questions.text, texte_brut) > 0,6 (TRGM_SIMILARITY, pg_trgm), triés par similarité décroissante, 5 au plus |
| Autre (SQLite des tests) | toutes les questions |
Puis same_question est appliqué à chaque candidat ; le premier qui correspond est le doublon.
Périmètre selon l'appelant :
| Appelant | Questions comparées |
|---|---|
| Création par un joueur | published seulement, la question elle-même exclue |
Import du contenu officiel (import_files) |
tous statuts (published_only=False), plus un index en mémoire du passage en cours : clé exacte globale, puis same_question dans le même thème |
Doublon à la création : statut rejected, revue auto / refus / « Une question très proche existe déjà » /
issues=["duplicate"], puis 422 avec {message, existing_id, existing_text}. Pas d'appel à Claude.
Code : back/app/services/duplicates.py (dedup_text, normalize, content_words, same_question, find_duplicate), back/app/services/content.py (_Index, _known_duplicate, import_files), back/alembic/versions/0001_initial.py (extension pg_trgm)
| Paramètre | Valeur |
|---|---|
| Transport | httpx, POST https://api.anthropic.com/v1/messages, pas de SDK |
| En-têtes | x-api-key, anthropic-version: 2023-06-01, anthropic-workspace-id si ANTHROPIC_WORKSPACE_ID est rempli |
| Modèle | MODERATION_MODEL, défaut claude-haiku-5-5 |
max_tokens |
400 |
thinking |
{"type": "disabled"} |
| Délai | 30 s |
| Exécution | BackgroundTasks de FastAPI, après l'envoi de la réponse, dans une session dédiée (SessionLocal) |
ask_model renvoie None sans clé, sur HTTP ≥ 400 (statut et message d'erreur journalisés, 200 caractères max), sur
erreur réseau, délai ou JSON invalide. Sinon : concaténation des blocs text de content.
SYSTEM_PROMPT)Le modèle vérifie une question de quiz grand public en français et répond uniquement par un objet JSON. Critères,
dans l'ordre :
- <id> : <périmètre> de ACTIVE_SCOPES sont ajoutées à la fin du prompt).Règles de verdict : refus si (1), (2) ou (5) échoue ; doute si un fait est incertain (chiffres, dates, records,
faits récents ou de niche) ou si (3) pose problème ; ok seulement avec une confiance réelle. « Ne devine jamais. »
Un mauvais thème n'est jamais un motif de refus : "theme": "<id>" + code wrong_theme.
Consignes par format : vrai/faux entièrement vrai ou faux, sans « souvent » ni « parfois » ; estimation : valeur et
unité exactes, plage qui encadre sans révéler, chiffres qui changent avec le temps en doute ; ordre : chaque
detail vérifié, ordre qui en découle, critère de tri explicite dans la question.
Le modèle ajoute difficulty (1 = tout le monde sait, 3 = amateur éclairé, 5 = spécialiste).
user_message)Une ligne par champ, selon le format. Jamais le pseudo de l'auteur.
| Format | Lignes |
|---|---|
| QCM | Thème : <nom> (<id>), Question, Options : A) … B) … (8 lettres max), Bonne réponse marquée : <lettre ou ?>, Explication de l'auteur |
| Vrai/faux | Format : vrai ou faux, Thème, Affirmation, Réponse marquée : vrai\|faux, Explication |
| Estimation | Format : estimation, Thème, Question, Bonne valeur : <v> <unité>, Plage du curseur : <min> à <max>, Tolérance : ±<t>, Explication |
| Ordre | Format : remets dans l'ordre, Thème, Question, Ordre correct : 1) label (detail) …, Explication |
Explication vide : « aucune ».
parse_verdict)Le parse prend le texte entre la première { et la dernière } (regex gourmande, DOTALL), puis json.loads.
| Champ | Traitement | Si absent ou invalide |
|---|---|---|
verdict |
minuscules ; doit être ok, doute ou refus |
doute |
confidence |
float, borné à [0, 1] |
0,0 |
reason |
strip, 300 caractères max |
« Sans précision » |
suggestion |
strip, 300 caractères max |
null |
issues |
codes de ISSUES seulement, 6 max |
[] |
difficulty |
entier 1 à 5 | null |
theme |
doit être une clé de ACTIVE_SCOPES |
null |
Pas de JSON trouvé ou JSON invalide : doute, confiance 0, raison « Réponse du modèle inexploitable ».
Appel en échec (None) : revue model / doute / « Vérification automatique indisponible ».
apply_model_verdict)Avant : add_model_review. Un theme égal au thème actuel est ignoré. Sinon il va dans suggested_category et
wrong_theme est ajouté aux issues s'il manque.
| Condition, dans l'ordre | Nouveau statut |
|---|---|
verdict == "refus" |
rejected |
ok et confidence ≥ 0,8 (PUBLISH_CONFIDENCE) et pas de suggested_category et MODERATION_MANUAL faux et questions published de l'auteur ≥ MODERATION_MIN_PUBLISHED (3) |
published |
| tout le reste (doute, ok peu sûr, mauvais thème, auteur trop récent, appel en échec, réponse illisible) | pending |
Puis notify_author :
| Cas | Effet |
|---|---|
statut inchangé, ou nouveau statut pending |
rien |
passe published ou rejected |
ligne notifications (kind="moderation", « Ta question est publiée » / « Ta question a été refusée », sous-titre = texte ou raison, 200 caractères) |
et l'ancien statut était pending |
push question en plus (« Ta question est en ligne » / « Ta question n'a pas été retenue ») |
Usage de difficulty : le rollup prend la dernière revue model ou auto qui en a une, la convertit en a priori
(d − 1) / 4 (difficulty_hint) et lisse la difficulté de question_stats :
1 − (correct + 6 × (1 − a priori)) / (plays + 6).
Code : back/app/services/moderation.py (SYSTEM_PROMPT, user_message, ask_model, call_model, parse_verdict, add_model_review, apply_model_verdict, notify_author, review_question, review_in_background, difficulty_hint), back/app/services/skill.py (smoothed_difficulty), back/app/services/analytics.py (rollup_questions), back/app/themes.py (ACTIVE_SCOPES)
Routes /api/admin/moderation, réservées aux admins.
| Route | Fonction | Effet |
|---|---|---|
GET ?status=&sort=&limit=&offset= |
queue |
limit 1 à 200 (défaut 50) ; counts par statut ; dernière revue de chaque question |
POST /{id}/approve |
approve → decide |
revue human / ok / « Approuvée par l'équipe », confiance 1,0 ; published ; signalements en cours dismissed |
POST /{id}/reject {reason} |
reject → decide |
revue human / refus / raison (1 à 300 caractères côté serveur) ; rejected ; signalements confirmed |
PUT /{id} |
edit → apply_edit puis decide |
correction puis published, « Corrigée par l'équipe » ; signalements confirmed |
POST /{id}/recheck |
recheck → review_question |
appel Claude synchrone, puis décision normale (§ 3) |
GET /{id}/reviews |
reviews |
historique, plus récent d'abord |
Tri de la file :
sort |
Ordre |
|---|---|
oldest (défaut) |
questions ayant au moins une revue doute dans leur historique d'abord, puis created_at croissant |
newest |
created_at décroissant |
reports |
questions.reports décroissant (compteur historique), puis created_at |
apply_edit, schéma EditIn)Tous les champs sont facultatifs et appliqués s'ils sont présents. Elle marche quel que soit le statut, y compris
rejected.
| Format | Champs | Contrôle |
|---|---|---|
| Tous | text, explanation, category_id |
thème existant (sinon 422 « Thème inconnu ») ; pas de longueur max sur text ni explanation |
| QCM | options (2 à 6), correct_index |
index dans les choix, aucune option vide |
| Vrai/faux | truth |
— |
| Estimation | value, unit (≤ 20) |
min < value < max ; la plage ne se modifie pas |
| Ordre | items dans le bon ordre |
même nombre d'éléments ; l'ordre d'affichage mélangé est conservé |
ValueError → 422. Pas de contrôle de doublon ni de nouvel appel à Claude après une correction.
« Ranger dans <thème> et publier » de la console = PUT avec category_id seul.
python -m app.cli recheck-all [--status pending|published|rejected] [--author <pseudo>], fonction recheck_all.
Formats CREATABLE_KINDS seulement, par position, pause de 0,3 s entre deux appels, commit après chaque question.
| Statut de départ | Effet |
|---|---|
published |
revue enregistrée ; refus → pending ; ok et doute ne changent rien ; pas de notification |
pending ou rejected |
décision normale (apply_model_verdict), notifications comprises |
| appel en échec | revue « indisponible », statut inchangé |
Sortie : total, ok, doute, refus, indisponible, et nombre de questions remises en attente.
Code : back/app/routers/moderation.py (queue, approve, reject, edit, recheck, reviews), back/app/services/moderation.py (decide, apply_edit, recheck_all, item_out), back/app/schemas.py (EditIn, RejectIn), back/app/cli.py (run_recheck)
POST /questions/{id}/report| Étape | Règle |
|---|---|
| Accès | load_question : question publiée, ou la sienne |
| Signalement déjà ouvert du même joueur | ignoré, 204 |
| Quota | REPORTS_PER_DAY (10) sur 24 h glissantes, tous ses signalements comptés ; sinon 429 |
| Poids | calculé et figé dans weight |
| Écriture | insertion, questions.reports += 1, flush ; IntegrityError (deux envois simultanés) → rollback silencieux |
| Seuil | requeue_if_reported, puis commit |
Motifs (ReportIn.reason) : wrong_answer, ambiguous, offensive, other. Commentaire ≤ 200.
report_weight)| Condition | Poids |
|---|---|
compte de moins de REPORT_MIN_DAYS jours ou days_played < REPORT_MIN_DAYS (3) |
0 |
| sinon | min(1, (confirmed + 1) / (dismissed + 1)), arrondi à 3 décimales |
confirmed et dismissed : nombre de signalements de questions passés du joueur avec cette issue
(reporter_records).
requeue_if_reported)Si la question est published et que la somme des weight des signalements non traités ≥ REPORT_THRESHOLD (3,0) :
revue auto / doute / « Plusieurs joueurs ont signalé cette question » / reported, statut pending. Effet
immédiat, questions de l'équipe comprises. L'auteur n'est pas prévenu (passage vers pending).
| Décision console | outcome des signalements ouverts |
|---|---|
| Approuver (« Remettre ») | dismissed |
| Refuser (« Retirer ») ou Corriger | confirmed |
| Revérifier | aucun changement |
resolve_reports met resolved_at et outcome. Le poids ouvert retombe donc à 0. Le compteur
questions.reports, lui, n'est jamais remis à zéro.
La page « Signalements » lit GET /api/admin/reports?target=question : 1 000 signalements ouverts max (MAX_OPEN),
groupés par question, score = somme des poids, threshold, et pour chaque signalement weight et
trust {account_days, days_played, confirmed, dismissed}. GET /api/admin/reports/counts compte les questions
distinctes avec un signalement ouvert. Il n'y a pas de route pour classer sans suite les signalements d'une question.
Code : back/app/routers/feed.py (report), back/app/services/moderation.py (reporter_records, report_weight, open_weight, requeue_if_reported, resolve_reports), back/app/routers/reports_admin.py (open_reports, counts), back/app/schemas.py (ReportIn)
| Tâche | Quand | Ce qu'elle fait |
|---|---|---|
review_in_background |
après chaque création hors équipe | filtre Claude (§ 3) ; exception journalisée, la question reste pending |
rollup_loop → rollup |
au démarrage puis toutes les 600 s (ROLLUP_EVERY_S) ; verrou Redis SET NX |
recalcule question_stats, paliers, purge, puis requeue_low_quality, puis clôture des saisons |
requeue_low_quality)Requête SQL : questions published, auteur hors équipe (team_condition), plays ≥ 30 (REQUEUE_MIN_PLAYS) et
correct × 100 < plays × 20 (REQUEUE_MAX_ACCURACY).
Pour chacune : si la dernière revue human / ok est postérieure ou égale à la dernière revue auto portant
low_accuracy, elle est ignorée. Sinon : revue auto / doute / « Trop peu de bonnes réponses : la réponse marquée
est peut-être fausse » / low_accuracy, statut pending.
Les seuils 30 et 20 % sont des constantes, pas des réglages.
Code : back/app/services/analytics.py (rollup, rollup_loop, rollup_questions, ROLLUP_EVERY_S), back/app/services/moderation.py (requeue_low_quality, REQUEUE_MIN_PLAYS, REQUEUE_MAX_ACCURACY), back/app/main.py (lancement des tâches)
| Variable | Défaut | Effet |
|---|---|---|
ANTHROPIC_API_KEY |
vide | vide : aucun appel, toute question reste pending avec une revue « indisponible » |
ANTHROPIC_WORKSPACE_ID |
vide | en-tête anthropic-workspace-id |
MODERATION_MODEL |
claude-haiku-5-5 |
modèle du filtre |
MODERATION_MANUAL |
faux | vrai : jamais de publication automatique |
MODERATION_MIN_PUBLISHED |
3 | questions publiées requises pour la publication automatique |
QUESTIONS_PER_DAY / QUESTIONS_PER_DAY_PLUS |
10 / 30 | limite quotidienne |
REPORT_THRESHOLD |
3.0 | somme des poids qui retire du fil |
REPORT_MIN_DAYS |
3 | âge du compte et jours joués minimum pour peser |
REPORTS_PER_DAY |
10 | signalements de questions par 24 h |
Constantes en dur : PUBLISH_CONFIDENCE 0,8, TRGM_SIMILARITY 0,6, REQUEUE_MIN_PLAYS 30, REQUEUE_MAX_ACCURACY 20.
Code : back/app/config.py (Settings)
pendingpublished. Deux questions identiques enpending passent toutes les deux.pg_trgm mais aucun index GIN sur questions.text. Lesimilarity parcourt donc la table.published_count compte les questions déjà publiées. Lors d'une revérification d'unepending (signalements, réussite basse, revérification) ne prévient pasVérifiés dans le code le 14 octobre 2026.
| Sujet | Documentation | Code |
|---|---|---|
| Doublon | back/MODERATION.md § 2 : doublon si similarity > 0.6 (pg_trgm), ou ratio difflib > 0,8 en SQLite |
pg_trgm ne fait que présélectionner 5 candidats ; la décision est « clé normalisée identique ou mêmes mots porteurs » (same_question). En SQLite, toutes les questions sont comparées, pas de difflib |
| Correction | back/MODERATION.md § 7 : PUT limité au QCM |
apply_edit corrige les 4 formats, plus le thème (category_id) |
| Compteur de signalements | back/MODERATION.md § 4 : « la question repart avec un compteur vide » |
seul le poids ouvert repart à 0. questions.reports n'est jamais remis à zéro ; il sert au tri reports et au champ reports de la fiche |
| Revérifier | back/MODERATION.md § 5 : « relance le filtre automatique » |
sur une question publiée, la décision s'applique : refus → rejected, doute / ok < 0,8 / mauvais thème / indisponible → pending. Elle quitte le fil |
recheck-all |
back/MODERATION.md § 7 : « jamais dépubliées » |
refus → pending, donc hors du fil. Les questions rejected passent par la décision normale et peuvent être publiées |
| Limite quotidienne | back/MODERATION.md § 7 : 10 créations par jour |
la question est insérée avant le contrôle de doublon : les doublons refusés comptent dans la limite |
| Unicité des signalements | back/MODERATION.md § 1 : unique par (question, joueur) |
index unique partiel : un seul signalement ouvert ; après traitement, le joueur peut signaler de nouveau |
| Tri « doutes d'abord » | docstring de queue : dernière revue « doute » |
toute question avec au moins une revue doute dans son historique passe devant, même approuvée depuis |
Voir aussi : Triche, Architecture.