Passed
Pull Request — master (#7353)
by
unknown
09:41
created

OpenAiProvider::requestChatCompletion()   B

Complexity

Conditions 8
Paths 19

Size

Total Lines 60
Code Lines 41

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 8
eloc 41
nc 19
nop 3
dl 0
loc 60
rs 8.0195
c 0
b 0
f 0

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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\Mime\Part\DataPart;
14
use Symfony\Component\Mime\Part\Multipart\FormDataPart;
15
use Symfony\Component\Security\Core\User\UserInterface;
16
use Symfony\Contracts\HttpClient\HttpClientInterface;
17
18
class OpenAiProvider implements AiProviderInterface, AiImageProviderInterface, AiVideoProviderInterface, AiDocumentProviderInterface
19
{
20
    private array $providerConfig;
21
    private string $apiKey;
22
    private string $organizationId;
23
    private int $monthlyTokenLimit;
24
25
    public function __construct(
26
        private readonly HttpClientInterface $httpClient,
27
        SettingsManager $settingsManager,
28
        private readonly AiRequestsRepository $aiRequestsRepository,
29
        private readonly Security $security
30
    ) {
31
        $configJson = $settingsManager->getSetting('ai_helpers.ai_providers', true);
32
        $config = \is_string($configJson) ? (json_decode($configJson, true) ?? []) : (\is_array($configJson) ? $configJson : []);
33
34
        if (!isset($config['openai']) || !\is_array($config['openai'])) {
35
            throw new RuntimeException('OpenAI configuration is missing.');
36
        }
37
38
        $this->providerConfig = $config['openai'];
39
40
        $this->apiKey = (string) ($this->providerConfig['api_key'] ?? '');
41
        $this->organizationId = (string) ($this->providerConfig['organization_id'] ?? '');
42
        $this->monthlyTokenLimit = (int) ($this->providerConfig['monthly_token_limit'] ?? 0);
43
44
        if ('' === trim($this->apiKey)) {
45
            throw new RuntimeException('OpenAI API key is missing.');
46
        }
47
    }
48
49
    public function generateQuestions(string $topic, int $numQuestions, string $questionType, string $language): ?string
50
    {
51
        $prompt = \sprintf(
52
            'Generate %d "%s" questions in Aiken format in the %s language about "%s".
53
            Ensure each question follows this format:
54
55
            1. The question text.
56
            A. Option A
57
            B. Option B
58
            C. Option C
59
            D. Option D
60
            ANSWER: (Correct answer letter)
61
62
            The output should be plain text without additional symbols or markdown.',
63
            $numQuestions,
64
            $questionType,
65
            $language,
66
            $topic
67
        );
68
69
        return $this->requestChatCompletion($prompt, 'quiz', 'text');
70
    }
71
72
    public function generateLearnPath(string $topic, int $chaptersCount, string $language, int $wordsCount, bool $addTests, int $numQuestions): ?array
73
    {
74
        $tableOfContentsPrompt = \sprintf(
75
            'Generate a structured table of contents for a course in "%s" with %d chapters on "%s".
76
            Return a numbered list, each chapter on a new line. No conclusion.',
77
            $language,
78
            $chaptersCount,
79
            $topic
80
        );
81
82
        $lpStructure = $this->requestChatCompletion($tableOfContentsPrompt, 'learnpath', 'text');
83
        if (!$lpStructure) {
84
            return ['success' => false, 'message' => 'Failed to generate course structure.'];
85
        }
86
87
        $lpItems = [];
88
        $chapters = explode("\n", trim($lpStructure));
89
        foreach ($chapters as $chapterTitle) {
90
            $chapterTitle = trim($chapterTitle);
91
            if ('' === $chapterTitle) {
92
                continue;
93
            }
94
95
            $chapterPrompt = \sprintf(
96
                'Create a learning chapter in HTML for "%s" in "%s" with %d words.
97
                Title: "%s". Assume the reader already knows the context.',
98
                $topic,
99
                $language,
100
                $wordsCount,
101
                $chapterTitle
102
            );
103
104
            $chapterContent = $this->requestChatCompletion($chapterPrompt, 'learnpath', 'text');
105
            if (!$chapterContent) {
106
                continue;
107
            }
108
109
            $lpItems[] = [
110
                'title' => $chapterTitle,
111
                'content' => "<html><head><title>{$chapterTitle}</title></head><body>{$chapterContent}</body></html>",
112
            ];
113
        }
114
115
        $quizItems = [];
116
        if ($addTests) {
117
            foreach ($lpItems as $chapter) {
118
                $quizPrompt = \sprintf(
119
                    'Generate %d multiple-choice questions in Aiken format in %s about "%s".
120
            Ensure each question follows this format:
121
122
            1. The question text.
123
            A. Option A
124
            B. Option B
125
            C. Option C
126
            D. Option D
127
            ANSWER: (Correct answer letter)
128
129
            Each question must have exactly 4 options and one answer line.
130
            Return only valid questions without extra text.',
131
                    $numQuestions,
132
                    $language,
133
                    $chapter['title']
134
                );
135
136
                $quizContent = $this->requestChatCompletion($quizPrompt, 'learnpath', 'text');
137
138
                if ($quizContent) {
139
                    $validQuestions = $this->filterValidAikenQuestions($quizContent);
140
141
                    if (!empty($validQuestions)) {
142
                        $quizItems[] = [
143
                            'title' => 'Quiz: '.$chapter['title'],
144
                            'content' => implode("\n\n", $validQuestions),
145
                        ];
146
                    }
147
                }
148
            }
149
        }
150
151
        return [
152
            'success' => true,
153
            'topic' => $topic,
154
            'lp_items' => $lpItems,
155
            'quiz_items' => $quizItems,
156
        ];
157
    }
158
159
    public function gradeOpenAnswer(string $prompt, string $toolName): ?string
160
    {
161
        return $this->requestChatCompletion($prompt, $toolName, 'text');
162
    }
163
164
    public function generateDocument(string $prompt, string $toolName, ?array $options = []): ?string
165
    {
166
        return $this->requestChatCompletion($prompt, $toolName, 'document');
167
    }
168
169
    public function generateImage(string $prompt, string $toolName, ?array $options = []): string|array|null
170
    {
171
        $userId = $this->getUserId();
172
        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...
173
            throw new RuntimeException('User not authenticated.');
174
        }
175
176
        $cfg = $this->getTypeConfig('image');
177
        $url = (string) ($cfg['url'] ?? 'https://api.openai.com/v1/images/generations');
178
        $model = (string) ($cfg['model'] ?? 'gpt-image-1');
179
        $size = (string) ($cfg['size'] ?? '1024x1024');
180
        $quality = (string) ($cfg['quality'] ?? 'standard');
181
        $n = (int) (($options['n'] ?? null) ?? ($cfg['n'] ?? 1));
182
183
        $promptTrimmed = trim($prompt);
184
        $promptForLog = mb_substr($promptTrimmed, 0, 200);
185
186
        if ('dall-e-3' === $model && $n !== 1) {
187
            error_log(sprintf('[AI][OpenAI][image] Model "%s" only supports n=1. Forcing n from %d to 1.', $model, $n));
188
            $n = 1;
189
        }
190
191
        $payload = [
192
            'model' => $model,
193
            'prompt' => $promptTrimmed,
194
            'size' => $size,
195
            'quality' => $quality,
196
            'n' => $n,
197
        ];
198
199
        // Best-effort: allow response_format for any model that supports it.
200
        $responseFormat = (string) ($cfg['response_format'] ?? 'b64_json');
201
        if ('' !== trim($responseFormat)) {
202
            $payload['response_format'] = $responseFormat;
203
        }
204
205
        error_log(sprintf(
206
            '[AI][OpenAI][image] Request: userId=%d tool=%s url=%s model=%s size=%s quality=%s n=%d prompt="%s"',
207
            $userId,
208
            $toolName,
209
            $url,
210
            $model,
211
            $size,
212
            $quality,
213
            $n,
214
            $promptForLog
215
        ));
216
217
        try {
218
            $response = $this->httpClient->request('POST', $url, [
219
                'headers' => $this->buildAuthHeaders(true),
220
                'json' => $payload,
221
            ]);
222
223
            $status = $response->getStatusCode();
224
            $headers = $response->getHeaders(false);
225
            $requestId = $this->extractRequestId($headers);
226
227
            $raw = $response->getContent(false);
228
            $rawForLog = mb_substr((string) $raw, 0, 2000);
229
230
            if ($status >= 400) {
231
                $decoded = json_decode((string) $raw, true);
232
                $msg = $decoded['error']['message'] ?? null;
233
                $type = $decoded['error']['type'] ?? null;
234
                $code = $decoded['error']['code'] ?? null;
235
                $param = $decoded['error']['param'] ?? null;
236
237
                $finalMsg = $msg ?: 'OpenAI returned an error response.';
238
                error_log(sprintf(
239
                    '[AI][OpenAI][image] HTTP %d request_id=%s type=%s code=%s param=%s body=%s',
240
                    $status,
241
                    (string) $requestId,
242
                    (string) $type,
243
                    (string) $code,
244
                    (string) $param,
245
                    $rawForLog
246
                ));
247
248
                return 'Error: '.$finalMsg;
249
            }
250
251
            $data = json_decode((string) $raw, true);
252
            if (!is_array($data)) {
253
                error_log(sprintf(
254
                    '[AI][OpenAI][image] Invalid JSON response. HTTP %d request_id=%s body=%s',
255
                    $status,
256
                    (string) $requestId,
257
                    $rawForLog
258
                ));
259
260
                return 'Error: Invalid JSON response from OpenAI.';
261
            }
262
263
            if (!isset($data['data'][0]) || !is_array($data['data'][0])) {
264
                error_log(sprintf(
265
                    '[AI][OpenAI][image] Missing image data[0]. HTTP %d request_id=%s keys=%s body=%s',
266
                    $status,
267
                    (string) $requestId,
268
                    implode(',', array_keys($data)),
269
                    $rawForLog
270
                ));
271
272
                return 'Error: OpenAI response missing image data.';
273
            }
274
275
            $item = $data['data'][0];
276
277
            $result = [
278
                'content' => null,
279
                'url' => null,
280
                'is_base64' => false,
281
                'content_type' => 'image/png',
282
                'revised_prompt' => $item['revised_prompt'] ?? null,
283
            ];
284
285
            if (isset($item['b64_json']) && \is_string($item['b64_json']) && '' !== $item['b64_json']) {
286
                $result['content'] = $item['b64_json'];
287
                $result['is_base64'] = true;
288
            } elseif (isset($item['url']) && \is_string($item['url']) && '' !== $item['url']) {
289
                $result['url'] = $item['url'];
290
                $result['is_base64'] = false;
291
            } else {
292
                error_log(sprintf(
293
                    '[AI][OpenAI][image] Response did not include b64_json or url. HTTP %d request_id=%s body=%s',
294
                    $status,
295
                    (string) $requestId,
296
                    $rawForLog
297
                ));
298
299
                return 'Error: OpenAI response did not include image content.';
300
            }
301
302
            $this->saveAiRequest(
303
                $userId,
304
                $toolName,
305
                $promptTrimmed,
306
                'openai',
307
                (int) ($data['usage']['prompt_tokens'] ?? 0),
308
                (int) ($data['usage']['completion_tokens'] ?? 0),
309
                (int) ($data['usage']['total_tokens'] ?? 0)
310
            );
311
312
            error_log(sprintf(
313
                '[AI][OpenAI][image] Success. HTTP %d request_id=%s revised_prompt=%s returned_base64=%s returned_url=%s',
314
                $status,
315
                (string) $requestId,
316
                isset($result['revised_prompt']) ? (string) $result['revised_prompt'] : '',
317
                $result['is_base64'] ? 'yes' : 'no',
318
                !empty($result['url']) ? 'yes' : 'no'
319
            ));
320
321
            return $result;
322
        } catch (Exception $e) {
323
            error_log('[AI][OpenAI][image] Exception: '.$e->getMessage());
324
            return 'Error: '.$e->getMessage();
325
        }
326
    }
327
328
    public function generateVideo(string $prompt, string $toolName, ?array $options = []): string|array|null
329
    {
330
        $userId = $this->getUserId();
331
        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...
332
            throw new RuntimeException('User not authenticated.');
333
        }
334
335
        $cfg = $this->getTypeConfig('video');
336
        $url = (string) ($cfg['url'] ?? 'https://api.openai.com/v1/videos');
337
        $model = (string) ($cfg['model'] ?? 'sora-2');
338
        $seconds = (string) (($options['seconds'] ?? null) ?? ($cfg['seconds'] ?? '4'));
339
        $size = (string) (($options['size'] ?? null) ?? ($cfg['size'] ?? '720x1280'));
340
341
        $promptTrimmed = trim($prompt);
342
        $promptForLog = mb_substr($promptTrimmed, 0, 200);
343
344
        error_log(sprintf(
345
            '[AI][OpenAI][video] Request: userId=%d tool=%s url=%s model=%s seconds=%s size=%s prompt="%s"',
346
            $userId,
347
            $toolName,
348
            $url,
349
            $model,
350
            $seconds,
351
            $size,
352
            $promptForLog
353
        ));
354
355
        try {
356
            $fields = [
357
                'model' => $model,
358
                'prompt' => $promptTrimmed,
359
                'seconds' => $seconds,
360
                'size' => $size,
361
            ];
362
363
            if (isset($options['input_reference_path']) && is_string($options['input_reference_path']) && '' !== $options['input_reference_path']) {
364
                $path = $options['input_reference_path'];
365
                if (is_readable($path)) {
366
                    $fields['input_reference'] = DataPart::fromPath($path);
367
                } else {
368
                    error_log('[AI][OpenAI][video] input_reference_path is not readable: '.$path);
369
                }
370
            }
371
372
            $formData = new FormDataPart($fields);
373
374
            $headers = array_merge(
375
                $this->buildAuthHeaders(false),
376
                $formData->getPreparedHeaders()->toArray(),
377
                [
378
                    'Accept' => 'application/json',
379
                ]
380
            );
381
382
            $response = $this->httpClient->request('POST', $url, [
383
                'headers' => $headers,
384
                'body' => $formData->bodyToIterable(),
385
            ]);
386
387
            $status = $response->getStatusCode();
388
            $respHeaders = $response->getHeaders(false);
389
            $requestId = $this->extractRequestId($respHeaders);
390
391
            $raw = (string) $response->getContent(false);
392
            $rawForLog = mb_substr($raw, 0, 2000);
393
394
            if ($status >= 400) {
395
                $err = $this->decodeOpenAiError($raw);
396
                $finalMsg = (string) ($err['message'] ?? '');
397
398
                if ('' === trim($finalMsg)) {
399
                    // Fallback when body is empty or not JSON.
400
                    $finalMsg = sprintf(
401
                        'OpenAI returned HTTP %d. Ensure your project/org has access to model "%s" and the organization is verified if required.',
402
                        $status,
403
                        $model
404
                    );
405
                }
406
407
                error_log(sprintf(
408
                    '[AI][OpenAI][video] HTTP %d request_id=%s type=%s code=%s param=%s error="%s" body=%s',
409
                    $status,
410
                    (string) $requestId,
411
                    (string) ($err['type'] ?? ''),
412
                    (string) ($err['code'] ?? ''),
413
                    (string) ($err['param'] ?? ''),
414
                    $finalMsg,
415
                    $rawForLog !== '' ? $rawForLog : '(empty body)'
416
                ));
417
418
                return 'Error: '.$finalMsg;
419
            }
420
421
            $data = json_decode($raw, true);
422
            if (!is_array($data)) {
423
                error_log(sprintf(
424
                    '[AI][OpenAI][video] Invalid JSON response. HTTP %d request_id=%s body=%s',
425
                    $status,
426
                    (string) $requestId,
427
                    $rawForLog !== '' ? $rawForLog : '(empty body)'
428
                ));
429
                return 'Error: Invalid JSON response from OpenAI.';
430
            }
431
432
            if (!isset($data['id']) || !is_string($data['id']) || '' === trim($data['id'])) {
433
                error_log(sprintf(
434
                    '[AI][OpenAI][video] Missing "id" in response. HTTP %d request_id=%s keys=%s body=%s',
435
                    $status,
436
                    (string) $requestId,
437
                    implode(',', array_keys($data)),
438
                    $rawForLog
439
                ));
440
                return 'Error: OpenAI response missing "id".';
441
            }
442
443
            $result = [
444
                'id' => $data['id'],
445
                'status' => (string) ($data['status'] ?? ''),
446
                'content' => null,
447
                'url' => null,
448
                'is_base64' => false,
449
                'content_type' => 'video/mp4',
450
                'revised_prompt' => null,
451
                'job' => $data,
452
            ];
453
454
            // Job created -> tokens not provided here (keep 0s)
455
            $this->saveAiRequest($userId, $toolName, $promptTrimmed, 'openai', 0, 0, 0);
456
457
            error_log(sprintf(
458
                '[AI][OpenAI][video] Job created. HTTP %d request_id=%s id=%s status=%s',
459
                $status,
460
                (string) $requestId,
461
                (string) $result['id'],
462
                (string) $result['status']
463
            ));
464
465
            return $result;
466
        } catch (Exception $e) {
467
            error_log('[AI][OpenAI][video] Exception: '.$e->getMessage());
468
            return 'Error: '.$e->getMessage();
469
        }
470
    }
