KutsumKutsum
Accueil
Utilisation de l'appli
Création de questions
Installation
Détails techniques
Accueil
Utilisation de l'appli
Création de questions
Installation
Détails techniques
  • Détails techniques (utilisateurs avancés seulement)

    • Détails techniques (utilisateurs avancés seulement)
    • Architecture générale
    • 🖼️ Gestion des images
    • Base de données
    • Génération IA / LLM
    • Système de scoring
    • Services backend
    • API REST
    • API Analytics (dashboard admin)
    • Configuration et environnement
    • Tests et qualité
    • Tests E2E (Playwright) — runbook
    • Tests backend (Jest)
    • Tests frontend (Jest)
    • Déploiement et DevOps
    • Security Documentation
    • Performance & Monitoring
    • Troubleshooting Guide
    • Moodle et LTI 1.3
    • Multi-tenant Kutsum
    • Éditeur de Questions pour Enseignants — note d'architecture
    • Landing Page Variants (App)
    • Types de questions et flux de correction
    • Compilation du validationConfig MathALÉA

Génération IA / LLM

Cette page documente le feature de génération de questions par LLM : feature flag tenant, stockage des clés API, résolution des providers, endpoints REST et runbooks local et production.

Vue d'ensemble

Le feature permet à un enseignant :

  • d'enregistrer une clé API LLM personnelle depuis /profile
  • de tester explicitement cette clé depuis /profile avant usage, s'il le souhaite
  • de générer des questions depuis /teacher/questions
  • d'importer dans sa bibliothèque personnelle uniquement les questions qui passent la validation canonique Kutsum

Le système est volontairement tenant-gated : rien n'apparaît tant que settings.aiGeneration n'est pas explicitement activé pour le tenant.

L'activation existante est au scope tenant. Activer settings.aiGeneration.enabled=true et allowUserKeys=true pour un tenant expose la gestion des clés LLM à tous les enseignants de ce tenant ; il n'existe pas encore de rollout natif par allowlist utilisateur.

Contrat tenant

Le feature est piloté par Organization.settings.aiGeneration.

Champs canoniques :

{
  "enabled": true,
  "allowUserKeys": true,
  "allowTenantKey": false,
  "useGlobalKey": false,
  "allowedProviders": ["albert", "openai", "deepseek", "gemini"],
  "maxTokensPerRequest": 4096,
  "maxPromptsPerUserPerDay": 100,
  "maxPromptsPerTenantPerDay": 800,
  "maxPromptsPerTenantPerHour": 100
}

Sémantique :

  • allowUserKeys contrôle la visibilité et l'accès CRUD du formulaire LlmApiKeyForm
  • enabled contrôle la disponibilité réelle de la génération IA
  • allowedProviders borne les providers visibles et résolvables
  • allowTenantKey et useGlobalKey activent la résolution des clés partagées TENANT/GLOBAL (todo 280) — voir Résolution des clés pour le garde-fou email requis, et Rate limiting et quotas pour maxPromptsPerTenantPerDay/PerHour

Le frontend expose seulement le sous-ensemble public via GET /api/v1/tenants/:subdomain/config.

Résolution des clés

La résolution du provider actif suit l'ordre :

  1. clé USER
  2. clé TENANT
  3. clé GLOBAL

La résolution s'arrête au premier match autorisé par settings.aiGeneration.

Garde-fou email pour les clés partagées TENANT/GLOBAL (todo 280). Une clé TENANT/GLOBAL est une ressource payante partagée par tout le tenant — son quota est celui du compte fournisseur (ex. Albert limite par compte, pas par utilisateur Kutsum), et un compte GUEST ou un TEACHER éphémère (sans email, ex. import MathALEA) peut être recréé sans friction, rendant un quota par-utilisateur seul insuffisant contre l'abus. En conséquence, la résolution TENANT/GLOBAL exige un compte avec email !== null && emailVerified === true (hasVerifiedEmailForSharedKey, aiQuestionGenerationService.ts) ; à défaut, la résolution échoue silencieusement (comme « aucune clé disponible », 403 NO_API_KEY) sans jamais révéler qu'une clé partagée existe. Ce garde-fou ne s'applique pas aux clés USER (BYOK) : un enseignant sans email vérifié garde l'usage de sa propre clé.

