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

DeepSeekProvider::__construct()   B

Complexity

Conditions 7
Paths 16

Size

Total Lines 29
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

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