@programisto/edrm-exams 0.3.13 → 0.3.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,20 @@
1
+ Tu es un correcteur de question de concours / test en ligne.
2
+ Tu dois donner un score à la question et à la réponse fournie par le candidat.
3
+ Tu dois répondre au format JSON, en français pour les explications de correction.
4
+ Modèle JSON attendu :
5
+ {
6
+ "score": score,
7
+ "comment": commentaire
8
+ }
9
+
10
+ ---
11
+
1
12
  Question : ${instruction}
2
- Type de question : ${questionType}
13
+ Type de question : ${questionType}
3
14
  - si MCQ les réponses possibles étaient : ${possibleResponses}
4
15
 
5
16
  Réponse du candidat à corriger :
6
17
  --------------------------------
7
- ${response}
18
+ ${response}
8
19
  --------------------------------
9
- Score à donner : De 0 point (tout faux ou réponse vide) à ${maxScore} points (réponse correcte), avec un commentaire de correction
20
+ Score à donner : de 0 point (tout faux ou réponse vide) à ${maxScore} points (réponse correcte), avec un commentaire de correction.
@@ -1,6 +1,35 @@
1
- Métier ciblé : ${job}
2
- Catégorie de la question : ${category} niveau ${expertiseLevel}
3
- Type de question : ${questionType}
4
- Format : json
1
+ Tu es un assistant qui crée des questions pour un outil de test en ligne.
2
+ L'utilisateur te demande de générer une question avec les critères suivants :
3
+ - Métier ciblé : ${job}
4
+ - Séniorité ciblée : ${seniority}
5
+ - Catégorie (domaine) : ${category}
6
+ - Niveau d'expertise sur cette catégorie : ${expertiseLevel} (sur une échelle : 'beginner' = 1 à 3, 'intermediate' = 3 à 6, 'advanced' = 6 à 10)
7
+ - Type de question demandé : ${questionType}
5
8
 
6
- Voici la liste des questions déjà présentes dans le test (pour éviter les doublons) : ${otherQuestions}
9
+ Contraintes :
10
+ - La question doit obligatoirement être en français.
11
+ - Les instructions et réponses doivent être compatibles avec LaTeX pour affichage dans des balises <MathJax>. Exemple : \\( \\frac{a}{b} = c \\)
12
+ - Le format de réponse doit être un JSON avec uniquement les champs du schéma ci-dessous, sans "_id".
13
+
14
+ Schéma JSON à produire :
15
+ {
16
+ "instruction": "string, required",
17
+ "questionType": "MCQ" | "free question" | "exercice",
18
+ "maxScore": "number, required",
19
+ "possibleResponses": [{"possibleResponse": "string", "valid": "boolean"}] // uniquement pour MCQ, 4 réponses dont une seule juste
20
+ "time": "number, en secondes, required",
21
+ "textType": "text" | "code" // "code" si la réponse attendue est du code, sinon "text"
22
+ }
23
+
24
+ Contraintes par type :
25
+ - MCQ : une seule réponse juste sur quatre au total ; time entre 30 s et 1 min.
26
+ - Free Question : ne rien mettre dans possibleResponses ; time entre 30 s et 5 min selon complexité.
27
+ - Exercice : ne rien mettre dans possibleResponses ; time entre 3 et 20 min selon complexité.
28
+
29
+ - Les instructions et la difficulté doivent être en rapport avec le métier et la séniorité.
30
+ - Le temps (time) doit laisser le temps de répondre sans favoriser la triche (surtout pour les MCQ).
31
+
32
+ Questions déjà présentes dans le test (éviter les doublons) :
33
+ ${otherQuestions}
34
+
35
+ Génère une question au format JSON correspondant à la demande et au schéma ci-dessus.
@@ -32,6 +32,9 @@ interface ContextBuilder {
32
32
  maxScore: number;
33
33
  }>;
34
34
  }
35
+ /**
36
+ * Génère une réponse via l'API Responses (gpt-5-mini).
37
+ * Pour forcer le JSON : text.format = { type: 'json_object' }.
38
+ */
35
39
  export declare function generateLiveMessage(messageType: keyof ContextBuilder, params: CreateQuestionParams | CorrectQuestionParams, json?: boolean): Promise<string>;
36
- export declare function generateLiveMessageAssistant(assistantId: string, messageType: keyof ContextBuilder, params: CreateQuestionParams | CorrectQuestionParams, json?: boolean): Promise<string>;
37
40
  export {};
@@ -2,6 +2,7 @@ import OpenAI from 'openai';
2
2
  import { fileURLToPath } from 'url';
3
3
  import fs from 'fs';
4
4
  import path from 'path';
5
+ const DEFAULT_MODEL = 'gpt-5-mini';
5
6
  const __filename = fileURLToPath(import.meta.url);