471
472
    public function getVideoJobStatus(string $jobId): ?array
473
    {
474
        $cfg = $this->getTypeConfig('video');
475
        $statusUrlTpl = (string) ($cfg['status_url'] ?? 'https://api.openai.com/v1/videos/{id}');
476
        $statusUrl = str_replace('{id}', rawurlencode($jobId), $statusUrlTpl);
477
478
        try {
479
            $response = $this->httpClient->request('GET', $statusUrl, [
480
                'headers' => $this->buildAuthHeaders(false),
481
            ]);
482
483
            $status = $response->getStatusCode();
484
            $headers = $response->getHeaders(false);
485
            $requestId = $this->extractRequestId($headers);
486
487
            $raw = (string) $response->getContent(false);
488
489
            if ($status >= 400) {
490
                $msg = $this->extractOpenAiErrorMessage($raw);
491
                error_log(sprintf(
492
                    '[AI][OpenAI][video] Status HTTP %d request_id=%s id=%s error="%s" body=%s',
493
                    $status,
494
                    $requestId,
495
                    $jobId,
496
                    $msg,
497
                    $this->safeTruncate($raw, 2000)
498
                ));
499
                return [
500
                    'id' => $jobId,
501
                    'status' => 'failed',
502
                    'error' => $msg,
503
                    'job' => null,
504
                ];
505
            }
506
507
            $data = json_decode($raw, true);
508
            if (!is_array($data)) {
509
                return [
510
                    'id' => $jobId,
511
                    'status' => '',
512
                    'error' => 'Invalid JSON response from OpenAI.',
513
                    'job' => null,
514
                ];
515
            }
516
517
            return [
518
                'id' => (string) ($data['id'] ?? $jobId),
519
                'status' => (string) ($data['status'] ?? ''),
520
                'error' => null,
521
                'job' => $data,
522
            ];
523
        } catch (Exception $e) {
524
            error_log('[AI][OpenAI][video] getVideoJobStatus exception: '.$e->getMessage());
525
            return [
526
                'id' => $jobId,
527
                'status' => '',
528
                'error' => $e->getMessage(),
529
                'job' => null,
530
            ];
531
        }
532
    }
533
534
    public function getVideoJobContentAsBase64(string $jobId, int $maxBytes = 15728640): ?array
535
    {
536
        $cfg = $this->getTypeConfig('video');
537
        $contentUrlTpl = (string) ($cfg['content_url'] ?? 'https://api.openai.com/v1/videos/{id}/content');
538
        $contentUrl = str_replace('{id}', rawurlencode($jobId), $contentUrlTpl);
539
540
        try {
541
            $response = $this->httpClient->request('GET', $contentUrl, [
542
                'headers' => array_merge($this->buildAuthHeaders(false), [
543
                    'Accept' => '*/*',
544
                ]),
545
            ]);
546
547
            $status = $response->getStatusCode();
548
            $headers = $response->getHeaders(false);
549
            $requestId = $this->extractRequestId($headers);
550
551
            $contentType = (string) ($headers['content-type'][0] ?? 'video/mp4');
552
            $raw = (string) $response->getContent(false);
553
554
            if ($status >= 400) {
555
                $msg = $this->extractOpenAiErrorMessage($raw);
556
                error_log(sprintf(
557
                    '[AI][OpenAI][video] Content HTTP %d request_id=%s id=%s error="%s" body=%s',
558
                    $status,
559
                    $requestId,
560
                    $jobId,
561
                    $msg,
562
                    $this->safeTruncate($raw, 2000)
563
                ));
564
                return [
565
                    'is_base64' => false,
566
                    'content' => null,
567
                    'url' => null,
568
                    'content_type' => $contentType,
569
                    'error' => $msg,
570
                ];
571
            }
572
573
            if ($raw !== '' && ($raw[0] === '{' || $raw[0] === '[')) {
574
                $json = json_decode($raw, true);
575
                if (is_array($json)) {
576
                    $url = $json['url'] ?? ($json['data']['url'] ?? null);
577
                    if (is_string($url) && '' !== trim($url)) {
578
                        return [
579
                            'is_base64' => false,
580
                            'content' => null,
581
                            'url' => trim($url),
582
                            'content_type' => 'video/mp4',
583
                            'error' => null,
584
                        ];
585
                    }
586
                }
587
588
                return [
589
                    'is_base64' => false,
590
                    'content' => null,
591
                    'url' => null,
592
                    'content_type' => 'video/mp4',
593
                    'error' => 'Content endpoint returned JSON but no URL was found.',
594
                ];
595
            }
596
597
            if (strlen($raw) > $maxBytes) {
598
                return [
599
                    'is_base64' => false,
600
                    'content' => null,
601
                    'url' => null,
602
                    'content_type' => $contentType,
603
                    'error' => 'Video exceeded the maximum allowed size.',
604
                ];
605
            }
606
607
            return [
608
                'is_base64' => true,
609
                'content' => base64_encode($raw),
610
                'url' => null,
611
                'content_type' => $contentType,
612
                'error' => null,
613
            ];
614
        } catch (Exception $e) {
615
            error_log('[AI][OpenAI][video] getVideoJobContentAsBase64 exception: '.$e->getMessage());
616
            return [
617
                'is_base64' => false,
618
                'content' => null,
619
                'url' => null,
620
                'content_type' => 'video/mp4',
621
                'error' => $e->getMessage(),
622
            ];
623
        }
624
    }
625
626
    private function requestChatCompletion(string $prompt, string $toolName, string $type): ?string
627
    {
628
        $userId = $this->getUserId();
629
        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...
630
            throw new RuntimeException('User not authenticated.');
631
        }
632
633
        $cfg = $this->getTypeConfig($type);
634
        if ('document' === $type && empty($cfg)) {
635
            $cfg = $this->getTypeConfig('text');
636
        }
637
638
        if (empty($cfg)) {
639
            error_log('[AI][OpenAI] Missing config for type: '.$type);
640
            return null;
641
        }
642
643
        $url = (string) ($cfg['url'] ?? 'https://api.openai.com/v1/chat/completions');
644
        $model = (string) ($cfg['model'] ?? 'gpt-4o-mini');
645
        $temperature = (float) ($cfg['temperature'] ?? 0.7);
646
        $maxTokens = (int) ($cfg['max_tokens'] ?? 1000);
647
648
        $payload = [
649
            'model' => $model,
650
            'messages' => [
651
                ['role' => 'system', 'content' => 'You are a helpful AI assistant that generates structured educational content.'],
652
                ['role' => 'user', 'content' => $prompt],
653
            ],
654
            'temperature' => $temperature,
655
            'max_tokens' => $maxTokens,
656
        ];
657
658
        try {
659
            $response = $this->httpClient->request('POST', $url, [
660
                'headers' => $this->buildAuthHeaders(true),
661
                'json' => $payload,
662
            ]);
663
664
            $data = $response->toArray(false);
665
666
            $generatedContent = $data['choices'][0]['message']['content'] ?? null;
667
            if (!\is_string($generatedContent) || '' === trim($generatedContent)) {
668
                error_log('[AI][OpenAI] Empty content returned for type: '.$type);
669
                return null;
670
            }
671
672
            $this->saveAiRequest(
673
                $userId,
674
                $toolName,
675
                $prompt,
676
                'openai',
677
                (int) ($data['usage']['prompt_tokens'] ?? 0),
678
                (int) ($data['usage']['completion_tokens'] ?? 0),
679
                (int) ($data['usage']['total_tokens'] ?? 0)
680
            );
681
682
            return $generatedContent;
683
        } catch (Exception $e) {
684
            error_log('[AI][OpenAI] Exception: '.$e->getMessage());
685
            return null;
686
        }
687
    }
688
689
    private function filterValidAikenQuestions(string $quizContent): array
690
    {
691
        $questions = preg_split('/\n{2,}/', trim($quizContent));
692
693
        $validQuestions = [];
694
        foreach ($questions as $questionBlock) {
695
            $lines = explode("\n", trim($questionBlock));
696
697
            if (\count($lines) < 6) {
698
                continue;
699
            }
700
701
            $options = \array_slice($lines, 1, 4);
702
            $validOptions = array_filter($options, static fn ($line) => (bool) preg_match('/^[A-D]\. .+/', $line));
703
704
            $answerLine = end($lines);
705
            if (4 === \count($validOptions) && \is_string($answerLine) && preg_match('/^ANSWER: [A-D]$/', $answerLine)) {
706
                $validQuestions[] = implode("\n", $lines);
707
            }
708
        }
709
710
        return $validQuestions;
711
    }
712
713
    private function getTypeConfig(string $type): array
714
    {
715
        $cfg = $this->providerConfig[$type] ?? null;
716
        return \is_array($cfg) ? $cfg : [];
717
    }
718
719
    private function buildAuthHeaders(bool $json): array
720
    {
721
        $headers = [
722
            'Authorization' => 'Bearer '.$this->apiKey,
723
        ];
724
725
        if ('' !== trim($this->organizationId)) {
726
            $headers['OpenAI-Organization'] = $this->organizationId;
727
        }
728
729
        $projectId = (string) ($this->providerConfig['project_id'] ?? '');
730
        if ('' !== trim($projectId)) {
731
            $headers['OpenAI-Project'] = $projectId;
732
        }
733
734
        if ($json) {
735
            $headers['Content-Type'] = 'application/json';
736
        }
737
738
        return $headers;
739
    }
