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

GeminiProvider::__construct()   B

Complexity

Conditions 9
Paths 19

Size

Total Lines 35
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 9
eloc 19
nc 19
nop 4
dl 0
loc 35
rs 8.0555
c 0
b 0
f 0
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 GeminiProvider implements AiProviderInterface, AiDocumentProviderInterface
19
{
20
    private string $apiKey;
21
22
    // Text
23
    private string $textModel;
24
    private string $textUrlTemplate;
25
    private float $textTemperature;
26
    private int $textMaxOutputTokens;
27
28
    // Document (fallbacks to text if missing)
29
    private string $documentModel;
30
    private string $documentUrlTemplate;
31
    private float $documentTemperature;
32
    private int $documentMaxOutputTokens;
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['gemini']) || !\is_array($config['gemini'])) {
43
            throw new RuntimeException('Gemini configuration is missing.');
44
        }
45
46
        $providerConfig = $config['gemini'];
47
48
        $this->apiKey = (string) ($providerConfig['api_key'] ?? '');
49
        if ('' === $this->apiKey) {
50
            throw new RuntimeException('Gemini API key is missing.');
51
        }
52
53
        $textCfg = $providerConfig['text'] ?? null;
54
        if (!\is_array($textCfg)) {
55
            throw new RuntimeException('Gemini configuration for text processing is missing.');
56
        }
57
58
        $this->textModel = (string) ($textCfg['model'] ?? 'gemini-2.5-flash');
59
        $this->textUrlTemplate = (string) ($textCfg['url'] ?? 'https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent');
60
        $this->textTemperature = (float) ($textCfg['temperature'] ?? 0.7);
61
        $this->textMaxOutputTokens = (int) ($textCfg['max_output_tokens'] ?? 1000);
62
63
        $docCfg = $providerConfig['document'] ?? null;
64
65
        $this->documentModel = \is_array($docCfg) ? (string) ($docCfg['model'] ?? $this->textModel) : $this->textModel;
66
        $this->documentUrlTemplate = \is_array($docCfg) ? (string) ($docCfg['url'] ?? $this->textUrlTemplate) : $this->textUrlTemplate;
67
        $this->documentTemperature = \is_array($docCfg) ? (float) ($docCfg['temperature'] ?? $this->textTemperature) : $this->textTemperature;
68
        $this->documentMaxOutputTokens = \is_array($docCfg) ? (int) ($docCfg['max_output_tokens'] ?? $this->textMaxOutputTokens) : $this->textMaxOutputTokens;
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->requestGemini(
92
            $this->buildUrl($this->textUrlTemplate, $this->textModel),
93
            $this->textTemperature,
94
            $this->textMaxOutputTokens,
95
            $prompt,
96
            'quiz'
97
        );
98
    }
99
100
    public function generateLearnPath(string $topic, int $chaptersCount, string $language, int $wordsCount, bool $addTests, int $numQuestions): ?array
101
    {
102
        $tocPrompt = \sprintf(
103
            'Generate a structured table of contents for a course in "%s" with %d chapters on "%s".
104
            Return a numbered list, each chapter on a new line. No conclusion.',
105
            $language,
106
            $chaptersCount,
107
            $topic
108
        );
109
110
        $lpStructure = $this->requestGemini(
111
            $this->buildUrl($this->textUrlTemplate, $this->textModel),
112
            $this->textTemperature,
113
            $this->textMaxOutputTokens,
114
            $tocPrompt,
115
            'learnpath'
116
        );
117
118
        if (!$lpStructure) {
119
            return ['success' => false, 'message' => 'Failed to generate course structure.'];
120
        }
121
122
        $lpItems = [];
123
        $chapters = explode("\n", trim($lpStructure));
124
        foreach ($chapters as $chapterTitle) {
125
            $chapterTitle = trim($chapterTitle);
126
            if ('' === $chapterTitle) {
127
                continue;
128
            }
129
130
            $chapterPrompt = \sprintf(
131
                'Create a learning chapter in HTML for "%s" in "%s" with %d words.
132
                Title: "%s". Assume the reader already knows the context.',
133
                $topic,
134
                $language,
135
                $wordsCount,
136
                $chapterTitle
137
            );
138
139
            $chapterContent = $this->requestGemini(
140
                $this->buildUrl($this->textUrlTemplate, $this->textModel),
141
                $this->textTemperature,
142
                $this->textMaxOutputTokens,
143
                $chapterPrompt,
144
                'learnpath'
145
            );
146
147
            if (!$chapterContent) {
148
                continue;
149
            }
150
151
            $lpItems[] = [
152
                'title' => $chapterTitle,
153
                'content' => "<html><head><title>{$chapterTitle}</title></head><body>{$chapterContent}</body></html>",
154
            ];
155
        }
156
157
        $quizItems = [];
158
        if ($addTests) {
159
            foreach ($lpItems as $chapter) {
160
                $quizPrompt = \sprintf(
161
                    'Generate %d multiple-choice questions in Aiken format in %s about "%s".
162
            Ensure each question follows this format:
163
164
            1. The question text.
165
            A. Option A
166
            B. Option B
167
            C. Option C
168
            D. Option D
169
            ANSWER: (Correct answer letter)
170
171
            Each question must have exactly 4 options and one answer line.
172
            Return only valid questions without extra text.',
173
                    $numQuestions,
174
                    $language,
175
                    $chapter['title']
176
                );
177
178
                $quizContent = $this->requestGemini(
179
                    $this->buildUrl($this->textUrlTemplate, $this->textModel),
180
                    $this->textTemperature,
181
                    $this->textMaxOutputTokens,
182
                    $quizPrompt,
183
                    'learnpath'
184
                );
185
186
                if (!$quizContent) {
187
                    continue;
188
                }
189
190
                $valid = $this->filterValidAikenQuestions($quizContent);
191
                if (!empty($valid)) {
192
                    $quizItems[] = [
193
                        'title' => 'Quiz: '.$chapter['title'],
194
                        'content' => implode("\n\n", $valid),
195
                    ];
196
                }
197
            }
198
        }
199
200
        return [
201
            'success' => true,
202
            'topic' => $topic,
203
            'lp_items' => $lpItems,
204
            'quiz_items' => $quizItems,
205
        ];
206
    }
