Passed
Pull Request — master (#7304)
by Yannick
09:41
created

DeepSeekProvider   A

Complexity

Total Complexity 27

Size/Duplication

Total Lines 247
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 152
c 0
b 0
f 0
dl 0
loc 247
rs 10
wmc 27

7 Methods

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