740
741
    private function saveAiRequest(
742
        int $userId,
743
        string $toolName,
744
        string $requestText,
745
        string $provider,
746
        int $promptTokens = 0,
747
        int $completionTokens = 0,
748
        int $totalTokens = 0
749
    ): void {
750
        try {
751
            $aiRequest = new AiRequests();
752
            $aiRequest
753
                ->setUserId($userId)
754
                ->setToolName($toolName)
755
                ->setRequestText($requestText)
756
                ->setPromptTokens($promptTokens)
757
                ->setCompletionTokens($completionTokens)
758
                ->setTotalTokens($totalTokens)
759
                ->setAiProvider($provider)
760
            ;
761
762
            $this->aiRequestsRepository->save($aiRequest);
763
        } catch (Exception $e) {
764
            error_log('[AI] Failed to save AiRequests record: '.$e->getMessage());
765
        }
766
    }
767
768
    private function getUserId(): ?int
769
    {
770
        $user = $this->security->getUser();
771
        return $user instanceof UserInterface ? $user->getId() : null;
772
    }
773
774
    private function extractRequestId(array $headers): string
775
    {
776
        return (string) ($headers['x-request-id'][0] ?? $headers['request-id'][0] ?? '');
777
    }
778
779
    private function extractOpenAiErrorMessage(string $raw): string
