Issues (2130)

plugin/ai_helper/AiHelperPlugin.php (1 issue)

Labels
Severity
1
<?php
2
/* For license terms, see /license.txt */
3
4
use Chamilo\PluginBundle\Entity\AiHelper\Requests;
5
use Doctrine\ORM\Tools\SchemaTool;
6
7
require_once __DIR__.'/src/deepseek/DeepSeek.php';
8
/**
9
 * Description of AiHelperPlugin.
10
 *
11
 * @author Christian Beeznest
12
 */
13
class AiHelperPlugin extends Plugin
14
{
15
    public const TABLE_REQUESTS = 'plugin_ai_helper_requests';
16
    public const OPENAI_API = 'openai';
17
    public const DEEPSEEK_API = 'deepseek';
18
19
    protected function __construct()
20
    {
21
        $version = '1.2';
22
        $author = 'Christian Fasanando';
23
24
        $message = 'Description';
25
26
        $settings = [
27
            $message => 'html',
28
            'tool_enable' => 'boolean',
29
            'api_name' => [
30
                'type' => 'select',
31
                'options' => $this->getApiList(),
32
            ],
33
            'api_key' => 'text',
34
            'organization_id' => 'text',
35
            'tool_lp_enable' => 'boolean',
36
            'tool_quiz_enable' => 'boolean',
37
            'tokens_limit' => 'text',
38
        ];
39
40
        parent::__construct($version, $author, $settings);
41
    }
42
43
    /**
44
     * Get the list of APIs available.
45
     *
46
     * @return array
47
     */
48
    public function getApiList()
49
    {
50
        $list = [
51
            self::OPENAI_API => 'OpenAI',
52
            self::DEEPSEEK_API => 'DeepSeek',
53
        ];
54
55
        return $list;
56
    }
57
58
    /**
59
     * Get the completion text from the selected API.
60
     *
61
     * @return string|array
62
     */
63
    public function getCompletionText(string $prompt, string $toolName)
64
    {
65
        if (!$this->validateUserTokensLimit(api_get_user_id())) {
66
            return [
67
                'error' => true,
68
                'message' => $this->get_lang('ErrorTokensLimit'),
69
            ];
70
        }
71
72
        $apiName = $this->get('api_name');
73
74
        switch ($apiName) {
75
            case self::OPENAI_API:
76
                return $this->openAiGetCompletionText($prompt, $toolName);
77
            case self::DEEPSEEK_API:
78
                return $this->deepSeekGetCompletionText($prompt, $toolName);
79
            default:
80
                return [
81
                    'error' => true,
82
                    'message' => 'API not supported.',
83
                ];
84
        }
85
    }
86
87
    /**
88
     * Get completion text from OpenAI.
89
     */
90
    public function openAiGetCompletionText(string $prompt, string $toolName)
91
    {
92
        try {
93
            require_once __DIR__.'/src/openai/OpenAi.php';
94
95
            $apiKey = $this->get('api_key');
96
            $organizationId = $this->get('organization_id');
97
98
            $ai = new OpenAi($apiKey, $organizationId);
99
100
            $params = [
101
                'model' => 'gpt-3.5-turbo-instruct',
102
                'prompt' => $prompt,
103
                'temperature' => 0.2,
104
                'max_tokens' => 2000,
105
                'frequency_penalty' => 0,
106
                'presence_penalty' => 0.6,
107
                'top_p' => 1.0,
108
            ];
109
110
            $complete = $ai->completion($params);
111
            $result = json_decode($complete, true);
112
113
            if (isset($result['error'])) {
114
                $errorMessage = $result['error']['message'] ?? 'Unknown error';
115
                error_log("OpenAI Error: $errorMessage");
116
117
                return [
118
                    'error' => true,
119
                    'message' => $errorMessage,
120
                ];
121
            }
122
123
            $resultText = $result['choices'][0]['text'] ?? '';
124
125
            if (!empty($resultText)) {
126
                $this->saveRequest([
127
                    'user_id' => api_get_user_id(),
128
                    'tool_name' => $toolName,
129
                    'prompt' => $prompt,
130
                    'prompt_tokens' => (int) ($result['usage']['prompt_tokens'] ?? 0),
131
                    'completion_tokens' => (int) ($result['usage']['completion_tokens'] ?? 0),
132
                    'total_tokens' => (int) ($result['usage']['total_tokens'] ?? 0),
133
                ]);
134
            }
135
136
            return $resultText ?: 'No response generated.';
137
        } catch (Exception $e) {
138
            return [
139
                'error' => true,
140
                'message' => 'An error occurred while connecting to OpenAI: '.$e->getMessage(),
141
            ];
142
        }
143
    }
144
145
    /**
146
     * Get completion text from DeepSeek.
147
     */
148
    public function deepSeekGetCompletionText(string $prompt, string $toolName)
149
    {
150
        $apiKey = $this->get('api_key');
151
152
        $url = 'https://api.deepseek.com/chat/completions';
153
154
        $payload = [
155
            'model' => 'deepseek-chat',
156
            'messages' => [
157
                [
158
                    'role' => 'system',
159
                    'content' => ($toolName === 'quiz')
160
                        ? 'You are a helpful assistant that generates Aiken format questions.'
161
                        : 'You are a helpful assistant that generates learning path contents.',
162
                ],
163
                [
164
                    'role' => 'user',
165
                    'content' => $prompt,
166
                ],
167
            ],
168
            'stream' => false,
169
        ];
170
171
        $ch = curl_init($url);
172
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
173
        curl_setopt($ch, CURLOPT_POST, true);
174
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
175
        curl_setopt($ch, CURLOPT_HTTPHEADER, [
176
            'Content-Type: application/json',
177
            "Authorization: Bearer $apiKey",
178
        ]);
179
180
        $response = curl_exec($ch);
181
182
        if ($response === false) {
183
            error_log('cURL error: '.curl_error($ch));
184
            curl_close($ch);
185
186
            return ['error' => true, 'message' => 'Request to AI provider failed.'];
187
        }
188
189
        curl_close($ch);
190
191
        $result = json_decode($response, true);
192
193
        if (isset($result['error'])) {
194
            return [
195
                'error' => true,
196
                'message' => $result['error']['message'] ?? 'Unknown error',
197
            ];
198
        }
199
200
        $resultText = $result['choices'][0]['message']['content'] ?? '';
201
        $this->saveRequest([
202
            'user_id' => api_get_user_id(),
203
            'tool_name' => $toolName,
204
            'prompt' => $prompt,
205
            'prompt_tokens' => 0,
206
            'completion_tokens' => 0,
207
            'total_tokens' => 0,
208
        ]);
209
210
        return $resultText;
211
    }
212
213
    /**
214
     * Generate questions based on the selected AI provider.
215
     *
216
     * @param int    $nQ           Number of questions
217
     * @param string $lang         Language for the questions
218
     * @param string $topic        Topic of the questions
219
     * @param string $questionType Type of questions (e.g., 'multiple_choice')
220
     *
221
     * @throws Exception If an error occurs
222
     *
223
     * @return string Questions generated in Aiken format
224
     */
225
    public function generateQuestions(int $nQ, string $lang, string $topic, string $questionType = 'multiple_choice'): string
226
    {
227
        $apiName = $this->get('api_name');
228
229
        switch ($apiName) {
230
            case self::OPENAI_API:
231
                return $this->generateOpenAiQuestions($nQ, $lang, $topic, $questionType);
232
            case self::DEEPSEEK_API:
233
                return $this->generateDeepSeekQuestions($nQ, $lang, $topic, $questionType);
234
            default:
235
                throw new Exception("Unsupported API provider: $apiName");
236
        }
237
    }
238
239
    /**
240
     * Validates tokens limit of a user per current month.
241
     */
242
    public function validateUserTokensLimit(int $userId): bool
243
    {
244
        $em = Database::getManager();
245
        $repo = $em->getRepository('ChamiloPluginBundle:AiHelper\Requests');
246
247
        $startDate = api_get_utc_datetime(
248
            null,
249
            false,
250
            true)
251
            ->modify('first day of this month')->setTime(00, 00, 00)
0 ignored issues
show
A parse error occurred: The alleged octal '0' is invalid
Loading history...
252
        ;
253
        $endDate = api_get_utc_datetime(
254
            null,
255
            false,
256
            true)
257
            ->modify('last day of this month')->setTime(23, 59, 59)
258
        ;
259
260
        $qb = $repo->createQueryBuilder('e')
261
            ->select('sum(e.totalTokens) as total')
262
            ->andWhere('e.requestedAt BETWEEN :dateMin AND :dateMax')
263
            ->andWhere('e.userId = :user')
264
            ->setMaxResults(1)
265
            ->setParameters(
266
                [
267
                    'dateMin' => $startDate->format('Y-m-d h:i:s'),
268
                    'dateMax' => $endDate->format('Y-m-d h:i:s'),
269
                    'user' => $userId,
270
                ]
271
            );
272
        $result = $qb->getQuery()->getOneOrNullResult();
273
        $totalTokens = !empty($result) ? (int) $result['total'] : 0;
274
275
        $valid = true;
276
        $tokensLimit = $this->get('tokens_limit');
277
        if (!empty($tokensLimit)) {
278
            $valid = ($totalTokens <= (int) $tokensLimit);
279
        }
280
281
        return $valid;
282
    }
283
284
    /**
285
     * Get the plugin directory name.
286
     */
287
    public function get_name(): string
288
    {
289
        return 'ai_helper';
290
    }
291
292
    /**
293
     * Get the class instance.
294
     *
295
     * @staticvar AiHelperPlugin $result
296
     */
297
    public static function create(): AiHelperPlugin
298
    {
299
        static $result = null;
300
301
        return $result ?: $result = new self();
302
    }
303
304
    /**
305
     * Save user information of openai request.
306
     *
307
     * @return int
308
     */
309
    public function saveRequest(array $values)
310
    {
311
        $em = Database::getManager();
312
313
        $objRequest = new Requests();
314
        $objRequest
315
            ->setUserId($values['user_id'])
316
            ->setToolName($values['tool_name'])
317
            ->setRequestedAt(new DateTime())
318
            ->setRequestText($values['prompt'])
319
            ->setPromptTokens($values['prompt_tokens'])
320
            ->setCompletionTokens($values['completion_tokens'])
321
            ->setTotalTokens($values['total_tokens'])
322
        ;
323
        $em->persist($objRequest);
324
        $em->flush();
325
326
        return $objRequest->getId();
327
    }
328
329
    /**
330
     * Install the plugin. Set the database up.
331
     */
332
    public function install()
333
    {
334
        $em = Database::getManager();
335
336
        if ($em->getConnection()->getSchemaManager()->tablesExist([self::TABLE_REQUESTS])) {
337
            return;
338
        }
339
340
        $schemaTool = new SchemaTool($em);
341
        $schemaTool->createSchema(
342
            [
343
                $em->getClassMetadata(Requests::class),
344
            ]
345
        );
346
    }
347
348
    /**
349
     * Unistall plugin. Clear the database.
350
     */
351
    public function uninstall()
352
    {
353
        $em = Database::getManager();
354
355
        if (!$em->getConnection()->getSchemaManager()->tablesExist([self::TABLE_REQUESTS])) {
356
            return;
357
        }
358
359
        $schemaTool = new SchemaTool($em);
360
        $schemaTool->dropSchema(
361
            [
362
                $em->getClassMetadata(Requests::class),
363
            ]
364
        );
365
    }
366
367
    /**
368
     * Generate questions using OpenAI.
369
     */
370
    private function generateOpenAiQuestions(int $nQ, string $lang, string $topic, string $questionType): string
371
    {
372
        $prompt = sprintf(
373
            'Generate %d "%s" questions in Aiken format in the %s language about "%s", making sure there is a \'ANSWER\' line for each question. \'ANSWER\' lines must only mention the letter of the correct answer, not the full answer text and not a parenthesis. The line starting with \'ANSWER\' must not be separated from the last possible answer by a blank line. Each answer starts with an uppercase letter, a dot, one space and the answer text without quotes. Include an \'ANSWER_EXPLANATION\' line after the \'ANSWER\' line for each question. The terms between single quotes above must not be translated. There must be a blank line between each question.',
374
            $nQ,
375
            $questionType,
376
            $lang,
377
            $topic
378
        );
379
380
        $result = $this->openAiGetCompletionText($prompt, 'quiz');
381
        if (isset($result['error']) && true === $result['error']) {
382
            throw new Exception($result['message']);
383
        }
384
385
        return $result;
386
    }
387
388
    /**
389
     * Generate questions using DeepSeek.
390
     */
391
    private function generateDeepSeekQuestions(int $nQ, string $lang, string $topic, string $questionType): string
392
    {
393
        $apiKey = $this->get('api_key');
394
        $prompt = sprintf(
395
            'Generate %d "%s" questions in Aiken format in the %s language about "%s", making sure there is a \'ANSWER\' line for each question. \'ANSWER\' lines must only mention the letter of the correct answer, not the full answer text and not a parenthesis. The line starting with \'ANSWER\' must not be separated from the last possible answer by a blank line. Each answer starts with an uppercase letter, a dot, one space and the answer text without quotes. Include an \'ANSWER_EXPLANATION\' line after the \'ANSWER\' line for each question. The terms between single quotes above must not be translated. There must be a blank line between each question.',
396
            $nQ,
397
            $questionType,
398
            $lang,
399
            $topic
400
        );
401
        $payload = [
402
            'model' => 'deepseek-chat',
403
            'messages' => [
404
                [
405
                    'role' => 'system',
406
                    'content' => 'You are a helpful assistant that generates Aiken format questions.',
407
                ],
408
                [
409
                    'role' => 'user',
410
                    'content' => $prompt,
411
                ],
412
            ],
413
            'stream' => false,
414
        ];
415
416
        $deepSeek = new DeepSeek($apiKey);
417
        $response = $deepSeek->generateQuestions($payload);
418
419
        return $response;
420
    }
421
}
422