openai added

This commit is contained in:
Stepan 2025-11-13 22:40:22 +01:00
parent 5d08a44965
commit 595332008b
13 changed files with 763 additions and 42 deletions

View File

@ -55,6 +55,8 @@ MAIL_USERNAME=null
MAIL_PASSWORD=null MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}" MAIL_FROM_NAME="${APP_NAME}"
MAIL_ACTIVATION_FRONTEND=https://localhost:3156/auth/email-activation/${ACTIVATION_CODE}
MAIL_RESET_PASS_FRONTEND=https://localhost:3156/auth/reset-password/${FORGOT_CODE}
AWS_ACCESS_KEY_ID= AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY= AWS_SECRET_ACCESS_KEY=
@ -63,3 +65,5 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
OPENAI_MODEL=gpt-4.1-mini
OPENAI_KEY=

View File

@ -7,6 +7,7 @@ HoshiAI-be — the backend part of the HoshiAI project for the Web Programming c
* [Laravel (Sanctum for api)](https://laravel.com/) * [Laravel (Sanctum for api)](https://laravel.com/)
* [L5-Swagger](https://github.com/DarkaOnLine/L5-Swagger) * [L5-Swagger](https://github.com/DarkaOnLine/L5-Swagger)
* [mobiledetect/mobiledetectlib](https://packagist.org/packages/mobiledetect/mobiledetectlib) * [mobiledetect/mobiledetectlib](https://packagist.org/packages/mobiledetect/mobiledetectlib)
* [openai-php/client](https://github.com/openai-php/client)
## External API ## External API
* [ip-api.com](http://ip-api.com/) * [ip-api.com](http://ip-api.com/)
@ -37,6 +38,7 @@ MAIL_PASSWORD=
MAIL_ENCRYPTION= MAIL_ENCRYPTION=
MAIL_FROM_ADDRESS= MAIL_FROM_ADDRESS=
... ...
OPENAI_MODEL=gpt-4.1-mini
OPENAI_KEY= OPENAI_KEY=
``` ```
4. Make migrations to your db 4. Make migrations to your db

View File

@ -26,7 +26,8 @@ class CategoryController extends Controller
*/ */
public function index() public function index()
{ {
return CategoryResource::collection(Category::all()); $categories = Category::withCount('user_tests', 'questions')->get();
return CategoryResource::collection($categories);
} }
/** /**
@ -91,7 +92,7 @@ class CategoryController extends Controller
*/ */
public function show(Category $category) public function show(Category $category)
{ {
return new CategoryResource($category); return new CategoryResource($category->withCount('user_tests', 'questions'));
} }
/** /**

View File

@ -7,6 +7,7 @@ use App\Models\Log;
use App\Models\Question; use App\Models\Question;
use App\Rules\ValidVariants; use App\Rules\ValidVariants;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use OpenAI;
class QuestionController extends Controller class QuestionController extends Controller
{ {
@ -23,8 +24,7 @@ class QuestionController extends Controller
'variants' => ['array', 'nullable'], 'variants' => ['array', 'nullable'],
'variants.*.id' => ['required', 'integer'], 'variants.*.id' => ['required', 'integer'],
'variants.*.text' => ['required', 'string'], 'variants.*.text' => ['required', 'string'],
'correct_answers' => 'required|array', 'correct_answers' => 'required|array'
'is_pending_question' => 'integer'
]; ];
} }
/** /**
@ -154,8 +154,7 @@ class QuestionController extends Controller
* example=1 * example=1
* ) * )
* ), * ),
* @OA\Property(property="category_id", type="integer", nullable=true, example=3), * @OA\Property(property="category_id", type="integer", nullable=true, example=3)
* @OA\Property(property="is_pending_question", type="integer", example=0)
* ) * )
* ), * ),
* @OA\Response( * @OA\Response(
@ -217,8 +216,7 @@ class QuestionController extends Controller
* example=1 * example=1
* ) * )
* ), * ),
* @OA\Property(property="category_id", type="integer", nullable=true, example=2), * @OA\Property(property="category_id", type="integer", nullable=true, example=2)
* @OA\Property(property="is_pending_question", type="integer", example=0)
* ) * )
* ), * ),
* @OA\Response( * @OA\Response(
@ -272,4 +270,163 @@ class QuestionController extends Controller
return ['message' => 'The question was deleted']; 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,
]);
}
} }

View File

@ -15,7 +15,7 @@ class UserTestController extends Controller
{ {
/** /**
* @OA\Get( * @OA\Get(
* path="/api/user-tests", * path="/api/user-tests/me",
* summary="Get list of user tests for the authenticated user", * summary="Get list of user tests for the authenticated user",
* description="Retrieve all UserTests belonging to the authenticated user, with related category, test, and answers.", * description="Retrieve all UserTests belonging to the authenticated user, with related category, test, and answers.",
* tags={"UserTests"}, * tags={"UserTests"},
@ -34,7 +34,7 @@ class UserTestController extends Controller
* ) * )
* ) * )
*/ */
public function index(Request $request) public function me(Request $request)
{ {
$user = $request->user(); $user = $request->user();
$userTests = UserTest::where('user_id', $user->id) $userTests = UserTest::where('user_id', $user->id)
@ -45,25 +45,97 @@ class UserTestController extends Controller
return UserTestResource::collection($userTests); return UserTestResource::collection($userTests);
} }
/**
* @OA\Get(
* path="/api/user-tests",
* summary="Get a list of user tests (only creator or admin)",
* description="Returns all user tests. Only the creator or an admin can access this endpoint.",
* tags={"UserTests"},
* security={{"sanctum":{}}},
* @OA\Parameter(
* name="test_id",
* in="query",
* description="Filter by Test ID",
* required=false,
* @OA\Schema(type="integer", example=5)
* ),
* @OA\Parameter(
* name="question_id",
* in="query",
* description="Filter by Question ID (via answers.question)",
* required=false,
* @OA\Schema(type="integer", example=12)
* ),
* @OA\Response(
* response=200,
* description="List of user tests retrieved successfully",
* @OA\JsonContent(
* type="array",
* @OA\Items(ref="#/components/schemas/UserTestResource")
* )
* ),
* @OA\Response(
* response=401,
* description="Unauthenticated — missing or invalid token"
* ),
* @OA\Response(
* response=403,
* description="Forbidden — only the creator or admin can access this resource"
* )
* )
*/
public function index(Request $request)
{
$this->authorize('viewAny', UserTest::class);
$query = UserTest::query()
->with(['user', 'category', 'answers.question'])
->orderBy('id', 'desc');
// filter by test_id
if ($request->has('test_id')) {
$query->where('test_id', $request->input('test_id'));
}
// filter by question_id
if ($request->has('question_id')) {
$questionId = $request->input('question_id');
$query->whereHas('answers.question', function ($q) use ($questionId) {
$q->where('questions.id', $questionId);
});
}
$userTests = $query->get();
return UserTestResource::collection($userTests);
}
/** /**
* @OA\Post( * @OA\Post(
* path="/api/user-tests", * path="/api/user-tests",
* summary="Create a new user test", * summary="Create a new user test",
* description="Generate a new UserTest for the authenticated user with questions from a specific category and difficulty range.",
* tags={"UserTests"}, * tags={"UserTests"},
* security={{"bearerAuth":{}}}, * security={{"bearerAuth":{}}},
* @OA\RequestBody( * @OA\RequestBody(
* required=true, * required=true,
* @OA\JsonContent( * @OA\JsonContent(
* required={"category_id"}, * required={"category_id"},
* @OA\Property(property="category_id", type="integer", example=3, description="Category from which to select questions"), * @OA\Property(property="category_id", type="integer", example=3),
* @OA\Property(property="min_difficulty", type="integer", nullable=true, example=0, description="Minimum difficulty of questions"), * @OA\Property(property="min_difficulty", type="integer", example=0),
* @OA\Property(property="max_difficulty", type="integer", nullable=true, example=10, description="Maximum difficulty of questions") * @OA\Property(property="max_difficulty", type="integer", example=10)
* )
* ),
* @OA\Response(
* response=200,
* description="User test created successfully",
* @OA\JsonContent(
* @OA\Property(property="message", type="string", example="This test isn't available now!")
* ) * )
* ), * ),
* @OA\Response( * @OA\Response(
* response=422, * response=422,
* description="Validation error or no questions found" * description="Validation error"
* ) * )
* ) * )
*/ */

View File

@ -28,10 +28,16 @@ use Illuminate\Http\Resources\Json\JsonResource;
* example="2025-11-10T18:00:00Z" * example="2025-11-10T18:00:00Z"
* ), * ),
* @OA\Property( * @OA\Property(
* property="count", * property="questions_count",
* type="integer", * type="integer",
* example=5, * example=5,
* description="Dynamic count of related items" * description="Dynamic count of questions by category"
* ),
* @OA\Property(
* property="user_tests_count",
* type="integer",
* example=5,
* description="Dynamic count of user tests"
* ) * )
* ) * )
*/ */
@ -48,7 +54,8 @@ class CategoryResource extends JsonResource
'id' => $this->id, 'id' => $this->id,
'name' => $this->name, 'name' => $this->name,
'created_at' => $this->created_at, 'created_at' => $this->created_at,
'count' => $this->count ?? 0 'questions_count' => $this->when(isset($this->questions_count), $this->questions_count, 0),
'user_tests_count' => $this->when(isset($this->user_tests_count), $this->user_tests_count, 0),
]; ];
} }
} }

