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

GrokProvider::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 GrokProvider implements AiProviderInterface, AiImageProviderInterface, 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
    // Image
29
    private string $imageApiUrl;
30
    private string $imageModel;
31
    private array $imageDefaultOptions = [];
32
33
    // Document (usually same as text, but configurable)
34
    private string $documentApiUrl;
35
    private string $documentModel;
36
    private float $documentTemperature;
37
    private int $documentMaxTokens;
38
39
    public function __construct(
40
        private readonly HttpClientInterface $httpClient,
41
        private readonly SettingsManager $settingsManager,
42
        private readonly AiRequestsRepository $aiRequestsRepository,
43
        private readonly Security $security
44
    ) {
45
        $config = $this->readProvidersConfig();
46
47
        if (!isset($config['grok']) || !\is_array($config['grok'])) {
48
            throw new RuntimeException('Grok configuration is missing.');
49
        }
50
51
        $providerConfig = $config['grok'];
52
53
        $this->apiKey = (string) ($providerConfig['api_key'] ?? '');
54
        if ('' === $this->apiKey) {
55
            throw new RuntimeException('Grok API key is missing.');
56
        }
57
58
        // TEXT config (required for AiProviderInterface usage)
59
        $textCfg = $providerConfig['text'] ?? null;
60
        if (!\is_array($textCfg)) {
61
            throw new RuntimeException('Grok configuration for text processing is missing.');
62
        }
63
64
        $this->textApiUrl = (string) ($textCfg['url'] ?? 'https://api.x.ai/v1/chat/completions');
65
        $this->textModel = (string) ($textCfg['model'] ?? 'grok-beta');
66
        $this->textTemperature = (float) ($textCfg['temperature'] ?? 0.7);
67
        $this->textMaxTokens = (int) ($textCfg['max_tokens'] ?? 1000);
68
69
        // IMAGE config (optional but required to support "image" type)
70
        $imageCfg = $providerConfig['image'] ?? null;
71
        if (\is_array($imageCfg)) {
72
            $this->imageApiUrl = (string) ($imageCfg['url'] ?? 'https://api.x.ai/v1/images/generations');
73
            $this->imageModel = (string) ($imageCfg['model'] ?? 'grok-2-image');
74
75
            // Default options; can be overridden per-call via $options
76
            $this->imageDefaultOptions = [
77
                'response_format' => (string) ($imageCfg['response_format'] ?? 'b64_json'),
78
                'n' => (int) ($imageCfg['n'] ?? 1),
79
            ];
80
        } else {
81
            $this->imageApiUrl = '';
82
            $this->imageModel = '';
83
            $this->imageDefaultOptions = [];
84
        }
85
86
        // DOCUMENT config (optional; fallback to text if absent)
87
        $docCfg = $providerConfig['document'] ?? null;
88
89
        $this->documentApiUrl = \is_array($docCfg) ? (string) ($docCfg['url'] ?? $this->textApiUrl) : $this->textApiUrl;
90
        $this->documentModel = \is_array($docCfg) ? (string) ($docCfg['model'] ?? $this->textModel) : $this->textModel;
91
        $this->documentTemperature = \is_array($docCfg) ? (float) ($docCfg['temperature'] ?? $this->textTemperature) : $this->textTemperature;
92
        $this->documentMaxTokens = \is_array($docCfg) ? (int) ($docCfg['max_tokens'] ?? $this->textMaxTokens) : $this->textMaxTokens;
93
    }
94
95
    public function generateQuestions(string $topic, int $numQuestions, string $questionType, string $language): ?string
96
    {
97
        $prompt = \sprintf(
98
            'Generate %d "%s" questions in Aiken format in the %s language about "%s".
99
            Ensure each question follows this format:
100
101
            1. The question text.
102
            A. Option A
103
            B. Option B
104
            C. Option C
105
            D. Option D
106
            ANSWER: (Correct answer letter)
107
108
            The output should be plain text without additional symbols or markdown.',
109
            $numQuestions,
110
            $questionType,
111
            $language,
112
            $topic
113
        );
114
115
        return $this->requestText($this->textApiUrl, $this->textModel, $this->textTemperature, $this->textMaxTokens, $prompt, 'quiz');
116
    }
117
118
    public function generateLearnPath(string $topic, int $chaptersCount, string $language, int $wordsCount, bool $addTests, int $numQuestions): ?array
