433 lines
16 KiB
PHP
433 lines
16 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Http\Resources\QuestionResource;
|
|
use App\Models\Log;
|
|
use App\Models\Question;
|
|
use App\Rules\ValidVariants;
|
|
use Illuminate\Http\Request;
|
|
use OpenAI;
|
|
|
|
class QuestionController extends Controller
|
|
{
|
|
const PAGINATION_COUNT = 20;
|
|
|
|
private static function get_field_rules(): array
|
|
{
|
|
return [
|
|
'title' => 'required|string|max:255',
|
|
'description' => 'nullable|string',
|
|
'type' => 'required|in:single,multiple,text',
|
|
'difficulty' => 'required|integer',
|
|
'category_id' => 'nullable|exists:categories,id',
|
|
'variants' => ['array', 'nullable'],
|
|
'variants.*.id' => ['required', 'integer'],
|
|
'variants.*.text' => ['required', 'string'],
|
|
'correct_answers' => 'required|array'
|
|
];
|
|
}
|
|
/**
|
|
* @OA\Get(
|
|
* path="/api/questions",
|
|
* summary="Get a paginated list of questions (paginated)",
|
|
* description="Retrieve questions. Optional filter by category_id.",
|
|
* tags={"Questions"},
|
|
* @OA\Parameter(
|
|
* name="category_id",
|
|
* in="query",
|
|
* description="Filter questions by category ID",
|
|
* required=false,
|
|
* @OA\Schema(type="integer")
|
|
* ),
|
|
* @OA\Parameter(
|
|
* name="page",
|
|
* in="query",
|
|
* description="Page number for pagination",
|
|
* required=false,
|
|
* @OA\Schema(type="integer", default=1)
|
|
* ),
|
|
* @OA\Response(
|
|
* response=200,
|
|
* description="Paginated list of questions",
|
|
* @OA\JsonContent(
|
|
* @OA\Property(
|
|
* property="data",
|
|
* type="array",
|
|
* @OA\Items(ref="#/components/schemas/QuestionResource")
|
|
* ),
|
|
* @OA\Property(
|
|
* property="links",
|
|
* type="object",
|
|
* example={
|
|
* "first": "http://localhost/api/questions?page=1",
|
|
* "last": "http://localhost/api/questions?page=10",
|
|
* "prev": null,
|
|
* "next": "http://localhost/api/questions?page=2"
|
|
* }
|
|
* ),
|
|
* @OA\Property(
|
|
* property="meta",
|
|
* type="object",
|
|
* example={
|
|
* "current_page": 1,
|
|
* "from": 1,
|
|
* "last_page": 10,
|
|
* "path": "http://localhost/api/questions",
|
|
* "per_page": 15,
|
|
* "to": 15,
|
|
* "total": 150
|
|
* }
|
|
* )
|
|
* )
|
|
* )
|
|
* )
|
|
*/
|
|
public function index(Request $request)
|
|
{
|
|
$query = Question::with(['author', 'category']);
|
|
|
|
if ($request->has('category_id')) {
|
|
$query->where('category_id', $request->query('category_id'));
|
|
}
|
|
|
|
$questions = $query->paginate(self::PAGINATION_COUNT);
|
|
|
|
return QuestionResource::collection($questions);
|
|
}
|
|
|
|
/**
|
|
* @OA\Get(
|
|
* path="/api/questions/{id}",
|
|
* summary="Get a single question",
|
|
* description="Retrieve a single question by its ID, including author and category",
|
|
* tags={"Questions"},
|
|
* @OA\Parameter(
|
|
* name="id",
|
|
* in="path",
|
|
* description="ID of the question to retrieve",
|
|
* required=true,
|
|
* @OA\Schema(type="integer")
|
|
* ),
|
|
* @OA\Response(
|
|
* response=200,
|
|
* description="Question retrieved successfully",
|
|
* @OA\JsonContent(ref="#/components/schemas/QuestionResource")
|
|
* )
|
|
* )
|
|
*/
|
|
public function show(Question $question)
|
|
{
|
|
$question->load(['author', 'category']);
|
|
return new QuestionResource($question);
|
|
}
|
|
|
|
/**
|
|
* @OA\Post(
|
|
* path="/api/questions",
|
|
* summary="Create a new question (only admin or creator)",
|
|
* description="Store a new question in the system.",
|
|
* tags={"Questions"},
|
|
* security={{"bearerAuth":{}}},
|
|
* @OA\RequestBody(
|
|
* required=true,
|
|
* @OA\JsonContent(
|
|
* required={"title","type","difficulty","variants","correct_answers"},
|
|
* @OA\Property(property="title", type="string", maxLength=255, example="Sample question"),
|
|
* @OA\Property(property="description", type="string", nullable=true, example="Optional description"),
|
|
* @OA\Property(property="type", type="string", enum={"single","multiply","text"}, example="single"),
|
|
* @OA\Property(property="difficulty", type="integer", example=2),
|
|
* @OA\Property(
|
|
* property="variants",
|
|
* type="array",
|
|
* @OA\Items(
|
|
* type="object",
|
|
* @OA\Property(property="id", type="integer", example=1),
|
|
* @OA\Property(property="text", type="string", example="Option 1")
|
|
* )
|
|
* ),
|
|
* @OA\Property(
|
|
* property="correct_answers",
|
|
* type="array",
|
|
* @OA\Items(
|
|
* type="integer",
|
|
* example=1
|
|
* )
|
|
* ),
|
|
* @OA\Property(property="category_id", type="integer", nullable=true, example=3)
|
|
* )
|
|
* ),
|
|
* @OA\Response(
|
|
* response=200,
|
|
* description="Question created successfully",
|
|
* @OA\JsonContent(ref="#/components/schemas/QuestionResource")
|
|
* )
|
|
* )
|
|
*/
|
|
public function store(Request $request)
|
|
{
|
|
$this->authorize('create', Question::class);
|
|
$fields = $request->validate(self::get_field_rules());
|
|
$fields['author_id'] = $request->user()->id;
|
|
|
|
$question = Question::create($fields);
|
|
|
|
Log::writeLog("Question '" . $question->title . "' is created by " . $request->user()->username);
|
|
|
|
return new QuestionResource($question);
|
|
}
|
|
|
|
/**
|
|
* @OA\Put(
|
|
* path="/api/questions/{id}",
|
|
* summary="Update a question (only admin or creator)",
|
|
* description="Update an existing question by ID.",
|
|
* tags={"Questions"},
|
|
* security={{"bearerAuth":{}}},
|
|
* @OA\Parameter(
|
|
* name="id",
|
|
* in="path",
|
|
* required=true,
|
|
* description="ID of the question to update",
|
|
* @OA\Schema(type="integer")
|
|
* ),
|
|
* @OA\RequestBody(
|
|
* required=true,
|
|
* @OA\JsonContent(
|
|
* required={"title","type","difficulty","variants","correct_answers"},
|
|
* @OA\Property(property="title", type="string", maxLength=255, example="Updated question"),
|
|
* @OA\Property(property="description", type="string", nullable=true, example="Updated description"),
|
|
* @OA\Property(property="type", type="string", enum={"single","multiply","text"}, example="multiply"),
|
|
* @OA\Property(property="difficulty", type="integer", example=3),
|
|
* @OA\Property(
|
|
* property="variants",
|
|
* type="array",
|
|
* @OA\Items(
|
|
* type="object",
|
|
* @OA\Property(property="id", type="integer", example=1),
|
|
* @OA\Property(property="text", type="string", example="Option 1")
|
|
* )
|
|
* ),
|
|
* @OA\Property(
|
|
* property="correct_answers",
|
|
* type="array",
|
|
* @OA\Items(
|
|
* type="integer",
|
|
* example=1
|
|
* )
|
|
* ),
|
|
* @OA\Property(property="category_id", type="integer", nullable=true, example=2)
|
|
* )
|
|
* ),
|
|
* @OA\Response(
|
|
* response=200,
|
|
* description="Question updated successfully",
|
|
* @OA\JsonContent(ref="#/components/schemas/QuestionResource")
|
|
* )
|
|
* )
|
|
*/
|
|
public function update(Request $request, Question $question)
|
|
{
|
|
$this->authorize('update', $question);
|
|
$fields = $request->validate(self::get_field_rules());
|
|
$fields['variants'] = json_encode($fields['variants']);
|
|
|
|
$question->update($fields);
|
|
|
|
Log::writeLog("Question '" . $question->title . "' is updated by " . $request->user()->username);
|
|
|
|
return new QuestionResource($question);
|
|
}
|
|
|
|
/**
|
|
* @OA\Delete(
|
|
* path="/api/questions/{id}",
|
|
* summary="Delete a question (only admin or creator)",
|
|
* description="Delete a question by ID .",
|
|
* tags={"Questions"},
|
|
* security={{"bearerAuth":{}}},
|
|
* @OA\Parameter(
|
|
* name="id",
|
|
* in="path",
|
|
* required=true,
|
|
* description="ID of the question to delete",
|
|
* @OA\Schema(type="integer")
|
|
* ),
|
|
* @OA\Response(
|
|
* response=200,
|
|
* description="Question deleted successfully",
|
|
* @OA\JsonContent(
|
|
* @OA\Property(property="message", type="string", example="The hitcount was deleted")
|
|
* )
|
|
* )
|
|
* )
|
|
*/
|
|
public function destroy(Request $request, Question $question)
|
|
{
|
|
$this->authorize('delete', $question);
|
|
$question->delete();
|
|
Log::writeLog("Question '" . $question->title . "' is deleted by " . $request->user()->username);
|
|
|
|
return ['message' => 'The question was deleted'];
|
|
}
|
|
|
|
/**
|
|
* @OA\Post(
|
|
* path="/api/questions/openai-generate",
|
|
* summary="Generate a question using OpenAI (Creator or Admin)",
|
|
* description="Generates a new question via OpenAI API. Only authorized users (creator or admin) can call this endpoint.",
|
|
* tags={"Questions"},
|
|
* security={{"bearerAuth":{}}},
|
|
* @OA\RequestBody(
|
|
* required=true,
|
|
* @OA\JsonContent(
|
|
* required={"type","category_id","language","difficulty","promt"},
|
|
* @OA\Property(property="type", type="string", description="Type of the question", enum={"single","multiple","text"}, example="single"),
|
|
* @OA\Property(property="category_id", type="integer", description="ID of the category", example=3),
|
|
* @OA\Property(property="language", type="string", description="Language for AI prompt", example="en"),
|
|
* @OA\Property(property="difficulty", type="integer", description="Difficulty level (0-10)", example=5),
|
|
* @OA\Property(property="promt", type="string", description="The prompt for AI to generate the question", example="Explain HTTP methods")
|
|
* )
|
|
* ),
|
|
* @OA\Response(
|
|
* response=200,
|
|
* description="AI-generated question",
|
|
* @OA\JsonContent(
|
|
* @OA\Property(property="question", type="object",
|
|
* @OA\Property(property="title", type="string", example="Sample question"),
|
|
* @OA\Property(property="description", type="string", example="Optional description"),
|
|
* @OA\Property(property="type", type="string", enum={"single","multiple","text"}),
|
|
* @OA\Property(property="difficulty", type="integer", example=5),
|
|
* @OA\Property(property="variants", type="array",
|
|
* @OA\Items(
|
|
* @OA\Property(property="id", type="integer"),
|
|
* @OA\Property(property="text", type="string")
|
|
* )
|
|
* ),
|
|
* @OA\Property(property="correct_answers", type="array",
|
|
* @OA\Items(oneOf={@OA\Schema(type="integer"), @OA\Schema(type="string")})
|
|
* ),
|
|
* @OA\Property(property="category_id", type="integer", example=3)
|
|
* )
|
|
* )
|
|
* ),
|
|
* @OA\Response(
|
|
* response=422,
|
|
* description="Validation error",
|
|
* @OA\JsonContent(
|
|
* @OA\Property(property="message", type="string"),
|
|
* @OA\Property(property="errors", type="object")
|
|
* )
|
|
* ),
|
|
* @OA\Response(
|
|
* response=500,
|
|
* description="AI returned invalid JSON",
|
|
* @OA\JsonContent(
|
|
* @OA\Property(property="error", type="string"),
|
|
* @OA\Property(property="raw", type="string")
|
|
* )
|
|
* )
|
|
* )
|
|
*/
|
|
public function openai_generate(Request $request)
|
|
{
|
|
$this->authorize('create', Question::class);
|
|
|
|
$fields = $request->validate([
|
|
'type' => 'required|string|in:single,multiple,text',
|
|
'category_id' => 'required|exists:categories,id',
|
|
'language' => 'required|string',
|
|
'difficulty' => 'required|integer',
|
|
'promt' => 'required|string'
|
|
]);
|
|
$type = $fields['type'];
|
|
$lang = $fields['language'];
|
|
$difficulty = $fields['difficulty'];
|
|
$cat_id = $fields['category_id'];
|
|
$promt_json = json_encode($fields['promt']);
|
|
|
|
$question_type_promt = "";
|
|
$answer = "";
|
|
$variations = <<< EOT
|
|
[
|
|
{
|
|
"id": 1,
|
|
"text": "Option 1"
|
|
},
|
|
{
|
|
"id": 2,
|
|
"text": "Option 2"
|
|
},
|
|
{
|
|
"id": 3,
|
|
"text": "Option 3"
|
|
},
|
|
{
|
|
"id": 4,
|
|
"text": "Option 4"
|
|
}
|
|
]
|
|
EOT;
|
|
switch ($type) {
|
|
case 'single':
|
|
$question_type_promt = "Create 4 answer options, with one of them being correct (the \"correct_answers\" field is an array containing the ID of the correct option).";
|
|
$answer = json_encode([2]); ;
|
|
break;
|
|
case 'multiple':
|
|
$question_type_promt = "Create between 4 and 6 answer options, with several of them being correct (the \"correct_answers\" field is an array containing the IDs of the correct options).";
|
|
$answer = json_encode([1,2]); ;
|
|
break;
|
|
case 'text':
|
|
$question_type_promt = "Generate a question where there can only be a single, precise answer that can be written without an extended explanation. The \"correct_answers\" field should be an array containing only ONE answer as a string. The \"variations\" must be empty array";
|
|
$answer = json_encode(["Answer"]);
|
|
$variations = '[]';
|
|
break;
|
|
}
|
|
|
|
$openai_promt = <<< EOT
|
|
Generate 1 question using the following template (this is example):
|
|
|
|
{
|
|
"title": "Sample question",
|
|
"description": "Optional description",
|
|
"type": "$type",
|
|
"difficulty": $difficulty,
|
|
"variants": $variations,
|
|
"correct_answers": $answer,
|
|
"category_id": $cat_id
|
|
}
|
|
|
|
You can change only variants, correct_answers, description and title.
|
|
Your response should be only JSON with no explanations, exactly following the template.
|
|
|
|
$question_type_promt
|
|
|
|
Question difficulty: $difficulty/10
|
|
The question should be based on the following prompt with language $lang:
|
|
$promt_json
|
|
|
|
EOT;
|
|
$client = OpenAI::client(env("OPENAI_KEY"));
|
|
|
|
$response = $client->responses()->create([
|
|
'model' => env('OPENAI_MODEL', 'gpt-4.1-mini'),
|
|
'input' => $openai_promt,
|
|
'temperature' => 0,
|
|
]);
|
|
$ai_answer = $response->outputText;
|
|
|
|
$question_json = json_decode($ai_answer, true);
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
return response()->json([
|
|
'error' => 'AI returned invalid JSON',
|
|
'raw' => $ai_answer,
|
|
], 500);
|
|
}
|
|
|
|
return response()->json([
|
|
'question' => $question_json,
|
|
]);
|
|
}
|
|
}
|