Passed
Pull Request — master (#6396)
by Angel Fernando Quiroz
08:47
created

OpenAiProvider::requestOpenAI()   A

Complexity

Conditions 5
Paths 11

Size

Total Lines 52
Code Lines 34

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 34
nc 11
nop 2
dl 0
loc 52
rs 9.0648
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\Security\Core\User\UserInterface;
14
use Symfony\Contracts\HttpClient\HttpClientInterface;
15
16
class OpenAiProvider implements AiProviderInterface
17
{
18
    private string $apiUrl;
19
    private string $apiKey;
20
    private string $model;
21
    private float $temperature;
22
    private HttpClientInterface $httpClient;
23
    private AiRequestsRepository $aiRequestsRepository;
24
    private Security $security;
25
26
    public function __construct(
27
        HttpClientInterface $httpClient,
28
        SettingsManager $settingsManager,
29
        AiRequestsRepository $aiRequestsRepository,
30
        Security $security
31
    ) {
32
        $this->httpClient = $httpClient;
33
        $this->aiRequestsRepository = $aiRequestsRepository;
34
        $this->security = $security;
35
36
        // Get AI providers from settings
37
        $configJson = $settingsManager->getSetting('ai_helpers.ai_providers', true);
38
        $config = json_decode($configJson, true) ?? [];
39
40
        if (!isset($config['openai'])) {
41
            throw new RuntimeException('OpenAI configuration is missing.');
42
        }
43
44
        $this->apiUrl = $config['openai']['url'] ?? 'https://api.openai.com/v1/chat/completions';
45
        $this->apiKey = $config['openai']['api_key'] ?? '';
46
        $this->model = $config['openai']['model'] ?? 'gpt-3.5-turbo';
47
        $this->temperature = $config['openai']['temperature'] ?? 0.7;
48
49
        if (empty($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->requestOpenAI($prompt, 'quiz');
75
    }
76
77
    public function generateLearnPath(string $topic, int $chaptersCount, string $language, int $wordsCount, bool $addTests, int $numQuestions): ?array
78
    {
79
        // Step 1: Generate the Table of Contents
80
        $tableOfContentsPrompt = \sprintf(
81
            'Generate a structured table of contents for a course in "%s" with %d chapters on "%s".
82
            Return a numbered list, each chapter on a new line. No conclusion.',
83
            $language,
84
            $chaptersCount,
85
            $topic
86
        );
87
88
        $lpStructure = $this->requestOpenAI($tableOfContentsPrompt, 'learnpath');
89
        if (!$lpStructure) {
90
            return ['success' => false, 'message' => 'Failed to generate course structure.'];
91
        }
92
93
        // Step 2: Generate content for each chapter
94
        $lpItems = [];
95
        $chapters = explode("\n", trim($lpStructure));
96
        foreach ($chapters as $index => $chapterTitle) {
97
            $chapterTitle = trim($chapterTitle);
98
            if (empty($chapterTitle)) {
99
                continue;
100
            }
101
102
            $chapterPrompt = \sprintf(
103
                'Create a learning chapter in HTML for "%s" in "%s" with %d words.
104
                Title: "%s". Assume the reader already knows the context.',
105
                $topic,
106
                $language,
107
                $wordsCount,
108
                $chapterTitle
109
            );
110
111
            $chapterContent = $this->requestOpenAI($chapterPrompt, 'learnpath');
112
            if (!$chapterContent) {
113
                continue;
114
            }
115
116
            $lpItems[] = [
117
                'title' => $chapterTitle,
118
                'content' => "<html><head><title>{$chapterTitle}</title></head><body>{$chapterContent}</body></html>",
119
            ];
120
        }
121
122
        // Step 3: Generate quizzes if enabled
123
        $quizItems = [];
124
        if ($addTests) {
125
            foreach ($lpItems as &$chapter) {
126
                $quizPrompt = \sprintf(
127
                    'Generate %d multiple-choice questions in Aiken format in %s about "%s".
128
            Ensure each question follows this format:
129
130
            1. The question text.
131
            A. Option A
132
            B. Option B
133
            C. Option C
134
            D. Option D
135
            ANSWER: (Correct answer letter)
136
137
            Each question must have exactly 4 options and one answer line.
138
            Return only valid questions without extra text.',
139
                    $numQuestions,
140
                    $language,
141
                    $chapter['title']
142
                );
143
144
                $quizContent = $this->requestOpenAI($quizPrompt, 'learnpath');
145
146
                if ($quizContent) {
147
                    $validQuestions = $this->filterValidAikenQuestions($quizContent);
148
149
                    if (!empty($validQuestions)) {
150
                        $quizItems[] = [
151
                            'title' => 'Quiz: '.$chapter['title'],
152
                            'content' => implode("\n\n", $validQuestions),
153
                        ];
154
                    }
155
                }
156
            }
157
        }
158
159
        return [
160
            'success' => true,
161
            'topic' => $topic,
162
            'lp_items' => $lpItems,
163
            'quiz_items' => $quizItems,
164
        ];
165
    }
166
167
    private function filterValidAikenQuestions(string $quizContent): array
168
    {
169
        $questions = preg_split('/\n{2,}/', trim($quizContent));
170
171
        $validQuestions = [];
172
        foreach ($questions as $questionBlock) {
173
            $lines = explode("\n", trim($questionBlock));
174
175
            if (\count($lines) < 6) {
176
                continue;
177
            }
178
179
            $options = \array_slice($lines, 1, 4);
180
            $validOptions = array_filter($options, fn ($line) => preg_match('/^[A-D]\. .+/', $line));
181
182
            $answerLine = end($lines);
183
            if (4 === \count($validOptions) && preg_match('/^ANSWER: [A-D]$/', $answerLine)) {
184
                $validQuestions[] = implode("\n", $lines);
185
            }
186
        }
187
188
        return $validQuestions;
189
    }
190
191
    private function requestOpenAI(string $prompt, string $toolName): ?string
192
    {
193
        $userId = $this->getUserId();
194
        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...
195
            throw new RuntimeException('User not authenticated.');
196
        }
197
198
        $payload = [
199
            'model' => $this->model,
200
            'messages' => [
201
                ['role' => 'system', 'content' => 'You are a helpful AI assistant that generates structured educational content.'],
202
                ['role' => 'user', 'content' => $prompt],
203
            ],
204
            'temperature' => $this->temperature,
205
            'max_tokens' => 1000,
206
        ];
207
208
        try {
209
            $response = $this->httpClient->request('POST', $this->apiUrl, [
210
                'headers' => [
211
                    'Authorization' => 'Bearer '.$this->apiKey,
212
                    'Content-Type' => 'application/json',
213
                ],
214
                'json' => $payload,
215
            ]);
216
217
            $statusCode = $response->getStatusCode();
218
            $data = $response->toArray();
219
220
            if (200 === $statusCode && isset($data['choices'][0]['message']['content'])) {
221
                $generatedContent = $data['choices'][0]['message']['content'];
222
223
                $aiRequest = new AiRequests();
224
                $aiRequest->setUserId($userId)
225
                    ->setToolName($toolName)
226
                    ->setRequestText($prompt)
227
                    ->setPromptTokens($data['usage']['prompt_tokens'] ?? 0)
228
                    ->setCompletionTokens($data['usage']['completion_tokens'] ?? 0)
229
                    ->setTotalTokens($data['usage']['total_tokens'] ?? 0)
230
                    ->setAiProvider('openai')
231
                ;
232
233
                $this->aiRequestsRepository->save($aiRequest);
234
235
                return $generatedContent;
236
            }
237
238
            return null;
239
        } catch (Exception $e) {
240
            error_log('ERROR - OpenAI Request failed: '.$e->getMessage());
241
242
            return null;
243
        }
244
    }
245
246
    private function getUserId(): ?int
247
    {
248
        $user = $this->security->getUser();
249
250
        return $user instanceof UserInterface ? $user->getId() : null;
251
    }
252
}
253