@programisto/edrm-exams 0.2.12 → 0.3.0

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,10 +1,6 @@
1
- Tu est un correcteur de question de concours.
2
- Tu doit donner un score à cette question : ${instruction} dont la réponse donnée par le candidat est : ${response}.
3
- Il s'agissait d'une question de type ${questionType} (et si la question est de type MCQ alors les réponses possibles étaient les suivantes : ${possibleResponses})
4
- le score maximum pour cette question est ${maxScore}
5
- Tu doit donner un score entre 0 et ${maxScore} et un commentaire de correction.
6
- Tu dois répondre au format JSON : la reponse envoyer doit correspondre exactement à ce model json :
7
- {
8
- "score": score,
9
- "comment": commentaire
10
- }
1
+ Question : ${instruction}
2
+ Type de question : ${questionType}
3
+ - si MCQ les réponses possibles étaient : ${possibleResponses}
4
+
5
+ Réponse du candidat : ${response}
6
+ Score à donner : De 0 à ${maxScore} avec un commentaire de correction
@@ -1,68 +1,6 @@
1
- Tu es un assistant qui crée des questions pour un test.
2
- créer une question en fonction de du métier : ${job}, du niveau de seniorité : ${seniority}, de la categorie de question : ${category} et le niveau d'expertise de cette categorie : ${expertiseLevel} (allant de 1 à 10, de 1 à 3 = 'beginner', de 3 à 6 = 'intermediate', de 6 à 10 = 'advanced').
3
- - la question doit être de type : ${questionType}.
4
- - la question doit être dans la langue suivante : Français
5
- - voici le model pour une question :
6
- const questionModel = new mongoose.Schema(
7
- {
8
- instruction:
9
- {
10
- type: String,
11
- required: true
12
- },
13
- questionType:
14
- {
15
- type: String,
16
- enum:
17
- ["MCQ", "free question", "exercice"],
18
- required: true,
19
- },
20
- maxScore:
21
- {
22
- type: Number,
23
- required: true,
24
- },
25
- possibleResponses: // only on MCQ (multiple choice responses)
26
- {
27
- type:
28
- [{
29
- possibleResponse:
30
- {
31
- type: String,
32
- required: true,
33
- },
34
- valid:
35
- {
36
- type: Boolean,
37
- required: true
38
- }
39
- }],
40
- },
41
- time: // in secondes
42
- {
43
- type: Number,
44
- required: true,
45
- },
46
- textType: //type d'input demandé à l'utilisateur
47
- {
48
- type: String,
49
- enum:
50
- ["text", "code"],
51
- required: false
52
- }
53
- });
54
- - toutes les instruction et les réponses doivent toujours être compatible avec LaTeX pour être afficher dans des balises <MathJax>
55
- voici un exemple : Voici une formule mathématique : \\( \\frac{a}{b} = c \\)
56
- - voici des containtes pour chaque type de question :
57
- - MCQ
58
- (il ne peut y avoir qu'une seule réponse juste sur quatre au total) le temps pour ces questions vont de 30 secondes à 1 minute
59
- - free question ( ne rien mettre dans possibleResponses)
60
- le temps pour ces questions vont de 30 secondes à 5 minutes en fonction de la complexité de la réponse attendue (à toi de juger)
61
- - exercice ( ne rien mettre dans possibleResponses )
62
- le temps pour ces questions vont de 3 à 20 minutes en fonction de la complexité de la réponse attendue (à toi de juger)
63
- - les instruction et la difficulté doivent être en rapport avec targetJob et seniorityLevel de ${test}
64
- - le temps pour répondre à la question dans time doit laisser le temps aux utilisateur d'y répondre, sans non plus lui laisser trop de temps pour qu'il triche (surtout sur les QCM).
65
- - le format de la réponses doit être en json avec seulement les elements présent dans le model ci dessus et pas besoin d'ajouter d"_id".
66
- - si la réponse attendue à une question de type free question ou exercice est un bout de code, alors remplit textType en "code", sinon laisse "text"
1
+ Métier ciblé : ${job}
2
+ Catégorie de la question : ${category} niveau ${expertiseLevel}
3
+ Type de question : ${questionType}
4
+ Format : json
67
5
 
68
- Pour ne pas faire de doublon, voici la liste des questions déjà présentes dans le test : ${otherQuestions}
6
+ Voici la liste des questions déjà présentes dans le test (pour éviter les doublons) : ${otherQuestions}
@@ -33,4 +33,5 @@ interface ContextBuilder {
33
33
  }>;
34
34
  }
35
35
  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>;
36
37
  export {};
@@ -74,6 +74,59 @@ export async function generateLiveMessage(messageType, params, json) {
74
74
  }
75
75
  return 'Brain freezed, I cannot generate a live message right now.';