En v1 locale, le runbook active seulement la gestion de clés personnelles (allowUserKeys=true).

Providers supportés

Providers actuellement câblés :

ProviderModèle de facturationAdapter
albertprompt (par requête LLM)OpenAI-compatible
openaitokenOpenAI-compatible
deepseektokenOpenAI-compatible
geminitokenOpenAI-compatible
mistraltokenOpenAI-compatible
anthropictokennatif (@anthropic-ai/sdk, API Messages)

Le modèle de facturation (billingModel) a une importance architecturale : voir section Rate limiting et quotas.

Résolution du modèle par défaut. Aucun modèle n'est codé en dur comme source de vérité. Quand l'appelant ne choisit pas de modèle, l'adapter résout un défaut depuis la liste vivante /v1/models du provider (mise en cache 1 h) en suivant une liste de préférences ordonnée (defaultModelPreference, PROVIDER_REGISTRY dans llmService.ts). Si la liste est indisponible et qu'aucun modèle n'est choisi, une erreur explicite est levée (sélection forcée) plutôt qu'un appel avec un modèle deviné. Un provider custom peut fournir un defaultModel côté tenant, utilisé tel quel.

Pour Albert, la préférence est ['openai/gpt-oss-120b', /gpt-oss-120b/i, /^albert/] (todo 280) — Albert proxie des modèles tiers sous des identifiants préfixés par le vendeur (ex. "openai/gpt-oss-120b", "mistralai/Mistral-Small-3.2-24B") : aucun ne commence réellement par "albert", donc l'ancienne préférence [/^albert/] seule ne matchait jamais rien et retombait sur le premier modèle arbitraire de la liste vivante. gpt-oss-120b est préféré explicitement quand il est disponible dans le catalogue Albert du moment.

Conséquence pratique :

  • tous les providers ci-dessus sont opérationnels en local
  • anthropic n'est pas OpenAI-compatible : il a son propre adapter (format Messages, streaming content_block_delta, validation via GET /v1/models)

Endpoints REST

Gestion des clés utilisateur

  • GET /api/v1/llm-api-keys
  • POST /api/v1/llm-api-keys
  • POST /api/v1/llm-api-keys/test
  • DELETE /api/v1/llm-api-keys/:id

Tous ces endpoints exigent :

  • authentification enseignant
  • tenant résolu
  • settings.aiGeneration.allowUserKeys === true

Le stockage est chiffré en AES-256-GCM via LLM_KEY_MASTER_KEY.

Le consentement explicite est séparé du stockage :

  • POST /api/v1/llm-api-keys enregistre la clé sans contacter le provider
  • POST /api/v1/llm-api-keys/test effectue le ping provider sur demande explicite de l'enseignant

Génération de questions

  • GET /api/v1/ai/access
  • GET /api/v1/ai/models
  • POST /api/v1/ai/generate-questions

GET /ai/access retourne les providers effectivement disponibles pour l'utilisateur courant. Chaque availableKeyOptions[] porte un quotaRemaining: number | null — le nombre de générations restantes avant que le quota Kutsum ne bloque cette option aujourd'hui (min entre le quota par-utilisateur et le quota tenant-wide, jour/heure), null si aucun plafond n'est configuré ou si l'option est USER (BYOK, jamais quotée par Kutsum). Utilisé par le bandeau GenerateQuestionsForm pour afficher un compteur exact (todo 280). Le label d'une option TENANT/GLOBAL (buildFallbackLabel) est tenant-aware : "IA Kutsum (Albert)" sur le tenant public app — provider nommé (forme courte, "Albert" pas "Albert (Étalab)") car c'est aujourd'hui un choix fixe et connu, et certains enseignants veulent explicitement le savoir. Sur un tenant nommé réel (ex. UTBM) le provider n'est pas nommé ("IA de l'établissement") : chaque établissement choisit le sien, potentiellement autre chose qu'Albert, potentiellement amené à changer — le figer dans le label le rendrait obsolète. Même raisonnement pour GLOBAL ("IA partagée", cross-tenant par nature).