207
208
    public function gradeOpenAnswer(string $prompt, string $toolName): ?string
209
    {
210
        return $this->requestGemini(
211
            $this->buildUrl($this->textUrlTemplate, $this->textModel),
212
            $this->textTemperature,
213
            $this->textMaxOutputTokens,
214
            $prompt,
215
            $toolName
216
        );
217
    }
218
219
    public function generateDocument(string $prompt, string $toolName, ?array $options = []): ?string
220
    {
221
        $format = isset($options['format']) ? (string) $options['format'] : '';
222
        if ('' !== $format) {
223
            $prompt .= "\n\nOutput format: {$format}.";
224
        }
225
226
        return $this->requestGemini(
227
            $this->buildUrl($this->documentUrlTemplate, $this->documentModel),
228
            $this->documentTemperature,
229
            $this->documentMaxOutputTokens,
230
            $prompt,
231
            $toolName
232
        );
233
    }
234
235
    private function requestGemini(string $url, float $temperature, int $maxOutputTokens, string $prompt, string $toolName): ?string
236
    {
237
        $userId = $this->getUserId();
238
        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...
239
            throw new RuntimeException('User not authenticated.');
240
        }
241
242
        $payload = [
243
            'contents' => [
244
                [
245
                    'parts' => [
246
                        ['text' => $prompt],
247
                    ],
248
                ],
249
            ],
250
            'generationConfig' => [
251
                'temperature' => $temperature,
252
                'maxOutputTokens' => $maxOutputTokens,
253
            ],
254
        ];
255
256
        try {
257
            $response = $this->httpClient->request('POST', $url, [
258
                'headers' => [
259
                    'x-goog-api-key' => $this->apiKey,
260
                    'Content-Type' => 'application/json',
261
                ],
262
                'json' => $payload,
263
            ]);
264
265
            $statusCode = $response->getStatusCode();
266
            $data = $response->toArray(false);
267
268
            if (
269
                200 !== $statusCode
270
                || !isset($data['candidates'][0]['content']['parts'][0]['text'])
271
            ) {
272
                error_log('[AI][Gemini] Invalid response (status='.$statusCode.').');
273
                return null;
274
            }
275
276
            $generatedContent = (string) $data['candidates'][0]['content']['parts'][0]['text'];
277
278
            // Gemini usually returns usageMetadata, not usage.prompt_tokens
279
            $usageMeta = $data['usageMetadata'] ?? [];
280
            $promptTokens = (int) ($usageMeta['promptTokenCount'] ?? 0);
281
            $completionTokens = (int) ($usageMeta['candidatesTokenCount'] ?? 0);
282
            $totalTokens = (int) ($usageMeta['totalTokenCount'] ?? ($promptTokens + $completionTokens));
283
284
            $aiRequest = new AiRequests();
285
            $aiRequest
286
                ->setUserId($userId)
287
                ->setToolName($toolName)
288
                ->setRequestText($prompt)
289
                ->setPromptTokens($promptTokens)
290
                ->setCompletionTokens($completionTokens)
291
                ->setTotalTokens($totalTokens)
292
                ->setAiProvider('gemini')
293
            ;
294
295
            $this->aiRequestsRepository->save($aiRequest);
296
297
            return $generatedContent;
298
        } catch (Exception $e) {
299
            error_log('[AI][Gemini] Exception: '.$e->getMessage());
300
            return null;
301
        }
302
    }
303
304
    private function filterValidAikenQuestions(string $quizContent): array
305
    {
306
        $questions = preg_split('/\n{2,}/', trim($quizContent)) ?: [];
307
308
        $validQuestions = [];
309
        foreach ($questions as $questionBlock) {
310
            $lines = explode("\n", trim($questionBlock));
311
312
            if (\count($lines) < 6) {
313
                continue;
314
            }
315
316
            $options = \array_slice($lines, 1, 4);
317
            $validOptions = array_filter($options, static fn ($line) => (bool) preg_match('/^[A-D]\. .+/', $line));
318
319
            $answerLine = (string) end($lines);
320
            if (4 === \count($validOptions) && preg_match('/^ANSWER: [A-D]$/', $answerLine)) {
321
                $validQuestions[] = implode("\n", $lines);
322
            }
323
        }
324
325
        return $validQuestions;
326
    }
327
328
    private function buildUrl(string $template, string $model): string
329
    {
330
        // If template expects %s, inject model; else keep as-is
331
        return str_contains($template, '%s') ? \sprintf($template, $model) : $template;
332
    }
333
334
    private function getUserId(): ?int
335
    {
336
        $user = $this->security->getUser();
337
        return $user instanceof UserInterface ? $user->getId() : null;
338
    }
339
340
    private function readProvidersConfig(): array
341
    {
342
        $configJson = $this->settingsManager->getSetting('ai_helpers.ai_providers', true);
343
344
        if (\is_string($configJson)) {
345
            return json_decode($configJson, true) ?? [];
346
        }
347
348
        if (\is_array($configJson)) {
349
            return $configJson;
350
        }
351
352
        return [];
353
    }
354
}
355