76
76
  }
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
+ }
77
130
  function removeQuotes(str) {
78
131
  if (str.startsWith('"') && str.endsWith('"')) {
79
132
  return str.substring(1, str.length - 1);
@@ -1,6 +1,6 @@
1
1
  import { enduranceListener, enduranceEventTypes, enduranceEmitter } from '@programisto/endurance-core';
2
2
  import TestQuestion from '../models/test-question.model.js';
3
- import { generateLiveMessage } from '../lib/openai.js';
3
+ import { generateLiveMessageAssistant } from '../lib/openai.js';
4
4
  import TestResult, { TestState } from '../models/test-result.model.js';
5
5
  import CandidateModel from '../models/candidate.model.js';
6
6
  import ContactModel from '../models/contact.model.js';
@@ -31,7 +31,7 @@ async function correctTest(options) {
31
31
  continue;
32
32
  }
33
33
  maxScore += question.maxScore;
34
- const scoreResponse = await generateLiveMessage('correctQuestion', {
34
+ const scoreResponse = await generateLiveMessageAssistant(process.env.OPENAI_ASSISTANT_ID_CORRECT_QUESTION || '', 'correctQuestion', {
35
35
  question: {
36
36
  _id: question._id.toString(),
37
37
  instruction: question.instruction,
@@ -48,16 +48,49 @@ async function correctTest(options) {
48
48
  }, true);
49
49
  console.log('Correction result:', { scoreResponse });
50
50
  const parsedResult = JSON.parse(scoreResponse);
51
- finalscore += parsedResult.score;
52
- dbResponse.score = parsedResult.score;
51
+ // Valider le score retourné par l'IA
52
+ let validScore = 0;
53
+ if (parsedResult.score !== undefined && parsedResult.score !== null) {
54
+ const score = parseFloat(parsedResult.score.toString());
55
+ if (!isNaN(score) && isFinite(score) && score >= 0) {
56
+ validScore = score;
57
+ }
58
+ else {
59
+ console.warn('Invalid score returned by AI:', parsedResult.score);
60
+ }
61
+ }
62
+ finalscore += validScore;
63
+ dbResponse.score = validScore;
53
64
  dbResponse.comment = parsedResult.comment || '';
54
65
  }
66
+ // S'assurer que finalscore est un nombre valide
67
+ if (isNaN(finalscore) || !isFinite(finalscore)) {
68
+ console.warn('Invalid finalscore calculated, setting to 0:', finalscore);
69
+ finalscore = 0;
70
+ }
71
+ // S'assurer que maxScore est un nombre valide
72
+ if (isNaN(maxScore) || !isFinite(maxScore)) {
73
+ console.warn('Invalid maxScore calculated, setting to 0:', maxScore);
74
+ maxScore = 0;
75
+ }
55
76
  // Mettre à jour le score final et l'état
56
77
  result.score = finalscore;
57
78
  result.state = TestState.Finish;
58
79
  // Forcer la sauvegarde des sous-documents responses
59
80
  result.markModified('responses');
60
- const scorePercentage = Math.ceil((finalscore / maxScore) * 100);
81
+ // Calculer le pourcentage de score en évitant la division par zéro
82
+ let scorePercentage = 0;
83
+ if (maxScore > 0) {
84
+ scorePercentage = Math.ceil((finalscore / maxScore) * 100);
85
+ }
86
+ else if (finalscore > 0) {
87
+ // Si maxScore est 0 mais qu'il y a un score, on met 100%
88
+ scorePercentage = 100;
89
+ }
90
+ // S'assurer que le score est un nombre valide
91
+ if (isNaN(scorePercentage) || !isFinite(scorePercentage)) {
92
+ scorePercentage = 0;
93
+ }
61
94
  // Sauvegarder les modifications avec findByIdAndUpdate pour éviter les conflits de version
62
95
  await TestResult.findByIdAndUpdate(result._id, {
63
96
  $set: {
@@ -80,7 +113,7 @@ async function correctTest(options) {
80
113
  lastname: contact.lastname,
81
114
  score: result.score,
82
115
  testName: test?.title || '',
83
- testLink: testLink
116
+ testLink
84
117
  }
85
118
  });
86
119
  }
@@ -0,0 +1,7 @@
1
+ import { EnduranceSchema } from '@programisto/endurance-core';
2
+ declare class TestJob extends EnduranceSchema {
3
+ name: string;
4
+ static getModel(): import("@typegoose/typegoose").ReturnModelType<typeof TestJob, import("@typegoose/typegoose/lib/types").BeAnObject>;
5
+ }
6
+ declare const TestJobModel: import("@typegoose/typegoose").ReturnModelType<typeof TestJob, import("@typegoose/typegoose/lib/types").BeAnObject>;
7
+ export default TestJobModel;
@@ -0,0 +1,29 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __metadata = (this && this.__metadata) || function (k, v) {
8
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
+ };
10
+ import { EnduranceSchema, EnduranceModelType } from '@programisto/endurance-core';
11
+ let TestJob = class TestJob extends EnduranceSchema {
12
+ name;
13
+ static getModel() {
14
+ return TestJobModel;
15
+ }
16
+ };
17
+ __decorate([
18
+ EnduranceModelType.prop({ required: true }),
19
+ __metadata("design:type", String)
20
+ ], TestJob.prototype, "name", void 0);
21
+ TestJob = __decorate([
22
+ EnduranceModelType.modelOptions({
23
+ options: {
24
+ allowMixed: EnduranceModelType.Severity.ALLOW
25
+ }
26
+ })
27
+ ], TestJob);
28
+ const TestJobModel = EnduranceModelType.getModelForClass(TestJob);
29
+ export default TestJobModel;
@@ -2,19 +2,13 @@ import { EnduranceSchema } from '@programisto/endurance-core';
2
2
  import Company from './company.model.js';
3
3
  import TestQuestion from './test-question.model.js';
4
4
  import TestCategory from './test-category.models.js';
5
+ import TestJob from './test-job.model.js';
5
6
  import User from './user.model.js';
6
7
  declare enum TestState {
7
8
  Draft = "draft",
8
9
  Published = "published",
9
10
  Archived = "archived"
10
11
  }
11
- declare enum JobType {
12
- FrontEnd = "front-end",
13
- BackEnd = "back-end",
14
- Fullstack = "fullstack",
15
- DevOps = "devops",
16
- Data = "data"
17
- }
18
12
  declare enum SeniorityLevel {
19
13
  Student = "student",
20
14
  Junior = "junior",
@@ -44,9 +38,10 @@ declare class Test extends EnduranceSchema {
44
38
  duration?: number;
45
39
  passingScore?: number;
46
40
  categories?: TestCategoryWithExpertise[];
47
- targetJob: JobType;
41
+ targetJob: typeof TestJob;
48
42
  seniorityLevel: SeniorityLevel;
49
43
  static getModel(): import("@typegoose/typegoose").ReturnModelType<typeof Test, import("@typegoose/typegoose/lib/types.js").BeAnObject>;
44
+ migrateTargetJob(): Promise<void>;
50
45
  }
51
46
  declare const TestModel: import("@typegoose/typegoose").ReturnModelType<typeof Test, import("@typegoose/typegoose/lib/types.js").BeAnObject>;
52
47
  export default TestModel;
@@ -9,6 +9,7 @@ var __metadata = (this && this.__metadata) || function (k, v) {
9
9
  };
10
10
  import { EnduranceSchema, EnduranceModelType } from '@programisto/endurance-core';
11
11
  import Company from './company.model.js';
12
+ import TestJob from './test-job.model.js';
12
13
  import User from './user.model.js';
13
14
  var TestState;
14
15
  (function (TestState) {
@@ -19,19 +20,6 @@ var TestState;
19
20
  // eslint-disable-next-line no-unused-vars
20
21
  TestState["Archived"] = "archived";
21
22
  })(TestState || (TestState = {}));
22
- var JobType;
23
- (function (JobType) {
24
- // eslint-disable-next-line no-unused-vars
25
- JobType["FrontEnd"] = "front-end";
26
- // eslint-disable-next-line no-unused-vars
27
- JobType["BackEnd"] = "back-end";
28
- // eslint-disable-next-line no-unused-vars
29
- JobType["Fullstack"] = "fullstack";
30
- // eslint-disable-next-line no-unused-vars
31
- JobType["DevOps"] = "devops";
32
- // eslint-disable-next-line no-unused-vars
33
- JobType["Data"] = "data";
34
- })(JobType || (JobType = {}));
35
23
  var SeniorityLevel;
36
24
  (function (SeniorityLevel) {
37
25
  // eslint-disable-next-line no-unused-vars
@@ -67,6 +55,28 @@ let Test = class Test extends EnduranceSchema {
67
55
  static getModel() {
68
56
  return TestModel;
69
57
  }
58
+ // Méthode pour migrer automatiquement les anciennes données
59
+ async migrateTargetJob() {
60
+ const testData = this;
61
+ // Si targetJob est une string (ancien format), on la migre
62
+ if (typeof testData.targetJob === 'string') {
63
+ try {
64
+ // Chercher si le job existe déjà
65
+ let jobType = await TestJob.findOne({ name: testData.targetJob });
66
+ // Si pas trouvé, on le crée
67
+ if (!jobType) {
68
+ jobType = new TestJob({ name: testData.targetJob });
69
+ await jobType.save();
70
+ }
71
+ // Mettre à jour la référence
72
+ this.targetJob = jobType._id;
73
+ await this.save();
74
+ }
75
+ catch (error) {
76
+ console.error('Erreur lors de la migration du targetJob:', error);
77
+ }
78
+ }
79
+ }
70
80
  };
71
81
  __decorate([
72
82
  EnduranceModelType.prop({ required: true }),
@@ -105,8 +115,8 @@ __decorate([
105
115
  __metadata("design:type", Array)
106
116
  ], Test.prototype, "categories", void 0);
107
117
  __decorate([
108
- EnduranceModelType.prop({ required: true, enum: JobType }),
109
- __metadata("design:type", String)
118
+ EnduranceModelType.prop({ ref: () => TestJob, required: true }),
119
+ __metadata("design:type", Object)
110
120
  ], Test.prototype, "targetJob", void 0);
111
121
  __decorate([
112
122
  EnduranceModelType.prop({ required: true, enum: SeniorityLevel }),
@@ -3,7 +3,26 @@ import CandidateModel from '../models/candidate.model.js';
3
3
  import ContactModel from '../models/contact.model.js';
4
4
  import TestResult from '../models/test-result.model.js';
5
5
  import Test from '../models/test.model.js';
6
+ import TestJob from '../models/test-job.model.js';
6
7
  import jwt from 'jsonwebtoken';
8
+ // Fonction utilitaire pour récupérer le nom du job
9
+ async function getJobName(targetJob) {
10
+ // Si c'est déjà une string (ancien format), on la retourne directement
11
+ if (typeof targetJob === 'string') {
12
+ return targetJob;
13
+ }
14
+ // Si c'est un ObjectId, on récupère le job
15
+ if (targetJob && typeof targetJob === 'object' && targetJob._id) {
16
+ const job = await TestJob.findById(targetJob._id);
17
+ return job ? job.name : 'Job inconnu';
18
+ }
19
+ // Si c'est juste un ObjectId
20
+ if (targetJob && typeof targetJob === 'object' && targetJob.toString) {
21
+ const job = await TestJob.findById(targetJob);
22
+ return job ? job.name : 'Job inconnu';
23
+ }
24
+ return 'Job inconnu';
25
+ }
7
26
  class CandidateRouter extends EnduranceRouter {
8
27
  constructor() {
9
28
  super(EnduranceAuthMiddleware.getInstance());
@@ -383,7 +402,7 @@ class CandidateRouter extends EnduranceRouter {
383
402
  const categoriesDocs = await TestCategory.find({ _id: { $in: allCategoryIds } }).lean();
384
403
  const categoriesMap = new Map(categoriesDocs.map(cat => [cat._id.toString(), cat.name]));
385
404
  // Combiner les résultats avec les informations des tests et des catégories
386
- const resultsWithTests = results.map(result => {
405
+ const resultsWithTests = await Promise.all(results.map(async (result) => {
387
406
  const test = testsMap.get(result.testId.toString());
388
407
  let categoriesWithNames = [];
389
408
  if (test && test.categories) {
@@ -398,13 +417,13 @@ class CandidateRouter extends EnduranceRouter {
398
417
  ? {
399
418
  title: test.title,
400
419
  description: test.description,
401
- targetJob: test.targetJob,
420
+ targetJob: await getJobName(test.targetJob),
402
421
  seniorityLevel: test.seniorityLevel,
403
422
  categories: categoriesWithNames
404
423
  }
405
424
  : null
406
425
  };
407
- });
426
+ }));
408
427
  const totalPages = Math.ceil(total / limit);
409
428
  return res.json({
410
429
  data: resultsWithTests,
@@ -1,6 +1,7 @@
1
1
  import { EnduranceRouter } from '@programisto/endurance-core';
2
2
  declare class ExamsRouter extends EnduranceRouter {
3
3
  constructor();
4
+ private generateAndSaveQuestion;
4
5
  setupRoutes(): void;
5
6
  }
6
7
  declare const router: ExamsRouter;
@@ -3,13 +3,80 @@ import Test from '../models/test.model.js';
3
3
  import TestQuestion from '../models/test-question.model.js';
4
4
  import TestResult from '../models/test-result.model.js';
5
5
  import TestCategory from '../models/test-category.models.js';
6
+ import TestJob from '../models/test-job.model.js';
6
7
  import Candidate from '../models/candidate.model.js';
7
8
  import ContactModel from '../models/contact.model.js';
8
- import { generateLiveMessage } from '../lib/openai.js';
9
+ import { generateLiveMessage, generateLiveMessageAssistant } from '../lib/openai.js';
10
+ // Fonction utilitaire pour récupérer le nom du job
11
+ async function getJobName(targetJob) {
12
+ // Si c'est déjà une string (ancien format), on la retourne directement
13
+ if (typeof targetJob === 'string') {
14
+ return targetJob;
15
+ }
16
+ // Si c'est un ObjectId, on récupère le job
17
+ if (targetJob && typeof targetJob === 'object' && targetJob._id) {
18
+ const job = await TestJob.findById(targetJob._id);
19
+ return job ? job.name : 'Job inconnu';
20
+ }
21
+ // Si c'est juste un ObjectId
22
+ if (targetJob && typeof targetJob === 'object' && targetJob.toString) {
23
+ const job = await TestJob.findById(targetJob);
24
+ return job ? job.name : 'Job inconnu';
25
+ }
26
+ return 'Job inconnu';
27
+ }
28
+ // Fonction pour migrer automatiquement un test si nécessaire
29
+ async function migrateTestIfNeeded(test) {
30
+ if (typeof test.targetJob === 'string') {
31
+ await test.migrateTargetJob();
32
+ }
33
+ }
9
34
  class ExamsRouter extends EnduranceRouter {
10
35
  constructor() {
11
36
  super(EnduranceAuthMiddleware.getInstance());
12
37
  }
38
+ async generateAndSaveQuestion(test, categoryInfo, useAssistant = false) {
39
+ try {
40
+ const categoryDoc = await TestCategory.findById(categoryInfo.categoryId);
41
+ if (!categoryDoc) {
42
+ console.error('Catégorie non trouvée:', categoryInfo.categoryId);
43
+ return null;
44
+ }
45
+ // Récupérer les questions existantes pour éviter les doublons
46
+ const otherQuestionsIds = test.questions.map(question => question.questionId);
47
+ const otherQuestions = await TestQuestion.find({ _id: { $in: otherQuestionsIds } });
48
+ const jobName = await getJobName(test.targetJob);
49
+ const questionParams = {
50
+ job: jobName,
51
+ seniority: test.seniorityLevel,
52
+ category: categoryDoc.name,
53
+ questionType: ['MCQ', 'free question', 'exercice'][Math.floor(Math.random() * 3)],
54
+ expertiseLevel: categoryInfo.expertiseLevel,
55
+ otherQuestions: otherQuestions.map(question => question.instruction).join('\n')
56
+ };
57
+ let generatedQuestion;
58
+ if (useAssistant) {
59
+ generatedQuestion = await generateLiveMessageAssistant(process.env.OPENAI_ASSISTANT_ID_CREATE_QUESTION || '', 'createQuestion', questionParams, true);
60
+ }
61
+ else {
62
+ generatedQuestion = await generateLiveMessage('createQuestion', questionParams, true);
63
+ }
64
+ if (generatedQuestion === 'Brain freezed, I cannot generate a live message right now.') {
65
+ console.error('Échec de génération de question pour la catégorie:', categoryDoc.name);
66
+ return null;
67
+ }
68
+ const question = new TestQuestion(JSON.parse(generatedQuestion));
69
+ await question.save();
70
+ // Ajouter la question au test et sauvegarder immédiatement
71
+ test.questions.push({ questionId: question._id, order: test.questions.length });
72
+ await test.save();
73
+ return question;
74
+ }
75
+ catch (error) {
76
+ console.error('Erreur lors de la génération/sauvegarde de la question:', error);
77
+ return null;
78
+ }
79
+ }
13
80
  setupRoutes() {
14
81
  const authenticatedOptions = {
15
82
  requireAuth: false,
@@ -57,6 +124,78 @@ class ExamsRouter extends EnduranceRouter {
57
124
  res.status(500).json({ message: 'Internal server error' });
58
125
  }
59
126
  });
127
+ // Créer un job type
128
+ this.post('/jobs', authenticatedOptions, async (req, res) => {
129
+ const { name } = req.body;
130
+ if (!name) {
131
+ return res.status(400).json({ message: 'Error, name is required' });
132
+ }
133
+ try {
134
+ const newJob = new TestJob({ name });
135
+ await newJob.save();
136
+ res.status(201).json({ message: 'job created with success', job: newJob });
137
+ }
138
+ catch (err) {
139
+ console.error('error when creating job : ', err);
140
+ res.status(500).json({ message: 'Internal server error' });
141
+ }
142
+ });
143
+ // Lister tous les jobs
144
+ this.get('/jobs', authenticatedOptions, async (req, res) => {
145
+ try {
146
+ const jobs = await TestJob.find();
147
+ res.status(200).json({ array: jobs });
148
+ }
149
+ catch (err) {
150
+ console.error('error when getting jobs : ', err);
151
+ res.status(500).json({ message: 'Internal server error' });
152
+ }
153
+ });
154
+ // Obtenir un job par son ID
155
+ this.get('/jobs/:id', authenticatedOptions, async (req, res) => {
156
+ const { id } = req.params;
157
+ try {
158
+ const job = await TestJob.findById(id);
159
+ if (!job) {
160
+ return res.status(404).json({ message: 'no job founded with this id' });
161
+ }
162
+ res.status(200).json({ array: job });
163
+ }
164
+ catch (err) {
165
+ console.error('error when getting job : ', err);
166
+ res.status(500).json({ message: 'Internal server error' });
167
+ }
168
+ });
169
+ // Migrer tous les tests avec l'ancien format targetJob
170
+ this.post('/migrate-targetjobs', authenticatedOptions, async (req, res) => {
171
+ try {
172
+ const tests = await Test.find();
173
+ let migratedCount = 0;
174
+ let errorCount = 0;
175
+ for (const test of tests) {
176
+ try {
177
+ // Vérifier si le test a besoin de migration
178
+ if (typeof test.targetJob === 'string') {
179
+ await test.migrateTargetJob();
180
+ migratedCount++;
181
+ }
182
+ }
183
+ catch (error) {
184
+ console.error(`Erreur lors de la migration du test ${test._id}:`, error);
185
+ errorCount++;
186
+ }
187
+ }
188
+ res.status(200).json({
189
+ message: `Migration terminée. ${migratedCount} tests migrés, ${errorCount} erreurs.`,
190
+ migratedCount,
191
+ errorCount
192
+ });
193
+ }
194
+ catch (err) {
195
+ console.error('Erreur lors de la migration :', err);
196
+ res.status(500).json({ message: 'Erreur interne du serveur' });
197
+ }
198
+ });
60
199
  // Créer un test
61
200
  this.post('/test', authenticatedOptions, async (req, res) => {
62
201
  const { title, description, targetJob, seniorityLevel, categories, state = 'draft' } = req.body;
@@ -67,6 +206,19 @@ class ExamsRouter extends EnduranceRouter {
67
206
  try {
68
207
  const companyId = user?.companyId;
69
208
  const userId = user?._id;
209
+ // Traiter le targetJob - si c'est une string, on cherche ou crée le TestJob
210
+ let targetJobId;
211
+ if (typeof targetJob === 'string') {
212
+ let existingJob = await TestJob.findOne({ name: targetJob });
213
+ if (!existingJob) {
214
+ existingJob = new TestJob({ name: targetJob });
215
+ await existingJob.save();
216
+ }
217
+ targetJobId = existingJob._id;
218
+ }
219
+ else {
220
+ targetJobId = targetJob;
221
+ }
70
222
  const processedCategories = await Promise.all(categories?.map(async (category) => {
71
223
  let existingCategory = await TestCategory.findOne({ name: category.name });
72
224
  if (!existingCategory) {
@@ -82,7 +234,7 @@ class ExamsRouter extends EnduranceRouter {
82
234
  userId,
83
235
  title,
84
236
  description,
85
- targetJob,
237
+ targetJob: targetJobId,
86
238
  seniorityLevel,
87
239
  state,
88
240
  categories: processedCategories
@@ -108,8 +260,20 @@ class ExamsRouter extends EnduranceRouter {
108
260
  test.title = title;
109
261
  if (description)
110
262
  test.description = description;
111
- if (targetJob)
112
- test.targetJob = targetJob;
263
+ if (targetJob) {
264
+ // Traiter le targetJob - si c'est une string, on cherche ou crée le TestJob
265
+ if (typeof targetJob === 'string') {
266
+ let existingJob = await TestJob.findOne({ name: targetJob });
267
+ if (!existingJob) {
268
+ existingJob = new TestJob({ name: targetJob });
269
+ await existingJob.save();
270
+ }
271
+ test.targetJob = existingJob._id;
272
+ }
273
+ else {
274
+ test.targetJob = targetJob;
275
+ }
276
+ }
113
277
  if (seniorityLevel)
114
278
  test.seniorityLevel = seniorityLevel;
115
279
  if (state)
@@ -163,6 +327,8 @@ class ExamsRouter extends EnduranceRouter {
163
327
  if (!test) {
164
328
  return res.status(404).json({ message: 'no test founded with this id' });
165
329
  }
330
+ // Migration automatique si nécessaire
331
+ await migrateTestIfNeeded(test);
166
332
  const questions = [];
167
333
  for (const questionRef of test.questions) {
168
334
  console.log(questionRef);
@@ -172,7 +338,10 @@ class ExamsRouter extends EnduranceRouter {
172
338
  questions.push(question);
173
339
  }
174
340
  }
175
- res.status(200).json({ test, questions });
341
+ // Récupérer le nom du job pour l'affichage
342
+ const testObj = test.toObject();
343
+ testObj.targetJobName = await getJobName(testObj.targetJob);
344
+ res.status(200).json({ test: testObj, questions });
176
345
  }
177
346
  catch (err) {
178
347
  console.error('error when geting test : ', err);
@@ -195,7 +364,15 @@ class ExamsRouter extends EnduranceRouter {
195
364
  const query = {};
196
365
  // Filtres
197
366
  if (targetJob !== 'all') {
198
- query.targetJob = targetJob;
367
+ // Si on filtre par targetJob, on cherche d'abord le TestJob correspondant
368
+ const jobType = await TestJob.findOne({ name: targetJob });
369
+ if (jobType) {
370
+ query.targetJob = jobType._id;
371
+ }
372
+ else {
373
+ // Si le job n'existe pas, on ne retourne aucun résultat
374
+ query.targetJob = null;
375
+ }
199
376
  }
200
377
  if (seniorityLevel !== 'all') {
201
378
  query.seniorityLevel = seniorityLevel;
@@ -205,10 +382,13 @@ class ExamsRouter extends EnduranceRouter {
205
382
  }
206
383
  // Recherche sur testName et targetJob
207
384
  if (search) {
385
+ // Pour la recherche sur targetJob, on cherche d'abord les jobs qui correspondent
386
+ const matchingJobs = await TestJob.find({ name: { $regex: search, $options: 'i' } });
387
+ const jobIds = matchingJobs.map(job => job._id);
208
388
  query.$or = [
209
389
  { title: { $regex: search, $options: 'i' } },
210
390
  { description: { $regex: search, $options: 'i' } },
211
- { targetJob: { $regex: search, $options: 'i' } },
391
+ { targetJob: { $in: jobIds } },
212
392
  { seniorityLevel: { $regex: search, $options: 'i' } }
213
393
  ];
214
394
  }
@@ -226,9 +406,13 @@ class ExamsRouter extends EnduranceRouter {
226
406
  .exec(),
227
407
  Test.countDocuments(query)
228
408
  ]);
229
- // Récupérer les noms des catégories pour chaque test
409
+ // Récupérer les noms des catégories et des jobs pour chaque test
230
410
  const testsWithCategories = await Promise.all(tests.map(async (test) => {
411
+ // Migration automatique si nécessaire
412
+ await migrateTestIfNeeded(test);
231
413
  const testObj = test.toObject();
414
+ // Récupérer le nom du job
415
+ testObj.targetJobName = await getJobName(testObj.targetJob);
232
416
  if (testObj.categories && testObj.categories.length > 0) {
233
417
  const categoriesWithNames = await Promise.all(testObj.categories.map(async (category) => {
234
418
  const categoryDoc = await TestCategory.findById(category.categoryId);
@@ -437,8 +621,9 @@ class ExamsRouter extends EnduranceRouter {
437
621
  }
438
622
  const otherQuestionsIds = test.questions.map(question => question.questionId);
439
623
  const otherQuestions = await TestQuestion.find({ _id: { $in: otherQuestionsIds } });
440
- const generatedQuestion = await generateLiveMessage('createQuestion', {
441
- job: test.targetJob,
624
+ const jobName = await getJobName(test.targetJob);
625
+ const generatedQuestion = await generateLiveMessageAssistant(process.env.OPENAI_ASSISTANT_ID_CREATE_QUESTION || '', 'createQuestion', {
626
+ job: jobName,
442
627
  seniority: test.seniorityLevel,
443
628
  questionType,
444
629
  category,
@@ -715,7 +900,7 @@ class ExamsRouter extends EnduranceRouter {
715
900
  const question = await TestQuestion.findById(response.questionId);
716
901
  if (!question)
717
902
  continue;
718
- const score = await generateLiveMessage('correctQuestion', {
903
+ const score = await generateLiveMessageAssistant(process.env.OPENAI_ASSISTANT_ID_CORRECT_QUESTION || '', 'correctQuestion', {
719
904
  question: {
720
905
  _id: question._id.toString(),
721
906
  instruction: question.instruction,
@@ -819,45 +1004,32 @@ class ExamsRouter extends EnduranceRouter {
819
1004
  }
820
1005
  const generatedQuestions = [];
821
1006
  let questionsGenerated = 0;
822
- // Mélanger les catégories pour une répartition aléatoire
823
- const shuffledCategories = [...categoriesToUse].sort(() => Math.random() - 0.5);
824
- for (const categoryInfo of shuffledCategories) {
825
- // Arrêter si on a déjà généré le nombre de questions demandé
826
- if (questionsGenerated >= numberOfQuestions)
827
- break;
828
- const categoryDoc = await TestCategory.findById(categoryInfo.categoryId);
829
- if (!categoryDoc)
830
- continue;
831
- const otherQuestionsIds = test.questions.map(question => question.questionId);
832
- const otherQuestions = await TestQuestion.find({ _id: { $in: otherQuestionsIds } });
833
- // Calculer combien de questions générer pour cette catégorie
834
- const remainingQuestions = numberOfQuestions - questionsGenerated;
835
- const questionsForThisCategory = Math.min(remainingQuestions, Math.ceil(numberOfQuestions / categoriesToUse.length));
836
- for (let i = 0; i < questionsForThisCategory; i++) {
837
- const generatedQuestion = await generateLiveMessage('createQuestion', {
838
- job: test.targetJob,
839
- seniority: test.seniorityLevel,
840
- category: categoryDoc.name,
841
- questionType: ['MCQ', 'free question', 'exercice'][Math.floor(Math.random() * 3)],
842
- expertiseLevel: categoryInfo.expertiseLevel,
843
- otherQuestions: otherQuestions.map(question => question.instruction).join('\n')
844
- }, true);
845
- // Vérifier si la réponse est un JSON valide
846
- if (generatedQuestion === 'Brain freezed, I cannot generate a live message right now.') {
847
- console.error('Échec de génération de question pour la catégorie:', categoryDoc.name);
848
- continue; // Passer à la question suivante
849
- }
850
- try {
851
- const question = new TestQuestion(JSON.parse(generatedQuestion));
852
- await question.save();
1007
+ let attempts = 0;
1008
+ const maxAttempts = numberOfQuestions * 3; // Limite pour éviter les boucles infinies
1009
+ // Si on spécifie une catégorie, on génère toutes les questions pour cette catégorie
1010
+ if (category && category !== 'ALL') {
1011
+ const categoryInfo = categoriesToUse[0];
1012
+ while (questionsGenerated < numberOfQuestions && attempts < maxAttempts) {
1013
+ attempts++;
1014
+ const question = await this.generateAndSaveQuestion(test, categoryInfo, true);
1015
+ if (question) {
853
1016
  generatedQuestions.push(question);
854
- test.questions.push({ questionId: question._id, order: test.questions.length });
855
1017
  questionsGenerated++;
856
1018
  }
857
- catch (parseError) {
858
- console.error('Erreur lors du parsing de la question générée:', parseError);
859
- console.error('Réponse reçue:', generatedQuestion);
860
- continue; // Passer à la question suivante
1019
+ }
1020
+ }
1021
+ else {
1022
+ // Pour ALL, répartition aléatoire sur toutes les catégories
1023
+ const shuffledCategories = [...categoriesToUse].sort(() => Math.random() - 0.5);
1024
+ while (questionsGenerated < numberOfQuestions && attempts < maxAttempts) {
1025
+ attempts++;
1026
+ // Sélectionner une catégorie aléatoire
1027
+ const randomCategoryIndex = Math.floor(Math.random() * shuffledCategories.length);
1028
+ const categoryInfo = shuffledCategories[randomCategoryIndex];
1029
+ const question = await this.generateAndSaveQuestion(test, categoryInfo, true);
1030
+ if (question) {
1031
+ generatedQuestions.push(question);
1032
+ questionsGenerated++;
861
1033
  }
862
1034
  }
863
1035
  }
@@ -867,7 +1039,6 @@ class ExamsRouter extends EnduranceRouter {
867
1039
  message: 'Aucune question n\'a pu être générée. Veuillez réessayer plus tard.'
868
1040
  });
869
1041
  }
870
- await test.save();
871
1042
  res.status(200).json({
872
1043
  message: `${generatedQuestions.length} question(s) générée(s) avec succès`,
873
1044
  questions: generatedQuestions,
@@ -2,6 +2,25 @@ import { EnduranceRouter, EnduranceAuthMiddleware, enduranceEmitter, enduranceEv
2
2
  import CandidateModel from '../models/candidate.model.js';
3
3
  import TestResult, { TestState } from '../models/test-result.model.js';
4
4
  import Test from '../models/test.model.js';
5
+ import TestJob from '../models/test-job.model.js';
6
+ // Fonction utilitaire pour récupérer le nom du job
7
+ async function getJobName(targetJob) {
8
+ // Si c'est déjà une string (ancien format), on la retourne directement
9
+ if (typeof targetJob === 'string') {
10
+ return targetJob;
11
+ }
12
+ // Si c'est un ObjectId, on récupère le job
13
+ if (targetJob && typeof targetJob === 'object' && targetJob._id) {
14
+ const job = await TestJob.findById(targetJob._id);
15
+ return job ? job.name : 'Job inconnu';
16
+ }
17
+ // Si c'est juste un ObjectId
18
+ if (targetJob && typeof targetJob === 'object' && targetJob.toString) {
19
+ const job = await TestJob.findById(targetJob);
20
+ return job ? job.name : 'Job inconnu';
21
+ }
22
+ return 'Job inconnu';
23
+ }
5
24
  class ResultRouter extends EnduranceRouter {
6
25
  constructor() {
7
26
  super(EnduranceAuthMiddleware.getInstance());
@@ -81,7 +100,7 @@ class ResultRouter extends EnduranceRouter {
81
100
  ? {
82
101
  title: test.title,
83
102
  description: test.description,
84
- targetJob: test.targetJob,
103
+ targetJob: await getJobName(test.targetJob),
85
104
  seniorityLevel: test.seniorityLevel,
86
105
  categories: categoriesWithNames
87
106
  }
@@ -129,11 +148,14 @@ class ResultRouter extends EnduranceRouter {
129
148
  const questions = await TestQuestion.find({ _id: { $in: (test.questions || []).map((q) => q.questionId) } }).lean();
130
149
  const maxTime = questions.reduce((sum, q) => sum + (q.time || 0), 0);
131
150
  const numberOfQuestions = questions.length;
151
+ // Récupérer le nom du job
152
+ const targetJobName = await getJobName(test.targetJob);
132
153
  // Construire la réponse sans les questions
133
154
  const { questions: _questions, // on retire les questions
134
155
  ...testWithoutQuestions } = test;
135
156
  return res.json({
136
157
  ...testWithoutQuestions,
158
+ targetJobName,
137
159
  categories: categoriesWithNames,
138
160
  maxTime,
139
161
  numberOfQuestions
@@ -312,18 +334,25 @@ class ResultRouter extends EnduranceRouter {
312
334
  score: 0,
313
335
  comment: ''
314
336
  });
337
+ // Marquer explicitement le champ responses comme modifié
338
+ result.markModified('responses');
339
+ console.log('Avant sauvegarde - Responses:', result.responses);
315
340
  // Vérifier si c'était la dernière question
316
341
  const totalQuestions = test.questions.length;
317
342
  const answeredQuestions = result.responses.length;
318
343
  if (answeredQuestions === totalQuestions) {
319
344
  result.state = TestState.Finish;
320
- // Déclencher la correction automatique
321
- await enduranceEmitter.emit(enduranceEventTypes.CORRECT_TEST, result);
322
345
  }
323
346
  else {
324
347
  result.state = TestState.InProgress;
325
348
  }
326
- await result.save();
349
+ // Sauvegarder d'abord la réponse
350
+ const savedResult = await result.save();
351
+ console.log('Après sauvegarde - Responses:', savedResult.responses);
352
+ // Déclencher la correction automatique seulement après la sauvegarde
353
+ if (answeredQuestions === totalQuestions) {
354
+ await enduranceEmitter.emit(enduranceEventTypes.CORRECT_TEST, savedResult);
355
+ }
327
356
  return res.status(200).json({
328
357
  message: 'Réponse enregistrée',
329
358
  response,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@programisto/edrm-exams",
4
- "version": "0.2.12",
4
+ "version": "0.3.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },