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

AiController::videoJobStatus()   C

Complexity

Conditions 15
Paths 188

Size

Total Lines 90
Code Lines 65

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 15
eloc 65
nc 188
nop 2
dl 0
loc 90
rs 5.1833
c 1
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
/* For licensing terms, see /license.txt */
6
7
namespace Chamilo\CoreBundle\Controller;
8
9
use Chamilo\CoreBundle\AiProvider\AiImageProviderInterface;
10
use Chamilo\CoreBundle\AiProvider\AiProviderFactory;
11
use Chamilo\CoreBundle\AiProvider\AiVideoProviderInterface;
12
use Chamilo\CoreBundle\Entity\TrackEDefault;
13
use Chamilo\CoreBundle\Entity\TrackEExercise;
14
use Chamilo\CoreBundle\Repository\TrackEAttemptRepository;
15
use Chamilo\CourseBundle\Entity\CQuizAnswer;
16
use DateTime;
17
use Doctrine\ORM\EntityManagerInterface;
18
use Exception;
19
use Question;
20
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
21
use Symfony\Component\HttpFoundation\JsonResponse;
22
use Symfony\Component\HttpFoundation\Request;
23
use Symfony\Component\Routing\Attribute\Route;
24
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
25
use Symfony\Contracts\HttpClient\HttpClientInterface;
26
27
use const FILTER_SANITIZE_NUMBER_INT;
28
29
#[Route('/ai')]
30
class AiController extends AbstractController
31
{
32
    public function __construct(
33
        private readonly AiProviderFactory $aiProviderFactory,
34
        private readonly TrackEAttemptRepository $attemptRepo,
35
        private readonly EntityManagerInterface $em,
36
        private readonly HttpClientInterface $httpClient,
37
    ) {}
38
39
    #[Route('/capabilities', name: 'chamilo_core_ai_capabilities', methods: ['GET'])]
40
    public function capabilities(): JsonResponse
41
    {
42
        return new JsonResponse([
43
            'success' => true,
44
            'types' => [
45
                'text' => $this->aiProviderFactory->getProvidersForType('text'),
46
                'image' => $this->aiProviderFactory->getProvidersForType('image'),
47
                'video' => $this->aiProviderFactory->getProvidersForType('video'),
48
                'document' => $this->aiProviderFactory->getProvidersForType('document'),
49
                'document_process' => $this->aiProviderFactory->getProvidersForType('document_process'),
50
            ],
51
            'has' => [
52
                'text' => $this->aiProviderFactory->hasProvidersForType('text'),
53
                'image' => $this->aiProviderFactory->hasProvidersForType('image'),
54
                'video' => $this->aiProviderFactory->hasProvidersForType('video'),
55
                'document' => $this->aiProviderFactory->hasProvidersForType('document'),
56
            ],
57
        ]);
58
    }
59
60
    #[Route('/generate_aiken', name: 'chamilo_core_ai_generate_aiken', methods: ['POST'])]
61
    public function generateAiken(Request $request): JsonResponse
62
    {
63
        try {
64
            $data = json_decode($request->getContent(), true);
65
            if (!is_array($data)) {
66
                return new JsonResponse([
67
                    'success' => false,
68
                    'text' => 'Invalid JSON payload.',
69
                ], 400);
70
            }
71
72
            $nQ = (int) ($data['nro_questions'] ?? 0);
73
            $language = (string) ($data['language'] ?? 'en');
74
            $topic = trim((string) ($data['quiz_name'] ?? ''));
75
            $questionType = (string) ($data['question_type'] ?? 'multiple_choice');
76
            $aiProvider = $data['ai_provider'] ?? null;
77
78
            if ($nQ <= 0 || '' === $topic) {
79
                return new JsonResponse([
80
                    'success' => false,
81
                    'text' => 'Invalid request parameters. Ensure all fields are filled correctly.',
82
                ], 400);
83
            }
84
85
            $aiService = $this->aiProviderFactory->getProvider($aiProvider, 'text');
86
            $questions = $aiService->generateQuestions($topic, $nQ, $questionType, $language);
87
88
            if (empty($questions)) {
89
                return new JsonResponse([
90
                    'success' => false,
91
                    'text' => 'AI request returned an empty response.',
92
                ], 500);
93
            }
94
95
            if (\is_string($questions) && str_starts_with($questions, 'Error:')) {
96
                return new JsonResponse([
97
                    'success' => false,
98
                    'text' => $questions,
99
                ], 500);
100
            }
101
102
            return new JsonResponse([
103
                'success' => true,
104
                'text' => trim((string) $questions),
105
            ]);
106
        } catch (Exception $e) {
107
            error_log('[AI] Aiken generation failed: '.$e->getMessage());
108
109
            return new JsonResponse([
110
                'success' => false,
111
                'text' => 'An error occurred while generating questions. Please contact the administrator.',
112
            ], 500);
113
        }
114
    }
115
116
    #[Route('/open_answer_grade', name: 'chamilo_core_ai_open_answer_grade', methods: ['POST'])]
117
    public function openAnswerGrade(Request $request): JsonResponse
118
    {
119
        $exeId = $request->request->getInt('exeId', 0);
120
        $questionId = $request->request->getInt('questionId', 0);
121
        $courseId = $request->request->getInt('courseId', 0);
122
123
        if (0 === $exeId || 0 === $questionId || 0 === $courseId) {
124
            return $this->json(['error' => 'Missing parameters'], 400);
125
        }
126
127
        /** @var TrackEExercise|null $trackExercise */
128
        $trackExercise = $this->em->getRepository(TrackEExercise::class)->find($exeId);
129
        if (null === $trackExercise) {
130
            return $this->json(['error' => 'Exercise attempt not found'], 404);
131
        }
132
133
        $attempt = $this->attemptRepo->findOneBy([
134
            'trackExercise' => $trackExercise,
135
            'questionId' => $questionId,
136
            'user' => $trackExercise->getUser(),
137
        ]);
138
        if (null === $attempt) {
139
            return $this->json(['error' => 'Attempt not found'], 404);
140
        }
141
142
        $answerText = $attempt->getAnswer();
143
144
        if (ctype_digit($answerText)) {
145
            $cqa = $this->em->getRepository(CQuizAnswer::class)->find((int) $answerText);
146
            if ($cqa) {
147
                $answerText = $cqa->getAnswer();
148
            }
149
        }
150
151
        $courseInfo = api_get_course_info_by_id($courseId);
152
        if (empty($courseInfo['real_id'])) {
153
            return $this->json(['error' => 'Course info not found'], 500);
154
        }
155
156
        $question = Question::read($questionId, $courseInfo);
157
        if (false === $question) {
158
            return $this->json(['error' => 'Question not found'], 404);
159
        }
160
161
        $language = $courseInfo['language'] ?? 'en';
162
        $courseTitle = $courseInfo['title'] ?? '';
163
        $maxScore = $question->selectWeighting();
164
        $questionText = $question->selectTitle();
165
166
        $prompt = \sprintf(
167
            "In language %s, for the question: '%s', in the context of %s, provide a score between 0 and %d on one line, then feedback on the next line for the following answer: '%s'.",
168
            $language,
169
            $questionText,
170
            $courseTitle,
171
            $maxScore,
172
            $answerText
173
        );
174
175
        $provider = $this->aiProviderFactory->getProvider(null, 'text');
176
        $raw = trim((string) $provider->gradeOpenAnswer($prompt, 'open_answer_grade'));
177
178
        if ('' === $raw) {
179
            return $this->json(['error' => 'AI request failed'], 500);
180
        }
181
182
        if (str_contains($raw, "\n")) {
183
            [$scoreLine, $feedback] = explode("\n", $raw, 2);
184
        } else {
185
            $scoreLine = (string) $maxScore;
186
            $feedback = $raw;
187
        }
188
189
        $score = (int) filter_var($scoreLine, FILTER_SANITIZE_NUMBER_INT);
190
191
        $track = new TrackEDefault();
192
        $track
193
            ->setDefaultUserId($this->getUser()->getId())
194
            ->setDefaultEventType('ai_use_question_feedback')
195
            ->setDefaultValueType('attempt_id')
196
            ->setDefaultValue((string) $attempt->getId())
197
            ->setDefaultDate(new DateTime())
198
            ->setCId($courseId)
199
            ->setSessionId(api_get_session_id())
200
        ;
201
202
        $this->em->persist($track);
203
        $this->em->flush();
204
205
        return $this->json([
206
            'score' => $score,
207
            'feedback' => $feedback,
208
        ]);
209
    }
210
211
    #[Route('/generate_image', name: 'chamilo_core_ai_generate_image', methods: ['POST'])]
212
    public function generateImage(Request $request): JsonResponse
213
    {
214
        try {
215
            try {
216
                $this->denyIfNotTeacher();
217
            } catch (AccessDeniedException $e) {
218
                return new JsonResponse([
219
                    'success' => false,
220
                    'text' => 'Access denied.',
221
                ], 403);
222
            }
223
224
            $data = json_decode($request->getContent(), true);
225
            if (!is_array($data)) {
226
                return new JsonResponse([
227
                    'success' => false,
228
                    'text' => 'Invalid JSON payload.',
229
                ], 400);
230
            }
231
232
            $n = (int) ($data['n'] ?? 1);
233
            $language = (string) ($data['language'] ?? 'en');
234
            $prompt = trim((string) ($data['prompt'] ?? ''));
235
            $toolName = trim((string) ($data['tool'] ?? 'document'));
236
            $aiProvider = $data['ai_provider'] ?? null;
237
238
            if ($n <= 0 || '' === $prompt || '' === $toolName) {
239
                return new JsonResponse([
240
                    'success' => false,
241
                    'text' => 'Invalid request parameters. Ensure all fields are filled correctly.',
242
                ], 400);
243
            }
244
245
            $availableProviders = $this->aiProviderFactory->getProvidersForType('image');
246
            if (empty($availableProviders)) {
247
                return new JsonResponse([
248
                    'success' => false,
249
                    'text' => 'No AI providers available for image generation.',
250
                ], 400);
251
            }
252
253
            $explicitProvider = null;
254
            if (null !== $aiProvider && '' !== (string) $aiProvider) {
255
                $explicitProvider = (string) $aiProvider;
256
257
                if (!in_array($explicitProvider, $availableProviders, true)) {
258
                    return new JsonResponse([
259
                        'success' => false,
260
                        'text' => 'Selected AI provider is not available for image generation.',
261
                    ], 400);
262
                }
263
            }
264
265
            $providersToTry = $explicitProvider ? [$explicitProvider] : $availableProviders;
266
            $errors = [];
267
            $providerUsed = null;
268
            $result = null;
269
270
            foreach ($providersToTry as $providerName) {
271
                try {
272
                    $aiService = $this->aiProviderFactory->getProvider($providerName, 'image');
273
274
                    if (!$aiService instanceof AiImageProviderInterface) {
275
                        $errors[$providerName] = 'Provider does not implement image generation interface.';
276
                        continue;
277
                    }
278
279
                    $result = $aiService->generateImage($prompt, $toolName, [
280
                        'language' => $language,
281
                        'n' => $n,
282
                    ]);
283
284
                    if (empty($result)) {
285
                        $errors[$providerName] = 'Provider returned an empty response.';
286
                        continue;
287
                    }
288
289
                    if (\is_string($result) && str_starts_with($result, 'Error:')) {
290
                        $errors[$providerName] = $result;
291
                        $result = null;
292
                        continue;
293
                    }
294
295
                    $providerUsed = $providerName;
296
                    break;
297
                } catch (\Throwable $e) {
298
                    $errors[$providerName] = $e->getMessage();
299
                    continue;
300
                }
301
            }
302
303
            if (null === $providerUsed || empty($result)) {
304
                error_log('[AI][image] Image generation failed for all providers: '.json_encode($errors));
305
306
                return new JsonResponse([
307
                    'success' => false,
308
                    'text' => $explicitProvider
309
                        ? 'Image generation failed for the selected provider.'
310
                        : 'All image providers failed.',
311
                    'providers_tried' => $providersToTry,
312
                    'errors' => $errors,
313
                ], 500);
314
            }
315
316
            if (\is_string($result)) {
317
                $normalized = [
318
                    'content' => trim($result),
319
                    'url' => null,
320
                    'is_base64' => true,
321
                    'content_type' => 'image/png',
322
                    'revised_prompt' => null,
323
                ];
324
325
                return new JsonResponse([
326
                    'success' => true,
327
                    'text' => $normalized['content'],
328
                    'result' => $normalized,
329
                    'provider_used' => $providerUsed,
330
                    'providers_tried' => $providersToTry,
331
                    'errors' => $errors,
332
                ]);
333
            }
334
335
            $content = isset($result['content']) && \is_string($result['content']) ? trim($result['content']) : '';
336
            $url = isset($result['url']) && \is_string($result['url']) ? trim($result['url']) : '';
337
            $isBase64 = (bool) ($result['is_base64'] ?? false);
338
            $contentType = (string) ($result['content_type'] ?? 'image/png');
339
340
            if (!$isBase64 && '' === $content && '' !== $url) {
341
                try {
342
                    $fetched = $this->fetchUrlAsBase64($url, 10 * 1024 * 1024);
343
                    $result['content'] = $fetched['content'];
344
                    $result['content_type'] = $fetched['content_type'];
345
                    $result['is_base64'] = true;
346
                    $result['url'] = null;
347
                } catch (\Throwable $e) {
348
                    error_log('[AI][image] Failed to fetch image URL as base64: '.$e->getMessage());
349
350
                    return new JsonResponse([
351
                        'success' => false,
352
                        'text' => 'Image was generated, but could not be converted to base64 for preview.',
353
                    ], 500);
354
                }
355
            }
356
357
            $text = '';
358
            if (!empty($result['content']) && \is_string($result['content'])) {
359
                $text = trim($result['content']);
360
            }
361
362
            return new JsonResponse([
363
                'success' => true,
364
                'text' => $text,
365
                'result' => $result,
366
                'provider_used' => $providerUsed,
367
                'providers_tried' => $providersToTry,
368
                'errors' => $errors,
369
            ]);
370
        } catch (Exception $e) {
371
            error_log('[AI][image] Controller exception: '.$e->getMessage());
372
373
            return new JsonResponse([
374
                'success' => false,
375
                'text' => 'An error occurred while generating the image. Please contact the administrator.',
376
            ], 500);
377
        }
378
    }
379
380
    #[Route('/generate_video', name: 'chamilo_core_ai_generate_video', methods: ['POST'])]
381
    public function generateVideo(Request $request): JsonResponse
382
    {
383
        try {
384
            try {
385
                $this->denyIfNotTeacher();
386
            } catch (AccessDeniedException $e) {
387
                return new JsonResponse([
388
                    'success' => false,
389
                    'text' => 'Access denied.',
390
                ], 403);
391
            }
392
393
            $data = json_decode($request->getContent(), true);
394
            if (!is_array($data)) {
395
                return new JsonResponse([
396
                    'success' => false,
397
                    'text' => 'Invalid JSON payload.',
398
                ], 400);
399
            }
400
401
            $n = (int) ($data['n'] ?? 1);
402
            $language = (string) ($data['language'] ?? 'en');
403
            $prompt = trim((string) ($data['prompt'] ?? ''));
404
            $toolName = trim((string) ($data['tool'] ?? 'document'));
405
            $aiProvider = $data['ai_provider'] ?? null;
406
407
            // Optional overrides
408
            $seconds = isset($data['seconds']) ? trim((string) $data['seconds']) : null;
409
            $size = isset($data['size']) ? trim((string) $data['size']) : null;
410
411
            if ($n <= 0 || '' === $prompt || '' === $toolName) {
412
                return new JsonResponse([
413
                    'success' => false,
414
                    'text' => 'Invalid request parameters. Ensure all fields are filled correctly.',
415
                ], 400);
416
            }
417
418
            $availableProviders = $this->aiProviderFactory->getProvidersForType('video');
419
            if (empty($availableProviders)) {
420
                return new JsonResponse([
421
                    'success' => false,
422
                    'text' => 'No AI providers available for video generation.',
423
                ], 400);
424
            }
425
426
            $explicitProvider = null;
427
            if (null !== $aiProvider && '' !== (string) $aiProvider) {
428
                $explicitProvider = (string) $aiProvider;
429
430
                if (!in_array($explicitProvider, $availableProviders, true)) {
431
                    return new JsonResponse([
432
                        'success' => false,
433
                        'text' => 'Selected AI provider is not available for video generation.',
434
                    ], 400);
435
                }
436
            }
437
438
            $providersToTry = $explicitProvider ? [$explicitProvider] : $availableProviders;
439
            $errors = [];
440
            $providerUsed = null;
441
            $result = null;
442
443
            foreach ($providersToTry as $providerName) {
444
                try {
445
                    $aiService = $this->aiProviderFactory->getProvider($providerName, 'video');
446
447
                    if (!$aiService instanceof AiVideoProviderInterface) {
448
                        $errors[$providerName] = 'Provider does not implement video generation interface.';
449
                        continue;
450
                    }
451
452
                    $options = [
453
                        'language' => $language,
454
                        'n' => $n,
455
                    ];
456
457
                    if (null !== $seconds && $seconds !== '') {
458
                        $options['seconds'] = $seconds; // must be string: "4"|"8"|"12"
459
                    }
460
                    if (null !== $size && $size !== '') {
461
                        $options['size'] = $size;
462
                    }
463
464
                    $result = $aiService->generateVideo($prompt, $toolName, $options);
465
466
                    if (empty($result)) {
467
                        $errors[$providerName] = 'Provider returned an empty response.';
468
                        $result = null;
469
                        continue;
470
                    }
471
472
                    if (\is_string($result) && str_starts_with($result, 'Error:')) {
473
                        $errors[$providerName] = $result;
474
                        $result = null;
475
                        continue;
476
                    }
477
478
                    $providerUsed = $providerName;
479
                    break;
480
                } catch (\Throwable $e) {
481
                    $errors[$providerName] = $e->getMessage();
482
                    $result = null;
483
                    continue;
484
                }
485
            }
486
487
            if (null === $providerUsed || empty($result)) {
488
                error_log('[AI][video] Video generation failed for all providers: '.json_encode($errors));
489
490
                $firstError = '';
491
                foreach ($errors as $err) {
492
                    if (is_string($err) && '' !== trim($err)) {
493
                        $firstError = trim($err);
494
                        break;
495
                    }
496
                }
497
498
                $message = $firstError !== '' ? preg_replace('/^Error:\s*/', '', $firstError) : (
499
                $explicitProvider
500
                    ? 'Video generation failed for the selected provider.'
501
                    : 'All video providers failed.'
502
                );
503
504
                $statusCode = $this->mapVideoErrorToHttpStatus($message);
505
506
                return new JsonResponse([
507
                    'success' => false,
508
                    'text' => $message,
509
                    'providers_tried' => $providersToTry,
510
                    'errors' => $errors,
511
                ], $statusCode);
512
            }
513
514
            if (\is_string($result)) {
515
                $raw = trim($result);
516
517
                $normalized = [
518
                    'content' => null,
519
                    'url' => null,
520
                    'id' => null,
521
                    'status' => null,
522
                    'is_base64' => false,
523
                    'content_type' => 'video/mp4',
524
                    'revised_prompt' => null,
525
                ];
526
527
                if ($this->looksLikeUrl($raw)) {
528
                    $normalized['url'] = $raw;
529
                } elseif ($this->looksLikeBase64($raw)) {
530
                    $normalized['content'] = $raw;
531
                    $normalized['is_base64'] = true;
532
                } else {
533
                    $normalized['id'] = $raw;
534
                }
535
536
                return new JsonResponse([
537
                    'success' => true,
538
                    'text' => (string) ($normalized['url'] ?? $normalized['content'] ?? $normalized['id'] ?? ''),
539
                    'result' => $normalized,
540
                    'provider_used' => $providerUsed,
541
                    'providers_tried' => $providersToTry,
542
                    'errors' => $errors,
543
                ]);
544
            }
545
546
            if (!is_array($result)) {
547
                return new JsonResponse([
548
                    'success' => false,
549
                    'text' => 'Provider returned an unsupported response type.',
550
                ], 500);
551
            }
552
553
            $result['is_base64'] = (bool) ($result['is_base64'] ?? false);
554
            $result['content_type'] = (string) ($result['content_type'] ?? 'video/mp4');
555
            $result['revised_prompt'] = $result['revised_prompt'] ?? null;
556
557
            $url = isset($result['url']) && \is_string($result['url']) ? trim($result['url']) : '';
558
            $content = isset($result['content']) && \is_string($result['content']) ? trim($result['content']) : '';
559
560
            if (empty($content) && !empty($url) && false === (bool) ($result['is_base64'] ?? false)) {
561
                try {
562
                    $fetched = $this->fetchUrlAsBase64($url, 15 * 1024 * 1024);
563
                    $result['content'] = $fetched['content'];
564
                    $result['content_type'] = $fetched['content_type'];
565
                    $result['is_base64'] = true;
566
                    $result['url'] = null;
567
                } catch (\Throwable $e) {
568
                    error_log('[AI][video] Failed to fetch video URL as base64: '.$e->getMessage());
569
                }
570
            }
571
572
            $text = '';
573
            if (isset($result['url']) && \is_string($result['url']) && '' !== trim($result['url'])) {
574
                $text = trim($result['url']);
575
            } elseif (isset($result['content']) && \is_string($result['content']) && '' !== trim($result['content'])) {
576
                $text = trim($result['content']);
577
            } elseif (isset($result['id']) && \is_string($result['id']) && '' !== trim($result['id'])) {
578
                $text = trim($result['id']);
579
            }
580
581
            return new JsonResponse([
582
                'success' => true,
583
                'text' => $text,
584
                'result' => $result,
585
                'provider_used' => $providerUsed,
586
                'providers_tried' => $providersToTry,
587
                'errors' => $errors,
588
            ]);
589
        } catch (Exception $e) {
590
            error_log('[AI][video] Video generation failed: '.$e->getMessage());
591
592
            return new JsonResponse([
593
                'success' => false,
594
                'text' => 'An error occurred while generating the video. Please contact the administrator.',
595
            ], 500);
596
        }
597
    }
598
599
    /**
600
     * Returns a reasonable HTTP status code for known provider errors.
601
     */
602
    private function mapVideoErrorToHttpStatus(string $message): int
603
    {
604
        $m = strtolower(trim($message));
605
606
        if ($m === '') {
607
            return 500;
608
        }
609
610
        // OpenAI typical cases
611
        if (str_contains($m, 'invalid api key') || str_contains($m, 'incorrect api key') || str_contains($m, 'unauthorized')) {
612
            return 401;
613
        }
614
615
        if (str_contains($m, 'must be verified') || str_contains($m, 'verify organization') || str_contains($m, 'organization must be verified')) {
616
            return 403;
617
        }
618
619
        if (str_contains($m, 'does not have access') || str_contains($m, 'not authorized') || str_contains($m, 'permission')) {
620
            return 403;
621
        }
622
623
        if (str_contains($m, 'rate limit') || str_contains($m, 'too many requests')) {
624
            return 429;
625
        }
626
627
        if (str_contains($m, 'insufficient_quota') || str_contains($m, 'quota')) {
628
            return 402;
629
        }
630
631
        return 500;
632
    }
633
634
    private function looksLikeUrl(string $s): bool
635
    {
636
        $s = trim($s);
637
        if ($s === '') {
638
            return false;
639
        }
640
641
        return (bool) filter_var($s, FILTER_VALIDATE_URL);
642
    }
643
644
    private function looksLikeBase64(string $s): bool
645
    {
646
        $s = trim($s);
647
        if ($s === '' || strlen($s) < 64) {
648
            return false;
649
        }
650
651
        // Basic base64 charset check
652
        if (!preg_match('/^[A-Za-z0-9+\/=\r\n]+$/', $s)) {
653
            return false;
654
        }
655
656
        // Validate decode (strict)
657
        $decoded = base64_decode($s, true);
658
        if ($decoded === false) {
659
            return false;
660
        }
661
662
        // Video will likely be binary; just ensure not empty
663
        return $decoded !== '';
664
    }
665
666
    #[Route('/video_job/{id}', name: 'chamilo_core_ai_video_job', methods: ['GET'])]
667
    public function videoJobStatus(string $id, Request $request): JsonResponse
668
    {
669
        try {
670
            try {
671
                $this->denyIfNotTeacher();
672
            } catch (AccessDeniedException $e) {
673
                return new JsonResponse([
674
                    'success' => false,
675
                    'text' => 'Access denied.',
676
                ], 403);
677
            }
678
679
            $aiProvider = $request->query->get('ai_provider');
680
681
            $aiService = $this->aiProviderFactory->getProvider($aiProvider, 'video');
682
            if (!$aiService instanceof AiVideoProviderInterface) {
683
                return new JsonResponse([
684
                    'success' => false,
685
                    'text' => 'Selected AI provider does not support video generation.',
686
                ], 400);
687
            }
688
689
            if (!method_exists($aiService, 'getVideoJobStatus')) {
690
                return new JsonResponse([
691
                    'success' => false,
692
                    'text' => 'This AI provider does not expose a video job status method.',
693
                ], 400);
694
            }
695
696
            $job = $aiService->getVideoJobStatus($id);
697
            if (empty($job)) {
698
                return new JsonResponse([
699
                    'success' => false,
700
                    'text' => 'Failed to fetch video job status.',
701
                ], 500);
702
            }
703
704
            $status = (string) ($job['status'] ?? '');
705
            $jobError = isset($job['error']) && is_string($job['error']) ? trim($job['error']) : '';
706
707
            $result = [
708
                'id' => (string) ($job['id'] ?? $id),
709
                'status' => $status,
710
                'content' => null,
711
                'url' => null,
712
                'is_base64' => false,
713
                'content_type' => 'video/mp4',
714
                'revised_prompt' => null,
715
                'error' => $jobError !== '' ? $jobError : null,
716
            ];
717
718
            if (in_array($status, ['completed', 'succeeded', 'done'], true)) {
719
                if (method_exists($aiService, 'getVideoJobContentAsBase64')) {
720
                    $maxBytes = 15 * 1024 * 1024;
721
                    $content = $aiService->getVideoJobContentAsBase64($id, $maxBytes);
722
723
                    if (is_array($content)) {
724
                        $result['is_base64'] = (bool) ($content['is_base64'] ?? false);
725
                        $result['content'] = $content['content'] ?? null;
726
                        $result['url'] = $content['url'] ?? null;
727
                        $result['content_type'] = (string) ($content['content_type'] ?? 'video/mp4');
728
729
                        if (!empty($content['error'])) {
730
                            $result['error'] = is_string($content['error']) ? trim($content['error']) : (string) $content['error'];
731
732
                            return new JsonResponse([
733
                                'success' => true,
734
                                'text' => (string) $result['error'],
735
                                'result' => $result,
736
                                'provider_used' => $aiProvider,
737
                            ]);
738
                        }
739
                    }
740
                }
741
            }
742
743
            return new JsonResponse([
744
                'success' => true,
745
                'text' => $jobError !== '' ? $jobError : '',
746
                'result' => $result,
747
                'provider_used' => $aiProvider,
748
            ]);
749
        } catch (Exception $e) {
750
            error_log('[AI][video] Video job status failed: '.$e->getMessage());
751
752
            return new JsonResponse([
753
                'success' => false,
754
                'text' => 'An error occurred while checking the video status. Please contact the administrator.',
755
            ], 500);
756
        }
757
    }
758
759
    private function denyIfNotTeacher(): void
760
    {
761
        if (!$this->isGranted('ROLE_CURRENT_COURSE_TEACHER')
762
            && !$this->isGranted('ROLE_CURRENT_COURSE_SESSION_TEACHER')
763
            && !$this->isGranted('ROLE_TEACHER')
764
        ) {
765
            throw new \RuntimeException('Access denied.');
766
        }
767
    }
768
769
    private function fetchUrlAsBase64(string $url, int $maxBytes = 10485760): array
770
    {
771
        if (!$this->isSafeRemoteUrl($url)) {
772
            throw new \RuntimeException('Remote URL is not allowed.');
773
        }
774
775
        $response = $this->httpClient->request('GET', $url, [
776
            'headers' => [
777
                'Accept' => '*/*',
778
            ],
779
        ]);
780
781
        $headers = $response->getHeaders(false);
782
        $contentType = $headers['content-type'][0] ?? 'application/octet-stream';
783
784
        $lenHeader = $headers['content-length'][0] ?? null;
785
        if (null !== $lenHeader && is_numeric($lenHeader) && (int) $lenHeader > $maxBytes) {
786
            throw new \RuntimeException('Remote content is too large to inline as base64.');
787
        }
788
789
        $binary = $response->getContent(false);
790
791
        if (strlen($binary) > $maxBytes) {
792
            throw new \RuntimeException('Remote content exceeded the maximum allowed size.');
793
        }
794
795
        return [
796
            'content' => base64_encode($binary),
797
            'content_type' => (string) $contentType,
798
            'is_base64' => true,
799
            'url' => null,
800
        ];
801
    }
802
803
    private function isSafeRemoteUrl(string $url): bool
804
    {
805
        $parts = parse_url($url);
806
        if (!is_array($parts)) {
807
            return false;
808
        }
809
810
        $scheme = strtolower((string) ($parts['scheme'] ?? ''));
811
        if (!in_array($scheme, ['https'], true)) {
812
            return false;
813
        }
814
815
        $host = strtolower((string) ($parts['host'] ?? ''));
816
        if ('' === $host) {
817
            return false;
818
        }
819
820
        if (in_array($host, ['localhost', '127.0.0.1', '::1'], true)) {
821
            return false;
822
        }
823
824
        $ip = gethostbyname($host);
825
        if (filter_var($ip, FILTER_VALIDATE_IP)) {
826
            // Block private/reserved ranges (basic SSRF hardening).
827
            if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
828
                return false;
829
            }
830
        }
831
832
        return true;
833
    }
834
}
835