Passed
Pull Request — master (#6087)
by
unknown
08:38
created

OpenAiProvider::generateQuestions()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 18
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

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