GET /ai/models?provider=&keyId= retourne la liste vivante des modèles de chat ({ models }) du provider sélectionné, filtrée des modèles non-chat (embeddings, TTS, modération, etc.), pour alimenter le dropdown de choix de modèle. Échecs souples (provider sans endpoint /v1/models, injoignable) → 200 { models: [] } (le frontend retombe sur sa liste de modèles courants codée en dur) ; aucune clé résolue → 403 NO_API_KEY.

POST /ai/generate-questions :

  • construit le prompt système et utilisateur côté backend
  • appelle le provider LLM
  • parse la réponse JSON
  • valide chaque question avec les schémas canoniques Kutsum
  • upsert les questions valides dans la bibliothèque personnelle
  • journalise les appels réseau utilisés pour les quotas de prompts

Prompting et validation

Les templates de prompt sont stockés côté backend dans app/backend/src/core/services/promptTemplates/.

Composants clés :

  • aiPromptTemplates.ts : assemble les prompts système et utilisateur
  • aiQuestionGenerationService.ts : résolution de clé, quotas, appel provider, parsing, upsert
  • llmService.ts : abstraction provider

Contraintes importantes :

  • la requête frontend ne fournit qu'un prompt libre, le nombre de questions, les types, le provider et l'option includeExplanation
  • le backend impose le schéma JSON attendu
  • les questions invalides sont rejetées, seules les valides sont upsertées

Rate limiting et quotas

Trois mécanismes indépendants protègent la génération IA.

1. Burst HTTP — protection infrastructure

Limite le nombre d'appels POST /api/v1/ai/generate-questions par utilisateur et par heure.

  • Scope : par utilisateur (clé Redis = userId), indépendant du scope de la clé LLM
  • Défaut : 60 requêtes/heure
  • Configurable via AI_GENERATION_RATE_LIMIT_MAX_REQUESTS_PER_HOUR
  • En cas de dépassement, le backend retourne 429 avec code: 'API_RATE_LIMIT_EXCEEDED'

Ce mécanisme est purement technique (anti-abus). Il ne distingue pas les providers ni les scopes de clé.

2. Quota journalier de prompts — protection coût fournisseur

Limite le nombre de prompts LLM par utilisateur et par jour, pour les providers facturés par requête (billingModel: 'prompt', actuellement Albert uniquement).

Règle de scope :

Scope de la cléQuota journalier appliqué ?
USER (clé personnelle)Non — l'utilisateur gère lui-même son quota provider
TENANT (clé du tenant — établissement réel, ou Kutsum lui-même sur app)Oui, si maxPromptsPerUserPerDay est défini
GLOBAL (clé plateforme)Oui, si maxPromptsPerUserPerDay est défini

Pour les providers token-billed (OpenAI, Gemini, DeepSeek, Mistral), ce quota ne s'applique pas : la facturation est directement gérée par le compte provider de l'utilisateur.

Configuration :