119
    {
120
        $tocPrompt = \sprintf(
121
            'Generate a structured table of contents for a course in "%s" with %d chapters on "%s".
122
            Return a numbered list, each chapter on a new line. No conclusion.',
123
            $language,
124
            $chaptersCount,
125
            $topic
126
        );
127
128
        $lpStructure = $this->requestText($this->textApiUrl, $this->textModel, $this->textTemperature, $this->textMaxTokens, $tocPrompt, 'learnpath');
129
        if (!$lpStructure) {
130
            return ['success' => false, 'message' => 'Failed to generate course structure.'];
131
        }
132
133
        $lpItems = [];
134
        $chapters = explode("\n", trim($lpStructure));
135
        foreach ($chapters as $chapterTitle) {
136
            $chapterTitle = trim($chapterTitle);
137
            if ('' === $chapterTitle) {
138
                continue;
139
            }
140
141
            $chapterPrompt = \sprintf(
142
                'Create a learning chapter in HTML for "%s" in "%s" with %d words.
143
                Title: "%s". Assume the reader already knows the context.',
144
                $topic,
145
                $language,
146
                $wordsCount,
147
                $chapterTitle
148
            );
149
150
            $chapterContent = $this->requestText($this->textApiUrl, $this->textModel, $this->textTemperature, $this->textMaxTokens, $chapterPrompt, 'learnpath');
151
            if (!$chapterContent) {
152
                continue;
153
            }
154
155
            $lpItems[] = [
156
                'title' => $chapterTitle,
157
                'content' => "<html><head><title>{$chapterTitle}</title></head><body>{$chapterContent}</body></html>",
158
            ];
159
        }
160
161
        $quizItems = [];
162
        if ($addTests) {
163
            foreach ($lpItems as $chapter) {
164
                $quizPrompt = \sprintf(
165
                    'Generate %d multiple-choice questions in Aiken format in %s about "%s".
166
            Ensure each question follows this format:
167
168
            1. The question text.
169
            A. Option A
170
            B. Option B
171
            C. Option C
172
            D. Option D
173
            ANSWER: (Correct answer letter)
174
175
            Each question must have exactly 4 options and one answer line.
176
            Return only valid questions without extra text.',
177
                    $numQuestions,
178
                    $language,
179
                    $chapter['title']
180
                );
181
182
                $quizContent = $this->requestText($this->textApiUrl, $this->textModel, $this->textTemperature, $this->textMaxTokens, $quizPrompt, 'learnpath');
183
                if (!$quizContent) {
184
                    continue;
185
                }
186
187
                $validQuestions = $this->filterValidAikenQuestions($quizContent);
188
                if (!empty($validQuestions)) {
189
                    $quizItems[] = [
190
                        'title' => 'Quiz: '.$chapter['title'],
191
                        'content' => implode("\n\n", $validQuestions),
192
                    ];
193
                }
194
            }
195
        }
196
197
        return [
198
            'success' => true,
199
            'topic' => $topic,
200
            'lp_items' => $lpItems,
201
            'quiz_items' => $quizItems,
202
        ];
203
    }
204
205
    public function gradeOpenAnswer(string $prompt, string $toolName): ?string
206
    {
207
        return $this->requestText($this->textApiUrl, $this->textModel, $this->textTemperature, $this->textMaxTokens, $prompt, $toolName);
208
    }
209
210
    public function generateImage(string $prompt, string $toolName, ?array $options = []): string|array|null
211
    {
212
        if ('' === $this->imageApiUrl || '' === $this->imageModel) {
213
            error_log('[AI][Grok][Image] Image is not configured for this provider.');
214
            return null;
215
        }
216
217
        return $this->requestImage($prompt, $toolName, $options ?? []);
218
    }
219
220
    public function generateDocument(string $prompt, string $toolName, ?array $options = []): ?string
221
    {
222
        // Document generation is treated as structured text generation (Markdown/HTML/etc).
223
        // The caller can decide how to convert it to a file (PDF, DOCX...) later.
224
        $format = isset($options['format']) ? (string) $options['format'] : '';
225
        if ('' !== $format) {
226
            $prompt .= "\n\nOutput format: {$format}.";
227
        }
228
229
        return $this->requestText(
230
            $this->documentApiUrl,
231
            $this->documentModel,
232
            $this->documentTemperature,
233
            $this->documentMaxTokens,
234
            $prompt,
235
            $toolName
236
        );
237
    }
238
239
    private function filterValidAikenQuestions(string $quizContent): array
