Passed
Push — master ( 0befdb...4ef104 )
by
unknown
12:28 queued 01:07
created

MistralProvider::gradeOpenAnswer()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
c 0
b 0
f 0
nc 1
nop 2
dl 0
loc 3
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
/* For licensing terms, see /license.txt */
6
7
namespace Chamilo\CoreBundle\AiProvider;
8
9
use Chamilo\CoreBundle\Entity\AiRequests;
10
use Chamilo\CoreBundle\Repository\AiRequestsRepository;
11
use Chamilo\CoreBundle\Settings\SettingsManager;
12
use Exception;
13
use RuntimeException;
14
use Symfony\Bundle\SecurityBundle\Security;
15
use Symfony\Component\Security\Core\User\UserInterface;
16
use Symfony\Contracts\HttpClient\HttpClientInterface;
17
18
final class MistralProvider implements AiProviderInterface, AiDocumentProviderInterface
19
{
20
    private string $apiKey;
21
22
    // Text
23
    private string $textApiUrl;
24
    private string $textModel;
25
    private float $textTemperature;
26
    private int $textMaxTokens;
27
28
    // Document (fallbacks to text if missing)
29
    private string $documentApiUrl;
30
    private string $documentModel;
31
    private float $documentTemperature;
32
    private int $documentMaxTokens;
33
34
    public function __construct(
35
        private readonly HttpClientInterface $httpClient,
36
        private readonly SettingsManager $settingsManager,
37
        private readonly AiRequestsRepository $aiRequestsRepository,
38
        private readonly Security $security
39
    ) {
40
        $config = $this->readProvidersConfig();
41
42
        if (!isset($config['mistral']) || !\is_array($config['mistral'])) {
43
            throw new RuntimeException('Mistral configuration is missing.');
44
        }
45
46
        $providerConfig = $config['mistral'];
47
48
        $this->apiKey = (string) ($providerConfig['api_key'] ?? '');
49
        if ('' === $this->apiKey) {
50
            throw new RuntimeException('Mistral API key is missing.');
51
        }
52
53
        $textCfg = $providerConfig['text'] ?? null;
54
        if (!\is_array($textCfg)) {
55
            throw new RuntimeException('Mistral configuration for text processing is missing.');
56
        }
57
58
        $this->textApiUrl = (string) ($textCfg['url'] ?? 'https://api.mistral.ai/v1/chat/completions');
59
        $this->textModel = (string) ($textCfg['model'] ?? 'mistral-large-latest');
60
        $this->textTemperature = (float) ($textCfg['temperature'] ?? 0.7);
61
        $this->textMaxTokens = (int) ($textCfg['max_tokens'] ?? 1000);
62
63
        $docCfg = $providerConfig['document'] ?? null;
64
65
        $this->documentApiUrl = \is_array($docCfg) ? (string) ($docCfg['url'] ?? $this->textApiUrl) : $this->textApiUrl;
66
        $this->documentModel = \is_array($docCfg) ? (string) ($docCfg['model'] ?? $this->textModel) : $this->textModel;
67
        $this->documentTemperature = \is_array($docCfg) ? (float) ($docCfg['temperature'] ?? $this->textTemperature) : $this->textTemperature;
68
        $this->documentMaxTokens = \is_array($docCfg) ? (int) ($docCfg['max_tokens'] ?? $this->textMaxTokens) : $this->textMaxTokens;
69
    }
70
71
    public function generateQuestions(string $topic, int $numQuestions, string $questionType, string $language): ?string
72
    {
73
        $prompt = \sprintf(
74
            'Generate %d "%s" questions in Aiken format in the %s language about "%s".
75
            Ensure each question follows this format:
76
77
            1. The question text.
78
            A. Option A
79
            B. Option B
80
            C. Option C
81
            D. Option D
82
            ANSWER: (Correct answer letter)
83
84
            The output should be plain text without additional symbols or markdown.',
85
            $numQuestions,
86
            $questionType,
87
            $language,
88
            $topic
89
        );
90
91
        return $this->requestMistral($this->textApiUrl, $this->textModel, $this->textTemperature, $this->textMaxTokens, $prompt, 'quiz');
92
    }
93
94
    public function generateLearnPath(string $topic, int $chaptersCount, string $language, int $wordsCount, bool $addTests, int $numQuestions): ?array
95
    {
96
        $tocPrompt = \sprintf(
97
            'Generate a structured table of contents for a course in "%s" with %d chapters on "%s".
98
            Return a numbered list, each chapter on a new line. No conclusion.',
99
            $language,
100
            $chaptersCount,
101
            $topic
102
        );
103
104
        $lpStructure = $this->requestMistral($this->textApiUrl, $this->textModel, $this->textTemperature, $this->textMaxTokens, $tocPrompt, 'learnpath');
105
        if (!$lpStructure) {
106
            return ['success' => false, 'message' => 'Failed to generate course structure.'];
107
        }
108
109
        $lpItems = [];
110
        $chapters = explode("\n", trim($lpStructure));
111
        foreach ($chapters as $chapterTitle) {
112
            $chapterTitle = trim($chapterTitle);
113
            if ('' === $chapterTitle) {
114
                continue;
115
            }
116
117
            $chapterPrompt = \sprintf(
118
                'Create a learning chapter in HTML for "%s" in "%s" with %d words.
119
Title: "%s". Assume the reader already knows the context.',
120
                $topic,
121
                $language,
122
                $wordsCount,
123
                $chapterTitle
124
            );
125
126
            $chapterContent = $this->requestMistral($this->textApiUrl, $this->textModel, $this->textTemperature, $this->textMaxTokens, $chapterPrompt, 'learnpath');
127
            if (!$chapterContent) {
128
                continue;
129
            }
130
131
            $lpItems[] = [
132
                'title' => $chapterTitle,
133
                'content' => "<html><head><title>{$chapterTitle}</title></head><body>{$chapterContent}</body></html>",
134
            ];
135
        }
136
137
        $quizItems = [];
138
        if ($addTests) {
139
            foreach ($lpItems as $chapter) {
140
                $quizPrompt = \sprintf(
141
                    'Generate %d multiple-choice questions in Aiken format in %s about "%s".
142
            Ensure each question follows this format:
143
144
            1. The question text.
145
            A. Option A
146
            B. Option B
147
            C. Option C
148
            D. Option D
149
            ANSWER: (Correct answer letter)
150
151
            Each question must have exactly 4 options and one answer line.
152
            Return only valid questions without extra text.',
153
                    $numQuestions,
154
                    $language,
155
                    $chapter['title']
156
                );
157
158
                $quizContent = $this->requestMistral($this->textApiUrl, $this->textModel, $this->textTemperature, $this->textMaxTokens, $quizPrompt, 'learnpath');
159
                if (!$quizContent) {
160
                    continue;
161
                }
162
163
                $validQuestions = $this->filterValidAikenQuestions($quizContent);
164
                if (!empty($validQuestions)) {
165
                    $quizItems[] = [
166
                        'title' => 'Quiz: '.$chapter['title'],
167
                        'content' => implode("\n\n", $validQuestions),
168
                    ];
169
                }
170
            }
171
        }
172
173
        return [
174
            'success' => true,
175
            'topic' => $topic,
176
            'lp_items' => $lpItems,
177
            'quiz_items' => $quizItems,
178
        ];
179
    }
180
181
    public function gradeOpenAnswer(string $prompt, string $toolName): ?string
182
    {
183
        return $this->requestMistral($this->textApiUrl, $this->textModel, $this->textTemperature, $this->textMaxTokens, $prompt, $toolName);
184
    }
185
186
    public function generateDocument(string $prompt, string $toolName, ?array $options = []): ?string
187
    {
188
        $format = isset($options['format']) ? (string) $options['format'] : '';
189
        if ('' !== $format) {
190
            $prompt .= "\n\nOutput format: {$format}.";
191
        }
192
193
        return $this->requestMistral(
194
            $this->documentApiUrl,
195
            $this->documentModel,
196
            $this->documentTemperature,
197
            $this->documentMaxTokens,
198
            $prompt,
199
            $toolName
200
        );
201
    }
202
203
    private function requestMistral(string $url, string $model, float $temperature, int $maxTokens, string $prompt, string $toolName): ?string
204
    {
205
        $userId = $this->getUserId();
206
        if (!$userId) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $userId of type integer|null is loosely compared to false; this is ambiguous if the integer can be 0. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
207
            throw new RuntimeException('User not authenticated.');
208
        }
209
210
        $payload = [
211
            'model' => $model,
212
            'messages' => [
213
                ['role' => 'system', 'content' => 'You are a helpful AI assistant that generates structured educational content.'],
214
                ['role' => 'user', 'content' => $prompt],
215
            ],
216
            'temperature' => $temperature,
217
            'max_tokens' => $maxTokens,
218
        ];
219
220
        try {
221
            $response = $this->httpClient->request('POST', $url, [
222
                'headers' => [
223
                    'Authorization' => 'Bearer '.$this->apiKey,
224
                    'Content-Type' => 'application/json',
225
                ],
226
                'json' => $payload,
227
            ]);
228
229
            $statusCode = $response->getStatusCode();
230
            $data = $response->toArray(false);
231
232
            if (200 !== $statusCode || !isset($data['choices'][0]['message']['content'])) {
233
                error_log('[AI][Mistral] Invalid response (status='.$statusCode.').');
234
                return null;
235
            }
236
237
            $generatedContent = (string) $data['choices'][0]['message']['content'];
238
239
            $usage = $data['usage'] ?? [];
240
            $promptTokens = (int) ($usage['prompt_tokens'] ?? 0);
241
            $completionTokens = (int) ($usage['completion_tokens'] ?? 0);
242
            $totalTokens = (int) ($usage['total_tokens'] ?? ($promptTokens + $completionTokens));
243
244
            $aiRequest = new AiRequests();
245
            $aiRequest
246
                ->setUserId($userId)
247
                ->setToolName($toolName)
248
                ->setRequestText($prompt)
249
                ->setPromptTokens($promptTokens)
250
                ->setCompletionTokens($completionTokens)
251
                ->setTotalTokens($totalTokens)
252
                ->setAiProvider('mistral')
253
            ;
254
255
            $this->aiRequestsRepository->save($aiRequest);
256
257
            return $generatedContent;
258
        } catch (Exception $e) {
259
            error_log('[AI][Mistral] Exception: '.$e->getMessage());
260
            return null;
261
        }
262
    }
