DocumentationFor hosts
Artificial intelligence
How to turn on an AI provider in OpENEM, what uses AI (question explanations and topic classification) and how to cap the cost with quotas.
AI is optional. With no provider configured, the app works normally and only the features on this page are unavailable. The calls are paid with the key of whoever hosts the instance, so read the part about quotas before turning it on.
What uses AI
On-demand question explanations
The official exams come without explanations. After answering, the student sees the Ver comentário (see explanation) button. The first time someone asks, the API (POST /api/v1/questions/:id/explanation) generates the explanation, stores it in the database and returns it; the next students get the stored text, with no new call. The app warns that the explanation was generated by AI and may contain mistakes, and the prompt tells the AI never to contradict the official answer key.
Without a provider, the button answers that the question has no explanation yet. This feature uses AI_PROVIDER and AI_EXPLANATION_MODEL, spends the AI_EXPLANATION_DAILY_LIMIT quota and has its own limit of 10 requests per minute per student.
Topic classification
The import delivers the official questions with only their area. Spaced review, the heat map and recommendations work by topic, so a question without a topic is left out of them. The keyword classification, which runs without AI in the bootstrap, covers part of the bank; the AI script handles the rest. It does not run from the app: the host runs it from the terminal.
docker compose exec backend node dist-scripts/prisma/classify-questions.js --limit=50 --dry-run
docker compose exec backend node dist-scripts/prisma/classify-questions.js
Options: --dry-run shows the decisions without writing them, --limit and --area narrow the batch, and --min-confidence (default 0.6) sets the minimum confidence to store the topic. The script uses AI_PROVIDER and AI_CLASSIFY_MODEL. It goes through thousands of questions, so prefer a cheap model. Since AI_CLASSIFY_MODEL does not go through Compose, pass it on the call itself:
docker compose exec -e AI_CLASSIFY_MODEL=<a cheap model from your provider> backend node dist-scripts/prisma/classify-questions.js
The script does not go through the per-student quotas.
Providers
| Provider | Turned on by | Default model |
|---|---|---|
anthropic | ANTHROPIC_API_KEY | ANTHROPIC_MODEL, otherwise claude-opus-4-8 |
openai | OPENAI_API_KEY | OPENAI_MODEL, otherwise gpt-4o |
gemini | GEMINI_API_KEY (or GOOGLE_API_KEY) | GEMINI_MODEL, otherwise gemini-2.5-flash |
openai-compatible | OPENAI_BASE_URL | OPENAI_MODEL, otherwise llama3.1 |
The openai-compatible provider serves servers that mimic the OpenAI API, such as Ollama, Groq, OpenRouter, LM Studio and vLLM. The key is optional, because local servers ask for no authentication. Since not every server accepts a strict JSON schema, in this mode the API only asks for a JSON object and describes the format in the prompt.
How the provider is chosen
- Question explanations and classification use
AI_PROVIDER. - Without the variable, the first configured provider wins, in this order:
anthropic,openai,gemini,openai-compatible.
A provider chosen by the variable but without a key does not fall back to another one: the feature becomes unavailable.
In Docker Compose
Put the key and the model in the root .env:
ANTHROPIC_API_KEY=<your key>
ANTHROPIC_MODEL=claude-opus-4-8
AI_DAILY_LIMIT=500
- Always set the provider's model (
ANTHROPIC_MODEL,OPENAI_MODELorGEMINI_MODEL). Without it in.env, Compose passes the variable empty and the backend uses the empty model instead of the default. - Compose forwards
AI_PROVIDER, the keys, the models,OPENAI_BASE_URLand the quotas.AI_EXPLANATION_MODEL,AI_CLASSIFY_MODELandGOOGLE_API_KEYdo not reach the container; use adocker-compose.override.yml, as in Environment variables. - Inside the container,
localhostis the container itself. AnOPENAI_BASE_URLpointing tohttp://localhost:11434/v1does not reach an Ollama running on the host.
After changing .env, recreate the backend with docker compose up -d.
Quotas and cost
Rate limiting does not hold back cost: sign-up is open, and every new account would have its own limit. That is why paid calls go through a per-student quota, stored in the database (the AiUsage table) in a rolling 24-hour window, and through an optional global cap.
| Variable | Default | Counts |
|---|---|---|
AI_EXPLANATION_DAILY_LIMIT | 30 | explanations generated per student |
AI_DAILY_LIMIT | 0 (no cap) | every call from every student |
0turns the matching limit off.- Going over the limit returns HTTP 429.
- The quota is reserved before the call to the provider: a call that fails counts too.
- Opening an explanation that is already stored spends no quota. If two students ask for the same explanation at the same time, the API makes a single call (also across instances, with a lock in Redis), but the request counts against each student's quota.