Compare commits

...

3 Commits

6 changed files with 120 additions and 1 deletions
+10
View File
@@ -34,6 +34,16 @@ SPDX-License-Identifier: MPL-2.0
</div> </div>
## AI Quiz Generation
ClassQuiz-AI includes an OpenAI-compatible API endpoint for generating quizzes dynamically using LLMs.
- **Endpoint**: `POST /api/v1/ai/generate`
- **Environment Variables**:
- `AI_API_KEY`: API key for your LLM provider.
- `AI_BASE_URL`: Base URL (defaults to `https://api.openai.com/v1`).
- `AI_MODEL`: Model name (defaults to `gpt-4o-mini`).
## About ClassQuiz ## About ClassQuiz
ClassQuiz is a quiz app to learn interactively for students, ClassQuiz is a quiz app to learn interactively for students,
+2
View File
@@ -34,6 +34,7 @@ from classquiz.routers import (
quiztivity, quiztivity,
pixabay, pixabay,
moderation, moderation,
ai,
) )
from classquiz.socket_server import sio from classquiz.socket_server import sio
from classquiz.helpers import meilisearch_init from classquiz.helpers import meilisearch_init
@@ -104,6 +105,7 @@ app.include_router(login.router, tags=["auth"], prefix="/api/v1/login", include_
app.add_middleware(SessionMiddleware, secret_key=settings.secret_key) app.add_middleware(SessionMiddleware, secret_key=settings.secret_key)
app.include_router(users.router, tags=["users"], prefix="/api/v1/users", include_in_schema=True) app.include_router(users.router, tags=["users"], prefix="/api/v1/users", include_in_schema=True)
app.include_router(quiz.router, tags=["quiz"], prefix="/api/v1/quiz", include_in_schema=True) app.include_router(quiz.router, tags=["quiz"], prefix="/api/v1/quiz", include_in_schema=True)
app.include_router(ai.router, tags=["ai"], prefix="/api/v1/ai", include_in_schema=True)
app.include_router(utils.router, tags=["utils"], prefix="/api/v1/utils", include_in_schema=True) app.include_router(utils.router, tags=["utils"], prefix="/api/v1/utils", include_in_schema=True)
app.include_router(stats.router, tags=["stats"], prefix="/api/v1/stats", include_in_schema=True) app.include_router(stats.router, tags=["stats"], prefix="/api/v1/stats", include_in_schema=True)
app.include_router(storage.router, tags=["storage"], prefix="/api/v1/storage", include_in_schema=True) app.include_router(storage.router, tags=["storage"], prefix="/api/v1/storage", include_in_schema=True)
+40
View File
@@ -0,0 +1,40 @@
# SPDX-FileCopyrightText: 2026 LobotomyLabs
# SPDX-License-Identifier: MPL-2.0
import os
import json
import httpx
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from classquiz.auth import get_current_user
from classquiz.db.models import User, QuizInput
router = APIRouter()
class AIGenerateRequest(BaseModel):
topic: str
num_questions: int = 5
@router.post("/generate", response_model=QuizInput)
async def generate_ai_quiz(req: AIGenerateRequest, user: User = Depends(get_current_user)):
api_key = os.getenv("AI_API_KEY", "")
base_url = os.getenv("AI_BASE_URL", "https://api.openai.com/v1")
model = os.getenv("AI_MODEL", "gpt-4o-mini")
prompt = f"Generate a quiz about '{req.topic}' with exactly {req.num_questions} multiple-choice questions (ABCD type). Return ONLY valid JSON with keys: title, description, questions (list of {{question, time: '20', type: 'ABCD', answers: [{{answer, right: bool, color}}]}})."
async with httpx.AsyncClient(timeout=30.0) as client:
res = await client.post(
f"{base_url}/chat/completions",
headers={"Authorization": f"Bearer {api_key}"},
json={"model": model, "messages": [{"role": "user", "content": prompt}], "temperature": 0.7},
)
if res.status_code != 200:
raise HTTPException(status_code=502, detail=f"AI provider error: {res.text}")
content = res.json()["choices"][0]["message"]["content"].strip()
if content.startswith("```"):
content = content.split("```")[1]
if content.startswith("json"):
content = content[4:]
content = content.strip("` \n")
return QuizInput(**json.loads(content))
+12
View File
@@ -0,0 +1,12 @@
# SPDX-FileCopyrightText: 2026 LobotomyLabs
# SPDX-License-Identifier: MPL-2.0
import pytest
from fastapi.testclient import TestClient
from classquiz import app
client = TestClient(app)
def test_ai_router_exists():
response = client.post("/api/v1/ai/generate", json={"topic": "Python", "num_questions": 1})
assert response.status_code in [200, 401, 502]
+51
View File
@@ -0,0 +1,51 @@
<!--
SPDX-FileCopyrightText: 2026 LobotomyLabs
SPDX-License-Identifier: MPL-2.0
-->
<script lang="ts">
import BrownButton from '$lib/components/buttons/brown.svelte';
let { data = $bindable() } = $props();
let open = $state(false);
let topic = $state('');
let loading = $state(false);
async function generate() {
if (!topic) return;
loading = true;
try {
const res = await fetch('/api/v1/ai/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, num_questions: 5 })
});
if (res.ok) {
const quiz = await res.json();
data.title = quiz.title;
data.description = quiz.description;
data.questions = quiz.questions;
open = false;
} else {
alert('Generation failed');
}
} finally {
loading = false;
}
}
</script>
<div class="my-2 px-6">
<BrownButton onclick={() => (open = true)}> Generate with AI</BrownButton>
</div>
{#if open}
<div class="fixed z-50 inset-0 flex items-center justify-center bg-black/50">
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-xl w-96">
<h3 class="text-lg font-bold mb-4 dark:text-white">Generate Quiz with AI</h3>
<input type="text" bind:value={topic} placeholder="Enter topic (e.g. World History)" class="w-full p-2 border rounded mb-4 dark:bg-gray-700 dark:text-white" />
<div class="flex justify-end gap-2">
<button class="px-4 py-2 bg-gray-300 rounded" onclick={() => (open = false)}>Cancel</button>
<button class="px-4 py-2 bg-indigo-600 text-white rounded" onclick={generate} disabled={loading}>{loading ? 'Generating...' : 'Generate'}</button>
</div>
</div>
</div>
{/if}
+5 -1
View File
@@ -13,6 +13,7 @@ SPDX-License-Identifier: MPL-2.0
import { getLocalization } from '$lib/i18n'; import { getLocalization } from '$lib/i18n';
import AddNewQuestionPopup from '$lib/editor/AddNewQuestionPopup.svelte'; import AddNewQuestionPopup from '$lib/editor/AddNewQuestionPopup.svelte';
import BrownButton from '$lib/components/buttons/brown.svelte'; import BrownButton from '$lib/components/buttons/brown.svelte';
import AIModal from '$lib/editor/AIModal.svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
const { t } = getLocalization(); const { t } = getLocalization();
@@ -74,7 +75,7 @@ SPDX-License-Identifier: MPL-2.0
</script> </script>
<div class="h-screen relative"> <div class="h-screen relative">
<div class="h-10 flex justify-center w-full p-1 absolute z-20"> <div class="h-10 flex justify-center w-full p-1 absolute z-20 gap-2">
<div> <div>
<BrownButton onclick={() => (reorder_mode = !reorder_mode)} <BrownButton onclick={() => (reorder_mode = !reorder_mode)}
>{#if reorder_mode}{$t('editor.disable_reorder')}{:else}{$t( >{#if reorder_mode}{$t('editor.disable_reorder')}{:else}{$t(
@@ -82,6 +83,9 @@ SPDX-License-Identifier: MPL-2.0
)}{/if}</BrownButton )}{/if}</BrownButton
> >
</div> </div>
<div>
<AIModal bind:data />
</div>
</div> </div>
<div class="border-r-2 pt-6 px-6 overflow-scroll h-full"> <div class="border-r-2 pt-6 px-6 overflow-scroll h-full">
<div <div