Compare commits

...
10 Commits
23 changed files with 373 additions and 20 deletions
+1 -1
View File
@@ -30,6 +30,6 @@ Szkielet komponentu Joomla 6 do gromadzenia pytań testowych.
## Instalacja ## Instalacja
W Joomla wybierz **System → Install → Install from Folder** i wskaż ten katalog. Manifestem instalacyjnym jest `com_testquestions.xml` (wersja 1.42.0). W Joomla wybierz **System → Install → Install from Folder** i wskaż ten katalog. Manifestem instalacyjnym jest `com_testquestions.xml` (wersja 1.43.0).
Po instalacji komponent jest dostępny w panelu administratora jako **Pytania testowe**. Po instalacji komponent jest dostępny w panelu administratora jako **Pytania testowe**.
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<config>
<fields name="params">
<fieldset name="basic" label="COM_TESTQUESTIONS_AI_SETTINGS">
<field name="ai_provider" type="list" label="COM_TESTQUESTIONS_AI_PROVIDER">
<option value="openai">OpenAI</option>
<option value="grok">Grok</option>
</field>
<field name="ai_api_key" type="aiapikey" addfieldprefix="Komponent\Component\Testquestions\Administrator\Field" label="COM_TESTQUESTIONS_AI_API_KEY" filter="raw" autocomplete="off" size="120" class="w-100" />
</fieldset>
</fields>
</config>
@@ -66,6 +66,13 @@ COM_TESTQUESTIONS_AI_SETTINGS="Ustawienia AI"
COM_TESTQUESTIONS_AI_PROVIDER="Dostawca AI" COM_TESTQUESTIONS_AI_PROVIDER="Dostawca AI"
COM_TESTQUESTIONS_AI_API_KEY="Klucz API" COM_TESTQUESTIONS_AI_API_KEY="Klucz API"
COM_TESTQUESTIONS_AI_SETTINGS_MENU="Konfiguracja 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."
COM_TESTQUESTIONS_AI_PROVIDER_INVALID="Wybierz prawidłowego dostawcę AI."
COM_TESTQUESTIONS_AI_EVALUATE_ALL="AI — oceń wszystkie niezaliczone pytania otwarte (%s)"
COM_TESTQUESTIONS_AI_BULK_DONE="AI oceniło %s pytań."
COM_TESTQUESTIONS_AI_BULK_PARTIAL="AI oceniło %s pytań; nie udało się ocenić %s. Błąd: %s"
COM_TESTQUESTIONS_AI_NO_OPEN_QUESTIONS="Brak niezaliczonych pytań otwartych do oceny AI."
COM_TESTQUESTIONS_AI_EVALUATE="AI" COM_TESTQUESTIONS_AI_EVALUATE="AI"
COM_TESTQUESTIONS_AI_RESULT="Ocena AI" COM_TESTQUESTIONS_AI_RESULT="Ocena AI"
COM_TESTQUESTIONS_AI_NOT_CONFIGURED="Skonfiguruj dostawcę i klucz API w ustawieniach komponentu." COM_TESTQUESTIONS_AI_NOT_CONFIGURED="Skonfiguruj dostawcę i klucz API w ustawieniach komponentu."
@@ -0,0 +1 @@
-- No database changes required for version 1.42.1.
@@ -0,0 +1 @@
-- No database changes required for version 1.42.2.
@@ -0,0 +1 @@
-- No database changes required for version 1.42.3.
@@ -0,0 +1 @@
-- No database changes required for version 1.42.4.
@@ -0,0 +1 @@
-- No database changes required for version 1.42.5.
@@ -0,0 +1 @@
-- No database changes required for version 1.42.6.
@@ -0,0 +1 @@
-- No database changes required for version 1.42.7.
@@ -0,0 +1 @@
-- No database changes required for version 1.42.8.
@@ -0,0 +1 @@
-- No database changes required for version 1.42.9.
@@ -0,0 +1 @@
-- No database changes required for version 1.43.0.
@@ -0,0 +1,73 @@
<?php
namespace Komponent\Component\Testquestions\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
class AiController extends BaseController
{
public function save(): void
{
if (!Session::checkToken()) {
throw new \RuntimeException(Text::_('JINVALID_TOKEN'), 403);
}
$user = Factory::getApplication()->getIdentity();
if (!$user->authorise('core.admin', 'com_testquestions') && !$user->authorise('core.options', 'com_testquestions')) {
throw new \RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
}
$input = Factory::getApplication()->getInput();
$provider = strtolower($input->post->getCmd('ai_provider', ''));
if (!in_array($provider, ['openai', 'grok'], true)) {
throw new \RuntimeException(Text::_('COM_TESTQUESTIONS_AI_PROVIDER_INVALID'));
}
$db = Factory::getContainer()->get('DatabaseDriver');
$row = $db->setQuery(
$db->getQuery(true)
->select([$db->quoteName('extension_id'), $db->quoteName('params')])
->from($db->quoteName('#__extensions'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
->where($db->quoteName('element') . ' = ' . $db->quote('com_testquestions'))
)->loadObject();
if (!$row) {
throw new \RuntimeException(Text::_('COM_TESTQUESTIONS_AI_SAVE_FAILED'));
}
$settings = json_decode((string) $row->params, true);
$settings = is_array($settings) ? $settings : [];
$apiKey = trim((string) $input->post->get('ai_api_key', '', 'RAW'));
$settings['ai_provider'] = $provider;
if ($apiKey !== '') {
$settings['ai_api_key'] = $apiKey;
}
$extension = (object) [
'extension_id' => (int) $row->extension_id,
'params' => json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
];
if (!$db->updateObject('#__extensions', $extension, 'extension_id')) {
throw new \RuntimeException(Text::_('COM_TESTQUESTIONS_AI_SAVE_FAILED'));
}
$this->setRedirect(
Route::_('index.php?option=com_testquestions&view=ai', false),
Text::_('COM_TESTQUESTIONS_AI_SETTINGS_SAVED')
);
}
public function cancel(): void
{
$this->setRedirect(Route::_('index.php?option=com_testquestions&view=questions', false));
}
}
@@ -47,9 +47,53 @@ class ResultController extends BaseController
try { try {
$this->getModel('Result')->evaluateWithAi($id, $questionId); $this->getModel('Result')->evaluateWithAi($id, $questionId);
} catch (\Throwable $error) { } catch (\Throwable $error) {
$message = $error->getMessage() === 'AI_NOT_CONFIGURED' $errorMessage = $error->getMessage();
? Text::_('COM_TESTQUESTIONS_AI_NOT_CONFIGURED') $message = str_starts_with($errorMessage, 'AI_KEY_EMPTY|')
: Text::sprintf('COM_TESTQUESTIONS_AI_ERROR', $error->getMessage()); ? 'Diagnostyka konfiguracji AI (1.42.8): ' . substr($errorMessage, strlen('AI_KEY_EMPTY|'))
: ($errorMessage === 'AI_NOT_CONFIGURED'
? Text::_('COM_TESTQUESTIONS_AI_NOT_CONFIGURED')
: Text::sprintf('COM_TESTQUESTIONS_AI_ERROR', $errorMessage));
$type = 'error';
}
$this->setRedirect(
Route::_('index.php?option=com_testquestions&view=result&id=' . $id, false),
$message,
$type
);
}
public function aiAll(): void
{
if (!Session::checkToken()) {
throw new \RuntimeException(Text::_('JINVALID_TOKEN'), 403);
}
$input = Factory::getApplication()->getInput();
$id = $input->post->getInt('id');
$message = '';
$type = 'message';
try {
$summary = $this->getModel('Result')->evaluateAllWithAi($id);
if ($summary['failed'] > 0) {
$message = Text::sprintf(
'COM_TESTQUESTIONS_AI_BULK_PARTIAL',
$summary['evaluated'],
$summary['failed'],
$summary['first_error']
);
$type = 'warning';
} else {
$message = Text::sprintf('COM_TESTQUESTIONS_AI_BULK_DONE', $summary['evaluated']);
}
} catch (\Throwable $error) {
$errorMessage = $error->getMessage();
$message = $errorMessage === 'AI_NO_OPEN_QUESTIONS'
? Text::_('COM_TESTQUESTIONS_AI_NO_OPEN_QUESTIONS')
: ($errorMessage === 'AI_NOT_CONFIGURED'
? Text::_('COM_TESTQUESTIONS_AI_NOT_CONFIGURED')
: Text::sprintf('COM_TESTQUESTIONS_AI_ERROR', $errorMessage));
$type = 'error'; $type = 'error';
} }
@@ -0,0 +1,35 @@
<?php
namespace Komponent\Component\Testquestions\Administrator\Field;
defined('_JEXEC') or die;
use Joomla\CMS\Form\Field\PasswordField;
class AiapikeyField extends PasswordField
{
protected $type = 'Aiapikey';
protected function getInput(): string
{
$id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8');
$length = mb_strlen((string) $this->value);
return '<div class="input-group">'
. parent::getInput()
. '<button type="button" class="btn btn-outline-secondary" data-ai-key-toggle="' . $id . '">Pokaż</button>'
. '</div>'
. '<small class="form-text text-muted">Klucz: ' . (int) $length . ' znaków.</small>'
. '<script>
document.addEventListener("click", function (event) {
var button = event.target.closest("[data-ai-key-toggle]");
if (!button) return;
var input = document.getElementById(button.getAttribute("data-ai-key-toggle"));
if (!input) return;
var visible = input.type === "text";
input.type = visible ? "password" : "text";
button.textContent = visible ? "Pokaż" : "Ukryj";
});
</script>';
}
}
@@ -91,9 +91,50 @@ class ResultModel extends BaseDatabaseModel
} }
$params = ComponentHelper::getParams('com_testquestions'); $params = ComponentHelper::getParams('com_testquestions');
$provider = trim((string) $params->get('ai_provider', 'openai'));
$apiKey = trim((string) $params->get('ai_api_key', ''));
$componentKeyLength = mb_strlen($apiKey);
// 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
// (np. ********) nie zostanie potraktowana jako prawdziwy klucz.
$db = Factory::getContainer()->get('DatabaseDriver');
$storedParams = (string) $db->setQuery(
$db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
->where($db->quoteName('element') . ' = ' . $db->quote('com_testquestions'))
)->loadResult();
$storedParams = json_decode($storedParams, true);
if (is_array($storedParams)) {
$storedProvider = trim((string) ($storedParams['ai_provider'] ?? ''));
$storedApiKey = trim((string) ($storedParams['ai_api_key'] ?? ''));
if ($storedProvider !== '') {
$provider = $storedProvider;
}
if ($storedApiKey !== '' && !preg_match('/^\*+$/', $storedApiKey)) {
$apiKey = $storedApiKey;
}
}
if (trim($apiKey) === '') {
$databaseKey = is_array($storedParams) ? trim((string) ($storedParams['ai_api_key'] ?? '')) : '';
$databaseKeyLength = preg_match('/^\*+$/', $databaseKey) ? 0 : mb_strlen($databaseKey);
throw new \RuntimeException(
'AI_KEY_EMPTY|provider=' . ($provider !== '' ? $provider : '(pusty)')
. '|component_chars=' . $componentKeyLength
. '|database_chars=' . $databaseKeyLength
. '|database_params=' . (is_array($storedParams) ? 'json' : 'unreadable')
);
}
$feedback = (new AiService())->evaluate( $feedback = (new AiService())->evaluate(
(string) $params->get('ai_provider', 'openai'), $provider,
(string) $params->get('ai_api_key', ''), $apiKey,
strip_tags((string) $question->question), strip_tags((string) $question->question),
(string) ($answer['selected'] ?? ''), (string) ($answer['selected'] ?? ''),
(string) $question->answer_open (string) $question->answer_open
@@ -112,4 +153,51 @@ class ResultModel extends BaseDatabaseModel
return $feedback; return $feedback;
} }
public function evaluateAllWithAi(int $id): array
{
$item = $this->getItem($id);
if (!$item) {
throw new \RuntimeException('Nie znaleziono zapisanego testu.');
}
$questionIds = [];
foreach ($item->answers as $questionId => $answer) {
$question = $item->questions[(int) $questionId] ?? null;
if ($question && $question->question_type === 'open' && empty($answer['correct'])) {
$questionIds[] = (int) $questionId;
}
}
if (!$questionIds) {
throw new \RuntimeException('AI_NO_OPEN_QUESTIONS');
}
$evaluated = 0;
$failed = 0;
$firstError = '';
foreach ($questionIds as $questionId) {
try {
$this->evaluateWithAi($id, $questionId);
$evaluated++;
} catch (\Throwable $error) {
$failed++;
$firstError = $firstError ?: $error->getMessage();
if (in_array($error->getMessage(), ['AI_NOT_CONFIGURED'], true)
|| str_starts_with($error->getMessage(), 'AI_KEY_EMPTY|')) {
break;
}
}
}
return [
'evaluated' => $evaluated,
'failed' => $failed,
'first_error' => $firstError,
'total' => count($questionIds),
];
}
} }
@@ -11,14 +11,19 @@ class AiService
public function evaluate(string $provider, string $apiKey, string $question, string $answer, string $pattern): string public function evaluate(string $provider, string $apiKey, string $question, string $answer, string $pattern): string
{ {
$provider = strtolower(trim($provider)); $provider = strtolower(trim($provider));
$provider = preg_replace('/[^a-z]/', '', $provider);
$config = $provider === 'grok' $config = $provider === 'grok'
? ['url' => 'https://api.x.ai/v1/responses', 'model' => 'grok-4.7'] ? ['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.openai.com/v1/responses', 'model' => 'gpt-4.1-mini'];
if (!in_array($provider, ['openai', 'grok'], true) || trim($apiKey) === '') { if (trim($apiKey) === '') {
throw new \RuntimeException('AI_NOT_CONFIGURED'); throw new \RuntimeException('AI_NOT_CONFIGURED');
} }
if (!in_array($provider, ['openai', 'grok'], true)) {
throw new \RuntimeException('Nieprawidłowy dostawca AI: ' . $provider);
}
$input = "Oceń odpowiedź ucznia na pytanie otwarte.\n\n" $input = "Oceń odpowiedź ucznia na pytanie otwarte.\n\n"
. "Pytanie:\n" . $question . "\n\n" . "Pytanie:\n" . $question . "\n\n"
. "Wzorzec odpowiedzi:\n" . $pattern . "\n\n" . "Wzorzec odpowiedzi:\n" . $pattern . "\n\n"
@@ -0,0 +1,33 @@
<?php
namespace Komponent\Component\Testquestions\Administrator\View\Ai;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected array $settings = [];
public function display($tpl = null)
{
$db = Factory::getContainer()->get('DatabaseDriver');
$raw = (string) $db->setQuery(
$db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
->where($db->quoteName('element') . ' = ' . $db->quote('com_testquestions'))
)->loadResult();
$this->settings = json_decode($raw, true) ?: [];
ToolbarHelper::title(Text::_('COM_TESTQUESTIONS_AI_SETTINGS'));
ToolbarHelper::link('index.php?option=com_testquestions&view=questions', 'COM_TESTQUESTIONS_QUESTIONS', 'arrow-left');
return parent::display($tpl);
}
}
@@ -65,7 +65,7 @@ class HtmlView extends BaseHtmlView
); );
ToolbarHelper::link( ToolbarHelper::link(
'index.php?option=com_config&view=component&component=com_testquestions', 'index.php?option=com_testquestions&view=ai',
'COM_TESTQUESTIONS_AI_SETTINGS_MENU', 'COM_TESTQUESTIONS_AI_SETTINGS_MENU',
'cog' 'cog'
); );
@@ -0,0 +1,44 @@
<?php defined('_JEXEC') or die; ?>
<?php use Joomla\CMS\HTML\HTMLHelper; ?>
<?php use Joomla\CMS\Router\Route; ?>
<?php $provider = (string) ($this->settings['ai_provider'] ?? 'openai'); ?>
<?php $apiKey = (string) ($this->settings['ai_api_key'] ?? ''); ?>
<form action="<?php echo Route::_('index.php?option=com_testquestions&view=ai'); ?>" method="post" name="adminForm" id="adminForm">
<div class="card" style="max-width: 900px;">
<div class="card-body">
<div class="mb-3">
<label for="ai-provider" class="form-label"><?php echo \Joomla\CMS\Language\Text::_('COM_TESTQUESTIONS_AI_PROVIDER'); ?></label>
<select id="ai-provider" name="ai_provider" class="form-select" required>
<option value="openai" <?php echo $provider === 'openai' ? 'selected' : ''; ?>>OpenAI</option>
<option value="grok" <?php echo $provider === 'grok' ? 'selected' : ''; ?>>Grok</option>
</select>
</div>
<div class="mb-3">
<label for="ai-api-key" class="form-label"><?php echo \Joomla\CMS\Language\Text::_('COM_TESTQUESTIONS_AI_API_KEY'); ?></label>
<div class="input-group">
<input type="password" id="ai-api-key" name="ai_api_key" value="<?php echo htmlspecialchars($apiKey, ENT_QUOTES, 'UTF-8'); ?>" class="form-control" size="120" maxlength="4096" autocomplete="new-password">
<button type="button" class="btn btn-outline-secondary" id="ai-key-toggle">Pokaż</button>
</div>
<small class="form-text text-muted">
<?php echo $apiKey !== '' ? 'Zapisany klucz: ' . mb_strlen($apiKey) . ' znaków. Pozostaw pole bez zmian, aby zachować klucz.' : 'Nie zapisano jeszcze klucza API.'; ?>
</small>
</div>
<button type="submit" class="btn btn-primary" onclick="this.form.task.value='ai.save';">
<?php echo \Joomla\CMS\Language\Text::_('JTOOLBAR_SAVE'); ?>
</button>
<a class="btn btn-secondary" href="<?php echo Route::_('index.php?option=com_testquestions&view=questions'); ?>">
<?php echo \Joomla\CMS\Language\Text::_('JCANCEL'); ?>
</a>
</div>
</div>
<input type="hidden" name="task" value="ai.save">
<?php echo HTMLHelper::_('form.token'); ?>
</form>
<script>
document.getElementById('ai-key-toggle')?.addEventListener('click', function () {
const input = document.getElementById('ai-api-key');
const visible = input.type === 'text';
input.type = visible ? 'password' : 'text';
this.textContent = visible ? 'Pokaż' : 'Ukryj';
});
</script>
@@ -25,6 +25,18 @@
<dd class="col-sm-9"><?php echo floor((int) $this->item->execution_time / 60); ?>:<?php echo str_pad((string) ((int) $this->item->execution_time % 60), 2, '0', STR_PAD_LEFT); ?></dd> <dd class="col-sm-9"><?php echo floor((int) $this->item->execution_time / 60); ?>:<?php echo str_pad((string) ((int) $this->item->execution_time % 60), 2, '0', STR_PAD_LEFT); ?></dd>
</dl> </dl>
<form action="<?php echo Route::_('index.php?option=com_testquestions&view=result&id=' . (int) $this->item->id); ?>" method="post" name="adminForm" id="adminForm"> <form action="<?php echo Route::_('index.php?option=com_testquestions&view=result&id=' . (int) $this->item->id); ?>" method="post" name="adminForm" id="adminForm">
<?php $pendingAiCount = 0; ?>
<?php foreach ($this->item->answers as $questionId => $answer) : ?>
<?php $candidate = $this->item->questions[(int) $questionId] ?? null; ?>
<?php if ($candidate && $candidate->question_type === 'open' && empty($answer['correct'])) { $pendingAiCount++; } ?>
<?php endforeach; ?>
<?php if ($pendingAiCount > 0) : ?>
<div class="mb-3">
<button type="submit" class="btn btn-primary" formaction="<?php echo Route::_('index.php?option=com_testquestions&task=result.aiAll&id=' . (int) $this->item->id); ?>" onclick="this.form.task.value='result.aiAll';">
<?php echo \Joomla\CMS\Language\Text::sprintf('COM_TESTQUESTIONS_AI_EVALUATE_ALL', $pendingAiCount); ?>
</button>
</div>
<?php endif; ?>
<h2><?php echo \Joomla\CMS\Language\Text::_('COM_TESTQUESTIONS_RESULT_ANSWERS'); ?></h2> <h2><?php echo \Joomla\CMS\Language\Text::_('COM_TESTQUESTIONS_RESULT_ANSWERS'); ?></h2>
<?php foreach ($this->item->answers as $questionId => $answer) : ?> <?php foreach ($this->item->answers as $questionId => $answer) : ?>
<?php $question = $this->item->questions[(int) $questionId] ?? null; if (!$question) continue; ?> <?php $question = $this->item->questions[(int) $questionId] ?? null; if (!$question) continue; ?>
+2 -12
View File
@@ -4,7 +4,7 @@
<creationDate>2026-10-14</creationDate> <creationDate>2026-10-14</creationDate>
<author>Komponent</author> <author>Komponent</author>
<license>GNU General Public License version 2 or later</license> <license>GNU General Public License version 2 or later</license>
<version>1.42.0</version> <version>1.43.0</version>
<description>COM_TESTQUESTIONS_DESCRIPTION</description> <description>COM_TESTQUESTIONS_DESCRIPTION</description>
<element>com_testquestions</element> <element>com_testquestions</element>
<namespace path="src">Komponent\Component\Testquestions</namespace> <namespace path="src">Komponent\Component\Testquestions</namespace>
@@ -19,6 +19,7 @@
<administration> <administration>
<menu link="index.php?option=com_testquestions&amp;view=questions">COM_TESTQUESTIONS</menu> <menu link="index.php?option=com_testquestions&amp;view=questions">COM_TESTQUESTIONS</menu>
<files folder="administrator/components/com_testquestions"> <files folder="administrator/components/com_testquestions">
<filename>config.xml</filename>
<folder>forms</folder> <folder>forms</folder>
<folder>services</folder> <folder>services</folder>
<folder>sql</folder> <folder>sql</folder>
@@ -30,17 +31,6 @@
<language tag="en-GB">en-GB/com_testquestions.sys.ini</language> <language tag="en-GB">en-GB/com_testquestions.sys.ini</language>
</languages> </languages>
</administration> </administration>
<config>
<fields name="params">
<fieldset name="basic" label="COM_TESTQUESTIONS_AI_SETTINGS">
<field name="ai_provider" type="list" label="COM_TESTQUESTIONS_AI_PROVIDER">
<option value="openai">OpenAI</option>
<option value="grok">Grok</option>
</field>
<field name="ai_api_key" type="password" label="COM_TESTQUESTIONS_AI_API_KEY" filter="raw" autocomplete="off" />
</fieldset>
</fields>
</config>
<install> <install>
<sql> <sql>
<file driver="mysql" charset="utf8mb4">sql/install.mysql.utf8mb4.sql</file> <file driver="mysql" charset="utf8mb4">sql/install.mysql.utf8mb4.sql</file>