263
264
    private function filterValidAikenQuestions(string $quizContent): array
265
    {
266
        $questions = preg_split('/\n{2,}/', trim($quizContent)) ?: [];
267
268
        $validQuestions = [];
269
        foreach ($questions as $questionBlock) {
270
            $lines = explode("\n", trim($questionBlock));
271
272
            if (\count($lines) < 6) {
273
                continue;
274
            }
275
276
            $options = \array_slice($lines, 1, 4);
277
            $validOptions = array_filter($options, static fn ($line) => (bool) preg_match('/^[A-D]\. .+/', $line));
278
279
            $answerLine = (string) end($lines);
280
            if (4 === \count($validOptions) && preg_match('/^ANSWER: [A-D]$/', $answerLine)) {
281
                $validQuestions[] = implode("\n", $lines);
282
            }
283
        }
284
285
        return $validQuestions;
286
    }
287
288
    private function getUserId(): ?int
289
    {
290
        $user = $this->security->getUser();
291
        return $user instanceof UserInterface ? $user->getId() : null;
292
    }
293
294
    private function readProvidersConfig(): array
295
    {
296
        $configJson = $this->settingsManager->getSetting('ai_helpers.ai_providers', true);
297
298
        if (\is_string($configJson)) {
299
            return json_decode($configJson, true) ?? [];
300
        }
301
302
        if (\is_array($configJson)) {
303
            return $configJson;
304
        }
305
306
        return [];
307
    }
308
}
309