-
{
- if (termToDelete.value === null) {
- return ""
- }
+ if (termToDelete.value === null) return ""
return termToDelete.value.title
})
@@ -188,14 +192,36 @@ const canEditGlossary = computed(() => {
return basePermission && !platform.isStudentViewActive
})
-const isAdminOrTeacher = computed(() => securityStore.isAdmin || securityStore.isTeacher)
+async function loadCourseSettingsIfPossible() {
+ const courseId = course.value?.id
+ const sessionId = session.value?.id
+
+ if (!courseId) {
+ return
+ }
+
+ try {
+ await courseSettingsStore.loadCourseSettings(courseId, sessionId)
+ } catch (err) {
+ console.error("[Glossary] loadCourseSettings FAILED:", err)
+ }
+}
onMounted(async () => {
isLoading.value = true
+
+ await loadCourseSettingsIfPossible()
await fetchGlossaries()
await onStudentViewChange()
})
+watch(
+ () => [course.value?.id, session.value?.id],
+ async () => {
+ await loadCourseSettingsIfPossible()
+ },
+)
+
watch(
() => platform.isStudentViewActive,
async () => {
@@ -215,6 +241,31 @@ const debouncedSearch = debounce(() => {
fetchGlossaries()
}, 500)
+const aiHelpersEnabled = computed(() => {
+ const v = String(platform.getSetting("ai_helpers.enable_ai_helpers"))
+ return v === "true"
+})
+
+const glossaryGeneratorEnabled = computed(() => {
+ const v =
+ courseSettingsStore?.getSetting?.("glossary_terms_generator") ??
+ courseSettingsStore?.getSetting?.("glossary_terms_generators")
+
+ return String(v) === "true"
+})
+
+const canUseAiGlossaryGenerator = computed(() => {
+ return !!(canEditGlossary.value && aiHelpersEnabled.value && glossaryGeneratorEnabled.value)
+})
+
+function generateGlossaryTerms() {
+ if (!canEditGlossary.value) return
+ router.push({
+ name: "GenerateGlossaryTerms",
+ query: route.query,
+ })
+}
+
function addNewTerm() {
if (!canEditGlossary.value) return
router.push({
@@ -247,7 +298,7 @@ async function deleteTerm() {
isDeleteItemDialogVisible.value = false
await fetchGlossaries()
} catch (error) {
- console.error("Error deleting term:", error)
+ console.error("[Glossary] Error deleting term:", error)
notifications.showErrorNotification(t("Could not delete term"))
}
}
@@ -283,7 +334,7 @@ async function exportToDocuments() {
await glossaryService.exportToDocuments(postData)
notifications.showSuccessNotification(t("Exported to documents"))
} catch (error) {
- console.error("Error fetching links:", error)
+ console.error("[Glossary] Error exporting to documents:", error)
notifications.showErrorNotification(t("Could not export to documents"))
}
}
@@ -301,7 +352,7 @@ async function fetchGlossaries() {
try {
glossaries.value = await glossaryService.getGlossaryTerms(params)
} catch (error) {
- console.error("Error fetching links:", error)
+ console.error("[Glossary] Error fetching glossary terms:", error)
notifications.showErrorNotification(t("Could not fetch glossary terms"))
} finally {
isLoading.value = false
diff --git a/src/CoreBundle/Controller/AiController.php b/src/CoreBundle/Controller/AiController.php
index 19badccf47c..02ead206ffe 100644
--- a/src/CoreBundle/Controller/AiController.php
+++ b/src/CoreBundle/Controller/AiController.php
@@ -9,10 +9,13 @@
use Chamilo\CoreBundle\AiProvider\AiImageProviderInterface;
use Chamilo\CoreBundle\AiProvider\AiProviderFactory;
use Chamilo\CoreBundle\AiProvider\AiVideoProviderInterface;
+use Chamilo\CoreBundle\Entity\Course;
use Chamilo\CoreBundle\Entity\TrackEDefault;
use Chamilo\CoreBundle\Entity\TrackEExercise;
use Chamilo\CoreBundle\Repository\TrackEAttemptRepository;
+use Chamilo\CourseBundle\Entity\CGlossary;
use Chamilo\CourseBundle\Entity\CQuizAnswer;
+use Chamilo\CourseBundle\Repository\CGlossaryRepository;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
@@ -23,8 +26,10 @@
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Contracts\HttpClient\HttpClientInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;
use const FILTER_SANITIZE_NUMBER_INT;
+use const FILTER_VALIDATE_URL;
#[Route('/ai')]
class AiController extends AbstractController
@@ -34,8 +39,175 @@ public function __construct(
private readonly TrackEAttemptRepository $attemptRepo,
private readonly EntityManagerInterface $em,
private readonly HttpClientInterface $httpClient,
+ private readonly TranslatorInterface $translator,
) {}
+ #[Route('/text_providers', name: 'chamilo_core_ai_text_providers', methods: ['GET'])]
+ public function textProviders(): JsonResponse
+ {
+ try {
+ $this->denyIfNotTeacher();
+ } catch (AccessDeniedException $e) {
+ return new JsonResponse(['providers' => []], 403);
+ }
+
+ return new JsonResponse([
+ 'providers' => array_values($this->aiProviderFactory->getProvidersForType('text')),
+ ]);
+ }
+
+ #[Route('/glossary_default_prompt', name: 'chamilo_core_ai_glossary_default_prompt', methods: ['GET'])]
+ public function glossaryDefaultPrompt(Request $request): JsonResponse
+ {
+ try {
+ $this->denyIfNotTeacher();
+ } catch (AccessDeniedException $e) {
+ return new JsonResponse(['prompt' => ''], 403);
+ }
+
+ $cid = (int) $request->query->get('cid', 0);
+ $sid = (int) $request->query->get('sid', 0);
+ $n = (int) $request->query->get('n', 15);
+
+ if ($n < 1) {
+ $n = 1;
+ }
+ if ($n > 200) {
+ $n = 200;
+ }
+
+ if (0 === $cid) {
+ return new JsonResponse(['prompt' => ''], 400);
+ }
+
+ /** @var Course|null $course */
+ $course = $this->em->getRepository(Course::class)->find($cid);
+ if (null === $course) {
+ return new JsonResponse(['prompt' => ''], 404);
+ }
+
+ $courseTitle = (string) $course->getTitle();
+ $desc = $this->getGenericCourseDescription($cid, $sid);
+
+ $base = $this->translator->trans(
+ "Generate %d glossary terms for a course on '%s', each term on a single line, with its definition on the next line and one blank line between each term."
+ );
+
+ $prompt = sprintf($base, $n, $courseTitle);
+
+ if ('' !== $desc) {
+ $descPrefix = $this->translator->trans(
+ "This is a short description of the course '%s'."
+ );
+ $prompt .= ' '.sprintf($descPrefix, $courseTitle).' '.$desc;
+ }
+
+ return new JsonResponse(['prompt' => $prompt]);
+ }
+
+ #[Route('/generate_glossary_terms', name: 'chamilo_core_ai_generate_glossary_terms', methods: ['POST'])]
+ public function generateGlossaryTerms(Request $request): JsonResponse
+ {
+ try {
+ $this->denyIfNotTeacher();
+ } catch (AccessDeniedException $e) {
+ return new JsonResponse([
+ 'success' => false,
+ 'text' => 'Access denied.',
+ ], 403);
+ }
+
+ $data = json_decode($request->getContent(), true);
+ if (!is_array($data)) {
+ return new JsonResponse([
+ 'success' => false,
+ 'text' => 'Invalid JSON payload.',
+ ], 400);
+ }
+
+ $n = (int) ($data['n'] ?? 15);
+ $language = (string) ($data['language'] ?? 'en');
+ $prompt = trim((string) ($data['prompt'] ?? ''));
+ $providerName = isset($data['ai_provider']) ? (string) $data['ai_provider'] : null;
+ $cid = (int) ($data['cid'] ?? 0);
+ $toolName = trim((string) ($data['tool'] ?? 'glossary'));
+
+ if ($n < 1) {
+ $n = 1;
+ }
+ if ($n > 200) {
+ $n = 200;
+ }
+
+ if (0 === $cid || '' === $prompt) {
+ return new JsonResponse([
+ 'success' => false,
+ 'text' => 'Invalid request parameters.',
+ ], 400);
+ }
+
+ /** @var Course|null $course */
+ $course = $this->em->getRepository(Course::class)->find($cid);
+ if (null === $course) {
+ return new JsonResponse([
+ 'success' => false,
+ 'text' => 'Course not found.',
+ ], 404);
+ }
+
+ try {
+ $provider = $this->aiProviderFactory->getProvider($providerName, 'text');
+
+ if (!is_object($provider) || !method_exists($provider, 'generateText')) {
+ return new JsonResponse([
+ 'success' => false,
+ 'text' => 'Selected AI provider does not support text generation.',
+ ], 400);
+ }
+
+ // Preferred signature: generateText(string $prompt, array $options = []): string
+ try {
+ $raw = (string) $provider->generateText($prompt, [
+ 'language' => $language,
+ 'n' => $n,
+ 'tool' => $toolName,
+ ]);
+ } catch (\TypeError $e) {
+ // Backward compatibility: generateText(string $prompt, string $language): string
+ $raw = (string) $provider->generateText($prompt, $language);
+ }
+
+ $raw = trim($raw);
+
+ if ('' === $raw) {
+ return new JsonResponse([
+ 'success' => false,
+ 'text' => 'AI request returned an empty response.',
+ ], 500);
+ }
+
+ if (str_starts_with($raw, 'Error:')) {
+ $msg = trim((string) preg_replace('/^Error:\s*/', '', $raw));
+ return new JsonResponse([
+ 'success' => false,
+ 'text' => $msg !== '' ? $msg : $raw,
+ ], 500);
+ }
+
+ return new JsonResponse([
+ 'success' => true,
+ 'text' => $raw,
+ ]);
+ } catch (\Throwable $e) {
+ error_log('[AI][glossary] Generation failed: '.$e->getMessage());
+
+ return new JsonResponse([
+ 'success' => false,
+ 'text' => 'An error occurred while generating glossary terms.',
+ ], 500);
+ }
+ }
+
#[Route('/capabilities', name: 'chamilo_core_ai_capabilities', methods: ['GET'])]
public function capabilities(): JsonResponse
{
@@ -332,26 +504,15 @@ public function generateImage(Request $request): JsonResponse
]);
}
- $content = isset($result['content']) && \is_string($result['content']) ? trim($result['content']) : '';
$url = isset($result['url']) && \is_string($result['url']) ? trim($result['url']) : '';
- $isBase64 = (bool) ($result['is_base64'] ?? false);
- $contentType = (string) ($result['content_type'] ?? 'image/png');
-
- if (!$isBase64 && '' === $content && '' !== $url) {
- try {
- $fetched = $this->fetchUrlAsBase64($url, 10 * 1024 * 1024);
- $result['content'] = $fetched['content'];
- $result['content_type'] = $fetched['content_type'];
- $result['is_base64'] = true;
- $result['url'] = null;
- } catch (\Throwable $e) {
- error_log('[AI][image] Failed to fetch image URL as base64: '.$e->getMessage());
+ $content = isset($result['content']) && \is_string($result['content']) ? trim($result['content']) : '';
- return new JsonResponse([
- 'success' => false,
- 'text' => 'Image was generated, but could not be converted to base64 for preview.',
- ], 500);
- }
+ if ('' === $content && '' !== $url && false === (bool) ($result['is_base64'] ?? false)) {
+ $fetched = $this->fetchUrlAsBase64($url, 10 * 1024 * 1024);
+ $result['content'] = $fetched['content'];
+ $result['content_type'] = $fetched['content_type'];
+ $result['is_base64'] = true;
+ $result['url'] = null;
}
$text = '';
@@ -404,7 +565,6 @@ public function generateVideo(Request $request): JsonResponse
$toolName = trim((string) ($data['tool'] ?? 'document'));
$aiProvider = $data['ai_provider'] ?? null;
- // Optional overrides
$seconds = isset($data['seconds']) ? trim((string) $data['seconds']) : null;
$size = isset($data['size']) ? trim((string) $data['size']) : null;
@@ -455,7 +615,7 @@ public function generateVideo(Request $request): JsonResponse
];
if (null !== $seconds && $seconds !== '') {
- $options['seconds'] = $seconds; // must be string: "4"|"8"|"12"
+ $options['seconds'] = $seconds;
}
if (null !== $size && $size !== '') {
$options['size'] = $size;
@@ -501,11 +661,11 @@ public function generateVideo(Request $request): JsonResponse
: 'All video providers failed.'
);
- $statusCode = $this->mapVideoErrorToHttpStatus($message);
+ $statusCode = $this->mapVideoErrorToHttpStatus((string) $message);
return new JsonResponse([
'success' => false,
- 'text' => $message,
+ 'text' => (string) $message,
'providers_tried' => $providersToTry,
'errors' => $errors,
], $statusCode);
@@ -557,7 +717,7 @@ public function generateVideo(Request $request): JsonResponse
$url = isset($result['url']) && \is_string($result['url']) ? trim($result['url']) : '';
$content = isset($result['content']) && \is_string($result['content']) ? trim($result['content']) : '';
- if (empty($content) && !empty($url) && false === (bool) ($result['is_base64'] ?? false)) {
+ if ('' === $content && '' !== $url && false === (bool) ($result['is_base64'] ?? false)) {
try {
$fetched = $this->fetchUrlAsBase64($url, 15 * 1024 * 1024);
$result['content'] = $fetched['content'];
@@ -596,73 +756,6 @@ public function generateVideo(Request $request): JsonResponse
}
}
- /**
- * Returns a reasonable HTTP status code for known provider errors.
- */
- private function mapVideoErrorToHttpStatus(string $message): int
- {
- $m = strtolower(trim($message));
-
- if ($m === '') {
- return 500;
- }
-
- // OpenAI typical cases
- if (str_contains($m, 'invalid api key') || str_contains($m, 'incorrect api key') || str_contains($m, 'unauthorized')) {
- return 401;
- }
-
- if (str_contains($m, 'must be verified') || str_contains($m, 'verify organization') || str_contains($m, 'organization must be verified')) {
- return 403;
- }
-
- if (str_contains($m, 'does not have access') || str_contains($m, 'not authorized') || str_contains($m, 'permission')) {
- return 403;
- }
-
- if (str_contains($m, 'rate limit') || str_contains($m, 'too many requests')) {
- return 429;
- }
-
- if (str_contains($m, 'insufficient_quota') || str_contains($m, 'quota')) {
- return 402;
- }
-
- return 500;
- }
-
- private function looksLikeUrl(string $s): bool
- {
- $s = trim($s);
- if ($s === '') {
- return false;
- }
-
- return (bool) filter_var($s, FILTER_VALIDATE_URL);
- }
-
- private function looksLikeBase64(string $s): bool
- {
- $s = trim($s);
- if ($s === '' || strlen($s) < 64) {
- return false;
- }
-
- // Basic base64 charset check
- if (!preg_match('/^[A-Za-z0-9+\/=\r\n]+$/', $s)) {
- return false;
- }
-
- // Validate decode (strict)
- $decoded = base64_decode($s, true);
- if ($decoded === false) {
- return false;
- }
-
- // Video will likely be binary; just ensure not empty
- return $decoded !== '';
- }
-
#[Route('/video_job/{id}', name: 'chamilo_core_ai_video_job', methods: ['GET'])]
public function videoJobStatus(string $id, Request $request): JsonResponse
{
@@ -756,13 +849,77 @@ public function videoJobStatus(string $id, Request $request): JsonResponse
}
}
+ /**
+ * Returns a reasonable HTTP status code for known provider errors.
+ */
+ private function mapVideoErrorToHttpStatus(string $message): int
+ {
+ $m = strtolower(trim($message));
+
+ if ('' === $m) {
+ return 500;
+ }
+
+ if (str_contains($m, 'invalid api key') || str_contains($m, 'incorrect api key') || str_contains($m, 'unauthorized')) {
+ return 401;
+ }
+
+ if (str_contains($m, 'must be verified') || str_contains($m, 'verify organization') || str_contains($m, 'organization must be verified')) {
+ return 403;
+ }
+
+ if (str_contains($m, 'does not have access') || str_contains($m, 'not authorized') || str_contains($m, 'permission')) {
+ return 403;
+ }
+
+ if (str_contains($m, 'rate limit') || str_contains($m, 'too many requests')) {
+ return 429;
+ }
+
+ if (str_contains($m, 'insufficient_quota') || str_contains($m, 'quota')) {
+ return 402;
+ }
+
+ return 500;
+ }
+
+ private function looksLikeUrl(string $s): bool
+ {
+ $s = trim($s);
+ if ('' === $s) {
+ return false;
+ }
+
+ return (bool) filter_var($s, FILTER_VALIDATE_URL);
+ }
+
+ private function looksLikeBase64(string $s): bool
+ {
+ $s = trim($s);
+ if ('' === $s || strlen($s) < 64) {
+ return false;
+ }
+
+ if (!preg_match('/^[A-Za-z0-9+\/=\r\n]+$/', $s)) {
+ return false;
+ }
+
+ $decoded = base64_decode($s, true);
+ if (false === $decoded) {
+ return false;
+ }
+
+ return '' !== $decoded;
+ }
+
private function denyIfNotTeacher(): void
{
- if (!$this->isGranted('ROLE_CURRENT_COURSE_TEACHER')
+ if (
+ !$this->isGranted('ROLE_CURRENT_COURSE_TEACHER')
&& !$this->isGranted('ROLE_CURRENT_COURSE_SESSION_TEACHER')
&& !$this->isGranted('ROLE_TEACHER')
) {
- throw new \RuntimeException('Access denied.');
+ throw new AccessDeniedException('Access denied.');
}
}
@@ -823,7 +980,6 @@ private function isSafeRemoteUrl(string $url): bool
$ip = gethostbyname($host);
if (filter_var($ip, FILTER_VALIDATE_IP)) {
- // Block private/reserved ranges (basic SSRF hardening).
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return false;
}
@@ -831,4 +987,19 @@ private function isSafeRemoteUrl(string $url): bool
return true;
}
+
+ private function getGenericCourseDescription(int $cid, int $sid): string
+ {
+ try {
+ $repo = $this->em->getRepository(CGlossary::class);
+
+ if ($repo instanceof CGlossaryRepository) {
+ return $repo->getGenericCourseDescription($cid, $sid);
+ }
+ } catch (\Throwable) {
+ // Ignore repository instantiation differences.
+ }
+
+ return '';
+ }
}
diff --git a/src/CoreBundle/Controller/PlatformConfigurationController.php b/src/CoreBundle/Controller/PlatformConfigurationController.php
index 9886d57619d..360a5dc8863 100644
--- a/src/CoreBundle/Controller/PlatformConfigurationController.php
+++ b/src/CoreBundle/Controller/PlatformConfigurationController.php
@@ -228,6 +228,7 @@ public function courseSettingsList(
'learning_path_generator' => $courseSettingsManager->getCourseSettingValue('learning_path_generator'),
'image_generator' => $courseSettingsManager->getCourseSettingValue('image_generator'),
'video_generator' => $courseSettingsManager->getCourseSettingValue('video_generator'),
+ 'glossary_terms_generator' => $courseSettingsManager->getCourseSettingValue('glossary_terms_generator'),
'display_info_advance_inside_homecourse' => $courseSettingsManager->getCourseSettingValue('display_info_advance_inside_homecourse'),
];
diff --git a/src/CourseBundle/Repository/CGlossaryRepository.php b/src/CourseBundle/Repository/CGlossaryRepository.php
index 45a72c2e1ff..9abbfd79c61 100644
--- a/src/CourseBundle/Repository/CGlossaryRepository.php
+++ b/src/CourseBundle/Repository/CGlossaryRepository.php
@@ -15,15 +15,20 @@
use Chamilo\CoreBundle\Repository\ResourceWithLinkInterface;
use Chamilo\CourseBundle\Entity\CGlossary;
use Chamilo\CourseBundle\Entity\CGroup;
+use Doctrine\DBAL\Connection;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Routing\RouterInterface;
final class CGlossaryRepository extends ResourceRepository implements ResourceWithLinkInterface
{
+ private Connection $connection;
+
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, CGlossary::class);
+
+ $this->connection = $this->getEntityManager()->getConnection();
}
/*public function getResources(User $user, ResourceNode $parentNode, Course $course = null, Session $session = null, CGroup $group = null): QueryBuilder
@@ -42,4 +47,52 @@ public function getLink(ResourceInterface $resource, RouterInterface $router, ar
return '/main/glossary/index.php?'.http_build_query($params);
}
+
+ /**
+ * Best-effort extraction from Chamilo "course description" tool table.
+ * Tries the session-specific record first (if sid>0), then session_id=0.
+ *
+ * Returns plain text (HTML stripped, whitespace normalized).
+ */
+ public function getGenericCourseDescription(int $cid, int $sid = 0): string
+ {
+ if ($cid <= 0) {
+ return '';
+ }
+
+ $candidates = [];
+ if ($sid > 0) {
+ $candidates[] = $sid;
+ }
+ $candidates[] = 0;
+
+ foreach ($candidates as $sessionId) {
+ try {
+ $row = $this->connection->fetchAssociative(
+ "SELECT content
+ FROM c_course_description
+ WHERE c_id = :cid
+ AND session_id = :sid
+ ORDER BY id DESC
+ LIMIT 1",
+ [
+ 'cid' => $cid,
+ 'sid' => (int) $sessionId,
+ ]
+ );
+
+ if (!empty($row['content'])) {
+ $txt = strip_tags((string) $row['content']);
+ $txt = preg_replace('/\s+/', ' ', $txt ?? '') ?? '';
+
+ return trim($txt);
+ }
+ } catch (\Throwable) {
+ // Some installs may not have this table or may differ; ignore.
+ continue;
+ }
+ }
+
+ return '';
+ }
}