Compare commits
3 Commits
4d1f1d3165
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 41df7e6f9f | |||
| 7d2a309d0f | |||
| d0ed79a986 |
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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]
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user