Le quota journalier est configurable à deux niveaux (le tenant prime sur l'env) :

// Organization.settings.aiGeneration
{ "maxPromptsPerUserPerDay": 50 }
# Fallback si le tenant n'a pas défini de limite
AI_DEFAULT_MAX_PROMPTS_PER_USER_PER_DAY=100

En cas de dépassement, le backend retourne 429 avec code: 'PROMPT_QUOTA_EXCEEDED'.

3. Quota tenant-wide de prompts — protection du compte fournisseur partagé (todo 280)

Le quota journalier de la section précédente est par utilisateur : il ne protège pas le compte fournisseur lui-même contre un dépassement agrégé sur tout le tenant (ex. Albert limite son quota par compte Albert, pas par utilisateur Kutsum — N utilisateurs sous leur propre plafond peuvent collectivement dépasser le plafond réel du compte). Ce troisième mécanisme compte tous les utilisateurs confondus pour une clé TENANT/GLOBAL donnée, sur une fenêtre glissante jour et heure indépendantes.

Règle de scope : comme le quota par-utilisateur, ne s'applique qu'aux providers prompt-billed (Albert) et seulement aux clés TENANT/GLOBAL — jamais aux clés USER (BYOK).

Configuration :

// Organization.settings.aiGeneration
{ "maxPromptsPerTenantPerDay": 800, "maxPromptsPerTenantPerHour": 100 }
# Fallback si le tenant n'a pas défini de limite
AI_DEFAULT_MAX_PROMPTS_PER_TENANT_PER_DAY=800
AI_DEFAULT_MAX_PROMPTS_PER_TENANT_PER_HOUR=100

Un plafond non défini (ni côté tenant, ni côté env) = pas de limite tenant-wide côté Kutsum pour cette fenêtre (jour ou heure) — seul le quota par-utilisateur et le burst HTTP restent actifs. Même mapping d'erreur que le quota par-utilisateur : 429 / code: 'PROMPT_QUOTA_EXCEEDED'.

Comment choisir la valeur. Vérifier le plafond réel accordé par le fournisseur (pour Albert : GET /v1/me/info, champ limits — dépend du modèle et du palier expérimentation/production limitée, voir Tarifs et limites Albert) et fixer maxPromptsPerTenantPerDay/PerHour confortablement en dessous, pour laisser une marge face à un usage hors Kutsum du même compte fournisseur.

Variables d'environnement

Variables backend liées au feature :

LLM_KEY_MASTER_KEY=<64 hex chars>

# Burst HTTP (protection infrastructure, par user/heure)
AI_GENERATION_RATE_LIMIT_MAX_REQUESTS_PER_HOUR=60

# Quota journalier par utilisateur (TENANT/GLOBAL keys seulement, fallback si absent du tenant)
AI_DEFAULT_MAX_TOKENS_PER_REQUEST=4096
AI_DEFAULT_MAX_PROMPTS_PER_USER_PER_DAY=100

# Quota tenant-wide (tous utilisateurs confondus), TENANT/GLOBAL keys seulement, fallback si absent du tenant
AI_DEFAULT_MAX_PROMPTS_PER_TENANT_PER_DAY=800
AI_DEFAULT_MAX_PROMPTS_PER_TENANT_PER_HOUR=100

Notes :

  • LLM_KEY_MASTER_KEY est obligatoire pour enregistrer ou relire des clés API LLM
  • les variables AI_DEFAULT_* servent uniquement de fallback si le tenant n'a pas défini ses propres limites

Voir aussi Configuration.

Runbook local

1. Préparer l'env backend

Le backend local doit avoir une clé maître LLM. En développement local, app/backend/.env et app/backend/.env.test portent désormais une valeur de dev.

Si le backend tournait déjà avant l'ajout ou la modification de LLM_KEY_MASTER_KEY, le redémarrer.

2. Activer le feature pour le tenant app

Commande canonique :

cd app/backend
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/mathquest npx tsx prisma/enable-local-llm-feature.ts
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/mathquest_test npx tsx prisma/enable-local-llm-feature.ts

Ce helper :

  • cible app par défaut
  • parse les settings existants
  • active aiGeneration.enabled=true
  • active aiGeneration.allowUserKeys=true
  • définit allowedProviders=['albert','openai','deepseek','gemini','mistral']

Pour un autre tenant :

cd app/backend
KUTSUM_TENANT_SUBDOMAIN=utbm DATABASE_URL=postgresql://postgres:postgres@localhost:5432/mathquest npx tsx prisma/enable-local-llm-feature.ts

3. Utiliser un host tenant local

Le frontend ne dérive un tenant que depuis le host demandé. En pratique :

  • http://app.kutsum.local:3008 active le tenant principal
  • http://utbm.kutsum.local:3008 active le tenant UTBM
  • http://localhost:3008 n'a pas de sous-domaine, donc n'injecte pas x-kutsum-tenant

Conséquence : en bare localhost, tenantConfig retombe à null et le formulaire LLM reste masqué.

Le workaround DEV_TENANT_OVERRIDE=app existe pour du dev rapide, mais la validation canonique du feature doit se faire en multi-host. Voir Multi-tenant.

4. Vérifier l'UI

Une fois le tenant activé :

  1. se connecter avec un compte enseignant sur http://app.kutsum.local:3008
  2. ouvrir /profile : le bloc Clé API pour la génération IA doit apparaître
  3. enregistrer une clé pour un provider autorisé
  4. cliquer sur Tester la clé si l'on veut vérifier explicitement qu'elle est acceptée par le provider
  5. ouvrir /teacher/questions : le bouton de génération IA doit apparaître

5. Si le bouton n'apparaît pas

Vérifier dans cet ordre :

  1. host frontend réellement utilisé (app.kutsum.local, pas localhost)
  2. settings.aiGeneration.enabled === true sur le tenant actif
  3. settings.aiGeneration.allowUserKeys === true
  4. LLM_KEY_MASTER_KEY chargé par le backend
  5. présence d'au moins une clé valide pour un provider autorisé

Runbook production

Ce runbook couvre l'activation du feature sur un tenant déjà déployé en production.

1. Préparer l'env backend

Pré-requis backend :

  • LLM_KEY_MASTER_KEY doit être défini sur le backend de production
  • AI_GENERATION_RATE_LIMIT_MAX_REQUESTS_PER_HOUR reste optionnelle ; à défaut, la protection burst reste à 60 requêtes par heure et par utilisateur
  • AI_DEFAULT_MAX_TOKENS_PER_REQUEST et AI_DEFAULT_MAX_PROMPTS_PER_USER_PER_DAY restent des fallbacks globaux, utilisés seulement si le tenant ne fixe pas ses propres limites

Si le backend tournait déjà avant l'ajout ou la modification de ces variables, le redémarrer après mise à jour de l'env.

2. Appliquer les migrations

Depuis app/ :

cd app
npm run db:migrate:deploy

3. Activer le tenant cible

Le helper canonique reste app/backend/prisma/enable-local-llm-feature.ts : il met à jour organizations.settings dans la base pointée par DATABASE_URL. Le nom du fichier contient local, mais le script est valable en production tant qu'il cible la bonne base.

Exemple pour le tenant public principal app, avec Albert seulement :

cd app/backend
DATABASE_URL=postgresql://<prod-user>:<prod-pass>@<prod-host>:5432/<prod-db> \
LLM_ALLOWED_PROVIDERS=albert \
npx tsx prisma/enable-local-llm-feature.ts

Pour un autre tenant :

cd app/backend
KUTSUM_TENANT_SUBDOMAIN=utbm \
DATABASE_URL=postgresql://<prod-user>:<prod-pass>@<prod-host>:5432/<prod-db> \
LLM_ALLOWED_PROVIDERS=albert \
npx tsx prisma/enable-local-llm-feature.ts

Ce helper :

  • cible app par défaut
  • active aiGeneration.enabled=true
  • active aiGeneration.allowUserKeys=true
  • remplace allowedProviders par la liste passée via LLM_ALLOWED_PROVIDERS

Si LLM_ALLOWED_PROVIDERS est omise, le script active par défaut ['albert','openai','anthropic','deepseek','mistral','gemini']. En production, il vaut mieux expliciter la liste voulue.

3bis. Provisionner une clé partagée — todo 280

Malgré le nom générique ("shared"/tenant), ce runbook cible avant tout le tenant public app — l'instance publique de Kutsum, pas un établissement en particulier. Le label affiché aux utilisateurs en tient compte ("IA Kutsum (Albert)" sur app, "IA de l'établissement" — sans nommer le provider — seulement sur un vrai tenant nommé comme UTBM — voir Résolution des clés).

enable-local-llm-feature.ts n'active que le BYOK (allowUserKeys) : il ne crée aucune clé et ne touche jamais allowTenantKey/useGlobalKey. Pour une clé unique payée par l'organisation et partagée par tout le tenant (ex. une clé Albert), utiliser app/backend/prisma/provision-shared-llm-key.ts à la place — il active allowTenantKey, écrit la clé chiffrée (LlmApiKey, scope TENANT) et fixe les quotas en une seule opération, idempotent (upsert par (organizationId, provider)).

Le script est interactif par défaut (todo 280, itération post-retour mainteneur) — il ne demande que la clé API brute (saisie masquée, jamais loguée ni écrite sur disque — seule sa forme chiffrée AES-256-GCM finit en base) et, si besoin, le quota partagé quotidien (avec un défaut pré-rempli, Entrée pour l'accepter). DATABASE_URL et LLM_KEY_MASTER_KEY sont lus automatiquement depuis app/backend/.env (le même fichier que le backend en prod) — rien à coller sur la ligne de commande. Si LLM_KEY_MASTER_KEY est absente, le script en génère une et affiche la ligne à ajouter au .env, puis s'arrête (c'est un prérequis one-shot, à faire une seule fois).

cd app/backend
npx tsx prisma/provision-shared-llm-key.ts

Avec une clé Albert, le script tente aussi un appel best-effort à GET /v1/me/info et affiche tes vraies limites de compte avant de demander le quota partagé — pour éviter d'avoir à aller les chercher ailleurs (échoue silencieusement si injoignable, le prompt garde son défaut).

Défauts (modifiables en répondant au prompt, ou en pré-remplissant la variable d'env correspondante pour sauter la question) : AI_MAX_PROMPTS_PER_TENANT_PER_DAY proposé à 900 (conservateur pour le palier "expérimentation" d'Albert sur gpt-oss-120b, 1 000/jour), AI_MAX_PROMPTS_PER_USER_PER_DAY fixé à 3 sans prompt, KUTSUM_TENANT_SUBDOMAIN = app, LLM_PROVIDER = albert.

Mode non-interactif (CI/scripting) : passer LLM_TENANT_API_KEY et AI_MAX_PROMPTS_PER_TENANT_PER_DAY en variables d'env fait sauter les prompts correspondants (voir l'en-tête du fichier pour la liste complète).

Rappel : la résolution de la clé partagée exige un compte avec email vérifié (voir Résolution des clés) — les comptes GUEST et les profs éphémères (import MathALEA, démo page d'accueil) n'y ont jamais accès, quel que soit le quota.

4. Redémarrer les services

Si l'env backend a changé, ou si les process n'ont pas encore été redémarrés depuis le déploiement du code :

cd app
pm2 restart mathquest-backend mathquest-frontend --update-env

5. Vérifier la config publique du tenant

La vérification la plus rapide consiste à relire la config publique du tenant :

curl -s https://app.kutsum.org/api/v1/tenants/app/config | jq '.aiGeneration'

Attendu au minimum :

{
  "enabled": true,
  "allowUserKeys": true,
  "allowedProviders": ["albert"]
}

6. Vérifier l'UI réelle

Avec un compte enseignant sur le tenant activé :

  1. ouvrir /profile et vérifier que la section de clés LLM apparaît
  2. enregistrer une clé pour un provider autorisé
  3. tester la clé si l'on veut valider immédiatement le ping provider
  4. ouvrir /teacher/questions et vérifier que la génération IA fonctionne

7. Impact produit à connaître

  • l'activation est tenant-wide : tous les enseignants du tenant voient la gestion des clés LLM
  • ce n'est pas un rollout par utilisateur
  • avec des clés USER, le quota journalier de prompts Kutsum ne s'applique pas ; seule la protection burst HTTP reste active par défaut
  • avec une clé partagée (allowTenantKey/useGlobalKey), seuls les comptes avec un email vérifié peuvent l'utiliser — les GUEST et profs éphémères en sont exclus (voir Résolution des clés) ; le frontend leur propose toujours la génération IA s'ils ont leur propre clé BYOK
  • le frontend affiche un bandeau (GenerateQuestionsForm) invitant à ajouter sa propre clé (Gemini/Mistral suggérés, gratuits) dès qu'une génération utilise une clé TENANT/GLOBAL plutôt que la clé personnelle de l'utilisateur, avec le nombre exact de générations restantes (quotaRemaining) ; ce compteur se rafraîchit après chaque génération réussie (useAiAccess().refetch()), pas seulement au prochain chargement de page
  • le mapping erreur → message toast (getAiGenerationErrorMessage, app/frontend/src/utils/aiGenerationErrorMessage.ts) est unique, partagé par le bank prof et le flux élève — avant todo 280 le flux élève n'avait pas de mapping par code d'erreur et affichait parfois le message anglais brut du backend (ex. quota dépassé). Le backend envoie désormais errorCode en plus de code sur toutes les réponses d'erreur IA (api/v1/aiGeneration.ts, middleware/apiRateLimit.ts), pour que ApiRequestError.errorCode (lu par makeApiRequest) le capture

Impacts docs / ops

Quand le contrat LLM change, mettre à jour au minimum :

  • cette page
  • Configuration si les variables d'env changent
  • API REST si les endpoints ou codes d'erreur changent
  • éventuellement Multi-tenant si la stratégie d'activation tenant change
Dernière mise à jour: 06/08/2026 15:02
Contributors: alexisflesch, Claude Opus 4.8
Prev
Base de données
Next
Système de scoring