diff --git a/README.md b/README.md index 02d5929..9c5104c 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.41.0). +W Joomla wybierz **System → Install → Install from Folder** i wskaż ten katalog. Manifestem instalacyjnym jest `com_testquestions.xml` (wersja 1.42.0). 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 93aa779..9553d6a 100644 --- a/administrator/components/com_testquestions/language/en-GB/com_testquestions.ini +++ b/administrator/components/com_testquestions/language/en-GB/com_testquestions.ini @@ -62,6 +62,14 @@ COM_TESTQUESTIONS_SET_REORDER_INFO="Przeciągnij pytania, aby zmienić ich kolej COM_TESTQUESTIONS_SET_SAVE="Zapisz" COM_TESTQUESTIONS_SET_SAVE_AND_CLOSE="Zapisz i zamknij" COM_TESTQUESTIONS_SET_VARIANT_DURATION_INFO="Czas testu jest pobierany z zestawu nadrzędnego." +COM_TESTQUESTIONS_AI_SETTINGS="Ustawienia AI" +COM_TESTQUESTIONS_AI_PROVIDER="Dostawca AI" +COM_TESTQUESTIONS_AI_API_KEY="Klucz API" +COM_TESTQUESTIONS_AI_SETTINGS_MENU="Konfiguracja AI" +COM_TESTQUESTIONS_AI_EVALUATE="AI" +COM_TESTQUESTIONS_AI_RESULT="Ocena AI" +COM_TESTQUESTIONS_AI_NOT_CONFIGURED="Skonfiguruj dostawcę i klucz API w ustawieniach komponentu." +COM_TESTQUESTIONS_AI_ERROR="Nie udało się uzyskać odpowiedzi AI: %s" COM_TESTQUESTIONS_SET_DESCRIPTION="Opis zestawu" COM_TESTQUESTIONS_SET_QUESTIONS="Pytania w zestawie" COM_TESTQUESTIONS_SET_QUESTION_COUNT="Liczba pytań" diff --git a/administrator/components/com_testquestions/sql/updates/mysql/1.42.0.sql b/administrator/components/com_testquestions/sql/updates/mysql/1.42.0.sql new file mode 100644 index 0000000..3d79535 --- /dev/null +++ b/administrator/components/com_testquestions/sql/updates/mysql/1.42.0.sql @@ -0,0 +1 @@ +-- AI feedback is stored in the existing answers JSON column. diff --git a/administrator/components/com_testquestions/src/Controller/ResultController.php b/administrator/components/com_testquestions/src/Controller/ResultController.php index 69f2547..ed457f7 100644 --- a/administrator/components/com_testquestions/src/Controller/ResultController.php +++ b/administrator/components/com_testquestions/src/Controller/ResultController.php @@ -32,6 +32,34 @@ class ResultController extends BaseController ); } + public function ai(): void + { + if (!Session::checkToken()) { + throw new \RuntimeException(Text::_('JINVALID_TOKEN'), 403); + } + + $input = Factory::getApplication()->getInput(); + $id = $input->post->getInt('id'); + $questionId = $input->post->getInt('ai_question_id'); + $message = Text::_('COM_TESTQUESTIONS_AI_RESULT'); + $type = 'message'; + + try { + $this->getModel('Result')->evaluateWithAi($id, $questionId); + } catch (\Throwable $error) { + $message = $error->getMessage() === 'AI_NOT_CONFIGURED' + ? Text::_('COM_TESTQUESTIONS_AI_NOT_CONFIGURED') + : Text::sprintf('COM_TESTQUESTIONS_AI_ERROR', $error->getMessage()); + $type = 'error'; + } + + $this->setRedirect( + Route::_('index.php?option=com_testquestions&view=result&id=' . $id, false), + $message, + $type + ); + } + private function saveAssessment(): void { if (!Session::checkToken()) { diff --git a/administrator/components/com_testquestions/src/Model/ResultModel.php b/administrator/components/com_testquestions/src/Model/ResultModel.php index 5023377..2fc065c 100644 --- a/administrator/components/com_testquestions/src/Model/ResultModel.php +++ b/administrator/components/com_testquestions/src/Model/ResultModel.php @@ -6,6 +6,8 @@ defined('_JEXEC') or die; use Joomla\CMS\Factory; use Joomla\CMS\MVC\Model\BaseDatabaseModel; +use Joomla\CMS\Component\ComponentHelper; +use Komponent\Component\Testquestions\Administrator\Service\AiService; class ResultModel extends BaseDatabaseModel { @@ -72,4 +74,42 @@ class ResultModel extends BaseDatabaseModel return (bool) $db->updateObject('#__testquestions_results', $row, 'id'); } + + public function evaluateWithAi(int $id, int $questionId): string + { + $item = $this->getItem($id); + + if (!$item) { + throw new \RuntimeException('Nie znaleziono zapisanego testu.'); + } + + $answer = $item->answers[$questionId] ?? null; + $question = $item->questions[$questionId] ?? null; + + if (!$item || !$answer || !$question || $question->question_type !== 'open' || !empty($answer['correct'])) { + throw new \RuntimeException('Nieprawidłowe pytanie do oceny AI.'); + } + + $params = ComponentHelper::getParams('com_testquestions'); + $feedback = (new AiService())->evaluate( + (string) $params->get('ai_provider', 'openai'), + (string) $params->get('ai_api_key', ''), + strip_tags((string) $question->question), + (string) ($answer['selected'] ?? ''), + (string) $question->answer_open + ); + $item->answers[$questionId]['ai_feedback'] = $feedback; + + $db = Factory::getContainer()->get('DatabaseDriver'); + $row = (object) [ + 'id' => $id, + 'answers' => json_encode($item->answers, JSON_UNESCAPED_UNICODE), + ]; + + if (!$db->updateObject('#__testquestions_results', $row, 'id')) { + throw new \RuntimeException('Nie udało się zapisać oceny AI.'); + } + + return $feedback; + } } diff --git a/administrator/components/com_testquestions/src/Service/AiService.php b/administrator/components/com_testquestions/src/Service/AiService.php new file mode 100644 index 0000000..6b13b35 --- /dev/null +++ b/administrator/components/com_testquestions/src/Service/AiService.php @@ -0,0 +1,74 @@ + 'https://api.x.ai/v1/responses', 'model' => 'grok-4.7'] + : ['url' => 'https://api.openai.com/v1/responses', 'model' => 'gpt-4.1-mini']; + + if (!in_array($provider, ['openai', 'grok'], true) || trim($apiKey) === '') { + throw new \RuntimeException('AI_NOT_CONFIGURED'); + } + + $input = "Oceń odpowiedź ucznia na pytanie otwarte.\n\n" + . "Pytanie:\n" . $question . "\n\n" + . "Wzorzec odpowiedzi:\n" . $pattern . "\n\n" + . "Odpowiedź ucznia:\n" . ($answer !== '' ? $answer : 'Brak odpowiedzi') . "\n\n" + . "Napisz po polsku krótką ocenę: czy odpowiedź może zostać uznana za poprawną, jakie elementy są poprawne lub błędne oraz czy warto przyznać punkty. Nie zmieniaj wzorca odpowiedzi."; + + $payload = json_encode([ + 'model' => $config['model'], + 'input' => $input, + 'temperature' => 0.1, + ], JSON_UNESCAPED_UNICODE); + + $response = HttpFactory::getHttp()->post( + $config['url'], + $payload, + [ + 'Authorization' => 'Bearer ' . trim($apiKey), + 'Content-Type' => 'application/json', + ], + 60 + ); + $body = json_decode((string) $response->body, true); + + if ((int) $response->code < 200 || (int) $response->code >= 300) { + throw new \RuntimeException((string) ($body['error']['message'] ?? ('HTTP ' . (int) $response->code))); + } + + $text = $this->extractText($body); + if ($text === '') { + throw new \RuntimeException('Brak treści odpowiedzi AI.'); + } + + return $text; + } + + private function extractText(array $body): string + { + if (!empty($body['output_text']) && is_string($body['output_text'])) { + return trim($body['output_text']); + } + + $parts = []; + foreach ((array) ($body['output'] ?? []) as $output) { + foreach ((array) ($output['content'] ?? []) as $content) { + if (!empty($content['text']) && is_string($content['text'])) { + $parts[] = $content['text']; + } + } + } + + return trim(implode("\n", $parts)); + } +} diff --git a/administrator/components/com_testquestions/src/View/Questions/HtmlView.php b/administrator/components/com_testquestions/src/View/Questions/HtmlView.php index 2e2c729..e3defce 100644 --- a/administrator/components/com_testquestions/src/View/Questions/HtmlView.php +++ b/administrator/components/com_testquestions/src/View/Questions/HtmlView.php @@ -63,5 +63,11 @@ class HtmlView extends BaseHtmlView 'COM_TESTQUESTIONS_RESULTS', 'list' ); + + ToolbarHelper::link( + 'index.php?option=com_config&view=component&component=com_testquestions', + 'COM_TESTQUESTIONS_AI_SETTINGS_MENU', + 'cog' + ); } } diff --git a/administrator/components/com_testquestions/tmpl/result/default.php b/administrator/components/com_testquestions/tmpl/result/default.php index 8c839dd..0a6021d 100644 --- a/administrator/components/com_testquestions/tmpl/result/default.php +++ b/administrator/components/com_testquestions/tmpl/result/default.php @@ -38,6 +38,16 @@
+ question_type === 'open') : ?> +