6
7
  const __dirname = path.dirname(__filename);
7
8
  const openai = new OpenAI({
@@ -41,6 +42,10 @@ const contextBuilder = {
41
42
  return context;
42
43
  }
43
44
  };
45
+ /**
46
+ * Génère une réponse via l'API Responses (gpt-5-mini).
47
+ * Pour forcer le JSON : text.format = { type: 'json_object' }.
48
+ */
44
49
  export async function generateLiveMessage(messageType, params, json) {
45
50
  const MAX_RETRY = 2;
46
51
  let retryCount = 0;
@@ -49,17 +54,17 @@ export async function generateLiveMessage(messageType, params, json) {
49
54
  const message = text.replace(/\${(.*?)}/g, (_, v) => context[v]);
50
55
  while (retryCount <= MAX_RETRY) {
51
56
  try {
52
- const openAIParams = {
53
- model: 'gpt-4-1106-preview',
54
- temperature: 0.7,
55
- messages: [{ role: 'system', content: message }]
57
+ const createParams = {
58
+ model: DEFAULT_MODEL,
59
+ instructions: message,
60
+ input: json ? 'Réponds en JSON uniquement. Traite la demande.' : 'Traite la demande.'
56
61
  };
57
62
  if (json) {
58
- openAIParams.response_format = { type: 'json_object' };
63
+ createParams.text = { format: { type: 'json_object' } };
59
64
  }
60
- const result = await openai.chat.completions.create(openAIParams);
61
- const content = result.choices[0].message.content;
62
- if (!content) {
65
+ const result = await openai.responses.create(createParams);
66
+ const content = result.output_text;
67
+ if (!content || typeof content !== 'string') {
63
68
  throw new Error('No content in response');
64
69
  }
65
70
  return removeQuotes(content);
@@ -74,59 +79,6 @@ export async function generateLiveMessage(messageType, params, json) {
74
79
  }
75
80
  return 'Brain freezed, I cannot generate a live message right now.';
76
81
  }
77
- export async function generateLiveMessageAssistant(assistantId, messageType, params, json) {
78
- const MAX_RETRY = 2;
79
- let retryCount = 0;
80
- // Construire le contexte pour le message
81
- const context = await contextBuilder[messageType](params);
82
- const text = fs.readFileSync(path.join(__dirname, 'openai', `${messageType}.txt`), 'utf8');
83
- const message = text.replace(/\${(.*?)}/g, (_, v) => context[v]);
84
- while (retryCount <= MAX_RETRY) {
85
- try {
86
- // Créer un thread avec l'assistant
87
- const thread = await openai.beta.threads.create();
88
- // Ajouter le message avec le contexte
89
- await openai.beta.threads.messages.create(thread.id, {
90
- role: 'user',
91
- content: message
92
- });
93
- // Exécuter l'assistant
94
- const run = await openai.beta.threads.runs.create(thread.id, {
95
- assistant_id: assistantId
96
- });
97
- // Attendre que l'exécution soit terminée
98
- let runStatus = await openai.beta.threads.runs.retrieve(thread.id, run.id);
99
- while (runStatus.status === 'in_progress' || runStatus.status === 'queued') {
100
- await new Promise(resolve => setTimeout(resolve, 1000));
101
- runStatus = await openai.beta.threads.runs.retrieve(thread.id, run.id);
102
- }
103
- if (runStatus.status === 'failed') {
104
- throw new Error('Assistant execution failed');
105
- }
106
- // Récupérer les messages de réponse
107
- const messages = await openai.beta.threads.messages.list(thread.id);
108
- const lastMessage = messages.data[0]; // Le premier message est le plus récent
109
- if (!lastMessage || !lastMessage.content || lastMessage.content.length === 0) {
110
- throw new Error('No content in response');
111
- }
112
- const content = lastMessage.content[0];
113
- if (content.type === 'text') {
114
- return removeQuotes(content.text.value);
115
- }
116
- else {
117
- throw new Error('Unexpected content type');
118
- }
119
- }
120
- catch (error) {
121
- retryCount++;
122
- console.log(error);
123
- if (retryCount > MAX_RETRY) {
124
- return 'Brain freezed, I cannot generate a live message right now.';
125
- }
126
- }
127
- }
128
- return 'Brain freezed, I cannot generate a live message right now.';
129
- }
130
82
  function removeQuotes(str) {
131
83
  if (str.startsWith('"') && str.endsWith('"')) {
132
84
  return str.substring(1, str.length - 1);
@@ -1,11 +1,36 @@
1
1
  import { enduranceListener, enduranceEventTypes, enduranceEmitter } from '@programisto/endurance';
2
2
  import TestQuestion from '../models/test-question.model.js';
3
- import { generateLiveMessageAssistant } from '../lib/openai.js';
3
+ import { generateLiveMessage } from '../lib/openai.js';
4
4
  import { computeScoresByCategory } from '../lib/score-utils.js';
5
5
  import TestResult, { TestState } from '../models/test-result.model.js';
6
6
  import CandidateModel from '../models/candidate.model.js';
7
7
  import ContactModel from '../models/contact.model.js';
8
8
  import TestModel from '../models/test.model.js';
9
+ const QUESTION_TYPE_MCQ = 'MCQ';
10
+ /**
11
+ * Pour un QCM avec bonne réponse enregistrée (possibleResponses avec valid: true),
12
+ * retourne { score, comment } sans appeler OpenAI.
13
+ * Retourne null si la question n'est pas un QCM corrigeable automatiquement.
14
+ */
15
+ function correctMcqIfPossible(question, candidateResponse) {
16
+ if (question.questionType !== QUESTION_TYPE_MCQ)
17
+ return null;
18
+ const possibleResponses = question.possibleResponses;
19
+ if (!Array.isArray(possibleResponses) || possibleResponses.length === 0)
20
+ return null;
21
+ const validIndex = possibleResponses.findIndex((r) => r.valid === true);
22
+ if (validIndex === -1)
23
+ return null;
24
+ const normalizedCandidate = String(candidateResponse ?? '').trim();
25
+ const validChoice = possibleResponses[validIndex];
26
+ const validText = (validChoice?.possibleResponse ?? '').trim();
27
+ const isCorrect = normalizedCandidate === validText ||
28
+ normalizedCandidate === String(validIndex);
29
+ return {
30
+ score: isCorrect ? question.maxScore : 0,
31
+ comment: ''
32
+ };
33
+ }
9
34
  async function sendDiscordNotification(message) {
10
35
  const discordWebhook = process.env.TEST_CORRECTION_DISCORD_WEBHOOKS;
11
36
  if (discordWebhook) {
@@ -51,37 +76,46 @@ async function correctTest(options) {
51
76
  continue;
52
77
  }
53
78
  maxScore += question.maxScore;
54
- const scoreResponse = await generateLiveMessageAssistant(process.env.OPENAI_ASSISTANT_ID_CORRECT_QUESTION || '', 'correctQuestion', {
55
- question: {
56
- _id: question._id.toString(),
57
- instruction: question.instruction,
58
- possibleResponses: question.possibleResponses,
59
- questionType: question.questionType,
60
- maxScore: question.maxScore
61
- },
62
- result: {
63
- responses: [{
64
- questionId: dbResponse.questionId.toString(),
65
- response: dbResponse.response
66
- }]
67
- }
68
- }, true);
69
- console.log('Correction result:', { scoreResponse });
70
- const parsedResult = JSON.parse(scoreResponse);
71
- // Valider le score retourné par l'IA
72
- let validScore = 0;
73
- if (parsedResult.score !== undefined && parsedResult.score !== null) {
74
- const score = parseFloat(parsedResult.score.toString());
75
- if (!isNaN(score) && isFinite(score) && score >= 0) {
76
- validScore = score;
77
- }
78
- else {
79
- console.warn('Invalid score returned by AI:', parsedResult.score);
79
+ const mcqResult = correctMcqIfPossible(question, dbResponse.response ?? '');
80
+ let validScore;
81
+ let comment;
82
+ if (mcqResult !== null) {
83
+ validScore = mcqResult.score;
84
+ comment = mcqResult.comment;
85
+ }
86
+ else {
87
+ const scoreResponse = await generateLiveMessage('correctQuestion', {
88
+ question: {
89
+ _id: question._id.toString(),
90
+ instruction: question.instruction,
91
+ possibleResponses: question.possibleResponses,
92
+ questionType: question.questionType,
93
+ maxScore: question.maxScore
94
+ },
95
+ result: {
96
+ responses: [{
97
+ questionId: dbResponse.questionId.toString(),
98
+ response: dbResponse.response
99
+ }]
100
+ }
101
+ }, true);
102
+ console.log('Correction result:', { scoreResponse });
103
+ const parsedResult = JSON.parse(scoreResponse);
104
+ validScore = 0;
105
+ if (parsedResult.score !== undefined && parsedResult.score !== null) {
106
+ const score = parseFloat(parsedResult.score.toString());
107
+ if (!isNaN(score) && isFinite(score) && score >= 0) {
108
+ validScore = score;
109
+ }
110
+ else {
111
+ console.warn('Invalid score returned by AI:', parsedResult.score);
112
+ }
80
113
  }
114
+ comment = parsedResult.comment || '';
81
115
  }
82
116
  finalscore += validScore;
83
117
  dbResponse.score = validScore;
84
- dbResponse.comment = parsedResult.comment || '';
118
+ dbResponse.comment = comment;
85
119
  }
86
120
  // S'assurer que finalscore est un nombre valide
87
121
  if (isNaN(finalscore) || !isFinite(finalscore)) {
@@ -6,7 +6,7 @@ import TestCategory from '../models/test-category.models.js';
6
6
  import TestJob from '../models/test-job.model.js';
7
7
  import Candidate from '../models/candidate.model.js';
8
8
  import ContactModel from '../models/contact.model.js';
9
- import { generateLiveMessage, generateLiveMessageAssistant } from '../lib/openai.js';
9
+ import { generateLiveMessage } from '../lib/openai.js';
10
10
  import { computeScoresByCategory } from '../lib/score-utils.js';
11
11
  // Fonction utilitaire pour récupérer le nom du job
12
12
  async function getJobName(targetJob) {
@@ -53,7 +53,7 @@ class ExamsRouter extends EnduranceRouter {
53
53
  constructor() {
54
54
  super(EnduranceAuthMiddleware.getInstance());
55
55
  }
56
- async generateAndSaveQuestion(test, categoryInfo, useAssistant = false, questionTypeOverride) {
56
+ async generateAndSaveQuestion(test, categoryInfo, _useAssistant = false, questionTypeOverride) {
57
57
  try {
58
58
  const categoryDoc = await TestCategory.findById(categoryInfo.categoryId);
59
59
  if (!categoryDoc) {
@@ -74,13 +74,7 @@ class ExamsRouter extends EnduranceRouter {
74
74
  expertiseLevel: categoryInfo.expertiseLevel,
75
75
  otherQuestions: otherQuestions.map(question => question.instruction).join('\n')
76
76
  };
77
- let generatedQuestion;
78
- if (useAssistant) {
79
- generatedQuestion = await generateLiveMessageAssistant(process.env.OPENAI_ASSISTANT_ID_CREATE_QUESTION || '', 'createQuestion', questionParams, true);
80
- }
81
- else {
82
- generatedQuestion = await generateLiveMessage('createQuestion', questionParams, true);
83
- }
77
+ const generatedQuestion = await generateLiveMessage('createQuestion', questionParams, true);
84
78
  if (generatedQuestion === 'Brain freezed, I cannot generate a live message right now.') {
85
79
  console.error('Échec de génération de question pour la catégorie:', categoryDoc.name);
86
80
  return null;
@@ -1252,7 +1246,7 @@ class ExamsRouter extends EnduranceRouter {
1252
1246
  const otherQuestionsIds = test.questions.map(question => question.questionId);
1253
1247
  const otherQuestions = await TestQuestion.find({ _id: { $in: otherQuestionsIds } });
1254
1248
  const jobName = await getJobName(test.targetJob);
1255
- const generatedQuestion = await generateLiveMessageAssistant(process.env.OPENAI_ASSISTANT_ID_CREATE_QUESTION || '', 'createQuestion', {
1249
+ const generatedQuestion = await generateLiveMessage('createQuestion', {
1256
1250
  job: jobName,
1257
1251
  seniority: test.seniorityLevel,
1258
1252
  questionType,
@@ -1791,7 +1785,7 @@ class ExamsRouter extends EnduranceRouter {
1791
1785
  const question = await TestQuestion.findById(response.questionId);
1792
1786
  if (!question)
1793
1787
  continue;
1794
- const score = await generateLiveMessageAssistant(process.env.OPENAI_ASSISTANT_ID_CORRECT_QUESTION || '', 'correctQuestion', {
1788
+ const score = await generateLiveMessage('correctQuestion', {
1795
1789
  question: {
1796
1790
  _id: question._id.toString(),
1797
1791
  instruction: question.instruction,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@programisto/edrm-exams",
4
- "version": "0.3.13",
4
+ "version": "0.3.14",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -11,6 +11,7 @@
11
11
  "start": "node ./dist/bin/www",
12
12
  "dev": "tsc-watch --onSuccess \"node ./dist/bin/www\"",
13
13
  "test": "mocha",
14
+ "test:openai": "node -r dotenv/config node_modules/.bin/mocha",
14
15
  "build": "tsc && npm run copy-files",
15
16
  "copy-files": "copyfiles -u 1 'src/**/*.txt' dist",
16
17
  "lint": "eslint \"**/*.{ts,tsx}\"",
@@ -48,6 +49,7 @@
48
49
  "@typescript-eslint/parser": "^8.26.0",
49
50
  "commitlint": "^19.8.1",
50
51
  "copyfiles": "^2.4.1",
52
+ "dotenv": "^16.6.1",
51
53
  "eslint": "^8.57.1",
52
54
  "eslint-config-standard": "^17.1.0",
53
55
  "eslint-plugin-import": "^2.31.0",