diff --git a/README.md b/README.md index 8011a23..cfc7a58 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,6 @@ Szkielet komponentu Joomla 6 do gromadzenia pytań testowych. ## Instalacja -W Joomla wybierz **System → Install → Install from Folder** i wskaż ten katalog. Manifestem instalacyjnym jest `com_testquestions.xml` (wersja 1.43.4). +W Joomla wybierz **System → Install → Install from Folder** i wskaż ten katalog. Manifestem instalacyjnym jest `com_testquestions.xml` (wersja 1.43.5). Po instalacji komponent jest dostępny w panelu administratora jako **Pytania testowe**. diff --git a/administrator/components/com_testquestions/language/en-GB/com_testquestions.ini b/administrator/components/com_testquestions/language/en-GB/com_testquestions.ini index 73d7144..18c7a6f 100644 --- a/administrator/components/com_testquestions/language/en-GB/com_testquestions.ini +++ b/administrator/components/com_testquestions/language/en-GB/com_testquestions.ini @@ -67,6 +67,9 @@ COM_TESTQUESTIONS_AI_PROVIDER="Dostawca AI" COM_TESTQUESTIONS_AI_API_KEY="Klucz API" COM_TESTQUESTIONS_AI_OPENAI_KEY="Klucz API OpenAI" COM_TESTQUESTIONS_AI_GROK_KEY="Klucz API Grok" +COM_TESTQUESTIONS_AI_OPENAI_MODEL="Model OpenAI" +COM_TESTQUESTIONS_AI_GROK_MODEL="Model Grok" +COM_TESTQUESTIONS_AI_MODEL_INVALID="Wybierz prawidłowy model dla każdego dostawcy AI." COM_TESTQUESTIONS_AI_SETTINGS_MENU="Konfiguracja AI" COM_TESTQUESTIONS_AI_SETTINGS_SAVED="Ustawienia AI zapisano." COM_TESTQUESTIONS_AI_SAVE_FAILED="Nie udało się zapisać ustawień AI." diff --git a/administrator/components/com_testquestions/sql/updates/mysql/1.43.5.sql b/administrator/components/com_testquestions/sql/updates/mysql/1.43.5.sql new file mode 100644 index 0000000..70d72c4 --- /dev/null +++ b/administrator/components/com_testquestions/sql/updates/mysql/1.43.5.sql @@ -0,0 +1 @@ +-- No database changes required for version 1.43.5. diff --git a/administrator/components/com_testquestions/src/Controller/AiController.php b/administrator/components/com_testquestions/src/Controller/AiController.php index 265442f..d750c84 100644 --- a/administrator/components/com_testquestions/src/Controller/AiController.php +++ b/administrator/components/com_testquestions/src/Controller/AiController.php @@ -9,6 +9,7 @@ use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\Controller\BaseController; use Joomla\CMS\Router\Route; use Joomla\CMS\Session\Session; +use Komponent\Component\Testquestions\Administrator\Service\AiService; class AiController extends BaseController { @@ -29,6 +30,16 @@ class AiController extends BaseController throw new \RuntimeException(Text::_('COM_TESTQUESTIONS_AI_PROVIDER_INVALID')); } + $models = AiService::availableModels(); + $selectedModels = []; + foreach (['openai', 'grok'] as $modelProvider) { + $model = $input->post->getCmd('ai_' . $modelProvider . '_model', ''); + if (!isset($models[$modelProvider][$model])) { + throw new \RuntimeException(Text::_('COM_TESTQUESTIONS_AI_MODEL_INVALID')); + } + $selectedModels[$modelProvider] = $model; + } + $db = Factory::getContainer()->get('DatabaseDriver'); $row = $db->setQuery( $db->getQuery(true) @@ -47,6 +58,9 @@ class AiController extends BaseController $legacyKey = trim((string) ($settings['ai_api_key'] ?? '')); $settings['ai_provider'] = $provider; + foreach ($selectedModels as $modelProvider => $model) { + $settings['ai_' . $modelProvider . '_model'] = $model; + } foreach (['openai', 'grok'] as $keyProvider) { $fieldName = 'ai_' . $keyProvider . '_api_key'; $apiKey = trim((string) $input->post->get($fieldName, '', 'RAW')); diff --git a/administrator/components/com_testquestions/src/Model/ResultModel.php b/administrator/components/com_testquestions/src/Model/ResultModel.php index 0989532..c549a0b 100644 --- a/administrator/components/com_testquestions/src/Model/ResultModel.php +++ b/administrator/components/com_testquestions/src/Model/ResultModel.php @@ -96,6 +96,8 @@ class ResultModel extends BaseDatabaseModel $keyName = 'ai_' . $provider . '_api_key'; $apiKey = trim((string) $params->get($keyName, $params->get('ai_api_key', ''))); $componentKeyLength = mb_strlen($apiKey); + $defaultModels = ['openai' => 'gpt-4.1-mini', 'grok' => 'grok-4.7']; + $model = trim((string) $params->get('ai_' . $provider . '_model', $defaultModels[$provider] ?? '')); // Odczytaj zapisane parametry bezpośrednio z bazy. Dzięki temu nie użyjemy // nieaktualnej wartości z pamięci ComponentHelper, a maska pola hasła @@ -123,6 +125,12 @@ class ResultModel extends BaseDatabaseModel $keyName = 'ai_' . $provider . '_api_key'; $apiKey = trim((string) $params->get($keyName, $params->get('ai_api_key', ''))); $componentKeyLength = mb_strlen($apiKey); + $model = trim((string) (is_array($storedParams) + ? ($storedParams['ai_' . $provider . '_model'] ?? $params->get('ai_' . $provider . '_model', $defaultModels[$provider] ?? '')) + : $params->get('ai_' . $provider . '_model', $defaultModels[$provider] ?? ''))); + if ($model === '') { + $model = $defaultModels[$provider] ?? ''; + } $storedApiKey = is_array($storedParams) ? trim((string) ($storedParams[$keyName] ?? $storedParams['ai_api_key'] ?? '')) : ''; @@ -144,6 +152,7 @@ class ResultModel extends BaseDatabaseModel $aiResult = (new AiService())->evaluate( $provider, $apiKey, + $model, strip_tags((string) $question->question), (string) ($answer['selected'] ?? ''), (string) $question->answer_open, diff --git a/administrator/components/com_testquestions/src/Service/AiService.php b/administrator/components/com_testquestions/src/Service/AiService.php index 0be68c1..2cc084a 100644 --- a/administrator/components/com_testquestions/src/Service/AiService.php +++ b/administrator/components/com_testquestions/src/Service/AiService.php @@ -8,13 +8,30 @@ use Joomla\CMS\Http\HttpFactory; class AiService { - public function evaluate(string $provider, string $apiKey, string $question, string $answer, string $pattern, int $maxPoints): array + public static function availableModels(): array + { + return [ + 'openai' => [ + 'gpt-4.1-mini' => 'GPT-4.1 mini (starszy)', + 'gpt-5.6-luna' => 'GPT-5.6 Luna', + 'gpt-5.6-terra' => 'GPT-5.6 Terra', + 'gpt-5.6-sol' => 'GPT-5.6 Sol', + ], + 'grok' => [ + 'grok-4.7' => 'Grok 4.7', + 'grok-4.20' => 'Grok 4.20 Reasoning', + 'grok-4.20-non-reasoning' => 'Grok 4.20 Non-Reasoning', + ], + ]; + } + + public function evaluate(string $provider, string $apiKey, string $model, string $question, string $answer, string $pattern, int $maxPoints): array { $provider = strtolower(trim($provider)); $provider = preg_replace('/[^a-z]/', '', $provider); $config = $provider === 'grok' - ? ['url' => 'https://api.x.ai/v1/responses', 'model' => 'grok-4.7'] - : ['url' => 'https://api.openai.com/v1/responses', 'model' => 'gpt-4.1-mini']; + ? ['url' => 'https://api.x.ai/v1/responses'] + : ['url' => 'https://api.openai.com/v1/responses']; if (trim($apiKey) === '') { throw new \RuntimeException('AI_NOT_CONFIGURED'); @@ -24,6 +41,10 @@ class AiService throw new \RuntimeException('Nieprawidłowy dostawca AI: ' . $provider); } + if (!isset(self::availableModels()[$provider][$model])) { + throw new \RuntimeException('Wybrany model AI nie jest obsługiwany.'); + } + $input = "Oceń odpowiedź ucznia na pytanie otwarte i ustal liczbę uznanych punktów.\n" . "Maksymalna liczba punktów za to pytanie: " . max(0, $maxPoints) . ".\n" . "Przyznaj liczbę całkowitą od 0 do maksymalnej liczby punktów. Przyznaj punkty proporcjonalnie za poprawne elementy odpowiedzi; pełne punkty za komplet, część punktów za częściową odpowiedź. Nie przekraczaj limitu.\n\n" @@ -32,11 +53,15 @@ class AiService . "Odpowiedź ucznia:\n" . ($answer !== '' ? $answer : 'Brak odpowiedzi') . "\n\n" . "Zwróć wyłącznie poprawny obiekt JSON w formacie {\"awarded_points\": liczba_całkowita, \"feedback\": \"krótka ocena po polsku\"}. W ocenie opisz poprawne i brakujące elementy. Nie zmieniaj wzorca odpowiedzi."; - $payload = json_encode([ - 'model' => $config['model'], + $requestData = [ + 'model' => $model, 'input' => $input, - 'temperature' => 0.1, - ], JSON_UNESCAPED_UNICODE); + ]; + // GPT-5.x reasoning models do not accept a custom temperature. + if ($provider === 'grok' || $model === 'gpt-4.1-mini') { + $requestData['temperature'] = 0.1; + } + $payload = json_encode($requestData, JSON_UNESCAPED_UNICODE); $response = HttpFactory::getHttp()->post( $config['url'], @@ -73,7 +98,11 @@ class AiService throw new \RuntimeException('AI zwróciło nieprawidłowy format oceny i punktów.'); } - $usage = $this->getUsage($body, $provider, $config['model']); + $actualModel = (string) ($body['model'] ?? $model); + $usage = $this->getUsage($body, $provider, $model); + if ($usage !== null) { + $usage['model'] = $actualModel; + } return [ 'awarded_points' => min(max(0, $maxPoints), max(0, (int) $result['awarded_points'])), @@ -98,9 +127,12 @@ class AiService if ($provider === 'grok' && isset($usage['cost_in_usd_ticks']) && is_numeric($usage['cost_in_usd_ticks'])) { $cost = (float) $usage['cost_in_usd_ticks'] / 10000000000; $costSource = 'provider'; - } elseif ($provider === 'openai' && $model === 'gpt-4.1-mini') { + } elseif ($provider === 'openai' && isset($this->openAiRates()[$model])) { + [$inputRate, $cachedRate, $outputRate, $cacheWriteRate] = $this->openAiRates()[$model]; $uncachedTokens = $inputTokens - $cachedTokens; - $cost = ($uncachedTokens * 0.40 + $cachedTokens * 0.10 + $outputTokens * 1.60) / 1000000; + $cacheWriteTokens = min($uncachedTokens, max(0, (int) ($usage['input_tokens_details']['cache_write_tokens'] ?? 0))); + $uncachedTokens -= $cacheWriteTokens; + $cost = ($uncachedTokens * $inputRate + $cachedTokens * $cachedRate + $cacheWriteTokens * $cacheWriteRate + $outputTokens * $outputRate) / 1000000; $costSource = 'token_rates'; } @@ -116,6 +148,16 @@ class AiService ]; } + private function openAiRates(): array + { + return [ + 'gpt-4.1-mini' => [0.40, 0.10, 1.60, 0.40], + 'gpt-5.6-luna' => [0.20, 0.02, 1.20, 0.25], + 'gpt-5.6-terra' => [2.00, 0.20, 12.00, 2.50], + 'gpt-5.6-sol' => [4.00, 0.40, 20.00, 5.00], + ]; + } + private function extractText(array $body): string { if (!empty($body['output_text']) && is_string($body['output_text'])) { diff --git a/administrator/components/com_testquestions/tmpl/ai/default.php b/administrator/components/com_testquestions/tmpl/ai/default.php index ee16a4f..d27db32 100644 --- a/administrator/components/com_testquestions/tmpl/ai/default.php +++ b/administrator/components/com_testquestions/tmpl/ai/default.php @@ -1,10 +1,13 @@ + settings['ai_provider'] ?? 'openai'); ?> settings['ai_api_key'] ?? ''); ?> settings['ai_openai_api_key'] ?? ($provider === 'openai' ? $legacyKey : '')); ?> settings['ai_grok_api_key'] ?? ($provider === 'grok' ? $legacyKey : '')); ?> +settings['ai_openai_model'] ?? 'gpt-4.1-mini'); ?> +settings['ai_grok_model'] ?? 'grok-4.7'); ?>
@@ -15,6 +18,14 @@
+
+ + +
@@ -35,6 +46,14 @@
+
+ + +
diff --git a/com_testquestions.xml b/com_testquestions.xml index 95ae391..11f8420 100644 --- a/com_testquestions.xml +++ b/com_testquestions.xml @@ -4,7 +4,7 @@ 2026-10-14 Komponent GNU General Public License version 2 or later - 1.43.4 + 1.43.5 COM_TESTQUESTIONS_DESCRIPTION com_testquestions Komponent\Component\Testquestions