240
    {
241
        $questions = preg_split('/\n{2,}/', trim($quizContent)) ?: [];
242
243
        $valid = [];
244
        foreach ($questions as $block) {
245
            $lines = explode("\n", trim($block));
246
            if (\count($lines) < 6) {
247
                continue;
248
            }
249
250
            $options = \array_slice($lines, 1, 4);
251
            $validOptions = array_filter($options, static fn ($line) => (bool) preg_match('/^[A-D]\. .+/', $line));
252
253
            $answerLine = (string) end($lines);
254
            if (4 === \count($validOptions) && preg_match('/^ANSWER: [A-D]$/', $answerLine)) {
255
                $valid[] = implode("\n", $lines);
256
            }
257
        }
258
259
        return $valid;
260
    }
261
262
    private function requestText(string $url, string $model, float $temperature, int $maxTokens, string $prompt, string $toolName): ?string
263
    {
264
        $userId = $this->getUserId();
265
        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...
266
            throw new RuntimeException('User not authenticated.');
267
        }
268
269
        $system = 'You are a helpful AI assistant that generates structured educational content.';
270
        $payload = $this->buildTextPayload($url, $model, $system, $prompt, $temperature, $maxTokens);
271
272
        try {
273
            $response = $this->httpClient->request('POST', $url, [
274
                'headers' => [
275
                    'Authorization' => 'Bearer '.$this->apiKey,
276
                    'Content-Type' => 'application/json',
277
                ],
278
                'json' => $payload,
279
            ]);
280
281
            $status = $response->getStatusCode();
282
            $rawBody = $response->getContent(false);
283
            $data = json_decode($rawBody, true);
284
285
            if (200 !== $status || !\is_array($data)) {
286
                error_log('[AI][Grok][Text] Invalid response (status='.$status.').');
287
                return null;
288
            }
289
290
            $generated = $this->extractTextContent($data);
291
            if (null === $generated || '' === trim($generated)) {
292
                error_log('[AI][Grok][Text] Empty content returned by API.');
293
                return null;
294
            }
295
296
            $usage = $this->extractUsage($data);
297
298
            $this->logRequest($userId, $toolName, $prompt, $usage['prompt_tokens'], $usage['completion_tokens'], $usage['total_tokens'], 'grok');
299
300
            return $generated;
301
        } catch (Exception $e) {
302
            error_log('[AI][Grok][Text] Exception: '.$e->getMessage());
303
            return null;
304
        }
305
    }
306
307
    private function requestImage(string $prompt, string $toolName, array $options = []): string|array|null
308
    {
309
        $userId = $this->getUserId();
310
        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...
311
            throw new RuntimeException('User not authenticated.');
312
        }
313
314
        $merged = array_merge($this->imageDefaultOptions, $options);
315
316
        // Normalize response_format values
317
        if (isset($merged['response_format'])) {
318
            $rf = (string) $merged['response_format'];
319
            if ('base64' === $rf) {
320
                $merged['response_format'] = 'b64_json';
321
            }
322
        }
323
324
        $payload = array_merge([
325
            'model' => $this->imageModel,
326
            'prompt' => $prompt,
327
        ], $merged);
328
329
        try {
330
            $response = $this->httpClient->request('POST', $this->imageApiUrl, [
331
                'headers' => [
332
                    'Authorization' => 'Bearer '.$this->apiKey,
333
                    'Content-Type' => 'application/json',
334
                ],
335
                'json' => $payload,
336
            ]);
337
338
            $status = $response->getStatusCode();
339
            $rawBody = $response->getContent(false);
340
            $data = json_decode($rawBody, true);
341
342
            if (200 !== $status || !\is_array($data)) {
343
                error_log('[AI][Grok][Image] Invalid response (status='.$status.').');
344
                return null;
345
            }
346
347
            // Log request (images usually have no usage)
348
            $this->logRequest($userId, $toolName, $prompt, 0, 0, 0, 'grok');
349
350
            // Prefer base64
351
            if (isset($data['data'][0]['b64_json'])) {
352
                return [
353
                    'content' => (string) $data['data'][0]['b64_json'],
354
                    'is_base64' => true,
355
                    'content_type' => 'image/png',
356
                    'revised_prompt' => $data['data'][0]['revised_prompt'] ?? null,
357
                ];
358
            }
359
360
            // URL fallback
361
            if (isset($data['data'][0]['url'])) {
362
                return [
363
                    'url' => (string) $data['data'][0]['url'],
364
                    'is_base64' => false,
365
                    'content_type' => 'image/png',
366
                    'revised_prompt' => $data['data'][0]['revised_prompt'] ?? null,
367
                ];
368
            }
369
370
            error_log('[AI][Grok][Image] No usable image content found in response.');
371
            return null;
372
        } catch (Exception $e) {
373
            error_log('[AI][Grok][Image] Exception: '.$e->getMessage());
374
            return null;
375
        }
