Passed
Pull Request — master (#7304)
by Yannick
10:38
created

GeminiProvider   A

Complexity

Total Complexity 27

Size/Duplication

Total Lines 259
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 151
c 1
b 0
f 0
dl 0
loc 259
rs 10
wmc 27

7 Methods

Rating   Name   Duplication   Size   Complexity  
A filterValidAikenQuestions() 0 22 5
A gradeOpenAnswer() 0 3 1
A generateQuestions() 0 21 1
A __construct() 0 30 4
A requestGemini() 0 60 5
B generateLearnPath() 0 93 9
A getUserId() 0 5 2
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Chamilo\CoreBundle\AiProvider;
6
7
use Chamilo\CoreBundle\Entity\AiRequests;
8
use Chamilo\CoreBundle\Repository\AiRequestsRepository;
9
use Chamilo\CoreBundle\Settings\SettingsManager;
10
use Exception;
11
use RuntimeException;
12
use Symfony\Bundle\SecurityBundle\Security;
13
use Symfony\Component\Security\Core\User\UserInterface;
14
use Symfony\Contracts\HttpClient\HttpClientInterface;
15
16
class GeminiProvider implements AiProviderInterface
17
{
18
    private string $apiUrl;
19
    private string $apiKey;
20
    private string $model;
21
    private float $temperature;
22
    private HttpClientInterface $httpClient;
23
    private AiRequestsRepository $aiRequestsRepository;
24
    private Security $security;
25
26
    public function __construct(
27
        HttpClientInterface $httpClient,
28
        SettingsManager $settingsManager,
29
        AiRequestsRepository $aiRequestsRepository,
30
        Security $security
31
    ) {
32
        $this->httpClient = $httpClient;
33
        $this->aiRequestsRepository = $aiRequestsRepository;
34
        $this->security = $security;
35
36
        // Get AI providers from settings
37
        $configJson = $settingsManager->getSetting('ai_helpers.ai_providers', true);
38
        $config = json_decode($configJson, true) ?? [];
39
40
        if (!isset($config['gemini'])) {
41
            throw new RuntimeException('Gemini configuration is missing.');
42
        }
43
        if (!isset($config['gemini']['text'])) {
44
            throw new RuntimeException('Gemini configuration for text processing is missing.');
45
        }
46
47
        $this->apiKey = $config['gemini']['api_key'] ?? '';
48
        // Gemini expects endpoint like: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent
49
        $this->model = $config['gemini']['text']['model'] ?? 'gemini-2.5-flash';
50
        $tempApiUrl = $config['gemini']['text']['url'] ?? "https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent";
51
        $this->apiUrl = sprintf($tempApiUrl, $this->model);
52
        $this->temperature = $config['gemini']['text']['temperature'] ?? 0.7;
53
54
        if (empty($this->apiKey)) {
55
            throw new RuntimeException('Gemini API key is missing.');
56
        }
57
    }
58
59
    public function generateQuestions(string $topic, int $numQuestions, string $questionType, string $language): ?string
60
    {
61
        $prompt = \sprintf(
62
            'Generate %d "%s" questions in Aiken format in the %s language about "%s".
63
            Ensure each question follows this format:
64
65
            1. The question text.
66
            A. Option A
67
            B. Option B
68
            C. Option C
69
            D. Option D
70
            ANSWER: (Correct answer letter)
71
72
            The output should be plain text without additional symbols or markdown.',
73
            $numQuestions,
74
            $questionType,
75
            $language,
76
            $topic
77
        );
78
79
        return $this->requestGemini($prompt, 'quiz');
80
    }
81
82
    public function generateLearnPath(
83
        string $topic,
84
        int $chaptersCount,
85
        string $language,
86
        int $wordsCount,
87
        bool $addTests,
88
        int $numQuestions
89
    ): ?array {
90
        // Step 1: Generate the Table of Contents
91
        $tableOfContentsPrompt = \sprintf(
92
            'Generate a structured table of contents for a course in "%s" with %d chapters on "%s".
93
            Return a numbered list, each chapter on a new line. No conclusion.',
94
            $language,
95
            $chaptersCount,
96
            $topic
97
        );
98
99
        $lpStructure = $this->requestGemini($tableOfContentsPrompt, 'learnpath');
100
        if (!$lpStructure) {
101
            return ['success' => false, 'message' => 'Failed to generate course structure.'];
102
        }
103
104
        // Step 2: Generate content for each chapter
105
        $lpItems = [];
106
        $chapters = explode("\n", trim($lpStructure));
107
        foreach ($chapters as $index => $chapterTitle) {
108
            $chapterTitle = trim($chapterTitle);
109
            if (empty($chapterTitle)) {
110
                continue;
111
            }
112
113
            $chapterPrompt = \sprintf(
114
                'Create a learning chapter in HTML for "%s" in "%s" with %d words.
115
                Title: "%s". Assume the reader already knows the context.',
116
                $topic,
117
                $language,
118
                $wordsCount,
119
                $chapterTitle
120
            );
121
122
            $chapterContent = $this->requestGemini($chapterPrompt, 'learnpath');
123
            if (!$chapterContent) {
124
                continue;
125
            }
126
127
            $lpItems[] = [
128
                'title' => $chapterTitle,
129
                'content' => "<html><head><title>{$chapterTitle}</title></head><body>{$chapterContent}</body></html>",
130
            ];
131
        }
132
133
        // Step 3: Generate quizzes if enabled
134
        $quizItems = [];
135
        if ($addTests) {
136
            foreach ($lpItems as &$chapter) {
137
                $quizPrompt = \sprintf(
138
                    'Generate %d multiple-choice questions in Aiken format in %s about "%s".
139
            Ensure each question follows this format:
140
141
            1. The question text.
142
            A. Option A
143
            B. Option B
144
            C. Option C
145
            D. Option D
146
            ANSWER: (Correct answer letter)
147
148
            Each question must have exactly 4 options and one answer line.
149
            Return only valid questions without extra text.',
150
                    $numQuestions,
151
                    $language,
152
                    $chapter['title']
153
                );
154
155
                $quizContent = $this->requestGemini($quizPrompt, 'learnpath');
156
157
                if ($quizContent) {
158
                    $validQuestions = $this->filterValidAikenQuestions($quizContent);
159
160
                    if (!empty($validQuestions)) {
161
                        $quizItems[] = [
162
                            'title' => 'Quiz: '.$chapter['title'],
163
                            'content' => implode("\n\n", $validQuestions),
164
                        ];
165
                    }
166
                }
167
            }
168
        }
169
170
        return [
171
            'success' => true,
172
            'topic' => $topic,
173
            'lp_items' => $lpItems,
174
            'quiz_items' => $quizItems,
175
        ];
176
    }
177
178
    private function filterValidAikenQuestions(string $quizContent): array
179
    {
180
        $questions = preg_split('/\n{2,}/', trim($quizContent));
181
182
        $validQuestions = [];
183
        foreach ($questions as $questionBlock) {
184
            $lines = explode("\n", trim($questionBlock));
185
186
            if (\count($lines) < 6) {
187
                continue;
188
            }
189
190
            $options = \array_slice($lines, 1, 4);
191
            $validOptions = array_filter($options, fn($line) => preg_match('/^[A-D]\. .+/', $line));
192
193
            $answerLine = end($lines);
194
            if (4 === \count($validOptions) && preg_match('/^ANSWER: [A-D]$/', $answerLine)) {
195
                $validQuestions[] = implode("\n", $lines);
196
            }
197
        }
198
199
        return $validQuestions;
200
    }
201
202
    private function requestGemini(string $prompt, string $toolName): ?string
203
    {
204
        $userId = $this->getUserId();
205
        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...
206
            throw new RuntimeException('User not authenticated.');
207
        }
208
209
        // Gemini expects a "contents" array (of turns), with inner "parts" [{"text": "..."}]
210
        $payload = [
211
            'contents' => [
212
                [
213
                    'parts' => [
214
                        ['text' => $prompt]
215
                    ]
216
                ]
217
            ],
218
            'generationConfig' => [
219
                'temperature' => $this->temperature,
220
                'maxOutputTokens' => 1000,
221
            ],
222
        ];
223
224
        try {
225
            $response = $this->httpClient->request('POST', $this->apiUrl, [
226
                'headers' => [
227
                    'x-goog-api-key' => $this->apiKey,
228
                    'Content-Type' => 'application/json',
229
                ],
230
                'json' => $payload,
231
            ]);
232
233
            $statusCode = $response->getStatusCode();
234
            $data = $response->toArray();
235
236
            // Gemini returns "candidates" (array), first candidate, first part of content
237
            if (
238
                200 === $statusCode
239
                && isset($data['candidates'][0]['content']['parts'][0]['text'])
240
            ) {
241
                $generatedContent = $data['candidates'][0]['content']['parts'][0]['text'];
242
243
                $aiRequest = new AiRequests();
244
                $aiRequest->setUserId($userId)
245
                    ->setToolName($toolName)
246
                    ->setRequestText($prompt)
247
                    // Gemini does not currently return token counts, so we set to 0
248
                    ->setPromptTokens($data['usage']['prompt_tokens'] ?? 0)
249
                    ->setCompletionTokens($data['usage']['completion_tokens'] ?? 0)
250
                    ->setTotalTokens($data['usage']['total_tokens'] ?? 0)
251
                    ->setAiProvider('gemini');
252
253
                $this->aiRequestsRepository->save($aiRequest);
254
255
                return $generatedContent;
256
            }
257
258
            return null;
259
        } catch (Exception $e) {
260
            error_log('[AI][Gemini] Exception: '.$e->getMessage());
261
            return null;
262
        }
263
    }
264
265
    public function gradeOpenAnswer(string $prompt, string $toolName): ?string
266
    {
267
        return $this->requestGemini($prompt, $toolName);
268
    }
269
270
    private function getUserId(): ?int
271
    {
272
        $user = $this->security->getUser();
273
274
        return $user instanceof UserInterface ? $user->getId() : null;
275
    }
276
}
277