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

OpenAiProvider::getVideoJobContentAsBase64()   C

Complexity

Conditions 13
Paths 132

Size

Total Lines 97
Code Lines 69

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 13
eloc 69
nc 132
nop 2
dl 0
loc 97
rs 5.7563
c 0
b 0
f 0

How to fix   Long Method    Complexity   

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