780
    {
781
        $decoded = json_decode($raw, true);
782
        if (is_array($decoded)) {
783
            $msg = $decoded['error']['message'] ?? null;
784
            if (is_string($msg) && trim($msg) !== '') {
785
                return trim($msg);
786
            }
787
        }
788
        return 'OpenAI returned an error response.';
789
    }
790
791
    private function safeTruncate(string $s, int $max = 2000): string
792
    {
793
        return mb_substr($s, 0, $max);
794
    }
795
796
    private function decodeOpenAiError(string $raw): array
797
    {
798
        $decoded = json_decode($raw, true);
799
800
        if (!is_array($decoded)) {
801
            return [
802
                'message' => null,
803
                'type' => null,
804
                'code' => null,
805
                'param' => null,
806
            ];
807
        }
808
809
        $err = $decoded['error'] ?? null;
810
        if (!is_array($err)) {
811
            return [
812
                'message' => null,
813
                'type' => null,
814
                'code' => null,
815
                'param' => null,
816
            ];
817
        }
818
819
        return [
820
            'message' => isset($err['message']) && is_string($err['message']) ? $err['message'] : null,
821
            'type' => isset($err['type']) && is_string($err['type']) ? $err['type'] : null,
822
            'code' => isset($err['code']) && is_string($err['code']) ? $err['code'] : null,
823
            'param' => isset($err['param']) && is_string($err['param']) ? $err['param'] : null,
824
        ];
825
    }
826
}
827