376
    }
377
378
    private function buildTextPayload(string $url, string $model, string $system, string $prompt, float $temperature, int $maxTokens): array
379
    {
380
        // If using /responses endpoint
381
        if ($this->isResponsesEndpoint($url)) {
382
            return [
383
                'model' => $model,
384
                'input' => [
385
                    ['role' => 'system', 'content' => $system],
386
                    ['role' => 'user', 'content' => $prompt],
387
                ],
388
                'temperature' => $temperature,
389
                'max_output_tokens' => $maxTokens,
390
            ];
391
        }
392
393
        // OpenAI-compatible /chat/completions (xAI supports legacy endpoint)
394
        return [
395
            'model' => $model,
396
            'messages' => [
397
                ['role' => 'system', 'content' => $system],
398
                ['role' => 'user', 'content' => $prompt],
399
            ],
400
            'temperature' => $temperature,
401
            'max_tokens' => $maxTokens,
402
        ];
403
    }
404
405
    private function extractTextContent(array $data): ?string
406
    {
407
        // /responses style
408
        if (isset($data['output'][0]['content'][0]['text']) && \is_string($data['output'][0]['content'][0]['text'])) {
409
            return $data['output'][0]['content'][0]['text'];
410
        }
411
412
        // Sometimes APIs return output_text
413
        if (isset($data['output_text']) && \is_string($data['output_text'])) {
414
            return $data['output_text'];
415
        }
416
417
        // /chat/completions style
418
        if (isset($data['choices'][0]['message']['content']) && \is_string($data['choices'][0]['message']['content'])) {
419
            return $data['choices'][0]['message']['content'];
420
        }
421
422
        // Some providers may return choices[0].text
423
        if (isset($data['choices'][0]['text']) && \is_string($data['choices'][0]['text'])) {
424
            return $data['choices'][0]['text'];
425
        }
426
427
        return null;
428
    }
429
430
    private function extractUsage(array $data): array
431
    {
432
        // Default usage
433
        $usage = [
434
            'prompt_tokens' => 0,
435
            'completion_tokens' => 0,
436
            'total_tokens' => 0,
437
        ];
438
439
        if (isset($data['usage']) && \is_array($data['usage'])) {
440
            $u = $data['usage'];
441
442
            // Common OpenAI-compatible keys
443
            $usage['prompt_tokens'] = (int) ($u['prompt_tokens'] ?? $usage['prompt_tokens']);
444
            $usage['completion_tokens'] = (int) ($u['completion_tokens'] ?? $usage['completion_tokens']);
445
            $usage['total_tokens'] = (int) ($u['total_tokens'] ?? $usage['total_tokens']);
446
447
            // Some responses APIs use input_tokens/output_tokens
448
            if (isset($u['input_tokens'])) {
449
                $usage['prompt_tokens'] = (int) $u['input_tokens'];
450
            }
451
            if (isset($u['output_tokens'])) {
452
                $usage['completion_tokens'] = (int) $u['output_tokens'];
453
            }
454
            if (0 === $usage['total_tokens']) {
455
                $usage['total_tokens'] = $usage['prompt_tokens'] + $usage['completion_tokens'];
456
            }
457
        }
458
459
        return $usage;
460
    }
461
462
    private function logRequest(int $userId, string $toolName, string $prompt, int $promptTokens, int $completionTokens, int $totalTokens, string $provider): void
463
    {
464
        $aiRequest = new AiRequests();
465
        $aiRequest
466
            ->setUserId($userId)
467
            ->setToolName($toolName)
468
            ->setRequestText($prompt)
469
            ->setPromptTokens($promptTokens)
470
            ->setCompletionTokens($completionTokens)
471
            ->setTotalTokens($totalTokens)
472
            ->setAiProvider($provider)
473
        ;
474
475
        $this->aiRequestsRepository->save($aiRequest);
476
    }
477
478
    private function getUserId(): ?int
479
    {
480
        $user = $this->security->getUser();
481
        return $user instanceof UserInterface ? $user->getId() : null;
482
    }
483
484
    private function readProvidersConfig(): array
485
    {
486
        $configJson = $this->settingsManager->getSetting('ai_helpers.ai_providers', true);
487
488
        if (\is_string($configJson)) {
489
            return json_decode($configJson, true) ?? [];
490
        }
491
492
        if (\is_array($configJson)) {
493
            return $configJson;
494
        }
495
496
        return [];
497
    }
498
499
    private function isResponsesEndpoint(string $url): bool
500
    {
501
        return str_contains($url, '/responses');
502
    }
503
}
504