View File

@ -13,4 +13,16 @@ class Category extends Model
protected $fillable = [ protected $fillable = [
'name' 'name'
]; ];
public function tests()
{
return $this->hasMany(Test::class, 'category_id');
}
public function user_tests()
{
return $this->hasMany(UserTest::class, 'category_id');
}
public function questions()
{
return $this->hasMany(Question::class, 'category_id');
}
} }

View File

@ -43,4 +43,6 @@ class Test extends Model
return $this->belongsToMany(Question::class) return $this->belongsToMany(Question::class)
->withTimestamps(); ->withTimestamps();
} }
} }

View File

@ -13,7 +13,7 @@ class UserTestPolicy
*/ */
public function viewAny(User $user): bool public function viewAny(User $user): bool
{ {
return true; return $user->type == 'admin' || $user->type == 'creator';
} }
/** /**

View File

@ -12,7 +12,8 @@
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/sanctum": "^4.0", "laravel/sanctum": "^4.0",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",
"mobiledetect/mobiledetectlib": "^4.8" "mobiledetect/mobiledetectlib": "^4.8",
"openai-php/client": "^0.18.0"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",

228
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "a58d7ff4f8de3898bf7fa6350013d42a", "content-hash": "dec861e2bbb0078cfccbd798a66b1404",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
@ -2950,6 +2950,232 @@
], ],
"time": "2025-10-18T11:10:27+00:00" "time": "2025-10-18T11:10:27+00:00"
}, },
{
"name": "openai-php/client",
"version": "v0.18.0",
"source": {
"type": "git",
"url": "https://github.com/openai-php/client.git",
"reference": "3362ab004fcfc9d77df3aff7671fbcbe70177cae"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/openai-php/client/zipball/3362ab004fcfc9d77df3aff7671fbcbe70177cae",
"reference": "3362ab004fcfc9d77df3aff7671fbcbe70177cae",
"shasum": ""
},
"require": {
"php": "^8.2.0",
"php-http/discovery": "^1.20.0",
"php-http/multipart-stream-builder": "^1.4.2",
"psr/http-client": "^1.0.3",
"psr/http-client-implementation": "^1.0.1",
"psr/http-factory-implementation": "*",
"psr/http-message": "^1.1.0|^2.0.0"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.9.3",
"guzzlehttp/psr7": "^2.7.1",
"laravel/pint": "^1.24.0",
"mockery/mockery": "^1.6.12",
"nunomaduro/collision": "^8.8.0",
"pestphp/pest": "^3.8.2|^4.0.0",
"pestphp/pest-plugin-arch": "^3.1.1|^4.0.0",
"pestphp/pest-plugin-type-coverage": "^3.5.1|^4.0.0",
"phpstan/phpstan": "^1.12.25",
"symfony/var-dumper": "^7.2.6"
},
"type": "library",
"autoload": {
"files": [
"src/OpenAI.php"
],
"psr-4": {
"OpenAI\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nuno Maduro",
"email": "enunomaduro@gmail.com"
},
{
"name": "Sandro Gehri"
}
],
"description": "OpenAI PHP is a supercharged PHP API client that allows you to interact with the Open AI API",
"keywords": [
"GPT-3",
"api",
"client",
"codex",
"dall-e",
"language",
"natural",
"openai",
"php",
"processing",
"sdk"
],
"support": {
"issues": "https://github.com/openai-php/client/issues",
"source": "https://github.com/openai-php/client/tree/v0.18.0"
},
"funding": [
{
"url": "https://www.paypal.com/paypalme/enunomaduro",
"type": "custom"
},
{
"url": "https://github.com/gehrisandro",
"type": "github"
},
{
"url": "https://github.com/nunomaduro",
"type": "github"
}
],
"time": "2025-10-31T18:58:57+00:00"
},
{
"name": "php-http/discovery",
"version": "1.20.0",
"source": {
"type": "git",
"url": "https://github.com/php-http/discovery.git",
"reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d",
"reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d",
"shasum": ""
},
"require": {
"composer-plugin-api": "^1.0|^2.0",
"php": "^7.1 || ^8.0"
},
"conflict": {
"nyholm/psr7": "<1.0",
"zendframework/zend-diactoros": "*"
},
"provide": {
"php-http/async-client-implementation": "*",
"php-http/client-implementation": "*",
"psr/http-client-implementation": "*",
"psr/http-factory-implementation": "*",
"psr/http-message-implementation": "*"
},
"require-dev": {
"composer/composer": "^1.0.2|^2.0",
"graham-campbell/phpspec-skip-example-extension": "^5.0",
"php-http/httplug": "^1.0 || ^2.0",
"php-http/message-factory": "^1.0",
"phpspec/phpspec": "^5.1 || ^6.1 || ^7.3",
"sebastian/comparator": "^3.0.5 || ^4.0.8",
"symfony/phpunit-bridge": "^6.4.4 || ^7.0.1"
},
"type": "composer-plugin",
"extra": {
"class": "Http\\Discovery\\Composer\\Plugin",
"plugin-optional": true
},
"autoload": {
"psr-4": {
"Http\\Discovery\\": "src/"
},
"exclude-from-classmap": [
"src/Composer/Plugin.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com"
}
],
"description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations",
"homepage": "http://php-http.org",
"keywords": [
"adapter",
"client",
"discovery",
"factory",
"http",
"message",
"psr17",
"psr7"
],
"support": {
"issues": "https://github.com/php-http/discovery/issues",
"source": "https://github.com/php-http/discovery/tree/1.20.0"
},
"time": "2024-10-02T11:20:13+00:00"
},
{
"name": "php-http/multipart-stream-builder",
"version": "1.4.2",
"source": {
"type": "git",
"url": "https://github.com/php-http/multipart-stream-builder.git",
"reference": "10086e6de6f53489cca5ecc45b6f468604d3460e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-http/multipart-stream-builder/zipball/10086e6de6f53489cca5ecc45b6f468604d3460e",
"reference": "10086e6de6f53489cca5ecc45b6f468604d3460e",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0",
"php-http/discovery": "^1.15",
"psr/http-factory-implementation": "^1.0"
},
"require-dev": {
"nyholm/psr7": "^1.0",
"php-http/message": "^1.5",
"php-http/message-factory": "^1.0.2",
"phpunit/phpunit": "^7.5.15 || ^8.5 || ^9.3"
},
"type": "library",
"autoload": {
"psr-4": {
"Http\\Message\\MultipartStream\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Tobias Nyholm",
"email": "tobias.nyholm@gmail.com"
}
],
"description": "A builder class that help you create a multipart stream",
"homepage": "http://php-http.org",
"keywords": [
"factory",
"http",
"message",
"multipart stream",
"stream"
],
"support": {
"issues": "https://github.com/php-http/multipart-stream-builder/issues",
"source": "https://github.com/php-http/multipart-stream-builder/tree/1.4.2"
},
"time": "2024-09-04T13:22:54+00:00"
},
{ {
"name": "phpoption/phpoption", "name": "phpoption/phpoption",
"version": "1.9.4", "version": "1.9.4",

View File

@ -14,6 +14,7 @@ use Illuminate\Support\Facades\Route;
// UserTests // UserTests
Route::middleware('auth:sanctum')->group(function () { Route::middleware('auth:sanctum')->group(function () {
Route::get('user-tests', [UserTestController::class, 'index']); Route::get('user-tests', [UserTestController::class, 'index']);
Route::get('user-tests/me', [UserTestController::class, 'me']);
Route::post('user-tests', [UserTestController::class, 'store']); Route::post('user-tests', [UserTestController::class, 'store']);
Route::post('user-tests/by-test', [UserTestController::class, 'storeByTest']); Route::post('user-tests/by-test', [UserTestController::class, 'storeByTest']);
@ -42,6 +43,7 @@ Route::get('questions', [QuestionController::class, 'index']);
Route::get('questions/{question}', [QuestionController::class, 'show']); Route::get('questions/{question}', [QuestionController::class, 'show']);
Route::middleware('auth:sanctum')->group(function () { Route::middleware('auth:sanctum')->group(function () {
Route::post('questions/openai-generate', [QuestionController::class, 'openai_generate']);
Route::post('questions', [QuestionController::class, 'store']); Route::post('questions', [QuestionController::class, 'store']);
Route::put('questions/{question}', [QuestionController::class, 'update']); Route::put('questions/{question}', [QuestionController::class, 'update']);
Route::delete('questions/{question}', [QuestionController::class, 'destroy']); Route::delete('questions/{question}', [QuestionController::class, 'destroy']);

View File

@ -1246,10 +1246,6 @@
"type": "integer", "type": "integer",
"example": 3, "example": 3,
"nullable": true "nullable": true
},
"is_pending_question": {
"type": "integer",
"example": 0
} }
}, },
"type": "object" "type": "object"
@ -1389,10 +1385,6 @@
"type": "integer", "type": "integer",
"example": 2, "example": 2,
"nullable": true "nullable": true
},
"is_pending_question": {
"type": "integer",
"example": 0
} }
}, },
"type": "object" "type": "object"
@ -1461,6 +1453,176 @@
] ]
} }
}, },
"/api/questions/openai-generate": {
"post": {
"tags": [
"Questions"
],
"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.",
"operationId": "89ba54de2578b1b1f2e75d559ef4ef6d",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"required": [
"type",
"category_id",
"language",
"difficulty",
"promt"
],
"properties": {
"type": {
"description": "Type of the question",
"type": "string",
"enum": [
"single",
"multiple",
"text"
],
"example": "single"
},
"category_id": {
"description": "ID of the category",
"type": "integer",
"example": 3
},
"language": {
"description": "Language for AI prompt",
"type": "string",
"example": "en"
},
"difficulty": {
"description": "Difficulty level (0-10)",
"type": "integer",
"example": 5
},
"promt": {
"description": "The prompt for AI to generate the question",
"type": "string",
"example": "Explain HTTP methods"
}
},
"type": "object"
}
}
}
},
"responses": {
"200": {
"description": "AI-generated question",
"content": {
"application/json": {
"schema": {
"properties": {
"question": {
"properties": {
"title": {
"type": "string",
"example": "Sample question"
},
"description": {
"type": "string",
"example": "Optional description"
},
"type": {
"type": "string",
"enum": [
"single",
"multiple",
"text"
]
},
"difficulty": {
"type": "integer",
"example": 5
},
"variants": {
"type": "array",
"items": {
"properties": {
"id": {
"type": "integer"
},
"text": {
"type": "string"
}
},
"type": "object"
}
},
"correct_answers": {
"type": "array",
"items": {
"oneOf": [
{
"type": "integer"
},
{
"type": "string"
}
]
}
},
"category_id": {
"type": "integer",
"example": 3
}
},
"type": "object"
}
},
"type": "object"
}
}
}
},
"422": {
"description": "Validation error",
"content": {
"application/json": {
"schema": {
"properties": {
"message": {
"type": "string"
},
"errors": {
"type": "object"
}
},
"type": "object"
}
}
}
},
"500": {
"description": "AI returned invalid JSON",
"content": {
"application/json": {
"schema": {
"properties": {
"error": {
"type": "string"
},
"raw": {
"type": "string"
}
},
"type": "object"
}
}
}
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"/api/tests": { "/api/tests": {
"get": { "get": {
"tags": [ "tags": [
@ -2153,14 +2315,14 @@
] ]
} }
}, },
"/api/user-tests": { "/api/user-tests/me": {
"get": { "get": {
"tags": [ "tags": [
"UserTests" "UserTests"
], ],
"summary": "Get list of user tests for the authenticated user", "summary": "Get list of user tests for the authenticated user",
"description": "Retrieve all UserTests belonging to the authenticated user, with related category, test, and answers.", "description": "Retrieve all UserTests belonging to the authenticated user, with related category, test, and answers.",
"operationId": "bf312e7c74799e8dbaa29025b8f52f46", "operationId": "fef73e47f9c3188bea00ab44f7169105",
"responses": { "responses": {
"200": { "200": {
"description": "List of user tests", "description": "List of user tests",
@ -2186,13 +2348,70 @@
"bearerAuth": [] "bearerAuth": []
} }
] ]
}
},
"/api/user-tests": {
"get": {
"tags": [
"UserTests"
],
"summary": "Get a list of user tests (only creator or admin)",
"description": "Returns all user tests. Only the creator or an admin can access this endpoint.",
"operationId": "bf312e7c74799e8dbaa29025b8f52f46",
"parameters": [
{
"name": "test_id",
"in": "query",
"description": "Filter by Test ID",
"required": false,
"schema": {
"type": "integer",
"example": 5
}
},
{
"name": "question_id",
"in": "query",
"description": "Filter by Question ID (via answers.question)",
"required": false,
"schema": {
"type": "integer",
"example": 12
}
}
],
"responses": {
"200": {
"description": "List of user tests retrieved successfully",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/UserTestResource"
}
}
}
}
},
"401": {
"description": "Unauthenticated — missing or invalid token"
},
"403": {
"description": "Forbidden — only the creator or admin can access this resource"
}
},
"security": [
{
"sanctum": []
}
]
}, },
"post": { "post": {
"tags": [ "tags": [
"UserTests" "UserTests"
], ],
"summary": "Create a new user test", "summary": "Create a new user test",
"description": "Generate a new UserTest for the authenticated user with questions from a specific category and difficulty range.",
"operationId": "dab4526b31f8a1bf1abf3048c3a4ecbe", "operationId": "dab4526b31f8a1bf1abf3048c3a4ecbe",
"requestBody": { "requestBody": {
"required": true, "required": true,
@ -2204,21 +2423,16 @@
], ],
"properties": { "properties": {
"category_id": { "category_id": {
"description": "Category from which to select questions",
"type": "integer", "type": "integer",
"example": 3 "example": 3
}, },
"min_difficulty": { "min_difficulty": {
"description": "Minimum difficulty of questions",
"type": "integer", "type": "integer",
"example": 0, "example": 0
"nullable": true
}, },
"max_difficulty": { "max_difficulty": {
"description": "Maximum difficulty of questions",
"type": "integer", "type": "integer",
"example": 10, "example": 10
"nullable": true
} }
}, },
"type": "object" "type": "object"
@ -2227,8 +2441,24 @@
} }
}, },
"responses": { "responses": {
"200": {
"description": "User test created successfully",
"content": {
"application/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "This test isn't available now!"
}
},
"type": "object"
}
}
}
},
"422": { "422": {
"description": "Validation error or no questions found" "description": "Validation error"
} }
}, },
"security": [ "security": [
@ -2582,8 +2812,13 @@
"format": "date-time", "format": "date-time",
"example": "2025-11-10T18:00:00Z" "example": "2025-11-10T18:00:00Z"
}, },
"count": { "questions_count": {
"description": "Dynamic count of related items", "description": "Dynamic count of questions by category",
"type": "integer",
"example": 5
},
"user_tests_count": {
"description": "Dynamic count of user tests",
"type": "integer", "type": "integer",
"example": 5 "example": 5
} }